跳转至

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 的宿主"这条纪律立刻兑现了价值:

  1. 会话 id 构造未导出。单聊 id 必须归一化(否则 A→B 与 B→A 落到 两个会话,双方各看各的一半历史),属协议契约,却要上层自己拼。 已补 qim_sdk::conversation。
  2. 正文未接上,且暴露出更深一层:正文只在内联条目上随邮箱下发 (ADR-0005),大群与超预算正文不随行,因此必须同时透出 body_included——缺了它,上层无法区分"空消息"与"正文未随行", 大群消息会静默显示成空气泡。
  3. client_message_id 跨重启重复(最危险)。原实现每次启动都从 device_id<<64 开始,而它是服务端幂等键:重启后重复发号 → 服务端 判成上次的重发 → 回放旧 seq 且不再 fanout → 用户看到"已送达", 收件人永远收不到。实测踩到,已改为含启动毫秒并补防回归用例。

三者的共同点:都不会在单元测试里暴露,只在真实接入时暴露。

不做的替代方案

  • 每端各写一个原生 SDK:正是"维护多套",端上契约会有 N 份实现和 N 种错法。
  • 只出 C ABI,各语言自己写胶水:手写胶水就是"多套"的入口(见 §2)。
  • 核心先按 native 写,浏览器端再适配:§3 的差异渗透进每个 async fn 签名, 事后补等于重写。
  • 本地库交由上层实现:见 §5 理由。
  • 事件用回调:见 §4 理由。
  • SDK 只做帧收发、端上规则留给应用:这恰好是 SDK 存在的理由所在, 等于不解决问题。