RushWind Admin --- 契约驱动:203 条路由零手写的工程化拆解
大多数后端项目的 API 层是"人肉维护的目录":路由注册、参数解析、错误码映射、文档注释------四份东西描述同一个事实,靠人保证一致,于是永远不完全一致。RushWind Admin 把这层做成了编译产物:Protobuf 是唯一契约,构建期确定性生成路由、错误映射、服务接口与绑定计划------203 条路由、441 条错误状态映射、198 个服务接口方法,零手写;前端 TypeScript 客户端同源于契约,三套前端字节相同。本文拆解这条生成流水线的设计与治理,以及三个真实踩到的暗礁------每一个都值得写成生成器作者的检查清单。
一、手写 API 层的三个慢性病
漂移 。路由注册写的是 /admin/v1/users/list,文档里是 /admin/v1/user/list,前端调的又是第三个拼写。谁都没错,改的时候没人同步。漂移不立即爆炸------它在联调、在上线后第一次客户端升级时爆炸。
文档腐化。注释和代码是两个生命体。接口改了注释没改,读文档的人又比读代码的人多,于是错误被文档规模化传播。Swagger 注解那种"文档长在代码上"的方案只解决了半个问题:注解能腐化,而且没人给注解写测试。
多端失同步。一个 API 三个消费者(Web、小程序、内部服务),参数类型在三种语言里各写一份。契约改了,某一端的 DTO 忘了跟------测试环境没炸,生产炸。
三个慢性病的共同根因:同一个事实有多个事实源。契约驱动的药方也只有一句话------把事实源收敛到一个,其余全部生成。问题在于"其余全部生成"这六个字,从设计到落地有大量工程决策要是对的。本文就是把 RushWind Admin 的这些决策摊开。
二、契约即唯一事实源
契约就是 proto 文件,路由写在 google.api.http 注解里:
protobuf
service UserService {
// 示意:管理端用户列表
rpc ListUser(ListUserRequest) returns (ListUserResponse) {
option (google.api.http) = {
post: "/admin/v1/users/list"
body: "*"
additional_bindings { get: "/admin/v1/users" }
};
}
}
错误模型同样进契约。每个服务有一份错误 proto,错误原因枚举经注解携带 HTTP 状态码(示意):
protobuf
enum UserErrorReason {
option (errors.default_code) = 500;
USER_NOT_FOUND = 0 [(errors.code) = 404];
USER_DISABLED = 1 [(errors.code) = 403];
}
于是"UNAUTHORIZED 对应 401 还是 403"不再是谁拍脑袋定的,而是契约里的一条数据------review 契约的时候顺手就 review 了错误语义。
RushWind Admin 的契约规模:112 个 proto 文件、16 个模块、197 条 google.api.http 主绑定 (叠加 additional_bindings 后生成 203 条路由)、441 条 reason → HTTP 状态映射 、198 个服务接口方法。
连分页与查询也进了契约:page / pageSize / noPaging、orderBy(JSON 数组)、query(JSON 过滤语法)、fieldMask(字段掩码)统一定义在 URL query 上,三套前端共用同一份 transport 实现------不存在"这个端点分页参数叫 size、那个端点叫 pageSize"的惊喜。
三、构建期生成什么
bash
api/protos/ ──同步脚本(哈希门)──▶ protoc 注解描述符 ──▶ 构建期生成器
│
├─ 路由表 203 条(含 additional_bindings)
├─ 错误状态表 441 条 reason → HTTP 状态
├─ 服务接口 198 个 trait 方法
├─ 挂载胶水 逐路由 axum 挂载 + 免鉴权白名单
└─ 绑定计划 query/path/body 逐路由叶子展开
四个产物展开说:
路由表与挂载胶水 。生成器从描述符读出每条 google.api.http 绑定,发射逐路由的挂载代码(方法 + 路径 + 处理器),additional_bindings 随主绑定一起展开;并按"操作"维度把免鉴权端点(登录、验证码、刷新令牌等 8 个)拆到公开路由侧,其余全部落到鉴权门内侧。白名单本身也是生成的,和契约同步------不存在"加了路由忘了配白名单"这回事,新路由默认在门内,要公开必须在契约的操作分类里显式表态。
错误状态表。441 条映射构建期生成,运行期一次查表得到 HTTP 状态码。前端拿到的统一四字段信封:
json
{
"code": 401,
"reason": "UNAUTHORIZED",
"message": "access token expired",
"metadata": {}
}
服务接口 trait。198 个方法签名生成出来,服务实现层对着接口写------实现漏了哪个方法,编译器指名道姓,不存在"路由挂上了但处理器没写"的静默态。
前端客户端 。同一份契约生成 TypeScript 客户端(单文件 300KB+),React / Vue Element / Vue Vben 三套前端拿到的客户端字节相同。换后端实现、甚至换后端语言,前端一行不改------这件事的验证方式见第七节差分台架。
四、跨语言序列化:最容易翻车的两处
生成路由只是第一步,真正难的是线上字节形态的一致性------两个后端栈要对同一请求给出逐字节相同的响应,序列化语义必须一处定义、处处服从。两处重灾区用金样测试(golden samples)钉死:
64 位整数的字符串化 。JSON 里 int64 用字符串表达------"total": "0" 而不是 0,"userId": "123456789012345678" 而不是裸数字。原因众所周知:JavaScript 的 Number 在 2^53 失真,而后端 ID 常用雪花算法,正好踩线。这条规则在响应发射层统一执行,列表里每个对象的 ID 字段和 total 字段遵循同一拼写,没有例外清单。
presence 字段的省略 。proto3 里声明了 optional 的字段未设置时,JSON 里整个键消失;裸字段(无 presence 语义)则默认发射零值。同一条响应里两种形态并存是常态:
json
{ "items": [], "total": "0" }
total 是裸 int64(默认发射,且字符串化),而请求里未设置的 optional 字段不会出现在响应里。全靠序列化层按字段元数据逐个判断,人力维护必然出错------所以用金样锁定,每次构建重验。
除此之外还有一组低调但致命的细节:well-known 类型(Timestamp / Duration / FieldMask / Struct)的语义对齐、query 与 body 绑定的不同严格度、错误 Content-Type 的判定(一个分号位置不对就把合法请求打成 400)。这些各有金样或行为测试,本文不展开------只强调结论:跨语言互操作 90% 的事故出在序列化语义,而不是路由。
五、生成器的暗礁:三个真实案例
写生成器不是把 protoc 输出读一遍那么简单。三个真实踩到的坑,各有代表性:
坑一:第三方 proto 编译器丢弃自定义选项字节
管线的类型层本可用纯 Rust 的 protox 编译器(无外部依赖,构建快,CI 不用装东西),但 protox 序列化描述符时会丢弃自定义选项的字节------google.api.http 的路由、错误枚举的 errors.code 注解全部丢失,而丢失是静默的:构建照样绿,路由一条没有。
解法是双编译器管线:注解描述符必须走 protoc(CI 装 protobuf-compiler),类型层用 protox + 描述符过滤,两个产物各取所长。
教训:引入"等价"工具前,先确认你消费的恰好是它不丢的那部分------尤其是自定义选项这种容易被当二等公民的字节。
坑二:UTF-8 BOM
契约源里有两个 proto 文件带 UTF-8 BOM,protoc 容忍、protox 拒绝。双编译器管线让这个分歧藏在"本地能过、CI 挂掉"的脚本里最阴险的位置。
解法:同步脚本在复制与哈希两侧做 BOM 归一,MANIFEST 记录归一后的哈希。
教训:跨工具链的字节级管线,编码细节要在管线边界处显式处理一次,之后全程不再出现------"工具自己能容忍"不是不处理的理由,因为下一个工具未必容忍。
坑三:路由遮蔽
契约里恰好有一条 GET /admin/v1/apis/walk-route。axum 的匹配规则是静态段优先于参数段,而很多框架是先注册先匹配------于是这条路由会被更早生成的 GET /admin/v1/apis/{id} 吸收,两个框架上行为不同 :一边 walk-route 通,一边命中 {id} 返回 404 或错误对象。
解法是生成器做遮蔽分析:识别"会被更早静态/参数路由吸收"的挂载,被遮蔽的跳过并记录,外加一个守卫测试把"遮蔽集 == 登记集"钉死------契约再变时,测试会先于人发现新的遮蔽。
教训:生成器不理解目标框架的路由匹配语义,就会生成出语义不同的代码。生成器必须对目标框架的匹配优先级建模,而不是天真地按注册顺序发射。
三个坑的共同点:都是静默失败。生成器的价值高度依赖它"错得响亮"------宁可构建失败,不可静默少生成一条路由。
六、契约治理:防手改、防漂移
契约文件从契约源同步进仓,带两层门:
- MANIFEST 哈希门 :契约文件与其清单一起提交,任何手改在
--check下现形(本地与 CI 同一道门)。手改契约的 PR 会在 CI 被拒,没有商量; - 漂移检测:本地有契约源时,脚本顺带检查上游是否已变------不是拦截,而是提醒你该同步了。CI 环境没有契约源时此检测自动跳过,门不误伤。
同步是单向的:backend/api/protos/ 目录不该被手工编辑,这写进了贡献指南。听起来严苛,实际是解放------契约变更永远发生在 proto 里 ,其余一切(路由、错误表、客户端、OpenAPI 文档)由流水线重新生成。OpenAPI 规格同源于构建产物并内嵌进服务,/q/swagger-ui 与 /q/redoc 开箱即用,文档与代码从机制上不可能不一致。看一眼这份"产物形态的文档":
Swagger UI------全量接口按服务分组,带鉴权入口,可直接在线调试:

ReDoc------三栏式阅读视图,参数说明与请求 / 响应示例并列呈现:

七、差分台架:契约兼容是跑出来的
静态生成保证了"代码同源",但两个后端栈在运行期的行为对不对得上,要靠动态验证。仓库带一个差分回归台架:
- compose 拉起中间件与 Go / Rust 双栈后端,两侧跑同一份契约;
- 对 203 条路由 + 89 条 HEAD 探针自动 sweep,逐字节比对响应------状态码、头、body 全比;
- sweep 带分类短路:遮蔽路由、门控路由(401 形态)、公开路由、GET 上的 HEAD 各有预期形状,先分类再断言,报告读起来是人话;
- 豁免不是拍脑袋:四类豁免显式登记在
exemptions.json,每一类都有理由,review 时看得见。
这套台架的价值在换语言、升级依赖、重构生成器时兑现:契约兼容不是文档里的承诺,是每跑一次就复核一次的事实。配合 CI 四道门(fmt / clippy / test / 契约同步校验),"我只是升级了个依赖,行为怎么就变了"这类事故在这里活不过一个 build。
八、这套东西买到了什么,什么时候不值得
买到的:
- API 层从"目录"变成"产物":漂移在编译期暴露,文档与代码天生一致;
- 多端同源:三套前端、任何语言的消费者,看到同一个错误信封、同一套分页与查询语义、同一种整数字符串化;
- 重构安全网:生成器升级、契约演进、后端换语言,全量路由的差分回归兜底;
- 静默失败免疫:白名单、遮蔽、绑定计划全部生成且有守卫测试,"忘了配"这一类事故失去藏身之处。
不值得的场景也说清楚:一次性内部接口、单一消费者、生命周期以周计的原型------手写更快,生成流水线的维护成本回不了本。契约驱动的收益随契约规模、消费者数量与契约寿命增长,三者越大越久,越该把 API 层交给编译器。反过来说,小而短命的 API 硬上这套,属于用推土机种花。
系列文章: