1. Чому міграція — окремий крок, а не «нехай decode впорається»
Коли проєкт живе довше, ніж кілька вечорів, формат даних майже неминуче змінюється. Ми додаємо поле, перейменовуємо властивість, уточнюємо структуру — і раптом виявляється, що вчорашній JSON «ніби валідний», але сьогоднішня модель його не розуміє. І тут хочеться або плакати, або писати міграцію — краще друге, бо пізніше під час налагодження все одно доведеться розбиратися з наслідками.
Найчастіша помилка новачка — намагатися «продавити» старий JSON у нову модель хитрими Optional, значеннями за замовчуванням і надією на диво. Це іноді навіть працює… рівно до першої справді суттєвої зміни (наприклад, назви поля або типу). Тому ми робимо міграцію окремим, усвідомленим кроком: декодували V1 → перетворили у V2 → далі працюємо тільки з V2.
Важлива думка: міграція має бути максимально «нудною». Тобто передбачуваною, детермінованою і читабельною. Якщо під час міграції у вас інколи виходить так, а інколи інакше — вітаю, ви винайшли генератор багів.
2. Що саме ми мігруємо в LibraryCLI
Перш ніж писати код, варто дуже приземлено домовитися про терміни. Ми мігруємо не «книги» як ідею, а DTO для зберігання: структури, які відображають JSON «таким, як він лежить у файлі». Доменні типи (справжні Book і вся логіка) у нас можуть бути влаштовані інакше — і це нормально. Зараз фокус виключно на зберіганні.
Уявімо, що в LibraryCLI у нас була схема V1, а потім ми вирішили перейти на V2:
- у V1 у книги поле називалося name, а у V2 ми хочемо нормальне title (перейменування);
- у V2 ми додали tags, тому що користувачі хочуть шукати «фентезі», «класика», «про котиків» (нове поле);
- у V2 ми хочемо зберігати автора трохи акуратніше, але без зайвої складності: нехай буде author, і він там залишиться (щоб лекція не перетворилася на квест “split за комами”), а нові поля задаємо значеннями за замовчуванням.
Схему відмінностей зручно зафіксувати в табличці — це часто рятує від «ой, я забув про одне поле».
| Сутність | V1 (як було) | V2 (як стало) | Що робимо в міграції |
|---|---|---|---|
| Book | |
|
переносимо як є |
| Book | |
|
перейменовуємо |
| Book | (не було) | |
задаємо значення за замовчуванням |
| Container | |
|
явно встановлюємо |
Так, це виглядає «занадто просто». І це добре: спочатку вчимося робити міграції правильно на простому прикладі, а вже потім у житті ви додасте десять полів і одне приховане прокляття.
3. Реалізація міграції: DTO та базові функції
Оголошуємо DTO для V1 і V2
Коли в коді зʼявляються версії, найкращий друг читабельності — явні назви. Новачки часто називають усе просто BookDTO, потім ще раз BookDTO, а потім дивуються, чому мозок відмовляється це тримати в голові. Давайте одразу домовимося: BookDTOv1 і BookDTOv2 — це нормально. Це не «потворно», це чесно.
Нижче — мінімальні DTO. Зверніть увагу: вони маленькі й легко читаються. Ми поки не обговорюємо складні поля та хитрі CodingKeys — це окрема тема, а сьогодні наша мета саме міграція.
import Foundation
struct BookDTOv1: Codable {
let id: String
let name: String
}
import Foundation
struct BookDTOv2: Codable {
let id: String
let title: String
let tags: [String]
}
Поки що все виглядає майже однаково — і це теж частина реальності: багато міграцій справді зводяться до того, що одне поле перейменували, а інше додали.
Правило № 1: міграція елемента — чиста функція
Дуже хочеться в міграції одразу «прочитати файл», «щось залогувати», «виправити каталоги», «згенерувати нові id» і «заодно помолитися». Але міграція як алгоритм має бути простою: на вході старий DTO → на виході новий DTO.
Чому це важливо:
- так міграцію легко перевірити в голові, а потім і тестами, але це вже пізніше;
- так її легко повторити;
- так міграція не залежить від зовнішнього світу, а отже не ламається від випадковостей.
Почнемо з міграції однієї книги.
func migrateBookV1toV2(_ old: BookDTOv1) -> BookDTOv2 {
BookDTOv2(
id: old.id,
title: old.name,
tags: []
)
}
Зверніть увагу на три речі.
По-перше, ми не змінюємо id. Якщо ви в міграції вирішите «ой, давайте зробимо новий id», то весь сенс сховища зруйнується: посилання, індекси, команди користувача — усе поїде. id — це зазвичай ідентичність запису, а не «зручне поле на сьогодні».
По-друге, перейменування робиться максимально прямо: name переходить у title. Тут не треба мудрувати.
По-третє, tags ми задаємо значенням за замовчуванням [], і це свідоме рішення. У міграції краще «нудно й явно», ніж «розумно й незрозуміло».
Міграція контейнера файлу: оновлюємо версію і переносимо масив
Коли елемент мігрується однією функцією, контейнер мігрується майже сам собою: ми беремо масив старих елементів і застосовуємо map. Цей прийом вам уже знайомий із масивів і базових перетворень колекцій — і саме тому він тут так добре лягає.
Спочатку визначимо контейнери V1 і V2. У реальному проєкті вони лежатимуть у модулі зберігання (наприклад, Storage), але зараз нам важлива сама ідея.
import Foundation
struct LibraryFileV1: Codable {
let schemaVersion: Int
let items: [BookDTOv1]
}
import Foundation
struct LibraryFileV2: Codable {
let schemaVersion: Int
let items: [BookDTOv2]
}
Тепер міграція контейнера.
func migrateFileV1toV2(_ old: LibraryFileV1) -> LibraryFileV2 {
LibraryFileV2(
schemaVersion: 2,
items: old.items.map(migrateBookV1toV2)
)
}
Тут важливо, що schemaVersion ми не обчислюємо, не «залишаємо як було» і не тягнемо з old.schemaVersion + 1 «про всяк випадок». Ми явно кажемо: результат — це V2, і версія — 2. Це просте правило різко зменшує шанс отримати файл із даними V2, але із заголовком V1 (а такі баги, на жаль, справді живуть і розмножуються).
4. Значення за замовчуванням: як вибрати так, щоб потім не було соромно
Зі значеннями за замовчуванням є тонкий момент: «просто поставити порожнє» легко, але іноді неправильно. Тому давайте сформулюємо людський принцип вибору значення за замовчуванням.
Значення за замовчуванням має бути:
- детермінованим (один і той самий вхід → один і той самий вихід);
- не руйнувати сенс даних;
- бути безпечним для подальшої логіки застосунку.
Для tags: [String] значення за замовчуванням [] майже завжди є розумним. Чому? Тому що відсутність тегів у старій схемі логічно інтерпретувати як «теги не були задані». А порожній масив саме це і означає.
А от приклад значення за замовчуванням, яке виглядало б підозріло: присвоїти всім книгам тег "migrated" або "unknown". Це може стати у пригоді для діагностики, але це вже зміна даних користувача. Такі «службові мітки» краще зберігати окремо (наприклад, у логах) або робити явно і за політикою продукту, а не за натхненням розробника о 2-й годині ночі.
Іноді значення за замовчуванням хочеться зробити «розумним»: наприклад, tags = [title] або «витягти теги з назви». З погляду навчання це небезпечний шлях: міграція перетворюється на непередбачуваний парсер. Якщо вже ви вирішуєте робити «розумне» значення за замовчуванням, воно має бути простим і прозорим, і ви повинні бути готові пояснити його поведінку. Сьогодні ми свідомо обираємо «нудний» варіант.
5. Практика навколо міграції
«Пакетна» міграція: декодувати V1 і перетворити у V2
У реальному LibraryCLI міграція не живе у вакуумі. Зазвичай потік такий: завантажили Data з файлу, декодували, мігрували, далі працюємо тільки з новою схемою. Ми не будемо сьогодні заглиблюватися в читання й запис файлу (це інший шар відповідальності), але корисно побачити, як міграція виглядає в поєднанні з декодуванням.
import Foundation
func decodeV1AndMigrateToV2(data: Data) throws -> LibraryFileV2 {
let decoder = JSONDecoder()
let v1 = try decoder.decode(LibraryFileV1.self, from: data)
return migrateFileV1toV2(v1)
}
Зверніть увагу: тут throws зʼявляється через decode, а не через міграцію. Це добрий знак. Міграція як перетворення даних у нас не нервує і не кидає винятків — вона просто переводить дані в інший формат.
Звісно, у житті міграція іноді може «не впоратися» (наприклад, тип змінили так, що частину даних неможливо відновити). Але на старті ми тренуємо правильну архітектурну звичку: міграція максимально чиста і проста, а складність додаємо лише тоді, коли вона справді потрібна.
Якщо поля у V1 «брудні»: легка нормалізація в міграції
Іноді старі дані містять дивні пробіли, порожні рядки й «раптові таби», тому що колись давно ви приймали введення від людини, а людина — істота творча. Питання: чи можна в міграції трохи нормалізувати дані?
Можна, але обережно. Хороше правило: у міграції допустима проста, очевидна нормалізація, яка не змінює сенс. Наприклад, trim пробілів у заголовку — майже завжди безпечно і корисно.
import Foundation
func migrateBookV1toV2(_ old: BookDTOv1) -> BookDTOv2 {
let cleanedTitle = old.name.trimmingCharacters(in: .whitespacesAndNewlines)
return BookDTOv2(
id: old.id,
title: cleanedTitle,
tags: []
)
}
Тут ми зробили нормалізацію, але не перетворили міграцію на «комбайн логіки». Ми не намагаємося вгадувати мову, виправляти друкарські помилки або «покращувати» регістр. Міграція — це не редактор тексту і не AI-асистент.
Візуальна схема процесу V1 → V2
Коли коду стає більше, голові допомагає проста схема. Це не формальність для документації, а спосіб не переплутати кроки й не почати мігрувати «по дорозі» у пʼяти місцях.
Схема:
flowchart TD
A["Дані з файлу"] --> B["декодування LibraryFileV1"]
B --> C["migrateFileV1toV2"]
C --> D["LibraryFileV2 у памʼяті"]
D --> E["далі працюємо тільки з V2"]
Ключова ідея: після міграції у вас не має залишатися місць, де застосунок «іноді» працює з V1, а «іноді» з V2. Це майже гарантована плутанина. Перевели у V2 — і все, V1 лишається тільки як формат читання для старих файлів.
Як тримати міграції читабельними, коли версій стане більше
Навіть якщо сьогодні у нас тільки V1 → V2, уже зараз варто виховати корисну дисципліну: міграція — це набір маленьких функцій, а не один гігантський «switch-осьминіг», який робить усе одразу.
Хороший стиль — мати:
- функцію міграції одного елемента (migrateBookV1toV2);
- функцію міграції контейнера (migrateFileV1toV2);
- і окремо — код, який обирає гілку завантаження (це було в попередній лекції про schemaVersion і декодування заголовка).
Так ви отримуєте модульність: якщо завтра у вас зʼявиться V3, ви додасте нові типи BookDTOv3, LibraryFileV3 і функцію migrateFileV2toV3, не переписуючи стару логіку.
І ще один важливий нюанс: міграція — це місце, де корисно бути занудою і писати трохи довші назви. Наприклад, migrateFileV1toV2 краще, ніж migrate. У коді міграцій ясність — це продуктивність, бо ви менше часу витрачаєте на «а що тут відбувається?».
6. Типові помилки під час міграції V1 → V2
Помилка № 1: міграція змішана з I/O, і через це її неможливо нормально зрозуміти.
Коли функція міграції одночасно читає файл, пише новий файл, логує, створює каталоги й ще «трохи» перетворює дані, вона перетворюється на чорну скриньку. У результаті ви не можете повторно використати міграцію, не можете відтворити проблему на невеликому прикладі й постійно ловите побічні ефекти. Лікується просто: міграція — чиста функція, I/O — окремий шар.
Помилка № 2: забули оновити schemaVersion у результаті.
Це класика. Дані ви вже перетворили у V2, а schemaVersion випадково залишили 1 (або поставили old.schemaVersion, бо «ну він же вже є»). Потім завантажувач дивиться на schemaVersion, думає «це V1», намагається декодувати як V1 і падає. Тому версія в результативному контейнері має бути встановлена явно і відповідати структурі результату.
Помилка № 3: значення за замовчуванням вибрані “на емоціях”, а не як рішення формату.
Сьогодні настрій був: «нехай усім буде тег `misc`», завтра настрій інший — і у вас уже файли поводяться по-різному залежно від того, який розробник робив реліз. Значення за замовчуванням має бути стабільним правилом формату, а не імпровізацією. Якщо значення за замовчуванням змінюється — це вже нова версія схеми (або окрема політика оновлення даних).
Помилка № 4: міграція змінює ідентичність сутностей (наприклад, генерує нові id).
Якщо ви замінюєте старі id на нові, то для користувача це виглядатиме так, ніби бібліотека «забула» всі попередні книги, а замість них зʼявилися нові. Навіть якщо візуально назви збігаються, внутрішні посилання, команди видалення/оновлення та будь-які індекси перестануть працювати так, як очікувалося. id зазвичай переноситься як є, а якщо його формат змінюється — це окрема, дуже обережна історія.
Помилка № 5: після міграції застосунок продовжує жити у двох схемах одночасно.
Іноді роблять так: «ну ми мігруємо не все, а частина функцій нехай поки працює з V1». Це майже завжди призводить до розбіжностей і дивних багів, де половина коду вважає, що є name, а половина — що є title. Набагато надійніше: один раз привести дані до V2 і далі в застосунку бачити тільки V2.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ