Swift同步Shellby

一套同步协议,两个技术栈——以及一个被 zlib 逼改的契约

Shellby 复盘:六端 SSH 客户端要跨生态同步(Apple 是 SwiftData、Flutter 是 drift),走用户自带的第三方存储、无服务器、端到端加密。难点是让「同一份数据两端同步后一致」,而实现期一个 zlib 细节把「密文逐字节相等」这个契约直接推翻了。

Shellby 是六端 SSH 客户端:Apple 端 Swift + SwiftData,Flutter 端 drift(SQLite),覆盖 Android / Windows / Linux / 鸿蒙。iCloud 那套只能在 Apple 设备间同步;一个 iPhone + Android 的用户,得靠自己带的第三方存储(WebDAV / S3)把两端连起来,而且不能自建任何服务器。

存储方是零信任的——上传前端到端加密,它只看得到密文。真正的难点在别处:怎么让「同一份数据」在两个完全不同的技术栈上同步后保持一致? 这篇讲这套跨栈同步的设计,以及实现期一个 zlib 细节怎么逼我改掉了最初的契约。

契约不是「密文逐字节相等」

最初的设想很自然:两栈用同一套格式加密,产出的密文逐字节相同,一致性自动成立。这个设想活到了实现期,然后死在了压缩上。

档案要压缩,用的是 deflate。而 deflate 的压缩结果无法跨 zlib 实现保证逐字节相同:Dart(dart:io)内置的是 Chromium 版 zlib,它 level 6 的 deflate 输出比系统/标准 zlib 少 1 个字节——实测 Python 1.2.12 和 Apple 系统 zlib 的全部参数组合,都复现不出 Dart 那一版的输出。

这一下把「密文逐字节相等」当契约的路堵死了。而且再想一层,生产里每次加密都用随机的内容密钥和 nonce,两端本来就从不产生相同密文——密文相等从来就不是一个能成立的系统不变量。

于是契约改成三条更本质的:

  1. 两栈都能解密同一份共享向量,得到同一份规范化明文
  2. 各栈用同参数加密,自身确定;
  3. 头部和被包裹的内容密钥(wrappedCEK)跨栈逐字节一致。

一句话:契约是「可互相解密 + 各自确定」,不是「产物逐字节相同」。 顺带一个连锁决定:Swift 端直连系统 libz,而不用 Apple 的 Compression 框架(它自研 deflate,和 zlib 不一致);gzip 头里的 MTIME / XFL / OS 全钉死,因为 zlib 写的 OS 字节随平台变。

三层架构:把「语言无关」和「平台适配」切开

一致性最终靠三件事:规范化 JSON 两栈逐字节复刻、纯函数的确定性合并、域记录到载荷行的纯映射。对应的代码切成三层,Swift 和 Flutter 一一对位:

  • SyncKit:语言无关的同步核心——载荷模型、记录级 LWW 合并、信封加密档案、WebDAV / S3 provider、同步引擎。不依赖 SwiftData,只用 Foundation / CryptoKit / zlib;
  • SyncBridge域记录 ↔ 载荷行的纯映射。一组纯值类型 HostRecord / GroupRecord /… 和它们与 {id, updatedAt, data} 行的 encode/decode,不碰任何持久化框架;
  • SyncAppKit:SwiftData 适配层——把 SSHHost 这些模型读成域记录交给 SyncBridge,管 reconcile 差分和引擎编排。

关键在中间那层为什么是纯的。SyncBridge 是纯值类型加纯函数,好处有两个:一是可单测——不用起 ModelContainer 就能跑(SwiftData 的容器级测试在 SPM 包里会崩,是已知限制,所以逻辑必须能脱离容器验证);二是纯映射的输出本身就是跨栈契约的字节,Swift 和 Dart 各自 encode 同一条记录必须得到同一行,Tests/Fixtures/ 里的向量两栈共用。

纯映射里两个不起眼、但漏了就漂移的对齐:tags 序列化成 JSON 字符串(不是数组),对齐 Flutter drift 的 TEXT 列;凭证引用键统一成 host.<id>.password 这类规范,跨栈按同一 ref 对应同一凭证。

规范化 JSON:不能用 JSONEncoder

跨栈逐字节一致的第一道坎是序列化。JSONEncoder 用不了,得手写,因为它和 Dart 的 json.encode 三处不合:

  • .sortedKeys递归排序所有层级,而 Dart 只排序「行」的顶层键、保留嵌套 data 的原序
  • JSONEncoder 默认把 / 转义成 \/,Dart 不转义;
  • 需要精确控制紧凑无空白的格式。

所以自建一个有序 JSON 值模型(对象用 [(key, value)] 承载、保留插入序),严格复刻 Dart 的转义规则。于是一行的顶层键是排序的(data / id / updatedAt),但 data 内部是声明序(name / hostname / port …,非字母序)——这个「顶层排序、嵌套保序」的细节,是后面确定性合并能成立的前提。

确定性合并:任意顺序都收敛到同一结果

多设备各自离线改,合并不能用「后写覆盖整包」——那样并发会整包丢数据。用的是记录级的确定性 LWW:按 updatedAt 取较新,任意端以任意顺序合并都收敛到同一结果,没有服务端裁决。这刚好和 Apple CloudKit 的字段级「后写覆盖」语义对齐,双栈语义统一。

难点在平局——updatedAt 相等时怎么确定性地选一个:

func pickSurvivor(_ a: JSONValue?, _ b: JSONValue?) -> JSONValue? {
    let ua = recordUpdatedAt(a), ub = recordUpdatedAt(b)
    if ua != ub { return ua > ub ? a : b }
    // 平局:确定性选较大 JSON(顶层键排序视图),避免设备间发散。
    let ja = SyncPayload.sortTopLevel(a).canonicalString()
    let jb = SyncPayload.sortTopLevel(b).canonicalString()
    return compareUTF16(ja, jb) >= 0 ? a : b
}

两个藏得很深的前提:比较必须按 UTF-16 code unit(复刻 Dart 的 String.compareTo)——如果用 Swift 默认的 String <(走 Unicode 规范化比较),一条含中文主机名的记录就会和 Dart 分叉,两台设备合并出不同结果、永远收敛不了;以及前面说的 data 保持声明序,平局比较的字节才两栈一致。一个字符串比较的口径,就能决定分布式合并收不收敛。

reconcile:不 hook 增删改,改用快照差分

Apple 端有个现实约束:线上模型挂着 CloudKit 镜像,给它加同步字段要迁移、风险大。所以不去 hook 现有的增删改路径,改用快照差分——同步元数据存在一个独立的本地容器里(cloudKitDatabase: .none,不进主 schema,零迁移风险),每条记录存 {version, contentHash, deleted, deletedAt}

  • :快照时对每行算内容指纹(FNV-1a of data 的规范化字节,排除顶层 version),和上次比。变了就把 version 单调抬升 max(now, 旧version + 1)
  • :遍历元数据,「曾同步过、但当前域行没了」就生成 tombstone,deletedAt = max(now, version + 1),保证删除时间戳一定大于该记录上次同步的版本,合并时删除能胜出;
  • 不回环:落地远端行之后,按重新 fetch 的落地模型重算指纹写回元数据,让下次快照判「没变」——否则会把刚收到的远端更新当成本地新改又推回去。
public static func contentHash(_ row: JSONValue) -> String {
    let data = row["data"]?.canonicalBytes() ?? []   // 排除顶层 version:仅内容变化才抬版本
    var h: UInt64 = 0xcbf29ce484222325
    for b in data { h ^= UInt64(b); h = h &* 0x100000001b3 }
    return String(h, radix: 16)
}

有个诚实要记的副作用:指纹排除的是顶层 version,但 data 里的 lastConnectedAt(每次连主机都变)是算进指纹的——所以连一次主机就会 bump 那台 host 的同步版本、下次触发它重新入档。这是「版本只随内容变」设计自带的一个面。

推送用条件写做 CAS

多设备并发推同一个 blob,靠条件写:推送带 If-Match: <ETag>(WebDAV)或对应的 S3 条件头,如果返回 412(对端已先写),就重新拉取、重新合并、有界退避重试。这是对一个 blob 的 compare-and-swap,不需要任何服务端逻辑就能安全支持多设备并发。(阿里云 OSS 这类不支持条件写的后端怎么降级,是另一篇的故事。)

选协议,不选厂商

最后一个值得记的产品判断:provider 只做了 WebDAV 和 S3 两个协议,没有去接坚果云、阿里云盘、Dropbox 这些具体厂商。理由是:与其在 6 个平台维护 5 家网盘的 5 套 SDK,不如接 2 个开放协议——每个协议本身就同时有国内(坚果云 / 阿里云 OSS)和国际(Nextcloud / R2)的一流选项,都是纯 HTTP、Swift 和 Dart 各约 200 行就能实现、无厂商 SDK、用户自带账号。

复盘

  • 能跨实现保证的一致性,比「产物逐字节相同」弱得多,也现实得多。deflate 跨 zlib 不可复现、加密每次用随机密钥——把契约定成「可互相解密 + 各自确定」,比追求密文相等既正确又省事。定契约前先问一句:这个「相等」在所有实现上真的成立吗;
  • 跨栈契约的核心是一层纯映射。把「域模型 ↔ 线格式」做成不依赖任何持久化框架的纯值类型,它的输出就是契约字节,两栈共享同一批 fixture 校验——纯,才可测、才可对齐;
  • 一个字符串比较的口径能决定分布式合并收不收敛。LWW 平局用 UTF-16 还是 Unicode 规范化比较,非 ASCII 数据上就是「两端收敛」和「永远发散」的区别。确定性合并的每一步都要钉死到 code unit 级;
  • 改不动的现有模型,用旁路的快照差分而不是 hook CRUD。同步元数据独立存、内容指纹判增改、tombstone 判删、落地后重算指纹防回环——一整套不碰主 schema 就能上同步;
  • 接协议不接厂商。开放协议把「国内 + 国际」「多个一流选项」一次性收进来,还省掉每平台维护多套 SDK 的债。

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。