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 的整个同步层。不同意这个方案,或者被别的方式 烫过?反馈板随时开着。