RushWind Admin — 契约驱动:203 条路由零手写的工程化拆解

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 硬上这套,属于用推土机种花。


系列文章:

项目地址:Gitee | GitHub

相关推荐
明月_清风1 小时前
只会 Vibe Coding 的程序员,为什么可能会被淘汰?
后端·ai编程
喵个咪1 小时前
Go 写业务,Rust 扛底盘:一套可落地的混合架构
后端·rust·go
小小张说故事2 小时前
Python logging 日志不输出?根源在 propagate 这条链上
后端·python
ZealSinger2 小时前
Go slog生产落地LevelVar与共存
开发语言·后端·golang·go
ttwuai3 小时前
Go开源后台管理系统推荐:3个官方仓库怎么按技术栈和适用边界比较?
开发语言·golang·开源
w***48823 小时前
SpringBoot整合easy-es
spring boot·后端·elasticsearch
dpharness3 小时前
踩完 dsh-ads 的四个坑,我说说虚构排名该怎么看
后端
预知同行3 小时前
深入解析 AI 应用可观测性:OpenTelemetry GenAI 规范下的调用链追踪与 Token 成本治理
后端·架构
张宏宇3 小时前
我把微软开源的 tgrep 做成了本地版 GitHub Code Search:45,629 个文件里搜一次 10ms
前端·后端