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 у наступному оновленні. Не згодні з підходом або обпеклися інакше? Дошка відгуків відкрита.