JavaRush /Курси /Swift SELF /Міграції V1 → V2: функції перетворення

Міграції V1 → V2: функції перетворення

Swift SELF
Рівень 60 , Лекція 1
Відкрита

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
id: String
id: String
переносимо як є
Book
name: String
title: String
перейменовуємо
Book (не було)
tags: [String]
задаємо значення за замовчуванням
[]
Container
schemaVersion: 1
schemaVersion: 2
явно встановлюємо
2

Так, це виглядає «занадто просто». І це добре: спочатку вчимося робити міграції правильно на простому прикладі, а вже потім у житті ви додасте десять полів і одне приховане прокляття.

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.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ