01 SDK 契约中间表示与双发射器 本节摘要:OpenCode 的服务端 API 不是手写客户端的——它用一套契约驱动的代码生成(codegen)。本节讲这套 codegen 的核心:服务端的具体 HTTP API 是权威来源,被编译成一份中间表示(IR),再由两个独立发射器生成两种客户端。理解了这套「单一事实来源 + codegen」,你才知道为什么客户端永远不会和服务端漂移。 一、问题:客户端和服务端为什么会漂移 先看传统做法的痛点。如果手写客户端,经常出现「客户端和服务端漂移」: 服务端加了个新接口,客户端没跟上——客户端调用报「找不到」。 服务端改了某接口的参数,客户端还在用旧参数——调用出错。 服务端改了返回格式,客户端解析失败。
本节摘要:OpenCode 的服务端 API 不是手写客户端的——它用一套契约驱动的代码生成(codegen)。本节讲这套 codegen 的核心:服务端的具体 HTTP API 是权威来源,被编译成一份中间表示(IR),再由两个独立发射器生成两种客户端。理解了这套「单一事实来源 + codegen」,你才知道为什么客户端永远不会和服务端漂移。
先看传统做法的痛点。如果手写客户端,经常出现「客户端和服务端漂移」:
根因是「客户端和服务端是两份独立代码,靠人保持同步」。人总会忘、总会慢一拍。规模越大,漂移越严重。
OpenCode 的解法是把服务端 API 当单一事实来源,通过 codegen 自动生成客户端:
服务端 HTTP API(权威,手写的) │ ▼ codegen 编译 │ 中间表示(IR) │ ▼ 发射器生成 │ 客户端(自动生成,不是手写)
关键认知:客户端是自动生成的,不是手写的。服务端 API 一改,重新跑 codegen,客户端自动更新。这从根本上消灭了漂移——客户端永远和服务端一致,因为它是从服务端「长出来」的。
codegen 的第一步是编译——从服务端的 HTTP API 反射出一份中间表示(Contract IR):
服务端 API(一堆路由定义) │ ▼ compile(反射) │ 中间表示 IR: { groups: [ { name: "session", endpoints: [...] }, { name: "provider", endpoints: [...] }, ... ] }
IR 是一份「API 的结构化描述」——有哪些分组、每个分组有哪些端点、每个端点的方法/路径/输入/输出是什么。它独立于具体语言,是「API 的纯数据表示」。
为什么要先编成 IR?因为「从 API 直接生成客户端」会让生成器和 API 耦合太紧。先编成 IR,生成器只依赖 IR,不依赖 API——这样生成器可以独立演进,API 改了只要重新编 IR。
有了 IR,用两个独立发射器生成两种客户端:
| 发射器 | 产物 | 特点 |
|---|---|---|
| emitPromise | Promise 客户端 | 零 Effect 依赖、轻量、返回异步迭代 |
| emitEffect | Effect 客户端 | 富投影、保留全部类型、与效应生态集成 |
IR ──emitPromise──► Promise 客户端(给不用 Effect 的人) │ └──emitEffect──► Effect 客户端(给用 Effect 的人)
为什么两种?因为用户群体不同:
💡 同一份 IR 出两种客户端:这是 codegen 的威力——API 定义一次,生成两种风格客户端,覆盖不同用户。加新接口时,两种客户端自动同步更新。
两个发射器分别生成到不同目录:
generated),含所有 API 的 Promise 调用代码。generated-effect),含所有 API 的效应调用代码。这些代码是自动生成的,不该手改(改了下次重新生成会被覆盖)。用户把它们当库用——import 进来调 API。
codegen 有几个管理细节值得知道:
这些细节让 codegen 既安全(不误删手写)又干净(代码可读)。
codegen 不是什么都生成。它只生成 HTTP API 派生的客户端——也就是「调用服务端 API」的客户端代码。它不生成:
所以 codegen 的边界很清楚:它生成「怎么调 API」,不生成「API 怎么实现」。实现是服务端的事,调用是客户端的事,codegen 自动化后者。
IR 和双发射器讲清了,下一节详讲两种客户端的取舍。