1. Почему миграция — отдельный шаг, а не «пусть decode справится»
Когда проект живёт дольше пары вечеров, формат данных почти неизбежно меняется. Мы добавляем поле, переименовываем свойство, уточняем структуру — и внезапно выясняется, что вчерашний JSON «вроде валидный», но сегодняшняя модель его не понимает. И вот тут хочется либо плакать, либо писать миграцию (лучше второе — вода для глаз всё равно пригодится позже на отладке).
Самая частая ошибка новичка — пытаться «продавить» старый JSON в новую модель хитрыми Optional, значениями по умолчанию и надеждой на чудо. Это иногда работает… ровно до первого реально значимого изменения (например, смены имени поля или типа). Поэтому мы делаем миграцию отдельным, осознанным шагом: декодировали V1 → преобразовали в V2 → дальше работаем только с V2.
Важная мысль: миграция должна быть максимально «скучной». То есть предсказуемой, детерминированной и читаемой. Если при миграции у вас «иногда получается так, иногда иначе» — поздравляю, вы изобрели генератор багов.
2. Что именно мы мигрируем в LibraryCLI
Прежде чем писать код, полезно очень приземлённо договориться о терминах. Мы мигрируем не «книги» как идею, а DTO хранения: структуры, которые отражают JSON «как он лежит в файле». Доменные типы (настоящие Book и вся логика) у нас могут быть устроены иначе — и это нормально. Сейчас фокус исключительно на хранении.
Представим, что в LibraryCLI у нас была схема V1, а потом мы решили перейти на V2:
- в V1 у книги поле называлось name, а в V2 мы хотим нормальное title (переименование);
- в V2 мы добавили tags, потому что пользователи хотят искать «фэнтези», «классика», «про котиков» (новое поле);
- в V2 мы хотим хранить автора чуть аккуратнее, но не усложняя жизнь: пусть будет author и там же останется (чтобы лекция не превратилась в квест “сплит по запятым”), а вот новые поля зададим дефолтами.
Схему различий удобно зафиксировать в табличке — это часто спасает от «ой, я забыл про одно поле».
| Сущность | 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["Data из файла"] --> B["decode LibraryFileV1"]
B --> C["migrateFileV1toV2"]
C --> D["LibraryFileV2 в памяти"]
D --> E["дальше работаем только с V2"]
Ключевая идея: после миграции у вас не должно оставаться мест, где приложение «иногда» работает с V1, а «иногда» с V2. Это почти гарантированная путаница. Перевели в V2 — и всё, V1 остаётся только как формат чтения для старых файлов.
Как держать миграции читаемыми, когда версий станет больше
Даже если сегодня у нас только V1 → V2, уже сейчас стоит воспитать полезную дисциплину: миграция — это набор маленьких функций, а не один гигантский «switch-осьминог», который делает всё сразу.
Хороший стиль — иметь:
- функцию миграции одного элемента (migrateBookV1toV2);
- функцию миграции контейнера (migrateFileV1toV2);
- и отдельно — код, который выбирает ветку загрузки (это было в предыдущей лекции про schemaVersion и “header decode”).
Так вы получаете модульность: если у вас завтра появится 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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ