ADR-0010:客户端 SDK 的形态与绑定策略¶
- 状态:已接受
- 日期:2026-08-16
- 相关:
docs/11-client-sdk.md、ADR-0002(客户端传输)、ADR-0008(登录对账)
背景¶
一期服务端已可运行,需要一个通信层 SDK 让上层应用接入。要求是「Rust 实现、 作为二进制依赖、以便支持各个客户端」。一期目标平台为桌面(Windows / macOS / Linux)。
需要裁决三件事:核心与绑定如何分层、绑定用什么技术、本地存储放在哪一侧。
决策¶
1. 纯 Rust 核心 + 独立绑定层,核心不含任何 FFI 痕迹¶
qim-sdk(纯 Rust,Rust 应用可直接依赖)+ 若干薄绑定 crate
(qim-sdk-uniffi / qim-sdk-ffi / qim-sdk-wasm,见 §2)。
核心里不出现 #[no_mangle]、extern "C"、裸指针或绑定生成器的宏。
理由:绑定技术的寿命远短于核心逻辑。UniFFI、flutter_rust_bridge、napi、
wasm-bindgen 各有适用面且都在演进,把它们的宏渗进核心,等于让每次绑定选型
变更都去动游标推进和对账这些最不该动的代码。
2. 绑定一律生成,不手写;核心只有一份¶
目标是"一个通用 SDK,不维护多套"。多套是怎么来的:不是有人存心写两遍, 而是手写胶水——每加一门语言就手抄一遍类型、错误码、事件枚举, 从此每改一个字段要改 N 处,漏改的那处就是下一个线上缺陷。 所以纪律是:绑定层只能由工具从核心的类型定义生成。
| 目标 | 生成器 | 覆盖 |
|---|---|---|
| Swift / Kotlin / Python | UniFFI | iOS、Android、桌面脚本层(社区后端另有 C# / Go / Dart) |
| JS / TS | wasm-bindgen | 浏览器、Electron 渲染进程 |
| C ABI 头文件 | cbindgen | 以上都不覆盖的语言(C / C++ / Tauri FFI / 任意 dlopen) |
公开面为此刻意约束为「句柄 + 值类型 + 单向事件流」:不含泛型、 生命周期、trait object 跨界,也不含回调型 trait。这个形状是三个生成器 约束的交集——任何一处越界(例如公开一个带生命周期的借用类型)都会让 其中一个生成器失效,从而逼出手写胶水,也就逼出"多套"。
一期先出 UniFFI + C ABI 两条(桌面即可用);wasm-bindgen 在浏览器端 立项时打开,不需要改核心(前提是 §3 的可移植约束从第一天就遵守)。
两条已落地(2026-08-17):qim-sdk-ffi(cdylib/staticlib + examples/demo.c)
与 qim-sdk-uniffi(scripts/gen-bindings.sh 出 Swift/Kotlin/Python 三份,
Python 绑定连真实服务端跑通端到端)。核心一行未为绑定而改——这是本 ADR
的可证伪点,两条绑定都落地后仍成立,说明 §2 的分层判断是对的。
两个绑定层的表达力差异必须写在这里,否则会被误当成实现随意:
- 事件形态:UniFFI 用真正的和类型(Swift enum + associated values、
Kotlin sealed class);C ABI 表达不了,只能"扁平结构体 + kind 判别"。
- 调用形态:UniFFI 是 async(Swift async / Kotlin suspend / Python await);
C ABI 只能给阻塞式 qim_next_event(timeout)。
- 相同的一处妥协:u128 两边都不过界,统一拆 (hi, lo) 两个 u64
(hi = v >> 64)。Swift 无 128 位整数、Kotlin 只有 BigInteger、
UniFFI 本身也不支持 u128。不退化为十进制字符串:id 会做等值判断与
哈希,字符串表示一旦有前导零或大小写差异就静默变成两个键。
3. 核心从第一天起按可移植约束编写¶
"通用"的硬边界在 WASM:浏览器里开不了原始 TCP、没有多线程 tokio、
没有 rusqlite、SystemTime::now 在部分目标直接 panic。这些不是绑定层
能补的差异——它们渗透进核心的每一个 async fn 签名。
现在遵守是机械工作,事后补是重写,因此五条约束即刻生效:
| # | 约束 | 不遵守的后果 |
|---|---|---|
| 1 | 核心不持有运行时:不用 std::thread,不假定多线程 tokio;任务派发经注入的 Spawn |
wasm32 单线程;移动端要用宿主线程策略 |
| 2 | Send/Sync 用 cfg 切换的 trait 别名统一表达,不散落 #[cfg] |
wasm32 的 future 是 !Send,散落的 cfg 会长成第二套代码路径 |
| 3 | 一切 IO 在 trait 后:Transport(帧收发)、LocalStore(§27.3.1 七表)、Clock |
换端 = 换实现,而不是换核心 |
| 4 | 核心不碰墙钟与随机,由注入提供 | 部分 wasm 目标 panic;顺带让状态机可确定性测试 |
| 5 | 核心零阻塞,全 async | 浏览器主线程不可阻塞 |
代价约为核心复杂度的一成,换掉的是一次浏览器端的重写。
Transport 的第二实现(WebSocket)与 LocalStore 的第二实现(IndexedDB)
在浏览器端立项时再写——接口现在定,实现按需补。
约束 2 的写法参考 Matrix Rust SDK 的
SendOutsideWasm/SyncOutsideWasm别名模式:同一份核心同时产出 native 与 wasm 两个目标,是这条路已被走通的证据。
4. 上层看不到 async;事件用拉取式而非回调¶
绑定层自持运行时(native 为 tokio 独立线程,wasm 为宿主事件循环),
公开面为「命令 + 事件流 + 状态快照」。
事件默认由上层调用 next_event(timeout) 拉取。
理由:跨 FFI 的回调要从 SDK 的 Rust 线程回调进宿主运行时,重入与线程亲和性 是这类 SDK 最主要的崩溃来源(宿主 UI 线程亲和、GC 语言的附加线程、 回调中再调 SDK 造成死锁)。拉取式把线程归属交还上层,每种语言都能自行包成 回调或 Stream;反向不成立。回调包装作为可选项提供。
事件队列有界且满时阻塞生产者,不丢弃:事件丢失会让 UI 与已落盘的本地库 产生无法自愈的分歧。
5. 本地库由 SDK 自带(SQLite),不做成上层注入点¶
rusqlite bundled + WAL,实现 §27.3.1 的七张表与前向迁移。
trait LocalStore 由 §3 约束 3 引入,一期两个实现(SQLite / 内存,
后者供测试与压测),浏览器端再加 IndexedDB —— 但这三者都是 SDK 内的实现,
不对上层暴露注入点。
理由:§27.3.2 的冲突解决规则与 §27.3.3 的迁移纪律(尤其"迁移失败必须连同 游标一起丢")是端上最容易写错、且写错就静默丢消息的部分。把它交给应用层 实现,等于把 SDK 存在的理由让渡出去。
一期不做落盘加密;SQLCipher 与密钥托管留 ADR-0011,届时只换一处实现。
6. qim-loadgen 改为依赖 qim-sdk¶
压测客户端即真实 SDK(用内存存储实现,避免 1200 个 SQLite 实例)。
理由不是省代码,是回归保护:SDK 的游标推进、去重、登录对账从此每次性能门禁
都在 1200 连接 × 180 秒下被真实执行,而不是只有单元测试覆盖。
qim-loadgen 现有的 client.rs / cursor.rs / dedup.rs 已按契约实现,
是 SDK 状态机的直接来源。
后果¶
正面
- SDK 与服务端共用
qim-proto/qim-codec,帧布局、opcode、ID 位宽 不可能漂移,协议不一致类联调故障被编译期消灭。 - 端上契约(游标、去重、对账、迁移)只有一份实现,且被压测持续执行。
- 加一门语言 = 加一个生成器目标,不新增需维护的代码。
- 加一个平台 = 加
Transport/LocalStore的一个实现,核心零改动。
负面与已接受的代价
- §3 的五条可移植约束贯穿核心,约一成额外复杂度(
Spawn注入、Send别名、时钟注入)。这笔钱现在花,换的是不重写。 - 公开面被三个生成器的交集约束死:不能公开泛型、生命周期、trait object, 也不能公开回调型 trait。表达力受限是刻意的——越界就会逼出手写胶水。
- 拉取式事件流要求上层自己 pump,比"注册一个回调"多一次心智负担。 已提供可选回调包装抵消。
- SDK 体积包含 SQLite(native 目标)。桌面不敏感;移动端立项时再评估。
接入即验证到的三处公开面缺陷(qim-desktop 首次接入,2026-08-16)
"只依赖 SDK 的宿主"这条纪律立刻兑现了价值:
- 会话 id 构造未导出。单聊 id 必须归一化(否则 A→B 与 B→A 落到
两个会话,双方各看各的一半历史),属协议契约,却要上层自己拼。
已补
qim_sdk::conversation。 - 正文未接上,且暴露出更深一层:正文只在内联条目上随邮箱下发
(
ADR-0005),大群与超预算正文不随行,因此必须同时透出body_included——缺了它,上层无法区分"空消息"与"正文未随行", 大群消息会静默显示成空气泡。 client_message_id跨重启重复(最危险)。原实现每次启动都从device_id<<64开始,而它是服务端幂等键:重启后重复发号 → 服务端 判成上次的重发 → 回放旧 seq 且不再 fanout → 用户看到"已送达", 收件人永远收不到。实测踩到,已改为含启动毫秒并补防回归用例。
三者的共同点:都不会在单元测试里暴露,只在真实接入时暴露。
不做的替代方案
- 每端各写一个原生 SDK:正是"维护多套",端上契约会有 N 份实现和 N 种错法。
- 只出 C ABI,各语言自己写胶水:手写胶水就是"多套"的入口(见 §2)。
- 核心先按 native 写,浏览器端再适配:§3 的差异渗透进每个
async fn签名, 事后补等于重写。 - 本地库交由上层实现:见 §5 理由。
- 事件用回调:见 §4 理由。
- SDK 只做帧收发、端上规则留给应用:这恰好是 SDK 存在的理由所在, 等于不解决问题。