返回开发博客 踩坑实录

前向兼容的 Codable:旧客户端永远不能毁掉新客户端写的数据

SharkTTY 会在设备之间同步你保存的 SSH 主机。在给应用添加一种新连接类型时, 我们问了一个事后看来答案很不舒服的问题:新版应用写出的主机记录, 到达一台还在跑旧版的设备上,会发生什么?对一套再普通不过的 Swift Codable 代码来说, 答案是:旧设备的同步会静默冻结——而如果你用最顺手的方式去"修"它,事情会更糟: 旧设备会开始销毁新设备的数据。这两种失败都不需要什么奇怪的代码, 默认写法就能造出来。

失败一:拒绝未来的枚举

这套配置每个 Swift 代码库里都有:

enum ConnectionMethod: String, Codable { case ssh, mosh }

struct Host: Codable {
    let id: UUID
    var name: String
    var connectionMethod: ConnectionMethod
    // …
}

// 同步层的某处:
guard let hosts = try? decoder.decode([Host].self, from: doc) else { return }

String 原始值的 Codable 枚举是个 封闭世界:遇到不认识的原始值不会回落,而是直接抛错。所以当某天 新版应用写出 "connectionMethod": "et", 所有旧版读者都会在这一条记录上抛错——又因为整张列表是作为一个整体解码的, 一条读不了就意味着一条也没有try? 把错误吞掉,导入提前返回,设备永远抱着那份过期列表。不崩溃、没日志、无提示—— 同步就这么"不再是同步"了,发生在每一台还没更新的设备上。而在任何真实的灰度期里, 还没更新的设备恰恰是大多数。

失败二:销毁数据的"容错"解码

条件反射式的修法是宽松解码——不认识就取个默认值:

let raw = try c.decodeIfPresent(String.self, forKey: .connectionMethod)
connectionMethod = raw.flatMap(ConnectionMethod.init(rawValue:)) ?? .ssh   // 容错了……也有损了

这治好了冻结,同时埋下一颗雷。同步系统是会回写的:旧版应用编辑了 另一台主机,把整张列表序列化后上传——last-writer-wins。可它当初把 "et" 解成了 .ssh,回写的自然也是它; 而那些它没有对应属性的字段——新记录的额外键——在解码时就已被 Codable 丢掉, 上传里同样没有。一台过期设备上的一次编辑,就把新设备的配置悄悄改写回旧形状, 而且是全账号范围。冻结至少还保住了数据;"容错"解码则把数据礼貌地抹掉了。

契约:旧代码永远不能毁掉新代码写的东西

Protobuf 几十年前就用「未知字段保留」解决了这类问题;Swift 的 Codable 给了你工具, 但没给你这个默认值。两步把它补回来。

存线上的真值,暴露类型化的视图。每个枚举字段以原始字符串作为存储属性; 类型化枚举变成计算属性,回落只用于显示。读到未知值时优雅降级,写回时原样复现; 只有用户的显式修改——setter——才会覆盖它:

private(set) var connectionMethodRaw: String
var connectionMethod: ConnectionMethod {
    get { ConnectionMethod(rawValue: connectionMethodRaw) ?? .ssh }  // 回落只为「显示」
    set { connectionMethodRaw = newValue.rawValue }                  // 显式修改才覆盖
}

保住你不认识的键。用字符串键容器再扫一遍,把不属于你的键全部收进一个 JSONValue 包 (七个 case 的小枚举:null、bool、int、double、string、array、object——记得先试 Int64 再试 Double,否则端口号会变成浮点数回来)。 encode(to:) 写完已知字段后,把这个包原样回放:

// 解码:把「未来」收起来
let known = Set(CodingKeys.allCases.map(\.rawValue))
let any = try decoder.container(keyedBy: AnyCodingKey.self)
for key in any.allKeys where !known.contains(key.stringValue) {
    unknownFields[key.stringValue] = try any.decode(JSONValue.self, forKey: key)
}

// 编码:把「未来」原封不动地还回去
var any = encoder.container(keyedBy: AnyCodingKey.self)
for (key, value) in unknownFields {
    try any.encode(value, forKey: AnyCodingKey(stringValue: key))
}

两步齐备之后,旧客户端就成了一条安全的通路:能显示的显示,不认识的原样带过。 混跑多个版本的设备群——每次发版前后几周的常态——从此不再是隐患。

用测试把契约钉死

三个测试撑起整个契约。回环夹具:一条来自"未来版本"的记录—— 未知枚举值、未知标量、未知嵌套对象——必须无错解码,且重编码后每个原始键都完好。 显式修改测试:对类型化属性赋值必须覆盖原始值,同时未知键依然幸存。 金样测试:只含已知字段的记录,编码输出必须与旧代码逐字节一致—— 不能有 unknownFields 键, 不能有 …Raw 这类存储命名泄漏到线上格式里。有了最后这条,你才能放心重构内部实现而不悄悄分叉格式。

最后一句诚实的话:这一切都救不了已经发布出去的旧客户端——它们的解码器 依然拒绝未来。迁移的办法乏味且没有捷径:先落地容错且保留的解码器,等它铺开, 然后才允许新值开始流动。schema 演进本质是个排序问题,只是穿着序列化的衣服。

这套加固将随下一次更新覆盖 SharkTTY 的整个同步层。不同意这个方案,或者被别的方式 烫过?反馈板随时开着。