Назад до девблогу Звіт про пастку

Прямосумісний 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 }

Codable-enum зі String-основою — це закритий світ: невідоме сире значення не відступає до типового, а кидає помилку. Тож того дня, коли новіший застосунок запише "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 розв'язав це десятиліття тому збереженням невідомих полів; Codable у Swift дає інструменти, але не типову поведінку. Її відтворюють два кроки.

Зберігайте правду дроту, показуйте типізований вигляд. Кожне enum-поле тримає сирий рядок як збережену властивість; типізований enum стає обчислюваним виглядом із запасним значенням лише для показу. Читання невідомого значення деградує граційно; запис назад відтворює його точно. Перезаписати може лише свідоме редагування користувача — setter:

private(set) var connectionMethodRaw: String
var connectionMethod: ConnectionMethod {
    get { ConnectionMethod(rawValue: connectionMethodRaw) ?? .ssh }  // запасне — лише для ПОКАЗУ
    set { connectionMethodRaw = newValue.rawValue }                  // свідомі редагування перемагають
}

Зберігайте ключі, яких не розумієте. Другий прохід контейнером зі строковими ключами збирає кожен чужий ключ у торбу JSONValue (маленький enum на сім випадків: 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))
}

Коли обидві частини на місці, старий клієнт стає безпечним провідником: показує, що може, і пропускає крізь себе неторканим те, чого не може. Парк пристроїв зі змішаними версіями — нормальний стан світу впродовж тижнів довкола кожного випуску — перестає бути загрозою.

Прибити контракт тестами

Увесь контракт тримають три тести. Фікстура повного кола: запис із «майбутньої» версії — невідоме значення enum, невідомий скаляр, невідомий вкладений об'єкт — має декодуватися без помилки й перекодуватися з кожним початковим ключем неушкодженим. Тест свідомого редагування: присвоєння типізованій властивості має перезаписати сире значення, а невідомі ключі — вижити. І еталонний тест: запис лише з відомими полями має кодуватися байт-у-байт як вивід старого коду — без ключа unknownFields, без імен сховища на кшталт …Raw, що протікають у дріт. Саме останній дозволяє рефакторити нутрощі, не розгалужуючи формат нишком.

І чесне застереження наприкінці: ніщо з цього не лагодить клієнтів, які вже вийшли без цих механізмів. Їхні декодери досі відкидають майбутнє. Міграція нудна й неминуча — спершу випустіть толерантні декодери, що зберігають чуже, дайте їм розійтися, і лише тоді дозвольте новим значенням текти. Еволюція схеми — це задача впорядкування, перевдягнена в серіалізацію.

Це зміцнення охопить увесь шар синхронізації SharkTTY у наступному оновленні. Не згодні з підходом або обпеклися інакше? Дошка відгуків відкрита.