“t3code”这个代号,是我们在一个中型全栈项目里沉淀下来的一套编码公约。起因很朴素:前后端各写各的,接口文档靠口口相传,类型定义靠复制粘贴,直到某次后端把字段名从userName改成了nickname,前端编译直接崩了一片,线上事故从下午两点排查到晚上九点。那次之后,我们几个核心开发坐下来,认认真真地把“代码里的规矩”当成一个项目来做,这个项目就是 t3code。
它不是什么重量级框架,也没有引入任何新语言。t3code 更像是一套“三层契约”的落地规范:第一层管类型,第二层管接口传输,第三层管领域模型。配合少量代码生成器和几条强制检查的流水线脚本,让团队在写代码的时候,天然地避开那类“字段改名引发全线崩溃”的问题。这篇文章不吹概念,只讲我们是怎么设计、怎么落地、怎么踩坑的,如果你也在为多端协作、类型不同步、接口文档形同虚设而头疼,里面的方案和建议可以直接抄作业。
1. 内容整体设计与思路拆解
1.1 先搞清楚:t3code 解决的是“沟通问题”,不是“代码问题”
很多团队一谈规范化,第一反应是上微服务、上网关、上各种治理平台。但经历过事故复盘之后,我们意识到,真正出问题的不是代码写得差,而是代码与代码之间的契约没有显性化。后端接口改了一个字段,前端完全不知道;前端联调时按旧文档传参,后端新逻辑校验不通过;测试环境没问题,上线前突然类型不匹配……
这些问题本质上都是沟通问题,只是发生在“代码”这个载体上。t3code 的思路,是把前后端之间的隐形默契,变成一份显式的、机器可校验的契约,并且让这份契约成为代码生成、检查、构建流程里的一环。谁改了约定,谁就要承担连锁反应的可视化后果,而不是等到线上炸了才被发现。
1.2 为什么叫“t3code”:三个 T 分别代表什么
代号 T3,源自我们最初整理的三个困扰级别的缩写,后来恰好和项目里三条主线对应上,就这么沿用了。
| 层级 | 核心问题 | t3code 里的对应设计 |
|---|---|---|
| T1 类型层 | 前端拿到的是any,编译期完全失去保护 | 统一类型定义源,前后端共享同一份类型 |
| T2 传输层 | 接口入参出参经常和文档对不上 | 通过 OpenAPI Schema 生成接口契约 |
| T3 领域层 | 业务概念在代码里没有统一映射,各写各的 | 定义领域词汇表,约束命名与模型边界 |
三层互相咬合:领域层定义了业务的稳定性,传输层定义了边界的稳定性,类型层定义了代码的稳定性。每一层都解决一个具体的痛点,而不是为了规范而规范。
1.3 方案选型背后的取舍:不用微服务,不搞大平台
一开始也有人提议引入企业级的 API 管理平台,统一注册、统一鉴权、统一限流。犹豫了几天,还是放弃了。原因是:我们项目规模不大,核心开发十个人左右,引入一套平台意味着学习成本、部署成本、运维成本同步上涨,而且平台只能治理“接口注册”这件事,管不到前端类型是否同步、领域模型是否越界。
t3code 最后定下来的原则就三条:轻量、渐进、可见。
- 轻量:核心只是一份约定文档加几个脚本,不引入新框架;
- 渐进:允许老模块逐步迁移,不强求一次性改造完成;
- 可见:所有契约变更都进 diff,变成代码评审里的必备项,而不是靠自觉。
这个取舍在后来被证明是对的。平台型的方案往往需要“一把手推动”,而 t3code 这种轻量公约,只要两三个核心开发带头做,其他人跟着 lint、跟着 review 就够了。
2. 核心细节解析与实操要点
2.1 T1 类型层:让编译期替我们抓 bug
T1 层要解决的痛点最直接:前端从接口拿到的数据到底长什么样?如果没有类型定义,写代码的时候全靠猜,猜错了运行时才报错,传到页面上就是白屏或者 undefined。
我们最初的方案是后端手写 TypeScript 类型然后前端复制一份。这个方案的缺点所有人都知道,但很少有人认真思考过痛点到底在哪。你复制的是“当时”的类型,而后端改动后,复制件不会自动更新,于是前端仓库里的类型定义很快变成一具僵尸——文件名还在,内容早就和真实接口脱节。
t3code 的做法是消灭“复制”这个动作。类型定义只保留一份源头,其他所有使用方都通过生成器产出的文件来引用。成果是:编译期就能发现字段缺失、类型不匹配、枚举值溢出,而不是等运行时。
实操上有几个关键细节,值得展开说:
- 源头文件必须独立成一个包或者一个模块,不能散落在后端接口文件里。我们建了一个
contracts目录,里面按业务域划分,比如user.ts、order.ts,后端逻辑只负责实现,不负责定义协议类型。 - 前端引用生成文件时,要走相对固定的路径,比如
@core/contracts,而不是相对路径到处../飞。这样未来如果要把契约抽成独立 npm 包,改动范围可控。 - 枚举类型建议统一用 const 枚举并且显式标注字符串值,不要依赖数字隐式递增——否则前后端只要有一边加了枚举项,数字就会错位,而且这种错位在编译期完全看不出来。
2.2 T2 传输层:接口文档不再滞后
传输层解决的是接口文档滞后的问题。我们团队之前维护 OpenAPI 文档全靠手写,写着写着就和代码分叉。后来换成 t3code 推荐的模式:以代码为唯一事实源,文档是导出产物。
说白了,后端不要手写swagger.yaml,而是在路由层直接用装饰器或者注解把 schema 定义清楚,再用工具把 schema 导成 OpenAPI 文件,前端根据这个 OpenAPI 文件自动生成请求函数和类型定义。
这个链路最核心的收益是:接口文档不是给人看的,是给机器看的。人看文档总会漏、会滞后、会脑补,机器生成文档则只要源头代码没变,文档就永远不会失同步。
具体到落地,我们重点控制了几点:
- 所有接口必须有
operationId,而且命名规范和业务域的编号一致,比如UserController.getProfile。没有operationId的接口,生成器直接报错。 - 响应体不直接返回裸数组或者
string,要求包一层对象,哪怕只有一个字段也要包。这样后续扩展字段不会破坏已有契约。刚开始大家对这条特别抵触,觉得多此一举,直到后来有个接口从返回单对象升级成返回列表,没包一层的那几个接口前端改得想骂人,而包了层的接口只是加了一个字段,前后端各花十分钟就完成了。 - 分页参数统一约定为
page/pageSize,不用offset/limit、current/size混着来。传输层的统一约定,否定的不是某个风格,而是风格之间的随机切换。
2.3 T3 领域层:业务概念的代码映射
T1 管类型,T2 管接口,T3 管的是更上面一层:业务概念。这一层最容易忽略,但恰恰是它决定了前两层能不能稳定。
什么是领域层的约束?举例来说,我们在项目里有一个概念叫“订单”。它在后端数据库里是order,在接口协议里是OrderDTO,在状态流转里是order_status。如果代码里不同模块对同一概念用了不同的命名,比如有的写order、有的写purchase、有的写transaction,那 T1 和 T2 做的统一全部白费——因为不同模块之间的沟通成本又回到了单词混乱的状态。
t3code 的 T3 层定义了一个领域词汇表,本质上是一个 Markdown 文件加一个校验脚本。词汇表里列出:
- 业务名词的标准写法(中英文对照、代码变量名);
- 每个名词的归属模块,禁止跨模块随意引用;
- 状态枚举的定义域,比如
订单状态只在订单模块里定义,其他模块靠调用接口读取,而不是各自本地再写一份枚举。
校验脚本做的事情很简单,扫描代码里的关键词,把不在词汇表里的业务实体名词挑出来,提交 MR 的时候提示开发者确认。这个脚本不阻断构建,但会在 CI 的检查项里亮黄灯,问一句:“你用的这个词,词汇表里没有定义,请确认是否要新增并评审?”
这套机制看着朴素,实际效果出奇好。它让团队形成了一种习惯:涉及业务概念的时候,先看一眼词汇表,再动手写代码。等于是把 T3 层从“文档建议”变成了“检查项驱动”。
3. 实操过程与核心环节实现
3.1 第一步:盘点存量代码,生成契约基线
落地 t3code 不能直接上生成器,否则存量代码的报错会把你淹没,团队心态直接崩掉。我们花了约一周时间做盘点,产出一份「现状契约清单」,这一步是整个项目里最枯燥但最重要的工作。
做法分三步走:
- 把后端所有路由列出来,标注出涉及哪些业务域,统计接口总数、参数总数、响应体类型总数。
- 把前端所有手动写的接口请求文件列出来,逐个核对是否和后端真实返回匹配,对得上的打勾,对不上的记录到待办清单。
- 把两边无法对齐的条目按严重程度分级:会导致运行时报错的为 P0,会导致类型不安全的为 P1,纯命名不统一但不影响功能的为 P2。
我们当时统计结果是:接口 137 个,前端手写类型文件 89 个,真正完全对得上的不到 30%。这个数字比想象中还惨,但也因此坚定了团队把 t3code 做下去的决心——三分之一的对齐率意味着三分之二的功能在白盒之外跑着,能不出大事故已经是运气好。
基线产出的概念是“先冻结现状”。从那天开始,新增接口和修改现有接口必须走 t3code 的契约流程;存量部分按优先级逐批迁移,每迁移一批,基线清单上就划掉一批。
3.2 第二步:接入类型生成器,让前后端共享类型
选型的时候我们对比了几个方案,最后敲定的是以openapi-typescript为核心的链路。理由很直接:它和 OpenAPI 标准深度绑定,生态最稳,社区的坑也基本都填完了。
核心配置长这样,这是从我们项目里摘出来的关键部分:
// 生成脚本:scripts/generate-contracts.ts import { execSync } from 'node:child_process'; // 1. 从后端路由元数据导出 OpenAPI 原始文件 execSync('npm run export:openapi --prefix ../server'); // 2. 用 openapi-typescript 生成前端类型文件 execSync('npx openapi-typescript ../server/dist/openapi.json -o ./src/core/contracts/index.ts'); // 3. 生成接口请求函数,避免手写请求层 execSync('npx openapi-generator-cli generate -i ../server/dist/openapi.json -g typescript-fetch -o ./src/core/api'); console.log('contracts generated at', new Date().toISOString());有一段时间这段脚本里藏着三个非常隐蔽的坑,值得单独拿出来说:
- 第一步导出的 OpenAPI 文件里,后端如果写的是
number类型而不是format: int64,生成出来的前端类型就变成number,但如果后端真实返回的是字符串大整数,前端拿到后只能在运行时发现问题。我们后来给后端立了一条规矩:ID 字段一律显式标注format: int64,否则生成器直接报错。 - 第二步生成的
index.ts文件体积很快会膨胀到几百 KB,因为 OpenAPI 里所有 schema 都会被展开。我们后来在配置里开了transform选项,按业务域拆成多个文件,减少单个文件的编译压力,配合 import type 让编译体积进一步降下来。 - 第三步生成的请求层默认带上异常包装,但和后端实际返回结构不一致,我们花了很多时间重写模板。如果你们也要用
openapi-generator-cli,建议直接从仓库里 fork 模板改,不要在生成之后做字符串替换——生成后的代码会不断重新覆盖,手动改的那点补丁一次生成就没了。
3.3 第三步:用约束检查把规则固化成流水线
t3code 真正生效,靠的不是文档写得有多好,而是检查规则固化到 CI 里。我们一共加了四条检查,全部在 Merge Request 阶段执行:
- 契约 diff 检查:如果这次改动涉及后端接口代码,必须同时看到 OpenAPI 导出文件的变更,否则直接阻断。
- 类型文件一致性检查:跑一次生成脚本,把生成的
index.ts放到临时目录,和当前代码目录里的文件做 diff,不一致说明手改过生成文件,阻断。 - 领域词汇表检查:扫描新增代码里出现的业务实体关键词,发现不在词汇表里的名词给黄牌提醒,不阻断但要由评审人确认新增词汇是否合理。
- 禁止 any 检查:涉及接口数据的显式类型标注里不允许
any,如果出现,说明契约没接上,不予合并。
第四条是被团队吐槽最多的。有同事说“我临时调试一下,类型写成 any 怎么了”,我们的答复是:“临时调试写 any,改完不回头,这行代码就会活一年。与其赌大家的自觉性,不如做一条机械检查。”实际跑下来,这一条确实拦下来了十几个本来会漏到线上的安全隐患。
这里有个经验:CI 检查要做成“先容错、后收紧”的两阶段。刚上线第一周,任何阻断都会引起团队反弹,我们设置了白名单和快速解除机制;等项目跑了两个月,大家适应了,再逐步收掉白名单。如果一开始就满阈值全开,基本会在前三天被骂到取消。
3.4 完整示例:一个用户中心的 t3code 落地清单
为了让你对 t3code 的全貌有个直观印象,这里放一个用户中心小型模块的落地清单,不是假想的完美项目,就是当时我们真实跑的流程。
| 步骤 | 动作 | 产出 | 耗时估算 |
|---|---|---|---|
| 1 | 盘点用户模块现有接口与前端请求层 | 现状差距清单,标记 P0/P1/P2 | 1 天 |
| 2 | 在 contracts 目录新增user.ts,定义 UserProfile、UpdateUserInput 等核心类型 | 类型源文件 | 0.5 天 |
| 3 | 后端路由层接入文档生成器,导出 OpenAPI | openapi.json 基线文件 | 0.5 天 |
| 4 | 前端跑生成脚本,替换原有手写类型 | 全新的contracts/index.ts | 0.5 小时(脚本跑完) |
| 5 | 修改请求层调用方,让页面组件使用新类型 | 相关组件 diff | 2 天 |
| 6 | 提交 MR,过全部四条 CI 检查 | 可合并的 MR | 1 小时(含等待检查) |
看起来简单,实际上卡点都在第 2 步。定义 UserProfile 的时候,团队对“用户昵称到底能不能为空”“头像字段存的是 URL 还是相对路径”产生了分歧。这些分歧以前被一句“后端返回啥前端接啥”糊弄过去了,现在因为要写正式契约,逼着双方坐到一起把问题摊开。这个过程痛苦,但每个疑问被解决之后,代码的确定性就多了一分。
4. 常见问题与排查技巧实录
4.1 问题一:类型倒是生成了,前端却构建不动了
我们接入 t3code 后遇到的第一个严重问题,是前端项目构建时间从 40 秒涨到了 3 分钟。排查发现,生成的那个类型文件展开后体积巨大,并且每次构建都会触发 TypeScript 对这个大文件做全量检查。
排查思路如下:
- 先用
tsc --extendedDiagnostics确认耗时点,发现签名计算时间和文件大小成正比。 - 接着看文件里的类型,发现大部分 schema 是嵌套展开,生成了大量重复的中间类型。
- 解决方法是同时开三个开关:使用
import type代替import引入类型;在tsconfig.json中开启skipLibCheck;在生成器配置里将输出拆分为业务域子文件,而不是单个聚合文件。
改完之后构建时间降到 55 秒,其中类型检查的增量时间基本可以忽略。所以如果你遇到生成后构建变慢,不用慌,优先检查类型文件拆粒度和skipLibCheck配置,十有八九能解决。
4.2 问题二:契约文件越来越大,PR 越来越难审
生成器带来的另一个副作用是:每次后端加一个接口,生成的契约文件就会有大片 diff。前端同事做 code review 的时候,眼前全是自动生成代码的变更,真正需要人类关注的业务逻辑反而被淹没。
后来我们定了一个规矩:生成文件不进人工评审范围,用脚本代替人眼。具体做法是加了一个verify-contract脚本,自动对比生成结果和基线版本,输出一条统计摘要:“本次契约变更新增 5 个字段,修改 2 个字段,删除 0 个字段”。评审人只需要看这条摘要,然后在业务代码的 diff 上花精力。
再后来我们甚至把摘要直接贴到 MR 描述里,由 CI 自动生成。这样评审人一眼就知道这次接口变动的真实影响面,而不是在一堆自动生成的代码里去大海捞针。
4.3 问题三:老模块怎么迁移?先切接口还是先切类型
老模块接入 t3code,大家最纠结的是顺序。我们先尝试了“先切接口”,让后端全部接口先走契约流程,结果前端请求层还挂着旧类型,导致大量编译错误,前端同学怨声载道。后来又试了“先切类型”,前端把类型换成新生成的契约类型,但后端接口还没导出 OpenAPI,两边字段对不上,又乱了一圈。
最后趟出来的顺序是:后端先按新契约定义类型,前端先切到生成类型,再交给后端做接口实现适配。也就是“契约先行,接口跟进”。类型只是编译期的,改错了不炸线上,但接口一旦动了就可能线上事故。所以要让风险小的一步先走,风险大的后走,每个阶段保证另一端是稳定的。
具体到这个顺序,细节描述如下:
- 后端在
contracts目录定义新的UserProfile,同时保留旧的返回逻辑。 - 前端把请求层的返回类型改成新的
UserProfile,立刻开始编译,编译错误提示的字段缺失会告诉你要改哪些调用点。 - 等前端调用点都改完了,后端再更新接口实现,让真实返回和契约对齐。
- 最后一并跑生成脚本,更新基线,刷新文档。
这套顺序后来被写进了团队规范里,所有迁移都按“先契约后接口”执行,再也没有出现过两头堵车的情况。
4.4 排查思路与速查表
这段时间里我们把遇到的高频问题整理成了一张速查表,每次有人喊“ t3code 出问题了”,先对着表快速定位,省了很多扯皮的时间。
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| 生成类型和线上返回不符 | 后端导出的 OpenAPI 和实现不同步 | 核对后端路由层的 schema 标注,确认不是手写的文档;跑一次全量构建看是否刷新基线 |
| 前端编译失败,提示某字段不存在 | 后端契约里没定义这个字段,或前端调用点用了旧字段名 | 在契约目录搜字段名,确认归属;调用点改成契约字段名 |
| 契约 diff 里出现大量自动生成噪音 | 生成脚本版本不一致,或本地生成器和 CI 版本没对齐 | 统一生成脚本的依赖版本到 package-lock 固定,禁止本地升级 |
| 领域词汇检查一直黄灯 | 新业务概念没登记,或旧代码里的命名不统一 | 先补词汇表并说明归属模块,评审通过后黄灯解除 |
| 接口返回包了一层导致前端取值多了一层 | 后端调整了响应结构,但没同步契约 | 这种情况是我们的红线,必须让后端回滚或者所有调用方同步改,不能私下绕过 |
排查的逻辑很简单:任何异常先问“契约有没有变”,再问“生成器有没有跑”,最后才轮到“代码逻辑是不是写错了”。顺序反过来的话,很容易在错误的方向上浪费几个小时。
我个人在带团队走完一整个 t3code 周期之后的体会是:它真正的价值不在于那几条脚本和检查,而是逼着团队在写第一行业务代码之前,先讨论清楚“我们到底在表达什么业务概念”。行业里有个说法叫“共享内核”,t3code 用很笨的办法实现了类似的效果——不是靠一个平台统一所有,而是让每个概念都只有一种代码表达,让每次修改都要过一遍契约的照妖镜。
最后再分享一个小技巧:如果你们团队也想往这个方向走,建议先选一个最小业务闭环做试点,比如用户资料或者配置读取,不要一上来就覆盖全部核心链路。等这一个模块稳定运行两周,团队对“契约必须更新”这件事情形成肌肉记忆,再往其他模块铺开。我们自己就是靠这一个试点模块说服了所有人的——当你亲眼看到后端改字段后前端编译立刻报出同名错误,而不是等到上线后才被用户发现,那种确定性的爽感,比任何规范文档都有说服力。