上一篇讲了仓怎么组织,这篇讲仓的心脏:contracts/。这个报名工具有两个 Web 后台、一个小程序、一个 Rust 后端,接口的消费方四个,契约只有一份:44 个 proto 文件、44 个 service、361 个 rpc,后台的 TS、小程序的 TS、后端的 Rust 全部生成代码,没有一个接口类型是手写的。传输层跑的是 Connect 协议(HTTP/JSON),不过协议不是这篇的重点,契约的生成链才是。
contracts 目录管四样东西
1 | contracts/ |
数据库表结构也是契约:它和 proto 一样,一动就要多端跟着动,所以放进同一个目录、同一套纪律。这篇只讲 proto 这条线,DDL 的演进以后单独写。
proto 怎么切包
目录与 package 逐段对应,buf 的 PACKAGE_DIRECTORY_MATCH 检查强制:
1 | protos/myapp/discovery/v1/discovery.proto ↔ package myapp.discovery.v1 |
领域跟着领域模型走:activity、enrollment、order、conference 各一个包,平台运营面单列 ops。一个领域一个 v1 目录,跨包引用写全限定名。文件头长这样:
1 | edition = "2024"; |
edition 2024 是 Protobuf 的新一代形态(官方 overview),相比 proto3 最大的体感差异是字段默认显式 presence,「没传」和「传了零值」可区分。这条语义一路影响生成代码和测试,值得单独记一笔,写测试那篇再展开。
lint 用 buf 的 STANDARD 规则集,主动放松了两条:响应不必命名成 XxxResponse,请求响应体允许跨方法复用。都是为了让小消息不必为了过检查硬套壳。枚举零值统一 _UNSPECIFIED,buf 配置里一行 enum_zero_value_suffix 搞定。
一份 descriptor,三条生成链
1 | protos/*.proto |
关键取舍在 Rust 这条链:它不走 buf 插件。buf 只负责把 proto 编译成 descriptor-set.bin(一种序列化的 schema 描述),Rust 侧在 build.rs 里拿 descriptor 生成类型和 service trait。TS 两条链走 buf generate 插件。一份 schema,三种产物,入口一条命令:
1 | make -C contracts generate |
buf CLI 不全局安装,它是 packages/shared 的 devDependency,Makefile 里借 PATH 用。工具链版本跟着仓走,换机器 clone 完就能跑,不用先装一堆东西。
Rust 侧:唯一编译点和两个坑
proto 类型只在 core 这一个共享 crate 里编译,其它 crate 一律依赖它。集中一处,新鲜度校验才有地方做:构建脚本先过一道 ensure_descriptor_set_fresh,按 mtime 比对,任何 proto 比 descriptor-set.bin 新就构建失败,报错直接告诉你去跑 make -C contracts build。它防的是「改了 proto 忘了再 build,Rust 还在按旧契约编译」,把问题从误导性的类型错误提前成一句可操作的提示。
坑一:互相 import 的包必须同列。build.rs 要显式列出参与生成的 proto 文件,被 import 的包如果不列,生成器会静默丢弃那部分类型,于是 import 方和被 import 方各自长出一份同名类型。编译照样过,运行时对不上才发现。现在 build.rs 里凡有跨包 import 的地方,注释都写着「两包同列防类型分裂」。
坑二:改 proto 后必须全量 cargo build --workspace。部分重建可能让不同 crate 拿到不一致的 descriptor,wire 错配拖到运行时才爆。这条在仓里写成 MUST,坑变成纪律。
TS 两侧:一份 gitignore,一份入 git
管理端链落在 packages/shared:protoc-gen-es 生成类型,protoc-gen-connect-query 生成 TanStack Query 的 hooks,Connect 的 transport 和 client 也住在这个包里,两个 Web 后台共用一份。标准 npm 包形态,生成物 gitignored,type-check 前自动再生成,平时没人需要关心它的存在。
小程序链不一样。它无框架、无构建链,微信开发者工具逐文件转译,生成码必须直接躺在源码树里被 import。所以小程序的 25 个生成文件入 git,每个文件头三行注释:自 contracts 描述符生成、禁手改、再生成命令。
入 git 就有手改和漏再生成的风险,防法是 diff 门。make check 把所有链再生成一遍,然后 git diff --exit-code 加未跟踪文件检查:手改了、漏生成了、多出文件了,任何一种都红。这道门成立的前提是生成的确定性,同一份 proto 永远产出同一份文件,所以连生成代码的折行宽度都是定死的。
小程序为什么是独立的第二 template?buf v2 的 inputs 是 template 级单例,排除文件只能在 template 层做。小程序用不到运营域的契约,buf.gen.mp.yaml 在 inputs 里把 ops 目录整个排除:
1 | inputs: |
这还只是裁剪的第一层(文件级),后面还有 method 级清单和消息闭包两层,牵扯到小程序那条手写网络通道,留到写小程序的篇目再展开。
插件本身是「哨兵」姿态:非 unary 的 rpc、bytes 字段、map 枚举值这些没支持过的契约形态,命中就在生成期硬错,不静默生成残缺品;清单里写了不存在的键、闭包里有没声明的枚举字段,同样硬错。新契约特性必须显式扩张插件才放行:宁可在生成期吵,不在运行期炸。
兼容基线门
生成解决「三端一致」,breaking 检查解决「新旧一致」:
1 | buf breaking --against '../.git#branch=contracts-baseline,subdir=contracts' |
对着仓里的 contracts-baseline 分支逐文件比对,字段号重排、改类型、删字段,命中即红。基线分支钉着「已发布端最后一个版本」的 proto 状态,随发布由我手动前移。发布即承诺:承诺过的形状不能再动。
一个真实例子。discovery.proto 里有个 view_count 字段,功能已经裁决下线,服务端不再派生、永远下发 0,但字段本体不能删:已发布的小程序还按字段号读它,删掉就是 breaking。契约的义务是「发出去的端还能跑」,不是「当前代码还用不用」。真要删,等基线前移到所有在野版本都不读它之后。
契约不止生成代码
contracts 的派生物还有第四份:E-R 文档。make er 从近 7000 行的单文件 DDL 纯投影出 docs/generated/data-model.md:域索引、每个领域一张图、表说明、跨域外键汇总,生成器内置断言防漏表和悬空外键。产物入 git,与生成码过同一道 diff 门。
要点在「纯投影」三个字:表结构的真相只存在于 DDL,文档是它的只读视图。手工维护的 E-R 图没有不过期的,投影生成的没有会过期的。生成物不许手改的整套门禁值得单独写一篇,这里先按下。
两个小坑
edition 2024 的命名规则:字段名里除首段外,任何下划线分隔段不能以数字开头,per_target_24h_max 不合规,per_target24h_max 合规。lint 报错不直白,第一次撞上会懵,这里留个记号。
int64 跨前端必须加 jstype = JS_STRING:
1 | int64 participant_id = 5 [jstype = JS_STRING]; |
int64 在 TS 侧的默认形态是 bigint:精确,但跟 URL、模板字符串、比较运算打交道都别扭。标注之后生成 string,而 Connect 协议的 JSON 编码里 64 位整数本来就写成十进制字符串,string 全程直通。要是有人图省事往 number 改,53 位安全整数兜不住 64 位 id,精度截断属于低概率、难复现的那类 bug。规矩定死:凡跨前端消费的 int64,一律 JS_STRING。
改一个 rpc 的完整动作
把前面的坑串起来,改 proto 时照这个顺序走:
1 | make -C contracts generate # 三链再生成:descriptor + 两条 TS |
四步各有各的红法:generate 漏跑,freshness 校验会拦住 cargo;breaking 红,说明动了发布过的形状;diff 门红,说明有人手改过生成物或者忘了提交再生成;type-check 红,是生成码与使用方对不上。这套顺序在仓里写成了 AGENTS.md 协议,人和 AI 协作者照同一条路走。AI 改契约绕不过门禁,这件事后面写 AI 协作的篇目再展开。
小结
契约先行的收益不是省下写类型的工夫,而是把一整类 bug 挪了位置:接口对不上,从运行时的偶发错乱,变成生成期的编译错误和门禁红线。改 proto 的那个 commit 里,三端再生成、全量重建、breaking 过门,一个动作全带上。
contracts 里还有 DDL。上线之后的 schema 怎么演进、迁移链怎么配合,下一篇讲。