技术5/26/2026·8 min

一份 DSL 投影到多端:一个 Rust binary、多子命令、藏在 Bazel genrule 后面

monorepo 里两个小 DSL,各自把一份 spec 投影成 Swift / Go / TypeScript / Proto / ClickHouse DDL。两次的形状一样:一个 Rust binary、若干子命令、每个产物一条 genrule。

一份契约要跨语言成立

一个 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 / Checkcheck 子命令只解析和类型检查、不产出任何东西;它作为 CI gate 存在,让格式错误的 spec 在任何产物被构建之前就失败。

前后半程切开是关键:校验只活在 ir::check 一处,不按语言重复。metric checker 会拒绝声明了 bucketsparse 指标、没有 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]VRecord<string, V>。命名是另一根轴:Go emitter 跑一遍按词边界的缩写大写化,idToken → IDTokenapiKey → APIKeyavatarURL → 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),保证 1s1000ms 哈希相同。

这个单一值随后被打进每个产物: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。在它删掉你的依赖之前,先给这一步留预算。