RunAnywhere IDL 开发指南:39 个 .proto 文件如何驱动 Swift / Kotlin / TypeScript / Dart / C++ / Python 六语言 SDK
RunAnywhere IDL 开发指南:39 个 .proto 文件如何驱动 Swift / Kotlin / TypeScript / Dart / C++ / Python 六语言 SDK
本指南以 idl/ 目录的 schema 治理与代码生成体系为主题,完整讲解如何在 RunAnywhere SDK 单仓库中安全地新增或修改 .proto 文件、理解 rac_options.proto 自定义注解的生成语义,以及掌握 generate_all.sh 驱动下六语言代码生成与漂移门禁(SCHEMA_LOCK)的完整工作流。读完本文,你将具备直接参与该仓库 IDL 演进、排查生成代码缺失与 schema 漂移问题的实战能力。
IDL 在仓库中的定位:单一 schema 根
idl/ 是 RunAnywhere monorepo 的"单一 schema 根":目录下共 39 个 .proto 文件,统一使用 package runanywhere.v1,是仓库中所有跨平台类型的唯一事实来源,覆盖:
- 模型 / 错误 / 事件 / 选项等基础类型(
model_types.proto、errors.proto、sdk_events.proto等); - LLM / VLM / STT / TTS / VAD 各模态的配置结构(如 stt_options.proto、
llm_options.proto、vlm_options.proto); - SDK 事件目录(
sdk_events.proto全文件即"单一权威事件目录")。
idl/codegen/ 把这些 schema 渲染成 Swift、Kotlin(Square Wire)、TypeScript(ts-proto)、Dart、C++ 与 Python 六套绑定。仓库根目录的 AGENTS.md 规定了全仓库级约定与架构;idl/CLAUDE.md 只补充 idl 内部专属的 proto 编写约定与 codegen 细节。在动手之前,务必先读 docs/reference/generated-code-contract.md——它详细说明了每个生成脚本产出什么、落到哪个目录、固定工具链的引导脚本,以及 idl/VERSION / idl/SCHEMA_LOCK 漂移门禁的机制,本指南不再重复这些内容。
新增或修改 .proto 的标准流程
新建文件:八个 option 样板必须齐全
新建 .proto 文件时,从既有文件(例如 pipeline.proto)复制完整的 option 块样板。以 stt_options.proto 为例,一个合法文件的开头必须包含:
syntax = "proto3";
package runanywhere.v1;
import "errors.proto";
import "model_types.proto";
import "rac_options.proto";
option cc_enable_arenas = true;
option java_multiple_files = true;
option java_package = "ai.runanywhere.proto.v1";
option java_outer_classname = "SttOptionsProto";
option objc_class_prefix = "RAV1";
option csharp_namespace = "Runanywhere.V1";
option swift_prefix = "RA";
option go_package = "github.com/runanywhere/runanywhere-sdks/idl/v1;runanywherev1";
八个 option(cc_enable_arenas、java_multiple_files、java_package、java_outer_classname、objc_class_prefix、csharp_namespace、swift_prefix、go_package)被每个生成器依赖,且必须保持一致——缺失任何一个都会让对应语言静默采用默认(错误)的命名,例如 Kotlin 的包名会退回 java_package 之外的值、Swift 的类前缀会丢失 RA。这类错误不会在生成时报错,只会在编译期以"找不到类型"的形式暴露。
编辑已有 schema:先按语义升级 idl/VERSION
修改 message / enum 后,必须按语义更新 idl/VERSION(当前为 1.3.0)。idl/VERSION 是手工维护的、针对 schema 表面的 semver:
| 版本位 | 适用场景 | 示例 |
|---|---|---|
| patch | 仅注释 / 文档变更 | 修改字段的注释说明 |
| minor | 新增字段 / message / enum 值(wire 兼容) | 给 message 追加一个字段 |
| major | wire 破坏性变更 | 删除或重编号字段、修改既有字段类型 |
随后运行 ./idl/codegen/generate_all.sh——它会顺带为你重写 idl/SCHEMA_LOCK。CI 中由 schema_lock.sh --check --require-bump <base-lock> 强制区分 patch / minor / major:schema 摘要变化而 idl/VERSION 未动,即视为未完成版本升级而失败。
字段号纪律:永不重编号、永不复用
对已上线 wire 或已持久化的 message,绝不允许重编号或复用字段号。仓库中有三类具体的硬约束:
- model_types.proto 的
ModelInfo子图会序列化到磁盘上的.rac-manifest.binpb,因此它的reserved块是"承重墙"而非装饰——删除一个字段时必须把它加入reserved,绝不允许直接销毁该字段号(这正是 proto3 中"删除字段必须留reserved占位"这一规范在本仓库中的具体落地)。 - sdk_events.proto 对全文件声明了同一条规则:字段号、message 名、枚举值均 wire-stable,只有注释与分组可以改动。
- errors.proto 的数值必须与 core/include/rac/core/rac_error.h 完全一致——修改既有值属于 wire 破坏(必须升 major),而新增一个值则是安全的(升 minor)。
在 stt_options.proto 中可以看到这两条纪律的典型形态:已废弃但曾经上线的字段用 reserved 加 reserved "字段名" 双重占位(如 STTConfiguration 中 reserved 8, 9; reserved "vocabulary_list", "max_alternatives";),并在注释中说明"None were ever read by any backend"(从未被任何后端读取)作为废弃依据。
service 块:唯一的例外 tool_calling.proto
39 个文件中只有 tool_calling.proto 带有 service 块(ToolCalling),而且它是一个逻辑契约——只描述解析 / prompt 格式化 / 校验的形态,不是 wire 上的 RPC。整个仓库没有任何 gRPC 传输(已被进程内的 C 回调路径取代,见根目录 AGENTS.md),因此如果在其他文件中新增 service,生成的桩代码不会有任何调用方。
rac_options.proto:九个自定义注解与生成语义
idl/rac_options.proto 声明了 9 个 FieldOptions / EnumValueOptions 扩展,字段号落在 50000–99999 这个 Google 规定的"组织私有扩展"区间(50003、50008–50009 留空):
| 扩展 | 字段号 | 作用于 | 语义 |
|---|---|---|---|
rac_default |
50001 | 字段 | 默认值字符串,按字段声明类型解析:"3" 对应 int32、"0.7" 对应 float、"true" 对应 bool、"auto" 对应 string、枚举常量名对应 enum |
rac_required |
50002 | 字段 | validate() 拒绝该字段的 proto3 零值 |
rac_min |
50004 | 字段 | 整数字段的包含性下界 |
rac_max |
50005 | 字段 | 整数字段的包含性上界 |
rac_min_float |
50006 | 字段 | 浮点字段的包含性下界 |
rac_max_float |
50007 | 字段 | 浮点字段的包含性上界 |
rac_display_name |
50010 | 枚举值 | 面向 UI 与文档的可读标签 |
rac_analytics_key |
50011 | 枚举值 | 稳定分析键,枚举重命名不会破坏历史分析数据 |
rac_wire_string |
50012 | 枚举值 | 与手工字符串协议互操作时的线上字符串覆盖,codegen 优先使用它而非 proto 常量名 |
这些注解不直接由 protoc 消费。codegen/generate_{swift,kotlin,dart,ts}_convenience.py 四个后处理脚本在描述集(descriptor set)上做二次加工,为每种语言产出对应的便捷访问器:
| 注解 | 生成的访问器 |
|---|---|
rac_wire_string(枚举值,正向) |
wireString |
rac_wire_string(枚举值,反向) |
from(wireString:)(Swift) / fromWireString(Kotlin、Dart、TS) |
rac_display_name |
displayName |
rac_analytics_key |
analyticsKey |
rac_default(字段) |
defaults() 工厂 |
rac_required / rac_min / rac_max / rac_min_float / rac_max_float |
validate() |
一个来自 stt_options.proto 的完整注解示例——采样率默认 16000、合法区间 8000–48000:
int32 sample_rate = 3 [
(runanywhere.v1.rac_default) = "16000",
(runanywhere.v1.rac_min) = 8000,
(runanywhere.v1.rac_max) = 48000
];
它在 Kotlin(Wire)中生成的形态(完整设计见 idl/codegen/CONVENIENCE_CODEGEN_DESIGN.md):
public fun STTConfiguration.Companion.defaults(): STTConfiguration =
STTConfiguration(
language = STTLanguage.STT_LANGUAGE_EN,
sample_rate = 16000,
enable_punctuation = true,
enable_word_timestamps = true,
)
public fun STTConfiguration.validate() {
if (sample_rate < 8000 || sample_rate > 48000) {
throw SDKException.validationFailed(
"sample_rate must be in 8000...48000 (got $sample_rate)"
)
}
}
注意三个关键约束:
- 生成的
validate()不会被自动调用——调用方必须显式调用;同时core/的 commons 层会独立执行自己的边界校验,因此不要以为注解覆盖就是唯一的校验层。 - 各语言默认值字面量转换规则不同:Kotlin 的 int64 输出
16000L,Dart 因dart-protobuf用package:fixnum表示 int64 而输出Int64(16000),TypeScript 的 ts-proto 用long库而输出Long.fromNumber(16000)。 - 这些生成器共享 idl/codegen/_convenience_common.py 中的描述集解析、wire 格式读取与命名工具,避免四个生成器各自实现而再次产生 drift。
buf.yaml:手工运行 lint / breaking 检查
idl/buf.yaml 配置了 buf 的两类检查:
buf lint:使用 STANDARD 规则集,但排除PACKAGE_VERSION_SUFFIX和PACKAGE_DIRECTORY_MATCH——本仓库不对包做版本后缀,也不把runanywhere.v1包镜像进匹配的目录树;buf breaking:使用 WIRE 类别,排除EXTENSION_NO_DELETE和FIELD_SAME_DEFAULT。
buf 未接入任何 CI 工作流——本仓库的 schema 兼容性强制手段是 SCHEMA_LOCK 加漂移检查,而不是 buf。当你拿不准一次编辑是否 wire 破坏时,手工运行:
buf lint idl
buf breaking idl --against '.git#branch=main,subdir=idl'
codegen/ 流水线:generate_all.sh 之外还有什么
generate_all.sh 是总入口,默认运行六个语言(swift kotlin dart ts cpp python),支持两个实用参数:
--skip-dart:本地没有 Dart 3 时跳过 Dart 生成(CI 会用固定工具链重新生成);--only LIST:只跑swift,kotlin,dart,ts,cpp,python的子集——因为绑定代码不再提交,CI 编译某个 SDK 前必须先为它生成代码,而一个 Linux Web job 没必要为此安装 swift-protobuf 和 wire-compiler。
脚本内部有几个值得注意的实现细节:固定 LC_ALL=C / TZ=UTC / PYTHONHASHSEED=0 等环境变量保证"开发机与 CI 跑出相同字节";用 mkdir 原子锁(.codegen.lock.d)串行化 CMake configure、Gradle preBuild 与开发者手动运行三种并发来源,等待 900 秒后主动打破死锁而非无限阻塞;SCHEMA_LOCK 只在全量运行(六语言齐全)时刷新,--only 部分运行只打印 "not updated"——保证锁的语义是"所有绑定都与这些 schema 一致"。
除 generate_all.sh 覆盖的内容外,codegen/ 里还有四个专项设施:
便捷访问器生成器与回归测试
四个 generate_{swift,kotlin,dart,ts}_convenience.py 是 rac_options.proto 注解的消费方(见上文)。它们的回归测试套件是 idl/codegen/tests/test_convenience_generators.py:以夹具 proto(idl/codegen/tests/fixtures/test_options.proto)覆盖每个 rac_* 注解 × 标量类型的组合,并把每个生成器的输出与 golden/ 目录下的提交版金样文件做 diff。本地运行:
python3 idl/codegen/tests/test_convenience_generators.py
修改生成器或夹具后加 --update-golden 更新金样。该测试已被接入 idl-drift-check.yml,作为漂移检查之前的一步;退出码与"Python 环境不可用时的降级流程"见 idl/codegen/tests/README.md。
swift-modality-abi.yaml:模态 ABI 清单
idl/codegen/swift-modality-abi.yaml 是一个清单(manifest)而非 proto,驱动 generate_swift_modality_abi.py 渲染 CppBridge+ModalityProtoABI.swift:一张把每个 rac_* C 符号映射到其请求 / 响应 proto 类型与调用形态(invoke、stream、getWithContext 等)的 dlsym 表。当某个模态新增或变更 C 入口点时,编辑 YAML 而非生成的 Swift;文件头部注释详细记录了每种 kind 值的 wire 形态。
C++ defaults 头文件与默认值池
generate_cpp_defaults.py:产出随包发布的公共头文件core/include/rac/rac_defaults_generated.h(rac_default注解 → C 宏)。commons 的默认结构体(RAC_LLM_OPTIONS_DEFAULT等)就是由它组成的,也就是说真正跑推理的 C 层读取的默认值与各 SDK 生成器针对的是同一套注解。generate_defaults_pool.py:一次遍历为全部七种语言目标输出RADefaultsPool默认值常量(sdk_defaults.proto被排除在四个 message 生成器之外,其值以普通常量而非"没人会放到线上"的生成 message 类型到达各 SDK)。它跨语言运行、与--only子集无关,输出字节始终一致。
templates/ts_async_iterable.njk:共享 TS 流包装
idl/codegen/templates/ts_async_iterable.njk 是 generate_streams.sh 使用的 Nunjucks 模板,为每个 server-streaming RPC 形态渲染共享的 TypeScript AsyncIterable 流包装器,同时被 React Native 与 Web 两个 SDK 经 @runanywhere/proto-ts 消费(bindings/proto-ts/src/streams/ 下可见产物,如 llm_service_stream.ts、voice_agent_service_stream.ts)。历史上 RN / Web 各有一份字节相同的流脚本且会互相覆盖,现在统一为单一共享脚本渲染,杜绝单边篡改。
漂移检测与 CI 门禁:为什么 bindings 不提交
本仓库的绑定代码全部 gitignore 不提交,fresh clone 在 codegen 运行前没有任何 C++ / Kotlin / Swift / TypeScript / Dart / RN / Python 绑定。./scripts/setup/setup.sh 会先运行 codegen,./run codegen 按需运行。由此带来一个关键问题:旧式"重新生成后 git status 必须为空"的漂移检查对未跟踪文件永远失效(重新生成未跟踪文件总是干净树)。解决方案就是 idl/SCHEMA_LOCK:
generate_all.sh每次运行都会刷新它,记录每个idl/*.proto的 basename + 内容 sha256 的再摘要、proto 文件计数、idl/VERSION与core/VERSIONS中固定的 protoc 版本;- 由于它是被跟踪的唯一 codegen 产物,编辑 schema 而不重跑 codegen 会让锁过期,CI 失败;修改 schema 却不升
idl/VERSION同样失败; idl/codegen/generated_trees.txt是这张表的机器可读版本,并额外列出那些"位于被忽略树内部、但必须保持被跟踪"的 8 个手写文件(如bindings/proto-ts/src/index.ts、bindings/proto-ts/src/convenience/_errors.ts);check_generated_trees.sh负责双向校验——生成文件被跟踪要失败,手写文件停止被跟踪也要失败。
日常查看与验证命令:
./idl/codegen/schema_lock.sh --print # 当前 checkout 是哪个 IDL?形如 "idl 1.3.0 (39 protos, sha256 ...)"
./idl/codegen/ci-drift-check.sh # 与 CI 完全一致的整套门禁
CI 的 idl-drift-check.yml 采用"先生成、后验证"(generate-then-verify)而非"重新生成并 diff"——后者对忽略文件永远无法失败。此外,工具链本身也是"下载而非假定"的:bootstrap_protoc.sh / bootstrap_wire.sh / bootstrap_pyproto.sh 分别按 core/VERSIONS 中固定的 PROTOC_VERSION / WIRE_VERSION / PYTHON_PROTOBUF_VERSION 解析工具,并以 protoc.sha256 / wire.sha256 做校验和验证,缓存于 ~/.cache/runanywhere/;RAC_PROTOC / RAC_WIRE_COMPILER / RAC_PYTHON 可覆盖,RAC_PROTOC_NO_DOWNLOAD=1 等变量让离线主机大声失败而不是偷偷联网。
实践要点速查
- 新建
.proto:先复制样板文件的八个 option,缺一个就是静默错误;记得import "rac_options.proto"。 - 改字段:先想清楚是 patch / minor / major,改完升 idl/VERSION,再跑
./idl/codegen/generate_all.sh刷新 idl/SCHEMA_LOCK。 - 删字段:加入
reserved(字段号 + 名字双占位),尤其ModelInfo子图(.rac-manifest.binpb)与整个sdk_events.proto。 - 加校验/默认值:优先用
rac_*注解,让四语言便捷访问器生成器替你产出defaults()/validate()/wireString,而不是手写各 SDK 的扩展。 - 拿不准是否 wire 破坏:手工跑
buf lint idl与buf breaking idl --against '.git#branch=main,subdir=idl'(注意buf不参与 CI,最终裁决者是SCHEMA_LOCK)。 - 改生成器:跑
idl/codegen/tests/test_convenience_generators.py(必要时--update-golden),再确认ci-drift-check.sh通过。
从 schema 编辑到六语言绑定再到底层 C 默认值,idl/ 的这套"单一事实来源 + 固定工具链 + 锁文件门禁"设计,是整个 RunAnywhere SDK 跨平台一致性的地基。