一份契约要跨语言成立
一个 HTTP API 契约和一个遥测指标,结构上是同一个问题:一份定义要被不同语言写、被不同 toolchain 编译、落在仓库不同位置的代码同时遵守。
AIO API 的某个 endpoint 请求结构,iOS 客户端用 Swift 编码、后端用 Go 校验并提供服务、管理后台用 TypeScript 消费。一个 metric 的 bucket 布局,客户端(Swift)算、服务端(Go)预校验、dbt model 经 ClickHouse(SQL)查询。一旦这些定义漂移,结果就是只有部分客户端触发的 400,或者桶边界悄悄对不齐的 histogram。
仓库对两者用了同一套机制:DSL 文件是唯一事实源,一个 Rust binary 把它投影成各语言产物,Bazel genrule 把每个产物接到消费它的地方。当前有两个实例 —— aio-api-codegen(提交 be92d9ec8 引入)和 metric-codegen(提交 90fdb92a2 引入)—— 各自独立构建,但布局几乎相同。
DSL 输入
.aio API DSL 是声明式的,写法贴近 wire 结构。Server/services/aio-server/api/ 下有 18 份 .aio spec,合计 140 个 endpoint、111 个 object:
namespace devices
endpoint deviceRegister POST /api/v1/devices {
auth: optional
requires: [bundleID]
request {
deviceToken: string
environment: string
deviceID: string?
appVersion: string?
}
response: DeviceDTO
}
object DeviceDTO {
id: string
bundleID: string
deviceToken: string
isActive: bool
lastSeenAt: string
}
.metric DSL 描述的是 histogram,带命名的 bucket preset、enum dimension、以及采样策略:
namespace network
schema_version "1.0"
preset http_times = exponential(min: 1ms, max: 60s, buckets: 50)
dimension status_class = enum { "2xx" "4xx" "5xx" network_error canceled }
@owner("Network")
metric http_request_duration {
version = 1
kind = histogram
unit = milliseconds
bucket = http_times
tags = [ tag endpoint { cardinality: 100 } status_class http_method ]
sampling = rate(0.1)
}
两类文件都手写,与拥有该领域的代码放在一起。两边的生成产物都不入库。
一个 binary,每端一个子命令
Rust crate 切成 lexer → parser → ir → emit::{...},main.rs 是一层很薄的 clap 分发。metric binary:
#[derive(Subcommand)]
enum Cmd {
Proto { input: PathBuf, output: PathBuf },
Swift { input: PathBuf, output: PathBuf },
Go { input: PathBuf, output: PathBuf, package: String },
Sql { input: PathBuf, output: PathBuf },
Check { inputs: Vec<PathBuf> },
}
每个子命令跑的是同一段前半程 —— load() 做 lex → parse → ir::check —— 再把类型校验过的 Module 交给某一个 emitter。API binary 形状完全相同,只是把子命令换成 Swift / Go / Ts / Check。check 子命令只解析和类型检查、不产出任何东西;它作为 CI gate 存在,让格式错误的 spec 在任何产物被构建之前就失败。
前后半程切开是关键:校验只活在 ir::check 一处,不按语言重复。metric checker 会拒绝声明了 bucket 的 sparse 指标、没有 bucket 的 histogram、引用了未定义 dimension 的 tag、以及重复的 metric name —— 在 Swift、Go、SQL、Proto 看到它之前就拒掉。一整类「同时在三种语言里错」的缺陷,被收敛成一次 parse-time 失败。
每个产物一条 genrule
Bazel 这层胶水刻意做得薄。每个语言 target 都是一个包住 genrule 的宏,用对应子命令调那个 binary。摘自 api.bzl:
def aio_api_go_lib(name, spec, package, importpath, visibility = None):
native.genrule(
name = name + "_gen",
srcs = [spec],
outs = [name + ".go"],
cmd = "$(location " + _CODEGEN + ") go --input $(SRCS) --output $@ --package " + package,
tools = [_CODEGEN],
)
go_library(name = name, srcs = [":" + name + "_gen"], importpath = importpath)
api.bzl 暴露三个这样的宏(swift_lib / go_lib / ts_lib);metric.bzl 暴露四个(proto/swift/go/sql),外加一个 metric_spec 伞形宏一次性产出全部,并把共享的命令拼接抽进私有 _gen_one。于是 BUILD 文件读起来就是一串平铺的声明:
aio_api_swift_lib(name = "devices_swift", spec = "devices.aio")
aio_api_go_lib(name = "devices_go", spec = "devices.aio",
package = "devicesapi", importpath = ".../devicesapi")
aio_api_ts_lib(name = "devices_ts", spec = "devices.aio")
因为 spec 是唯一的 srcs 输入,.aio 或 .metric 改动时 Bazel 只重生成受影响的 target,别的不动。
语言真正分叉的地方
共享 IR 不等于产物相同。分叉集中在两处。
类型映射是对 (Lang, Type) 的一个 match。optional string 在 Swift 是 String?、Go 是 *string、TypeScript 是 string | null;map 分别是 [String: V]、map[K]V、Record<string, V>。命名是另一根轴:Go emitter 跑一遍按词边界的缩写大写化,idToken → IDToken、apiKey → APIKey、avatarURL → AvatarURL,贴合 Go 习惯;而 Swift 的 metric emitter 必须给数字开头的 enum case 加反引号 —— status_class = "1xx" 不能是裸 Swift 标识符,于是产出 case `1xx`。这类细节手工保持一致很啰嗦,集中处理一次就很省心。
两个 binary 在产出策略上不同:API codegen 构建一个可序列化的 view model,经 tera 模板(swift.tera / go.tera / ts.tera)渲染;metric codegen 用 push_str 直接拼字符串。两种都行。产物大且结构化时,模板把布局和逻辑分开;产物是一张平铺 registry 时,直接拼字符串可动的零件更少。
跨端不变量:一个指纹,三套运行时
metric 这边有一个 API 这边没有的约束:一个必须在 Swift、Go、ClickHouse 三处字节一致的数。histogram 的 bucket 布局在 IR 里被指纹化一次 —— 对规范化后的边界算 32-bit FNV-1a 哈希,即 layout_sig —— 在此之前单位先折算到基础单位(time → ns,size → byte),保证 1s 和 1000ms 哈希相同。
这个单一值随后被打进每个产物:Swift struct 拿到 public let layoutSig: UInt32 = 0x...,Go 的 Registry map 条目拿到 LayoutSig: 0x...,ClickHouse 的 metric_registry 表带一列 layout_sig UInt32。服务端的 Lookup(name, version) 会拒绝版本未注册的 incoming histogram;布局指纹让客户端和服务端就「同一个 metric 即同一组桶」达成一致,而不必每次请求都带上边界数组。在一处算它,是三套独立编译的运行时保持一致的唯一办法。
代价:不在磁盘上的生成文件
这套模式有一个值得点名的真实缺点。Go 产物由 genrule 生成,所以 .go 文件并不存在于源码树。Gazelle —— 它靠遍历源码生成 Go BUILD —— 找不到这个包,回退到对一个 monorepo 内部的 vanity import path 做 go mod download,拿到 404,于是判定该依赖 stale,把它从每个消费者的 BUILD 文件里删掉。
修法是显式的,落在 Server/BUILD.bazel:每个生成包一条 # gazelle:resolve 指令,把 importpath 直接映射到 Bazel label,短路掉源码遍历。当前有 18 条,每份 .aio 服务一条,注释块写明:每新增一个 aio_api_go_lib 就加一行。codegen-as-genrule 把一个手工、易错的一致性问题,换成了另一个手工步骤 —— 把每个生成包向 BUILD 生成器登记。更小,但不是零。
可迁移的结论
- 把事实源放进 DSL,生成所有消费方。 一份定义跨语言时,手工同步 N 份拷贝就是缺陷来源;一份 spec 加 codegen 直接消掉这些拷贝。
- 一个 binary、每端一个子命令、共享前半程。 校验属于 IR、只写一次,让坏 spec 在 parse 时失败,而不是在编译期失败 N 次。
check子命令把这一步变成 CI gate。 - 每个产物一条 genrule,spec 是唯一输入。 Bazel 于是只重建受影响的 target,BUILD 文件保持成一句平铺的「这份 spec 投到哪些语言」。
- 把必须一致的值集中算。 bucket 指纹在一处算好、打进 Swift / Go / SQL,正是三套运行时不会对「同一个 metric」产生分歧的原因。
- 算上 genrule 的盲区。 生成文件不在磁盘上,源码树工具(Gazelle)需要显式 resolution。在它删掉你的依赖之前,先给这一步留预算。