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 и там же останется (чтобы лекция не превратилась в квест “сплит по запятым”), а вот новые поля зададим дефолтами.

Схему различий удобно зафиксировать в табличке — это часто спасает от «ой, я забыл про одно поле».

Сущность 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["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.

1
Задача
Swift SELF, 60 уровень, 1 лекция
Недоступна
Пересадка книги
Пересадка книги
1
Задача
Swift SELF, 60 уровень, 1 лекция
Недоступна
Апгрейд контейнера
Апгрейд контейнера
1
Задача
Swift SELF, 60 уровень, 1 лекция
Недоступна
Конвертер файла
Конвертер файла
1
Задача
Swift SELF, 60 уровень, 1 лекция
Недоступна
Чистка названия
Чистка названия
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ