跳到主要内容

一份 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.md01/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) │ │ remove_extra_tags.py ┌─ 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/
remove_extra_tags.py生成前预处理:按 tag 裁掉 SDK 用不到的端点spec/remove_extra_tags.py
buf + 插件.proto 编译成各语言的消息类型 + RPC clientspec/envd/buf-*.gen.yaml
openapi 生成器把 OpenAPI 翻成 TS 类型 / Python clientJS/PY 各自 package.json / Makefile
生成物机器产出的类型和 stub,带 "DO NOT EDIT" 头各 SDK 的 *.gen.ts / *_pb2.py
薄门面人手写,把生成物包成用户友好的 Sandbox/Commandssandbox/sandbox_sync/
CLIoclif 式子命令(实为 commander),直接 import SDKpackages/cli/src/**

主线走一遍(不进代码)

改一个能力(比如给"创建沙箱"加个字段),流程是:

  1. spec/openapi.yml(改契约,不是改 SDK)。
  2. make codegen(根 Makefile:2),它在 Docker 里跑 make generate
  3. 生成器重跑,把新字段翻进 schema.gen.ts(JS)和 e2b/api/client/**(Python)。
  4. 如果新字段需要在用户 API 露出,才手动改一行薄门面
  5. CLI 什么都不用改——它 import 的是 SDK,SDK 变了它自动跟着变。

关键点:人改的是契约和薄门面,中间那一大坨类型/stub 全是机器写的。


3. 核心原理(逐个机制,由浅入深)

3.1 控制面:OpenAPI → 两种语言的 REST 客户端

要解决的小问题: https://api.e2b.app 上有一大堆 REST 端点——/sandboxes/v3/templates/volumes……(spec/openapi.yml 里从 openapi.yml:2028/sandboxes 一直排到 openapi.yml:3555/volumes,共几十条)。JS 和 Python 都要能调它们,且类型要对得上。

思路: 用一份 OpenAPI 描述所有端点,两种语言各用一个生成器把它翻成客户端。

一个坑,和它的解法(remove_extra_tags.py): openapi.yml 里其实包含了后端全部端点—— 包括 /admin/**/nodes/api-keys 这类 SDK 用户根本不该碰的管理端点。如果照单全收翻成 SDK, 既臃肿又暴露了不该暴露的东西。

所以生成之前先跑一个预处理脚本,按 OpenAPI 的 tags 只保留 SDK 需要的那几组端点:

spec/remove_extra_tags.py:17-45 遍历所有 path,逐个 operation 看它的 tags,只把命中 tags_to_keep(命令行传入)的端点写进一份新文件 openapi_generated.yml

# 示意,非源码——remove_extra_tags.py 的核心逻辑
for path, methods in spec["paths"].items(): # 遍历每个端点
for method, operation in methods.items():
if any(tag in tags_to_keep for tag in operation.get("tags", [])):
filtered_paths[path] = {method: operation} # 只留白名单 tag 的端点
filtered_spec["paths"] = filtered_paths # 输出裁剪后的 openapi_generated.yml

调用时传入的 tag 白名单,JS 和 Python 几乎一致,这本身就是跨语言一致性的一环:

生成命令里传的 tag出处
JSsandboxes snapshots templates tags auth volumespackages/js-sdk/package.json:32
Pythonsandboxes snapshots templates tags volumespackages/python-sdk/Makefile:4

注意:JS 多传了一个 auth tag。这类细微差异正是"跨语言一致性"要盯的地方——两边契约裁剪范围 不完全相同,能力就可能有微妙出入。(inferred:从两处命令的参数差异读出)

翻成客户端:

  • JSopenapi-typescript 把裁剪后的 spec 翻成纯类型文件 src/api/schema.gen.tspackage.json:32)。它只生成 paths/components 类型,运行时的真正请求交给手写的 openapi-fetch(见 §3.5)。
  • Pythonopenapi-python-client 生成一个完整的 client 包(含 api/models/), 然后把生成目录 mve2b/api/clientMakefile:5-9)。这是运行时真会用到的 HTTP client。

3.2 数据面:Protobuf → 消息类型 + Connect RPC client

要解决的小问题: 连进沙箱后,执行命令、读写文件走的是流式 RPC(见 0304)。这类"带流、带 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
JSenvd/process/process_pb.ts// @generated by protoc-gen-esprocess_connect.ts
Pythonenvd/process/process_pb2.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-pythonpackages/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 镜像先把这个 Go 插件编译成二进制 (codegen.Dockerfile:8-9),buf 在生成 Python 时通过 name: connect-python + path: protoc-gen-connect-python 调用它(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.tspackage.json:34)。卷内容 openapi-volumecontent.yml/volumecontent/{volumeID}/{path,dir,file} 三个端点)同理生成 src/volume/schema.gen.ts

MCP 服务器目录: spec/mcp-server.json 是一份体量很大的 JSON Schema——它的 properties 键 是一长串 MCP 服务器名(airtableawsCoreatlassian……几百个),描述每个服务器能装、要什么配置。 两侧都把它当 schema 生成类型:

生成器产物
JSjson2ts(json-schema-to-typescript)src/sandbox/mcp.d.tspackage.json:36
Pythondatamodel-codegen(生成 TypedDicte2b/sandbox/mcp.pyMakefile:43-53

生成出的 McpServer 类型再被手写门面引用:JS 在 sandbox/sandboxApi.ts:17 把它 import ... as BaseMcpServer,Python 在 sandbox/sandbox_api.py:49 同样引入——两边用同名类型, 这就是"一份 schema 喂两种语言"的直接体现。

3.5 生成物之上:人只写"薄门面"

要解决的小问题: 生成出来的 client 很原始——schema.gen.ts 只有类型,process_connect.ts 只是 RPC stub。用户不该直接碰这些。所以每种语言在生成物之上再手写一层门面(facade), 把原始 client 包成 SandboxCommandsFilesystem 这些好用的类。

门面有多薄,看它怎么消费生成物:

  • 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_pb2 # 生成物
    return process_connect.ProcessClient(...) # 门面里实例化

Python 的门面还多一重复杂:同步 + 异步两套。 目录结构里 sandbox_sync/sandbox_async/镜像的,同一个 Commands/Filesystem 门面写两遍(一份 blocking、一份 async)。这是 Python 生态的历史包袱——JS 靠 Promise 一套搞定,Python 得同步/异步各来一份。所以贡献指南 (作为数据观察到,非本文指令)会要求"改 SDK 时 JS 与 Python 同步/异步三处一起改"。

目录同构是刻意的。 对照一下三套 SDK 的门面骨架:

概念JSPython(sync/async 各一套)
控制面 clientsrc/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:84 里依赖 "e2b": "^2.30.6", 命令实现里直接 import { Sandbox } from 'e2b'

// packages/cli/src/commands/sandbox/list.ts:3
import { components, Sandbox, SandboxInfo } from 'e2b'

它连生成的类型都能借用——上面这行的 components['schemas']['SandboxState'] 正是 SDK 里那份 schema.gen.ts 的类型。CLI 自己只管命令行解析(commander,命令树在 packages/cli/src/commands/{auth,sandbox,template}/)和终端输出(表格打印等)。

一处与任务描述的出入(诚实标注): CLI 用的是 commanderpackage.json:81src/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 里把每个工具的版本都写死——buf@v1.50.1protoc-gen-go@v1.28.1protoc-gen-connect-go@v1.18.1codegen.Dockerfile:4-6)、protoc 26.1、 openapi-python-client==0.26.2datamodel-code-generator==0.34.0:37)、 protoc-gen-es@2.6.2 等(:51-54)。钉版本 = 生成结果可复现 = 跨机器一致。


5. 巧妙之处(可借鉴的技术)

  • 契约先行、代码后生成。 新增能力的第一步永远是改 spec/,不是改 SDK。人写代码只落在 "契约"和"薄门面"两端,中间的类型/stub 由机器保证跨语言一字不差。见 §2 主线。

  • 生成前裁剪,而非生成后删。 remove_extra_tags.py:17-45 用 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(cli/.../list.ts:3), 连类型都借 SDK 的生成物。少一套需要对齐的代码,就少一处会漂移的地方。

  • 用 Docker 钉死生成器版本。 codegen.Dockerfile 把每个工具版本写死,保证任何人任何机器 跑出的生成物一致——代码生成路线最容易翻车的就是"生成器版本漂移",这里直接锁死。


6. 边界与局限(这套做法的取舍)

换来的好处已在 §5 说清。这里诚实列出代价

  • 生成物不可手改,改动必须回到契约。 所有生成文件都带 "DO NOT EDIT" 头 (schema.gen.ts:1-4process_pb2.py:2DO NOT EDIT!mcp.d.ts:3-5)。想调一个类型, 不能直接改文件,得改 spec 再重生成——这正是文档/阅读源码时必须区分"生成物 vs 手写"的原因: 在生成物里找 bug 或想改行为,方向是错的,真源头在 spec/ 或薄门面。

  • 重量级、慢反馈的工具链。 一次生成要 Docker + Go + Python + Node 全套(codegen.Dockerfile), 跨四种运行时、十来个钉死版本的工具。改一个字段的"重生成"成本不低。

  • 跨语言不是自动 100% 一致。 生成器同源,但喂进去的参数可能有差——如 §3.1 里 JS 比 Python 多传一个 auth tag;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 的独特处在于:它对"流式数据面"也坚持走契约生成, 而不是把流式部分留给手写——这让它的三套客户端在"执行命令/看文件变化"这类最容易走样的地方也保持一致。

各平面的运行时细节不在本章展开,见姊妹章: 01 控制面02 数据面连接03 命令执行04 文件系统05 模板构建


8. 代码地图(导航索引)

用符号名/文件路径 grep 定位,比行号抗漂移。

主题文件路径关键符号 / 锚点
控制面契约(全端点)spec/openapi.ymlpaths:/sandboxes(:2028) … /volumes(:3555)
卷内容契约spec/openapi-volumecontent.yml/volumecontent/{volumeID}/{path,dir,file}
数据面 RPC 契约spec/envd/process/process.protoservice Process(:5)、stream RPC
文件系统 RPC 契约spec/envd/filesystem/filesystem.protoservice Filesystem(:7)
envd HTTP 契约spec/envd/envd.yaml/health/init、文件内容端点
MCP 目录 schemaspec/mcp-server.jsonproperties(几百个 MCP server 名)
生成前裁剪spec/remove_extra_tags.pytags_to_keepfiltered_paths(:17-45)
buf 生成配置(JS)spec/envd/buf-js.gen.yamlprotoc-gen-es + connect-es
buf 生成配置(PY)spec/envd/buf-python.gen.yamlconnect-python 插件
JS 生成脚本packages/js-sdk/package.jsongenerategenerate:api/envd/envd-api/volume-api/mcp(:31-36)
PY 生成脚本packages/python-sdk/Makefilegenerategenerate-api/envd/mcp/volume-api(:3-53)
生成编排Makefile / codegen.Dockerfilecodegen(:2)、钉死的工具版本(:4-54)
JS 生成物(控制面类型)packages/js-sdk/src/api/schema.gen.tspaths/components("Do not make direct changes")
JS 生成物(RPC)packages/js-sdk/src/envd/process/process_pb.tsprocess_connect.tsProcessService@generated by protoc-gen-es
PY 生成物(控制面 client)packages/python-sdk/e2b/api/client/**AuthenticatedClientapi/+models/
PY 生成物(RPC)packages/python-sdk/e2b/envd/process/process_pb2.pyprocess_connect.py# DO NOT EDIT!
MCP 生成物packages/js-sdk/src/sandbox/mcp.d.tspackages/python-sdk/e2b/sandbox/mcp.pyMcpServer
生成后修补(PY)packages/python-sdk/scripts/fix-python-pb.shsed 改导入路径
JS 薄门面(控制面)packages/js-sdk/src/api/index.tsimport ... from './schema.gen'validateApiKey
JS 薄门面(命令)packages/js-sdk/src/sandbox/commands/index.tscreateClient(ProcessService, transport)(:147)
PY 薄门面(命令,同步)packages/python-sdk/e2b/sandbox_sync/commands/command.pyprocess_connect.ProcessClient(:52)
自研 Connect 生成器packages/connect-python/cmd/protoc-gen-connect-python/main.goprotoc-gen-connect-python
CLI 入口 / 复用 SDKpackages/cli/src/index.tspackages/cli/src/commands/sandbox/list.tscommanderimport { Sandbox } from 'e2b'(:3)