1. 从“t3code”这个名字说起:它到底指什么
第一次看到“t3code”这个词,很多人会一头雾水。它不像“react”“docker”那样有明确的官方定义,也不像“python”那样一眼能看出是编程语言。我在几个技术社区翻了一圈,发现大家对这个词的使用场景其实相当分散:有人拿它当某个内部工具链的代号,有人用它指代一套轻量级的编码规范,还有人把它当成“third-tier code”的缩写,用来吐槽那些能跑但不敢动的祖传代码。既然输入里没有给出明确的正文和关键词,我就按最常见的理解来展开——把“t3code”当作一个轻量级、约定优于配置的编码实践集合来聊,它解决的是小团队在快速迭代中“代码越写越乱、新人上手越来越慢”的问题。
这个定位听起来有点虚,但落到实际项目里非常具体。你可以把它想象成一套“最小可行规范”:不追求大而全的架构文档,也不强制你上重型框架,而是用几条硬性约定把代码的可读性和可维护性兜住。适合谁来参考?我认为是三类人:一是三五个人小团队的 tech lead,需要一套不增加太多管理成本的规则;二是刚接手一个中型项目、发现代码风格五花八门的开发者;三是自己写 side project 想保持长期可维护的独立开发者。这三类人的共同点是:没有专职的架构师,也没有精力搞复杂的流程,但又不想让代码变成一锅粥。
我之所以愿意花时间拆解这个词,是因为它背后反映了一个真实痛点。大多数团队在项目初期都信奉“先跑起来再说”,等到代码量上来之后才发现,改一个字段要翻五个文件,加一个接口要复制三处相似逻辑。这时候再想引入完整的分层架构,成本已经高到没人愿意动。t3code 这类轻量约定的价值就在于:它可以在项目任何阶段以增量方式引入,不需要推倒重来。接下来的内容,我会从核心约定、落地步骤、常见坑、工具配合几个角度,把这件事讲透。
2. t3code 的三条核心约定与背后的取舍逻辑
2.1 命名即文档:为什么强制前缀比注释更有效
t3code 里最容易被记住的一条约定是强制命名前缀。具体来说,函数名、变量名、文件名都要带一个能表明其“层级”或“职责”的前缀。比如数据访问层的函数统一用db_开头,业务逻辑层用svc_,工具函数用util_,对外接口用api_。这个做法乍看很土,甚至有点反直觉——现代编程语言不是都强调命名要自然吗?但我在实际项目里试过之后发现,它的收益远超预期。
原因在于,注释是会过期的,而命名不会。你写// 这个函数从数据库取用户这样的注释,三个月后逻辑改了,注释大概率没人更新。但如果你把函数命名为db_get_user_by_id,任何人看到调用处都能立刻判断出这是一次数据库操作,改逻辑的时候也会下意识去检查事务和连接管理。这就是“命名即文档”的核心:把隐式信息变成显式约束。前缀还有一个好处是搜索友好。当你想找所有数据库操作时,直接搜db_就能列全,不用靠记忆去猜函数名。对于接手项目的新人来说,这比读十页架构文档都管用。
当然,这条约定也有代价。前缀会让名字变长,写起来多敲几个字符。我的经验是,在编辑器里配置好代码片段(snippet),输入dbf自动展开成db_find_,实际输入成本几乎可以忽略。另一个代价是“前缀膨胀”——如果团队不加控制,可能会出现db_get_user_by_id_from_master这种又长又具体的名字。我的建议是前缀只表达“层”,不表达“具体实现细节”,具体细节交给函数名后半部分。
2.2 单一出口原则:每个模块只暴露一个入口文件
第二条约定是每个模块只允许有一个对外入口文件,通常命名为index或模块名本身。模块内部可以拆成任意多个文件,但外部代码只能从这个入口文件导入。这条规则解决的是“依赖关系失控”的问题。在没有这条约定的项目里,经常出现 A 模块直接引用了 B 模块内部的某个工具函数,后来 B 模块重构把这个函数挪走了,A 模块就崩了。这种隐式耦合是维护成本的最大来源之一。
t3code 的做法是:模块内部的文件可以互相引用,但跨模块引用必须经过入口文件。入口文件里显式列出该模块对外提供的所有能力,相当于一份“契约”。这样做的好处是,重构模块内部实现时,只要入口文件的导出不变,外部代码就不受影响。我在一个中型项目里推行这条规则后,模块重构的平均耗时从两天降到了半天,因为需要检查的调用点从几十个变成了一个。
实现这条约定的关键是工具约束。光靠自觉很难坚持,因为直接引用内部文件往往更“方便”。我的做法是在代码检查工具里加一条规则:禁止从其他模块的非入口文件导入。以 JavaScript 生态为例,可以用 ESLint 的no-restricted-imports配合路径模式来限制。Python 项目可以用import-linter定义模块边界。工具会报错,人才会记住。
2.3 错误不跨层:每层只处理自己关心的异常
第三条约定是错误处理不跨层。意思是:数据访问层抛出的数据库异常,不应该直接透传到接口层;接口层返回给前端的错误信息,也不应该包含数据库细节。每一层只处理自己关心的异常类型,把其他异常包装成该层的标准错误再往上抛。这条规则听起来像老生常谈,但真正落地时,很多团队都卡在“包装成什么”这个问题上。
t3code 给出的方案很具体:每层定义一个基础错误类型,比如数据层用DbError,业务层用SvcError,接口层用ApiError。底层异常在跨越层边界时,必须被转换成上一层的错误类型,并保留原始错误作为cause字段。这样做的价值在于排查问题时信息不丢失,但对外暴露时不泄露内部细节。我见过太多项目把数据库连接字符串直接拼进错误信息返回给前端,这既是安全隐患,也让前端无法区分“用户输入错误”和“系统故障”。
这条约定的实操难点在于“转换时机”。我的经验是:在每层的入口函数处做统一转换,而不是在每个可能出错的地方写 try-catch。比如数据层的每个公开函数都用装饰器或高阶函数包一层,捕获原生异常并转成DbError。这样业务层拿到的永远是DbError,不需要关心底层是 MySQL 还是 PostgreSQL。换数据库时,业务层代码一行不用改。
3. 把 t3code 落到真实项目里的完整步骤
3.1 第一步:先画模块边界,再写第一行代码
很多团队推行编码规范失败,是因为从“写代码时注意”开始,而不是从“定边界”开始。t3code 的落地顺序恰好相反:先确定模块划分,再在模块内应用命名和导出约定。具体怎么做?拿一个典型的后端项目举例,我会先列出所有业务能力,比如“用户管理”“订单处理”“支付对接”“消息通知”,每个能力对应一个模块。然后确定模块之间的依赖方向:订单模块可以依赖用户模块,但用户模块不能反向依赖订单模块。这个依赖方向一旦定下来,就写进架构决策记录里,后续所有代码都遵守。
为什么这一步不能省?因为命名前缀和单一出口都是“模块内”的约定,如果模块边界本身是模糊的,前缀就失去了参照系。我见过一个项目,把“用户认证”和“用户资料”混在一个模块里,结果db_前缀的函数既查认证表又查资料表,单一出口文件导出了三十多个函数,完全失去了约束意义。重新划分模块后,认证模块只导出verify_token和create_session两个函数,资料模块只导出get_profile和update_profile,代码立刻清爽了。
画边界时有一个实用技巧:用“变化频率”作为划分依据。变化频率相近的功能放在同一个模块,变化频率差异大的分开。比如“支付渠道对接”经常变,“订单状态机”很少变,那就分成两个模块。这样改支付渠道时不会影响订单逻辑,测试范围也小。这个思路来自我早期做电商项目的教训:当时把支付和订单混在一起,每次新增支付方式都要回归测试整个订单流程,苦不堪言。
3.2 第二步:用脚手架固化目录结构,减少口头约定
边界定好之后,下一步是用脚手架工具生成标准目录结构。t3code 不依赖某个特定框架,但目录结构有推荐模板。以 Node.js 项目为例,一个模块的标准结构是这样的:
src/ modules/ user/ index.js # 唯一出口 user.service.js # 业务逻辑,函数以 svc_ 开头 user.repo.js # 数据访问,函数以 db_ 开头 user.errors.js # 该模块的错误类型定义 user.utils.js # 模块内工具,函数以 util_ 开头这个结构的关键在于每个文件的职责从文件名就能看出来。service只放业务逻辑,不直接操作数据库;repo只放数据访问,不包含业务判断;errors集中定义错误类型,不散落在各处。新人拿到这个结构,不需要问“这个函数该放哪”,看文件名就知道。
为了让这个结构真正落地,我会写一个简单的脚手架脚本。用plop或者自己写个 Node 脚本都行,输入模块名,自动生成上述文件模板,并且预置好导入导出语句。比如index.js默认导出service和errors,service.js默认导入repo。这样开发者新建模块时,结构天然就是对的,不需要靠记忆去遵守约定。我试过在三个项目里用这种方式,新人上手第一天就能提交符合规范的代码,code review 里关于“文件放错位置”的评论减少了八成。
3.3 第三步:配置检查工具,让违规代码无法提交
约定和脚手架都有了,但人总会犯错。最后一道防线是自动化检查。t3code 的检查规则不复杂,核心就三条:命名前缀、导入路径、错误类型。我用 ESLint 举例,配置大概长这样:
// .eslintrc.js module.exports = { rules: { 'no-restricted-imports': ['error', { patterns: [ // 禁止跨模块引用非入口文件 '@/modules/*/!(index)', ], }], 'no-restricted-syntax': ['error', { selector: 'CallExpression[callee.name=/^db_/]', message: 'db_ 函数只能在 repo 文件中调用', }], }, };这段配置的意思是:第一,禁止从其他模块的非index文件导入;第二,db_开头的函数只能在repo文件里调用,不能在service里直接调。第二条规则特别有用,它强制了“业务逻辑不直接碰数据库”的约定。我见过太多项目在 service 里直接写 SQL,导致事务边界混乱、测试困难。有了这条检查,想偷懒都偷不了。
检查工具配置好之后,一定要接入提交前钩子(pre-commit hook)。用husky加lint-staged,每次 commit 自动检查改动的文件。如果违规,直接拒绝提交并给出提示。这一步的体验很重要:提示信息要写清楚“为什么违规”和“怎么改”,而不是只报一个错误码。比如“检测到在 service 文件中调用了 db_ 函数,请将数据访问逻辑移到 repo 文件,或通过 service 调用 repo 的公开方法”。好的提示能让开发者自己纠正,而不是跑来问你。
4. 推行过程中最容易踩的四个坑
4.1 坑一:前缀命名过度细化,导致名字失控
第一个坑我在前面提过,但值得单独展开。t3code 要求命名带前缀,但很多人会不自觉地“加码”:db_get_user_by_id_and_status、svc_calculate_order_total_with_discount。名字越来越长,可读性反而下降。问题的根源是把“实现细节”塞进了前缀后面的部分。我的纠正方法是:前缀只表达层级,函数名主体只表达“做什么”,不表达“怎么做”。db_get_user就够了,具体按 id 还是按 email 查,通过参数区分。如果确实需要区分,用参数而不是函数名。
另一个相关问题是前缀不统一。有人用db_,有人用database_,有人用dao_。这在多人协作时会造成混乱。解决办法是在项目初期就把前缀清单写进 README,并且用检查工具强制。清单不用长,五六个前缀足够覆盖大多数场景。我通常建议的前缀集是:db_(数据访问)、svc_(业务逻辑)、api_(对外接口)、util_(工具)、cfg_(配置)。超过这个数量,说明模块划分可能有问题。
4.2 坑二:单一出口变成“大杂烩出口”
单一出口原则的初衷是控制依赖,但如果入口文件不加节制地导出所有东西,就变成了“什么都能从这拿”,约束名存实亡。我见过一个模块的index.js导出了四十多个函数,外部代码想用什么都能找到,结果模块内部稍微改一下实现,外部就崩一片。这违背了“入口即契约”的初衷。
正确的做法是入口文件只导出“该模块对外承诺的能力”,内部工具函数一律不导出。判断标准很简单:如果一个函数只有模块内部用,就不该出现在入口文件里。我在 code review 时会专门检查入口文件的导出列表,问一句“这个函数外部真的需要吗”。十次有八次,答案是“其实不需要,只是顺手导出了”。删掉这些多余导出后,模块的对外接口从四十个降到八个,重构时的心理负担小了很多。
4.3 坑三:错误包装丢失原始堆栈
错误不跨层的约定在执行时,最容易犯的错误是包装时把原始错误丢掉了。比如数据层捕获了数据库异常,转成DbError时只写了“数据库操作失败”,没有把原始异常作为cause传进去。这样上层排查问题时,只能看到“数据库操作失败”,不知道是连接超时还是唯一键冲突。这个问题在线上排查时特别致命,因为日志里没有足够信息定位根因。
我的做法是强制要求包装错误时必须保留 cause。在 JavaScript 里可以用new DbError('...', { cause: originalError }),Python 里可以用raise DbError(...) from original_error。然后在日志输出时,把 cause 链完整打印出来。这样对外返回的错误信息是干净的,但日志里有完整上下文。我还会在错误类型定义里加一个toLog()方法,专门用于日志输出,和toResponse()方法分开,避免把内部细节泄露给调用方。
4.4 坑四:检查工具太严,导致开发者绕过
最后一个坑是检查规则过于严格,开发者为了赶进度直接禁用规则。比如禁止跨模块导入的规则,如果配置成“任何非入口文件导入都报错”,那开发者在需要引用一个类型定义时也会被拦住,最后只能在文件顶部加一行// eslint-disable-next-line。规则被禁用的次数多了,就形同虚设。
平衡的方法是给规则留合理的例外通道。比如类型定义文件、常量文件可以跨模块直接引用,因为它们不包含逻辑,不会造成耦合。在 ESLint 配置里用allowImportNames或者路径白名单来放行。另一个技巧是把规则分成“错误”和“警告”两级:命名前缀用错误级别,导入路径先用警告级别跑两周,收集反馈后再升级。我在一个团队推行时,第一周只开警告,第二周统计发现违规集中在两个模块,针对性做了重构后才升级为错误,整个过程没有引起抵触。
5. 和现有工具链的配合方式
5.1 与 TypeScript 的结合:用类型系统强化约定
t3code 的约定是语言无关的,但如果项目用 TypeScript,可以用类型系统把约定“编译期化”。比如模块入口文件导出的函数,可以用export type UserModule = { getUser: ...; createUser: ... }定义成一个接口,外部代码只能通过这个接口类型来引用。这样即使有人绕过了 ESLint 检查,TypeScript 编译器也会报错。类型系统的好处是反馈更快——不用等提交时才发现问题,写代码时编辑器就标红了。
另一个结合点是用 branded type 区分层级。比如type DbError = Error & { __brand: 'db' },type SvcError = Error & { __brand: 'svc' }。这样在业务层捕获错误时,TypeScript 能帮你确认拿到的是DbError还是SvcError,避免错误类型混用。这个技巧我在一个金融项目里用过,效果很好,因为金融业务对错误分类要求极高,不同类型错误对应不同的用户提示和重试策略。
5.2 与测试框架的配合:按模块边界组织测试
t3code 的模块边界天然适合按模块组织测试。每个模块的测试文件放在模块目录下的__tests__文件夹里,测试入口文件的导出函数,不测试内部实现。这样做的好处是测试和实现解耦:内部重构时,只要入口契约不变,测试就不用改。我见过太多项目因为测试直接引用了内部函数,导致重构时测试大面积失败,最后没人敢重构。
具体做法是:index.test.js只导入index.js的导出,用 mock 替换掉repo层的实现。这样测试的是业务逻辑,不依赖真实数据库。数据层的测试单独放在repo.test.js里,用内存数据库或测试容器。两层测试分开跑,业务逻辑测试可以毫秒级完成,数据层测试才需要启动数据库。这种分层测试策略让 CI 时间从十分钟降到了两分钟,因为大部分改动只影响业务逻辑,不需要跑数据层测试。
5.3 与代码生成工具的边界:生成代码也要遵守约定
如果项目里用了代码生成工具(比如从数据库 schema 生成模型代码,或从 API 定义生成客户端代码),需要特别注意生成代码也要遵守 t3code 约定。我见过一个项目,手写代码都遵守命名前缀,但生成的模型代码是UserModel、OrderModel,没有前缀,结果在 service 里引用时,分不清哪些是生成的、哪些是手写的。
解决办法是在代码生成模板里加入前缀和目录约定。比如生成的模型代码统一放在generated/目录下,函数名加gen_前缀,并且不直接暴露给业务层,而是通过 repo 层包装后再使用。这样业务层永远不直接依赖生成代码,生成工具升级或更换时,只需要改 repo 层的包装代码。这个隔离层看起来多了一层,但换来的是生成工具的可替换性,长期来看非常值得。
6. 一个真实项目的改造记录与效果对比
6.1 改造前的状态:能跑但不敢动
去年我接手了一个内部管理系统,代码量大概三万行,三个人写了两年。改造前的状态很典型:目录结构是controllers/、services/、models/按技术分层,但每个目录下的文件命名毫无规律。有的用驼峰,有的用下划线,有的用中划线。数据库查询散落在 controller 和 service 里,事务边界靠开发者自觉。最要命的是,没有任何测试,改一个字段要手动点遍所有相关页面。
我做的第一件事是统计“改一个功能平均要动几个文件”。随机选了五个需求,结果是平均 4.2 个文件,最多的一个改了 7 个文件。这个数字说明耦合度很高,因为一个功能的变化应该尽量局限在一个模块内。另一个指标是“新人上手时间”,当时新来的同事花了两周才敢提交第一个 PR,而且第一版代码被 review 出十几个问题,大部分是“这个函数该放哪”和“命名不符合现有风格”。
6.2 改造过程:增量推进,不搞大爆炸
我没有选择重写,而是按模块增量改造。第一步是画出模块边界,把系统拆成“用户”“权限”“报表”“通知”四个模块。然后从“通知”模块开始改造,因为它依赖最少、影响面最小。改造内容包括:把散落的通知逻辑集中到notification模块,加index.js作为唯一出口,函数加svc_和db_前缀,错误统一包装。整个过程用了三天,改完后通知模块的代码从 1200 行降到 800 行,因为去掉了很多重复逻辑。
第二步是加检查工具。我先只开了命名前缀的检查,跑了一周,收集违规最多的文件,集中修复。然后开导入路径检查,又跑一周。最后开错误类型检查。每一步都留出适应期,避免一次性引入太多规则导致抵触。这个节奏很重要:规则是手段,不是目的。目的是让代码更好维护,如果规则太严导致大家不敢写代码,就本末倒置了。
6.3 改造后的数据:可量化的收益
改造完成三个月后,我重新统计了那几个指标。“改一个功能平均动几个文件”从 4.2 降到了 1.8,因为大部分改动局限在单个模块内。“新人上手时间”从两周降到了三天,因为目录结构和命名规则让新人能自己找到该改哪里。代码 review 的评论数量下降了六成,剩下的评论更多是关于业务逻辑的讨论,而不是“这个函数该放哪”。
还有一个意外收益是测试覆盖率上来了。因为模块边界清晰,写单元测试变得容易,大家愿意写。改造前测试覆盖率不到 10%,改造后到了 45%。虽然不算高,但关键模块的核心逻辑都有了测试,重构时心里有底了。这个收益不在预期内,但恰恰说明了一个道理:好的结构会自然引导出好的实践,不需要靠行政命令强推。
7. 关于 t3code 的几个常见疑问
7.1 小项目也需要这么麻烦吗
经常有人问:我就写个几百行的小工具,也要搞模块和前缀吗?我的回答是:看这个工具的生命周期。如果是一次性脚本,跑完就扔,那完全不需要。但如果是会长期维护、会加功能的小工具,哪怕只有几百行,也值得花十分钟把结构理一下。我自己的习惯是:任何预计会活过一周的代码,都按 t3code 的最小集来组织——至少分index和impl两个文件,函数加前缀。这个投入很小,但后续加功能时省下的时间远超投入。
另一个相关问题是“团队只有两个人,需要约定吗”。我的经验是:两个人比十个人更需要约定。因为人少的时候沟通成本看似低,但恰恰因为“反正就我们俩,口头说就行”,导致约定只存在于脑子里,新人进来或者其中一人休假时就抓瞎。把约定写下来、用工具检查,对两人团队来说反而是效率提升。
7.2 和“整洁架构”“领域驱动设计”是什么关系
t3code 和这些方法论不冲突,但层次不同。整洁架构和 DDD 是架构层面的指导,告诉你系统应该怎么分层、领域模型怎么建。t3code 是编码层面的约定,告诉你代码写出来长什么样。你可以用整洁架构设计系统,然后用 t3code 的命名和导出规则来写具体代码。两者是互补的。
反过来说,如果你已经用了 DDD,那 t3code 的模块边界可以直接对应到限界上下文,前缀可以对应到领域层、应用层、基础设施层。我见过一个 DDD 项目,领域层函数用domain_前缀,应用层用app_,基础设施层用infra_,效果很好。所以 t3code 不是替代品,而是一个轻量的落地抓手。
7.3 如果团队抵触怎么办
推行任何规范都会遇到抵触,这很正常。我的策略是先做试点,用数据说话。选一个大家公认“最乱”的模块,按 t3code 改造,然后对比改造前后的维护成本。数据摆出来之后,抵触会小很多。另一个技巧是让抵触者参与规则制定。比如有人觉得db_前缀不好看,想用repo_,那就让他说出理由,如果合理就采纳。规则是大家一起定的,执行意愿会高很多。
最忌讳的做法是“从上往下强推”。我见过一个 tech lead 直接发邮件说“从下周起所有代码必须加前缀”,结果一周后代码里全是// eslint-disable。规范要落地,必须让开发者感受到“这样做对我有好处”,而不是“又多了一个限制”。这个转变需要时间,但一旦跨过去,后面就顺了。
8. 我个人的几条实操心得
第一条心得是从最小的规则开始。不要一上来就搞全套,先选一条最容易执行的,比如命名前缀,跑两周。等大家习惯了,再加第二条。我试过一次性推五条规则,结果一周后全部被禁用。后来改成一次一条,反而都落地了。
第二条心得是检查工具的提示信息要写人话。不要只报“no-restricted-imports”,要写“检测到从 user 模块的非入口文件导入,请改为从 user/index.js 导入,或将该函数加入入口文件导出”。好的提示能减少一半的沟通成本。
第三条心得是定期回顾规则本身。每季度花半小时看看哪些规则经常被禁用,如果某条规则禁用率超过 20%,说明它可能不合理,需要调整。规则是为人服务的,不是人为规则服务。
第四条心得是把约定写进新人 onboarding 文档。新人第一天就告诉他“我们这样写代码”,比等他写了三个月再纠正要容易得多。文档不用长,一页纸说清楚前缀清单、目录结构、检查工具怎么跑,就够了。
最后一条心得是自己先做到。如果你要求别人加前缀,自己的代码却随手写,那规则就失去了威信。我在项目里会刻意让自己的 PR 成为范本,新人看几个 PR 就知道该怎么写了。这比任何文档都有效。