開発ブログへ戻る 落とし穴レポート

前方互換な Codable:古いクライアントに壊させない

SharkTTY は保存済みの SSH ホストをデバイス間で同期します。アプリに新しい接続タイプを 追加していたとき、私たちはある質問をしました。答えは居心地の悪いものでした。新しい アプリが書いたホストレコードが、まだ古いアプリを動かしているデバイスに届いたら何が 起きるのか? ごく普通の Swift Codable 構成での答えは:古いデバイスの同期が静かに凍りつく——そしてそれを「自然な」方法で直すと、 もっと悪化する:古いデバイスが新しいデバイスのデータを破壊し始めるのです。 どちらの失敗も、変わったコードは要りません。デフォルトの書き方から直接生まれます。

失敗その一:未来を拒む enum

どの 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 enum は 閉じた世界です。未知の raw 値はフォールバックせず、throw します。 だから新しいアプリが "connectionMethod": "et" を書いた日から、古い読み手はその 1 レコードで throw する——そしてリスト全体を一括で デコードしているため、読めない 1 件はゼロ件を意味します。 try? がエラーを飲み込み、 インポートは早期リターンし、デバイスは古いリストを永遠に抱え続ける。クラッシュなし、 ログなし、バナーなし。同期はただ……同期であることをやめます。まだ更新していないすべての デバイスで——そして現実のロールアウト期間中、それは大多数のデバイスです。

失敗その二:データを破壊する「寛容な」デコーダ

反射的な修正は寛容なデコードです。知らない値ならデフォルトを選ぶ:

let raw = try c.decodeIfPresent(String.self, forKey: .connectionMethod)
connectionMethod = raw.flatMap(ConnectionMethod.init(rawValue:)) ?? .ssh   // 寛容……そして非可逆

これは凍結を治し、地雷を植えます。同期システムは再エンコードするものです。古いアプリが 別のホストを編集し、リスト全体をシリアライズしてアップロードする——last-writer-wins。 しかしそのアプリは "et".ssh としてデコードして いたので、書き戻すのもそれです。そして自分にプロパティのないフィールド——新レコードの 追加キー——はデコード時点で Codable に捨てられており、アップロードにも含まれません。時代遅れのデバイス 1 台での 1 回の編集で、 新しいデバイスの設定がアカウント全体で静かに古い形へ書き換えられる。凍結は少なくとも データを守っていました。寛容なデコーダは、丁寧にそれを消します。

契約:古いコードは、新しいコードが書いたものを決して壊してはならない

Protobuf は何十年も前に未知フィールド保持でこれを解決しました。Swift の Codable は道具はくれますが、 デフォルトはくれません。2 つの手でそれを再現します。

ワイヤ上の真実を保存し、型付きのビューを公開する。各 enum フィールドは raw 文字列を格納プロパティとして保持し、型付き enum は表示専用のフォールバックを持つ 計算プロパティになります。未知の値を読めば表示は優雅に劣化し、書き戻せば正確に再現される。 それを上書きできるのは意図的なユーザー編集——setter——だけです:

private(set) var connectionMethodRaw: String
var connectionMethod: ConnectionMethod {
    get { ConnectionMethod(rawValue: connectionMethodRaw) ?? .ssh }  // フォールバックは「表示」のため
    set { connectionMethodRaw = newValue.rawValue }                  // 意図的な編集だけが勝つ
}

理解できないキーを保持する。文字列キーのコンテナでもう一周し、自分の ものでないキーをすべて JSONValue の袋に集めます(null・bool・int・double・string・array・object の 7 ケースの小さな enum——Double より先に Int64 を試すこと。 さもないとポート番号が浮動小数点で帰ってきます)。 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))
}

両方が揃えば、古いクライアントは安全な通り道になります。表示できるものは表示し、 できないものはラウンドトリップさせる。バージョン混在のデバイス群——どのリリースの 前後でも数週間は世界の通常状態——は、もはや危険ではありません。

テストで契約を釘付けにする

契約全体を支えるのは 3 つのテストです。ラウンドトリップのフィクスチャ: 「未来のバージョン」からのレコード——未知の enum 値、未知のスカラー、未知のネストした オブジェクト——がエラーなくデコードでき、再エンコード後に元のキーがすべて無傷であること。 意図的編集のテスト:型付きプロパティへの代入は raw 値を上書きしつつ、 未知キーは生き残ること。そしてゴールデンテスト:既知フィールドだけの レコードは、旧コードの出力とバイト単位で一致すること—— unknownFields キーも …Raw という格納名も、 ワイヤに漏れてはいけません。この最後の 1 本があるから、フォーマットを黙って分岐させる ことなく内部をリファクタリングできます。

最後に正直な注意をひとつ。これらはどれも、すでに出荷済みのクライアントを 修復しません。彼らのデコーダは今も未来を拒みます。移行は退屈で、避けられません—— まず寛容で保持するデコーダを載せ、行き渡らせ、それから初めて新しい値を流し始める。 スキーマの進化とは、シリアライゼーションの衣装を着た順序問題なのです。

この堅牢化は次のアップデートで SharkTTY の同期レイヤー全体に入ります。この方針に 異論がある方、別の焼かれ方をした方は フィードバックボードへ。