ChipCode 的七类 transport:一个 JSON 怎么统一九种工具链
ChipCode 接的九条工具链,交互方式几乎没有一处相同。
Arduino 是命令行,arduino-cli 跑完输出一堆文本;ESP-IDF 也是命令行,但构建系统是 CMake 套一层 Python;STM32Cube 生成 Makefile 再调 make;51 用 SDCC 编译、stcgal 烧录,两个独立进程;OpenSCAD 是渲染成图片;立创EDA 要走它自己的 HTTP 网关;Wokwi 是浏览器里的仿真;再加上插件开发脚手架和浏览器自动化。
九种东西,如果每个都写一套对接代码,插件层就会变成九个特例的集合。加第十个工具链的时候,你需要先读一遍前九个是怎么写的。
我们的解法是:先抽象“插件怎么跟外界说话”,而不是“插件是什么”。
抽象的落点:七个动词
把九种工具链的交互拆开,会发现它们跟宿主的接触面只有七类:
| transport | 含义 | 典型场景 |
|---|---|---|
cli |
起一个进程,读它的 stdout/stderr 和退出码 | arduino-cli、make、sdcc、stcgal |
http |
发请求等响应 | 立创EDA 网关、自建的本地服务 |
grpc |
长连接、强类型 | 需要流式返回的后端 |
mcp |
走 Model Context Protocol | 把外部工具当工具调用 |
mqtt |
发布订阅,设备侧 | 板子联调、远程设备 |
internal |
复用宿主自己的能力 | 文件系统、串口、编译缓存 |
backend |
挂一个长驻 stdio 后端 | 需要保持状态的子进程 |
这个分类不是先验设计出来的,是把九个实现摊在桌上,把重复的部分圈出来,发现刚好七堆。
清单怎么表达
插件的 capabilities 是“能力名 → 通道描述”的映射:
{
"capabilities": {
"compile": {
"transport": "cli",
"command": "arduino-cli",
"args": ["compile", "--fqbn", "{fqbn}", "{sketchDir}"],
"placement": "cwd"
},
"flash": {
"transport": "cli",
"command": "arduino-cli",
"args": ["upload", "-p", "{port}", "--fqbn", "{fqbn}", "{sketchDir}"]
},
"monitor": {
"transport": "internal",
"service": "serial",
"method": "open"
}
}
}
{fqbn}、{port}、{sketchDir} 这些是宿主注入的占位符,来自当前项目的板卡配置和用户选择的端口。
注意 compile 和 flash 用的是同一个 command,只是参数不同。这不是巧合——通道描述的是“怎么执行”,不是“执行什么”。同一份执行机制可以被多个能力复用。
运行时怎么路由
宿主侧有一个 capability key 到 driver 的映射:
const drivers: Record<Transport, Driver> = {
cli: cliDriver,
http: httpDriver,
grpc: grpcDriver,
mcp: mcpDriver,
mqtt: mqttDriver,
internal: internalDriver,
backend: backendDriver,
};
async function invoke(cap: Capability, ctx: InvokeContext) {
const driver = drivers[cap.transport];
if (!driver) throw new Error(`no driver for ${
cap.transport}`);
return driver.run(cap, ctx);
}
每个 driver 的职责是统一的:接收能力描述和上下文,返回一个 ToolResult。ToolResult 里固定包含三样东西:退出状态、标准输出/错误、以及一个可选的产物列表。
统一返回类型是这套设计能成立的关键。因为上层(证据账本、阶段状态机)不关心你用的是 cli 还是 mqtt,它只关心“这次调用成没成、产出了什么”。
代价:测试矩阵翻倍
说实话,这套设计有它的账单。
七个 driver,每个都要处理:超时、取消、编码(Windows 下的 GBK 输出是个老问题)、退出码语义、错误信息格式化。加一条通道,测试矩阵就多一层——不是七乘九,是七种通道行为各自要覆盖所有调用方的场景。
我们为此付出的具体代价是:cli driver 里光“进程被用户取消”这一种情况就有三个测试用例(进程未启动、启动后未退出、退出但未收完输出)。这些用例不好写,但不写就会在真机上偶发卡死。
另一个代价是抽象泄漏。mqtt 通道的“一次调用”其实是发布一条消息然后等回复,天然是异步的;cli 是同步阻塞的。把它们塞进同一个 invoke 签名里,就必须给 mqtt driver 加一个内部的请求-响应关联层(用消息 id 匹配)。这层东西纯属为了对齐接口而存在,写起来不爽。
为什么不做成万能适配器
有人问过:为什么不搞一个通用的“命令模板 + 输出正则提取”,一个通道打天下?
因为它会在错误处理上崩掉。
cli 的失败有明确信号:退出码。http 的失败是状态码。mqtt 的失败是超时。grpc 的失败是状态枚举。这四者的“失败”语义完全不同,强行统一成一个“成功/失败”布尔值,会丢掉“失败得多严重”这个信息——而这个信息决定了下一步该重试、该换路子、还是该让人来看。
我们现在保留的粒度是:pass / fail / inconclusive 三态。inconclusive 专指“没拿到有效结论”——超时属于这一类,输出格式对不上也属于这一类。
把“不知道”和“失败”分开,是这套东西里最不起眼但最有用的一条设计。
现在的样子
九条工具链,七个 driver。新增一条工具链的平均成本是一次清单编写加零行业务代码——前提是它的交互能落进这七类里。
落不进去的时候,就加第八类。加之前我们会先问一句:是确实多了一种交互方式,还是前七类里某一类的实现不够好。
目前为止答案是后者居多。