数据截至 (上游 commit b71439b0797e)
一份 spec,多个 SDK:代码生成与跨语言一致性
30 秒导读: E2B 要同时维护 JavaScript SDK、Python SDK(还分同步/异步两套)和一个命令行工具。 三套客户端要对同一个后端说同样的"话",靠的不是三拨人手写、互相对齐,而是把"该说什么话"写成 一批契约文件放在
spec/目录里,再用代码生成器把契约翻译成每种语言的客户端骨架。 本章讲这套"契约优先 + 代码生成"的机制:契约长什么样、生成链路怎么跑、生成物和手写代码的界线在哪、 以及这种做法换来了什么、又付出了什么。
1. 这是什么(零基础也能懂)
一个很实际的麻烦
假设你在做一个云服务,后端只有一套,但你要给用户三种接入方式:
- 一个 JavaScript/TypeScript 库(前端和 Node 用)
- 一个 Python 库(数据科学、AI agent 用)
- 一个 命令行工具 CLI(在终端里敲命令)
这三者都要调用同一个后端 API:创建沙箱、列出沙箱、构建模板、在沙箱里跑命令……
最朴素的做法是三拨人各写各的 HTTP 请求代码。问题马上来了:后端加了一个字段,三边要各改一次; 某一边把参数名拼错了,行为就悄悄不一致了;文档、类型、默认值三份,迟早对不上。
E2B 的解法:让机器去对齐
E2B 的做法是:把"后端接口长什么样"写成一份机器可读的契约,然后让代码生成器把契约翻译成每种语言的客户端。
一句话直觉——契约是"真理的唯一来源"(single source of truth),各语言 SDK 是它的"翻译件"。 就像一份法律合同先用中立的法律语言写死,再请翻译分别译成英文、日文、法文版:只要原件改了, 重新翻译一遍,三个译本自动同步;没人被允许"凭记忆"手写译本。
它由两类契约驱动两个平面
E2B 的系统分两个平面(详见 index.md 与 01/02), 契约也就分两大类:
| 平面 | 契约用什么写 | 管什么 |
|---|---|---|
| 控制面(编排器 REST API) | OpenAPI(spec/openapi.yml 等) | 沙箱生命周期、模板、卷等 https://api.e2b.app 上的 REST 端点 |
| 数据面(沙箱内 envd) | Protobuf(spec/envd/*/*.proto)+ OpenAPI(envd.yaml) | 连进沙箱后执行命令、读写文件的 RPC 与 HTTP |
外加一个特殊的:MCP 服务器目录(spec/mcp-server.json),是一个巨大的 JSON Schema,
描述"沙箱里可以装哪些 MCP 服务器、各自要什么配置"。它也被生成成类型。
2. 顶层全景(它大概怎么转)
一张图看懂"契约 → 生成器 → 三套客户端"
下面这张图从左到右是数据流向:左边是人手写的契约,中间是生成器,右边是生成物(机器产出、不许手改)。 最右边一列的薄门面才是人写的、给用户用的 API。
spec/(契约,人写) 生成器 各 SDK 里的生成物(勿手改)
┌──────────────────────┐
│ openapi.yml │──┐
│ (控制面 REST) │ │ redocly.yaml 裁剪 ┌─ JS: src/api/schema.gen.ts
│ │ ├─► (按 tag 裁剪) ─► openapi- ──┤
│ openapi- │ │ typescript / -python-client └─ PY: e2b/api/client/**
│ volumecontent.yml │──┘ │ e2b/volume/client/**
│ │
│ envd/process.proto │──┐ ┌─ JS: envd/**/ *_pb.ts
│ envd/filesystem.proto│ ├─► buf generate ──────────────┤ envd/**/ *_connect.ts
│ (数据面 RPC) │──┘ (protoc-gen-es / -python) └─ PY: e2b/envd/**/ *_pb2.py
│ │ │ *_connect.py
│ envd/envd.yaml │────► openapi-typescript ────────► JS: src/envd/schema.gen.ts
│ (envd HTTP) │
│ │
│ mcp-server.json │────► json2ts / datamodel- ──────┐─ JS: src/sandbox/mcp.d.ts
│ (MCP 目录 schema) │ codegen └─ PY: e2b/sandbox/mcp.py
└──────────────────────┘
┌───────────────────────────────┐
生成物之上,人手写"薄门面"(facade): ──────────► │ Sandbox / Commands / Filesystem │
把生成的原始 client 包成好用的类 │ (JS & Python 同构;CLI 复用 SDK)│
└───────────────────────────────┘
部件一句话职责
| 部件 | 干什么 | 在哪 |
|---|---|---|
| 契约文件 | 描述接口"长什么样",唯一真源 | spec/ |
redocly.yaml 裁剪配置 | 生成前预处理:按 tag 裁掉 SDK 用不到的端点 | redocly.yaml |
| buf + 插件 | 把 .proto 编译成各语言的消息类型 + RPC client | spec/envd/buf-*.gen.yaml |
| openapi 生成器 | 把 OpenAPI 翻成 TS 类型 / Python client | JS/PY 各自 package.json / Makefile |
| 生成物 | 机器产出的类型和 stub,带 "DO NOT EDIT" 头 | 各 SDK 的 *.gen.ts / *_pb2.py 等 |
| 薄门面 | 人手写,把生成物包成用户友好的 Sandbox/Commands 类 | sandbox/、sandbox_sync/… |
| CLI | oclif 式子命令(实为 commander),直接 import SDK | packages/cli/src/** |
主线走一遍(不进代码)
改一个能力(比如给"创建沙箱"加个字段),流程是:
- 改
spec/openapi.yml(改契约,不是改 SDK)。 - 跑
make codegen(根Makefile:2),它在 Docker 里跑make generate。 - 生 成器重跑,把新字段翻进
schema.gen.ts(JS)和e2b/api/client/**(Python)。 - 如果新字段需要在用户 API 露出,才手动改一行薄门面。
- CLI 什么都不用改——它 import 的是 SDK,SDK 变了它自动跟着变。
关键点:人改的是契约和薄门面,中间那一大坨类型/stub 全是机器写的。
3. 核心原理(逐个机制,由浅入深)
3.1 控制面:OpenAPI → 两种语言的 REST 客户端
要解决的小问题: https://api.e2b.app 上有一大堆 REST 端点——/sandboxes、/v3/templates、
/volumes……(spec/openapi.yml 里从 openapi.yml:2335 的 /sandboxes 一直排到 openapi.yml:4042
的 /volumes,共几十条)。JS 和 Python 都要能调它们,且类型要对得上。
思路: 用一份 OpenAPI 描述所有端点,两种语言各用一个生成器把它翻成客户端。
一个坑,和它的解法(redocly 的 filter-in decorator): openapi.yml 里其实包含了后端全部端点——
包括 /admin/**、/nodes、/api-keys 这类 SDK 用户根本不该碰的管理端点。如果照单全收翻成 SDK,
既臃肿又暴露了不该暴露的东西。
所以生成之前先跑 redocly bundle,按 OpenAPI 的 tags 只保留 SDK 需要的那几组端点
(旧版用一个预处理脚本 remove_extra_tags.py 干这件事,现已被 redocly decorator 取代):
redocly.yaml:24-31 给每个 SDK 声明 filter-in(按 tags 白名单保留 operation)和
filter-out(丢弃上游标了 x-not-implemented: true 的节点):
# 示意,非源码——redocly.yaml 的裁剪核心(js-sdk 侧)
decorators:
filter-in:
property: tags
value: [sandboxes, snapshots, templates, tags, auth, volumes, secrets]
matchStrategy: any
applyTo: Operation # 只留白名单 tag 的端点
调用时传入的 tag 白名单,JS 和 Python 几乎一致,这本身就是跨语言一致性的一环:
| 侧 | 生成命令里传的 tag | 出处 |
|---|---|---|
| JS | sandboxes snapshots templates tags auth volumes secrets | redocly.yaml:26 |
| Python | sandboxes snapshots templates tags volumes secrets | redocly.yaml:37 |
注意:JS 多传了一个
authtag。这类细微差异正是"跨语言一致性"要盯的地方——两边契约裁剪范围 不完全相同,能力就可能有微妙出入。(inferred:从两处命令的参数差异读出)
翻成客户端:
- JS 用
openapi-typescript把裁剪后的 spec 翻成纯类型文件src/api/schema.gen.ts(package.json:32)。它只生成paths/components类型,运行时的真正请求交给手写的openapi-fetch(见 §3.5)。 - Python 用
openapi-python-client生成一个完整的 client 包(含api/和models/), 然后把生成目录mv成e2b/api/client(Makefile:5-9)。这是运行时真会用到的 HTTP client。
3.2 数据面:Protobuf → 消息类型 + Connect RPC client
要解决的小问题: 连进沙箱后,执行命令、读写文件走的是流式 RPC(见 03、 04)。这类"带流、带 oneof、双向"的协议 ,OpenAPI 表达不了,得用 Protobuf。
契约长什么样: spec/envd/process/process.proto 定义了 Process 服务,注意几条流式 RPC——
返回值带 stream 的就是服务端流:
service Process { // process.proto:5
rpc List(ListRequest) returns (ListResponse);
rpc Connect(ConnectRequest) returns (stream ConnectResponse); // 服务端流
rpc Start(StartRequest) returns (stream StartResponse); // 服务端流
rpc StreamInput(stream StreamInputRequest) returns (StreamInputResponse); // 客户端流
...
}
filesystem.proto:7-20 里的 Filesystem 服务同理,WatchDir 是服务端流,其余是普通一问一答。
生成器:buf。 buf 是 protobuf 的现代构建工具,配置在两个 .gen.yaml 里,一个平台一份:
spec/envd/buf-js.gen.yaml:用protoc-gen-es(生消息类型)+connect-es(生 RPC client), 输出到packages/js-sdk/src/envd。spec/envd/buf-python.gen.yaml:用protoc(Python,生*_pb2.py)+ 自研的connect-python插件,输出到packages/python-sdk/e2b/envd。
产出的文件(都带机器生成头):
| 语言 | 消息类型 | RPC client |
|---|---|---|
| JS | envd/process/process_pb.ts(// @generated by protoc-gen-es) | process_connect.ts |
| Python | envd/process/process_pb.py(# DO NOT EDIT!) | process_connect.py |
filesystem 目录下同样成对。
为什么用 Connect 协议: Connect 是一套能跑在普通 HTTP/1.1 上的 gRPC 风格协议,浏览器和
Python httpx 都能直连,不需要 gRPC 那套 HTTP/2 底座。这正是 E2B 要的:SDK 得能从浏览器里
直接连进沙箱。
3.3 connect-python:为什么要自己写一个生成器插件
要解决的小问题: JS 有官方的 connect-es 插件生成 Connect client,Python 生态里当时没有
现成的对应物。E2B 只好自己造一个。
它是什么: packages/connect-python 是一个 Go 写的 protoc 插件 protoc-gen-connect-python
(packages/connect-python/cmd/protoc-gen-connect-python/main.go),外加一个 Python 运行时。
它的 README 直说自己是"Connect RPC 协议的 Python 客户端实现",还挂着一句 "🚧 Currently pending
an open RFC to be moved into the Connect RPC org 🚧"——即这是 E2B 先行造的、盼着被官方收编的轮子。
在链路里的位置: codegen 的 Docker 镜像把它与 protoc-gen-py 一起 pip 装进镜像
(codegen.Dockerfile:24),buf 在生成 Python 时通过 name: py + path: protoc-gen-py 生成
protobuf 消息、name: connectrpc + path: protoc-gen-connectrpc 生成 Connect 客户端(buf-python.gen.yaml)。
一个生成后的小修补(fix-python-pb.sh): protobuf 的 Python 生成物默认用
from process import ... 这种"平铺"导入,在包结构里会 import 失败。E2B 用一个 sed 脚本
把它改成带完整包路径的导入:
# packages/python-sdk/scripts/fix-python-pb.sh —— 生成后修补导入路径
sed -i.bak 's/from process import/from e2b.envd.process import/g' e2b/envd/process/* ...
sed -i.bak 's/from filesystem import/from e2b.envd.filesystem import/g' ...
这类"生成后打补丁"是代码生成路线常见的必要之恶:生成器不完美,就在链路里加一步收拾。
3.4 envd HTTP 与 MCP 目录:另外两条小生成线
除了上面两条主线,还有两条较小的:
envd 的 HTTP 面(文件内容通道): spec/envd/envd.yaml 是一份小 OpenAPI,描述 envd 上
/health、/metrics、/init 以及文件内容的 HTTP 端点(文件读写的数据走 HTTP,元数据走
RPC,见 04)。它只在 JS 侧用 openapi-typescript 生成
src/envd/schema.gen.ts(package.json:34)。卷内容 openapi-volumecontent.yml
(/volumecontent/{volumeID}/{path,dir,file} 三个端点)同理生成 src/volume/schema.gen.ts。
MCP 服务器目录: spec/mcp-server.json 是一份体量很大的 JSON Schema——它的 properties 键
是一长串 MCP 服务器名(airtable、awsCore、atlassian……几百个),描述每个服务器能装、要什么配置。
两侧都把它当 schema 生成类型:
| 侧 | 生成器 | 产物 |
|---|---|---|
| JS | json2ts(json-schema-to-typescript) | src/sandbox/mcp.d.ts(package.json:36) |
| Python | datamodel-codegen(生成 TypedDict) | e2b/sandbox/mcp.py(Makefile:43-53) |
生成出的 McpServer 类型再被手写门面引用:JS 在 sandbox/sandboxApi.ts:21 把它
import ... as BaseMcpServer,Python 在 sandbox/sandbox_api.py:71 同样引入——两边用同名类型,
这就是"一份 schema 喂两种语言"的直接体现。
3.5 生成物之上:人只写"薄门面"
要解决的小问题: 生成出来的 client 很原始——schema.gen.ts 只有类型,process_connect.ts
只是 RPC stub。用户不该直接碰这些。所以每种语言在生成物之上再手写一层门面(facade),
把原始 client 包成 Sandbox、Commands、Filesystem 这些好用的类。
门面有多薄,看它怎么消费生成物:
- JS 控制面门面
src/api/index.ts手写鉴权、错误处理、日志,底层直接createClient一个openapi-fetch,类型来自生成的schema.gen:import type { components, paths } from './schema.gen' // 用生成的类型 - JS 命令门面
src/sandbox/commands/index.ts:147把生成的ProcessService一行接上 transport:this.rpc = createClient(ProcessService, transport) // ProcessService 来自生成的 *_connect - Python 命令门面
e2b/sandbox_sync/commands/command.py:15,52同样 import 生成物、包成 client:from e2b.envd.process import process_connect, process_pb # 生成物return process_connect.ProcessClient(...) # 门面里实例化
Python 的门面还多一重复杂:同步 + 异步两套。 目录结构里 sandbox_sync/ 和 sandbox_async/
是镜像的,同一个 Commands/Filesystem 门面写两遍(一份 blocking、一份 async)。这是
Python 生态的历史包袱——JS 靠 Promise 一套搞定,Python 得同步/异步各来一份。所以贡献指南
(作为数据观察到,非本文指令)会要求"改 SDK 时 JS 与 Python 同步/异步三处一起改"。
目录同构是刻意的。 对照一下三套 SDK 的门面骨架:
| 概念 | JS | Python(sync/async 各一套) |
|---|---|---|
| 控制面 client | src/api/ | e2b/api/(client_sync/client_async) |
| 命令执行 | src/sandbox/commands/ | e2b/sandbox_{sync,async}/commands/ |
| 文件系统 | src/sandbox/filesystem/ | e2b/sandbox_{sync,async}/filesystem/ |
| 模板构建 | src/template/ | e2b/template_{sync,async}/ |
结构同构 + 生成物同源 = 跨语言行为一致,而且新人换语言几乎零学习成本。
3.6 CLI:几乎不碰契约,直接站在 SDK 肩上
要解决的小问题: CLI 也要能创建/列出沙箱、构建模板。它要不要也从 spec 生成一套?不要。
CLI 直接把 JS SDK 当依赖 import。 packages/cli/package.json:83 里依赖 "e2b": "workspace:^"
(monorepo 内直接指向本仓库的 js-sdk),命令实现里直接 import { Sandbox } from 'e2b':
// packages/cli/src/commands/sandbox/list.ts:4
import { components, Sandbox, SandboxInfo } from 'e2b'
它连生成的类型都能借用——上面这行的 components['schemas']['SandboxState'] 正是 SDK 里那份
schema.gen.ts 的类型。CLI 自己只管命令行解析(commander,命令树在
packages/cli/src/commands/{auth,sandbox,template}/)和终端输出(表格打印等)。
一处与任务描述的出入(诚实标注): CLI 用的是
commander(packages/cli/package.json:82、src/index.ts:4),不是 oclif。目录按auth/sandbox/template分子命令,风格类似 oclif, 但框架是 commander。
所以 CLI 是最省事的一层: 契约变了 → SDK 重生成 → CLI 只要升级 e2b 依赖版本就跟着有了新能力。
4. 生成链路怎么被一条命令串起来
上面各条线,最终由一层层 make/pnpm 脚本编排。想改契约的人只记一条命令:make codegen。
从顶到底的调用栈:
make codegen (根 Makefile:2)
└─ docker build -f codegen.Dockerfile (造一个装好所有生成器的镜像)
└─ docker run ... make generate (在容器里跑,环境干净可复现)
├─ make generate-js ─► cd packages/js-sdk && pnpm generate
│ └─ npm-run-all generate:* (package.json:31)
│ ├─ generate:api openapi-typescript → schema.gen.ts
│ ├─ generate:envd buf generate (buf-js.gen.yaml)
│ ├─ generate:envd-api openapi-typescript → envd/schema.gen.ts
│ ├─ generate:volume-api openapi-typescript → volume/schema.gen.ts
│ └─ generate:mcp json2ts → mcp.d.ts
└─ make generate-python ─► cd packages/python-sdk && make generate
└─ generate: generate-api generate-envd generate-mcp generate-volume-api
(python-sdk/Makefile:28)
为什么套一层 Docker(codegen.Dockerfile): 生成器版本必须钉死,否则不同机器生成出的
代码有微妙差异。Dockerfile 里把每个工具的版本都写死——[email protected]、[email protected]、
[email protected](codegen.Dockerfile:4-6)、protoc 26.1、
openapi-python-client==0.26.2、datamodel-code-generator==0.34.0(:37)、
[email protected] 等(:51-54)。钉版本 = 生成结果可复现 = 跨机器一致。
5. 巧妙之处(可借鉴的技术)
-
契约先行、代码后生成。 新增能力的第一步永远是改
spec/,不是改 SDK。人写代码只落在 "契约"和"薄门面"两端,中间的类型/stub 由机器保证跨语言一字不差。见 §2 主线。 -
生成前裁剪,而非生成后删。
redocly.yaml的filter-indecorator 用 OpenAPI 的tags在生成之前 就把 admin/nodes 等内部端点滤掉,SDK 里根本不会出现它们——比"生成全量再手工删"干净得多。 -
缺轮子就自己造,还想着回馈上游。 Python 没有 Connect 客户端生成器,E2B 就写了
connect-python(Go 插件 + Python 运行时),README 明说在推 RFC 进 Connect RPC 官方组织。 -
生成后打补丁是允许的现实。 protobuf Python 生成物导入路径不对,就用
fix-python-pb.sh一个 sed 收尾。承认生成器不完美,在链路里补一步,比手改生成文件健康。 -
CLI 复用 SDK 而非另起炉灶。 CLI 把
e2b当普通依赖 import(packages/cli/src/commands/sandbox/list.ts:3), 连类型都借 SDK 的生成物。少一套需要对齐的代码,就少一处会漂移的地方。 -
用 Docker 钉死生成器版本。
codegen.Dockerfile把每个工具版本写死,保证任何人任何机器 跑出的 生成物一致——代码生成路线最容易翻车的就是"生成器版本漂移",这里直接锁死。
6. 边界与局限(这套做法的取舍)
换来的好处已在 §5 说清。这里诚实列出代价:
-
生成物不可手改,改动必须回到契约。 所有生成文件都带 "DO NOT EDIT" 头 (
schema.gen.ts:1-4、process_pb.py:1的DO NOT EDIT!、mcp.d.ts:3-5)。想调一个类型, 不能直接改文件,得改 spec 再重生成——这正是文档/阅读源码时必须区分"生成物 vs 手写"的原因: 在生成物里找 bug 或想改行为,方向是错的,真源头在spec/或薄门面。 -
重量级、慢反馈的工具链。 一次生成要 Docker + Go + Python + Node 全套(
codegen.Dockerfile), 跨四种运行时、十来个钉死版本的工具。改一个字段的"重生成"成本不低。 -
跨语言不是自动 100% 一致。 生成器同源,但喂进去的参数可能有差——如 §3.1 里 JS 比 Python 多传一个
authtag;OpenAPI 生成器两侧也不同(JS 出纯类型、Python 出完整 client)。一致性靠 纪律维持,不是白送的。 -
同步/异步双写的 Python 门面。
sandbox_sync/与sandbox_async/镜像手写,同一处逻辑改两遍, 是这套结构里人力成本最高的部分( 生成器不管门面)。 -
自研
connect-python是维护负担。 在官方收编前,E2B 得自己养这个 Go 插件 + Python 运行时。
7. 横向对比(与 shelf 内 agent-runtime 兄弟库)
同处 ai-agent-reference 的 agent-runtime 货架上,各家"给多语言用户提供 SDK"的策略不同。E2B 的
标志性选择是把跨语言一致性下沉到代码生成器,而不是靠人对齐或靠单语言 + 跨语言胶水。
| 维度 | E2B 的取舍 |
|---|---|
| 一致性来源 | 契约 + 代码生成(机器保证),人只写门面 |
| 控制面协议 | OpenAPI REST,逐语言生成 client/类型 |
| 数据面协议 | Protobuf + Connect(能在浏览器/httpx 直连,不强依赖 gRPC HTTP/2) |
| 语言覆盖 | JS 一套 + Python 同步/异步两套 + CLI(复用 JS SDK) |
| 特色投入 | 自研 connect-python 生成器插件 |
要对比其它兄弟库如何分层各自的 SDK(例如以 Python 为主、其它语言薄封装,或纯 REST 不做流式),
可回到本 shelf 的 index.md 路由到对应子库。E2B 的独特处在于:它对"流式数据面"也坚持走契约生成,
而不是把流式部分留给手写——这让它的三套客户端在"执行命令/看文件变化"这类最容易走样的地方也保持一致。