1. “Битая запись”: JSON может быть валидным
Начнём с жизненной (и немного грустной) правды: файл может быть идеально валидным JSON, успешно декодироваться через JSONDecoder, проходить миграцию… и всё равно содержать мусор по смыслу. Например, книга с пустым title, с пробелами вместо id, с годом -300, или два разных объекта с одинаковым id. Это и есть “битые записи” — не «сломанный формат», а некорректные данные внутри корректного формата.
В LibraryCLI это особенно критично: мы строим хранилище, где id должен однозначно указывать на книгу. Если одна запись «битая», нам нужно решить: падать целиком, пропускать, чинить по дефолтам, или считать это ошибкой пользователя (и сообщить об этом нормальным человеческим языком).
Чтобы не превращать загрузку в магию, будем думать так:
- decode отвечает на вопрос: “JSON структурно похож на то, что мы ожидаем?”
- миграция отвечает на вопрос: “Можно ли привести старую структуру к новой?”
- валидация отвечает на вопрос: “Эти данные вообще имеют смысл для нашего домена?”
Где Decodable бессилен: типы есть, смысла нет
Очень легко попасть в ловушку: «Раз декодировалось — значит всё ок». У JSONDecoder действительно есть строгая позиция, но она про форму, а не про смысл. Он проверит, что year — это число (если мы так сказали), но не будет спорить с годом 999999. Он согласится на title: " " (строка же!), хотя читателю это будет выглядеть как «книга без названия, но с чувством пустоты».
Представьте почтовое отделение. Decodable — это человек, который проверяет, что на конверте есть адрес и индекс в нужном формате. А валидация — это уже проверка, что адрес существует, и вы не отправляете письмо «в никуда, но с энтузиазмом».
Практическое следствие для нас: после decode (и после миграции, если она была) должен существовать явный шаг:
DTO (актуальная версия) → Validation → Domain
Если этот шаг пропустить, ошибки не исчезнут — они просто переедут в другое место и выстрелят позже, обычно в самый неудобный момент (например, когда пользователь вызывает update, а у нас два объекта с одинаковым id).
2. Инварианты домена LibraryCLI
Прежде чем валидировать, надо честно ответить на вопрос: “А что мы вообще считаем корректным?”. Валидация — это не «пара if для галочки», это защита наших инвариантов: правил, которые должны быть истинны всегда, иначе приложение перестаёт быть предсказуемым.
Ниже — минимальный набор правил для книг в нашем учебном LibraryCLI. Мы специально держим их простыми, чтобы код был понятным и не превращался в бюрократию.
| Поле | Где живёт | Правило (инвариант) | Почему важно |
|---|---|---|---|
|
домен | непустой (после trim) и уникальный в файле | id — это «паспорт» книги |
|
домен | непустой (после trim) | иначе книга неотличима от призрака |
|
домен | либо отсутствует, либо в разумном диапазоне | год нужен как данные, а не как фантастика |
|
домен/DTO | можно пусто, но без пустых строк | иначе поиск по тегам станет «угадайкой» |
Заметьте тонкий момент: некоторые поля могут быть опциональными и это нормально. Но если поле есть — оно должно быть адекватным. Опциональность — не лицензия на мусор, это всего лишь «может отсутствовать».
3. Политики: строгий и мягкий режим
Когда мы нашли битую запись, дальше начинается самое интересное: политика. В учебном проекте нам важно увидеть два базовых подхода и понять, чем они отличаются по последствиям. В реальных продуктах между ними часто идут долгие споры (и это нормально: это уже про UX и ответственность).
В общих чертах подходы такие.
Строгий (fail-fast): если хотя бы одна запись некорректна, мы считаем файл проблемным и загрузку не продолжаем. Пользователь получает ошибку и должен исправить данные. Этот подход хорош, когда данные считаются «ценными и аккуратными», либо когда частичная загрузка опасна (например, можно потерять важные элементы незаметно).
Мягкий (best-effort): мы загружаем всё, что можем, а битые записи пропускаем (или приводим к дефолту) и обязательно формируем отчёт: сколько пропущено и почему. Этот подход хорош, когда файл мог редактироваться руками, когда приложение должно быть «живучим», и когда лучше показать пользователю хоть что-то, чем ничего.
Чтобы было проще сравнить, вот таблица:
| Подход | Что делаем при ошибке | Плюсы | Минусы |
|---|---|---|---|
| Строгий | прекращаем загрузку, возвращаем ошибку | предсказуемо, не теряем данные молча | один мусорный элемент ломает всё |
| Мягкий | пропускаем битое, грузим остальное, считаем потери | приложение “живёт”, пользователю есть с чем работать | есть риск «тихой потери», нужен отчёт |
В этой лекции мы реализуем мягкую загрузку с отчётом, потому что она нагляднее: мы увидим и валидацию, и сбор статистики, и работу с дубликатами id. Но при этом будем писать код так, чтобы при желании легко перейти к строгому режиму (обычно это просто “если есть issues — бросить ошибку”).
4. Каркас: отчёт о валидации
Очень соблазнительно сделать так: «если запись плохая — пропускаем». А потом пользователь спрашивает: “Почему у меня в библиотеке было 100 книг, а стало 97?” — и вы такие: “Э-э… ну… так получилось”. Это плохая стратегия, потому что она разрушает доверие.
Нам нужен отчёт: структурированная информация о том, что именно пошло не так. Даже если мы не показываем пользователю все детали (это отдельная тема про UX ошибок), как минимум мы должны уметь:
- посчитать количество битых записей,
- понять тип проблем (пустой title, дубликат id, странный год),
- при желании — указать индекс записи в файле.
Сделаем минимальный тип “issue” и результат валидации.
import Foundation
struct LoadIssue: CustomStringConvertible {
let index: Int
let message: String
var description: String { "#\(index): \(message)" }
}
Это намеренно просто: текстовое сообщение + индекс. Да, можно сделать красивее и типизированнее (enum-ошибки), но сегодня наша цель — научиться не терять проблему и аккуратно продолжать обработку.
Теперь контейнер результата:
import Foundation
struct ValidationResult<T> {
var value: T
var issues: [LoadIssue]
}
Такой результат удобно использовать и для “мягкого” режима, и для “строгого”: в строгом режиме вы просто проверяете issues.isEmpty и, если нет — бросаете ошибку.
5. Мини value objects: NonEmptyText, BookID, Year
Если мы хотим, чтобы доменные правила были не где-то в комментариях, а в коде, нам полезно иметь маленькие типы, которые гарантируют корректность значения. Это не «архитектурная мода», а способ сделать программу менее хрупкой: когда у вас в домене NonEmptyText, вы физически не можете создать пустой title без того, чтобы код явно сказал: “я разрешаю пустоту”.
NonEmptyText: текст, который не стыдно показывать людям
Сначала решим проблему “строка из пробелов”. Это классика: глазом видишь, что поле “пустое”, а компьютер видит символы и радуется. Мы нормализуем строку через trimmingCharacters(in:).
import Foundation
struct NonEmptyText: Hashable {
let value: String
init?(_ raw: String) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
self.value = trimmed
}
}
Обратите внимание на стиль: init? — это честный контракт “либо корректно, либо никак”. Мы не пытаемся «лечить» пустоту магией, мы требуем нормальные данные.
BookID: идентификатор как смысл, а не просто String
Технически id — строка. Но по смыслу это отдельная сущность. Мы хотим, чтобы id не был пустым и не состоял из пробелов. Плюс нам пригодится Hashable, чтобы класть id в Set и ловить дубликаты.
import Foundation
struct BookID: Hashable {
let value: String
init?(_ raw: String) {
guard let text = NonEmptyText(raw) else { return nil }
self.value = text.value
}
}
Да, это “обёртка над строкой”. И да, это полезно: когда вы видите BookID, вы понимаете, что это не просто «любой текст», а идентификатор.
Year: год в разумном диапазоне
Год часто бывает опциональным. Но если он указан, нам нужен разумный диапазон. Для учебного проекта возьмём что-то простое: 1450...2100. Это не «истина в последней инстанции», а пример инварианта.
import Foundation
struct Year: Hashable {
let value: Int
init?(_ raw: Int) {
guard (1450...2100).contains(raw) else { return nil }
self.value = raw
}
}
Здесь важно: мы не спорим, какой диапазон «правильный», мы показываем сам принцип — домен диктует правила.
6. DTO и домен: где именно валидируем
Теперь соберём всё в одну картинку. У нас есть DTO версии 2, который отражает JSON «как он лежит». И есть доменный Book, который мы хотим использовать в логике CLI-команд.
Ключевой принцип: валидируем на входе в домен, то есть при маппинге DTO → Domain. Тогда домен живёт с гарантией “внутри всё корректно”.
import Foundation
struct BookDTOv2: Codable {
let id: String
let title: String
let year: Int?
}
И доменная сущность:
import Foundation
struct Book: Hashable {
let id: BookID
let title: NonEmptyText
let year: Year?
}
Заметьте: доменный Book уже не содержит “грязных” String для title и id. Поэтому если Book создан, он уже «приличный гражданин».
7. Валидация списка: пропускаем мусор, но считаем потери
Самое важное место лекции: мы пишем функцию, которая получает массив DTO и пытается собрать массив доменных Book. Всё, что не получилось — оформляем как LoadIssue, увеличиваем счётчик проблем и идём дальше.
Проверка дубликатов через Set<BookID>
Дубликаты id — особенно неприятная история. В JSON они могут появиться по ошибке: кто-то скопировал запись и забыл поменять id. Для домена это катастрофа: “по какому id мы будем обновлять книгу?” — ответ: “да”.
Чтобы ловить дубликаты, используем Set и добавляем BookID по мере успешного создания.
import Foundation
var seen = Set<BookID>()
let id = BookID("b1")!
print(seen.contains(id)) // false
seen.insert(id)
print(seen.contains(id)) // true
Да, тут есть !, но он безопасен в демонстрационном фрагменте, потому что "b1" заведомо валиден. В реальном коде мы так делать не будем — там будет guard.
makeBook: маленькая функция без магии
Хороший стиль — вынести проверку одной записи в маленькую функцию. Тогда основной цикл не превращается в простыню.
import Foundation
func makeBook(dto: BookDTOv2, index: Int, seen: inout Set<BookID>) -> (Book?, LoadIssue?) {
guard let id = BookID(dto.id) else {
return (nil, LoadIssue(index: index, message: "Некорректный id"))
}
guard !seen.contains(id) else {
return (nil, LoadIssue(index: index, message: "Дубликат id=\(id.value)"))
}
guard let title = NonEmptyText(dto.title) else {
return (nil, LoadIssue(index: index, message: "Пустой title для id=\(id.value)"))
}
let year = dto.year.flatMap(Year.init)
seen.insert(id)
return (Book(id: id, title: title, year: year), nil)
}
Обратите внимание на несколько моментов.
Мы возвращаем пару (Book?, LoadIssue?). Это простой способ сказать: либо книга, либо проблема. Можно было сделать Result<Book, LoadIssue>, но LoadIssue у нас не Error, а “запись в отчёте”, поэтому такой вариант читается проще.
Ещё нюанс: year мы делаем через flatMap(Year.init). Это означает: если dto.year == nil, то year == nil. Если год есть, но не проходит диапазон — тоже получится nil. Это и есть пример мягкой политики: странный год не ломает книгу целиком, а просто «теряется». В строгом режиме вы бы вместо этого добавили LoadIssue и, возможно, выбросили запись целиком.
Главная функция: “валидируем файл” и получаем результат + issues
Теперь применим makeBook ко всему списку.
import Foundation
func validateBooks(_ items: [BookDTOv2]) -> ValidationResult<[Book]> {
var books: [Book] = []
var issues: [LoadIssue] = []
var seen = Set<BookID>()
for (index, dto) in items.enumerated() {
let (book, issue) = makeBook(dto: dto, index: index, seen: &seen)
if let book { books.append(book) }
if let issue { issues.append(issue) }
}
return ValidationResult(value: books, issues: issues)
}
Это сердце “мягкой” загрузки. Мы не падаем на первой проблеме. Мы сохраняем всё, что можно, и обязательно возвращаем отчёт.
8. Мини-демо: один JSON, три книги, одна “битая”
Чтобы почувствовать поведение, полезно собрать маленький пример, где одна запись заведомо плохая. Мы не читаем файл с диска (это не тема этой лекции), а берём JSON-строку и превращаем в Data.
import Foundation
struct LibraryFileV2: Decodable {
let schemaVersion: Int
let items: [BookDTOv2]
}
let json = #"{"schemaVersion":2,"items":[{"id":"b1","title":"Swift"},{"id":" ","title":"Bad"},{"id":"b2","title":" "}]} "#
let data = Data(json.utf8)
let file = try JSONDecoder().decode(LibraryFileV2.self, from: data)
let result = validateBooks(file.items)
print("OK: \(result.value.count)") // OK: 1
print("Issues: \(result.issues.count)") // Issues: 2
Что здесь произошло по смыслу:
- первая книга валидна,
- вторая имеет id из пробелов → отбрасываем и пишем issue,
- третья имеет пустой title после trim → отбрасываем и пишем issue.
И вот теперь важное: если бы мы просто делали map без валидации, мы бы протащили мусор в домен. А затем мусор начал бы ломать команды CLI в неожиданных местах.
9. Как это встраивается в загрузку
Важно увидеть общую схему, но не уходить в темы, которые будут разбираться позже (надёжная запись, recovery и прочее). В рамках этой лекции нам достаточно понимать: валидация стоит после миграции и до доменной работы.
Схема:
flowchart TD
A[Data из файла] --> B[decode header: schemaVersion]
B --> C{version}
C -->|2| D[decode LibraryFileV2]
C -->|1| E[decode V1] --> F[migrate V1->V2] --> D
D --> G[validateBooks: DTO->Domain]
G --> H[books + issues]
То есть валидация — это не «что-то внутри миграции» и не «что-то в JSONDecoder». Это отдельный явный шаг, который можно логировать, тестировать и обсуждать без магических побочных эффектов.
10. Типичные ошибки при валидации на загрузке
Ошибка №1: “Раз декодировалось — значит корректно”.
Это самая частая логическая ловушка. JSONDecoder проверяет структуру и типы, но не защищает смысл. В результате в домен попадают пустые title, дубликаты id и странные значения, а баг проявляется позже — в совершенно другом месте, где вы уже не связываете проблему с загрузкой.
Ошибка №2: Валидация до миграции.
Если вы валидируете V1-данные, а потом мигрируете, вы вынуждены держать два набора правил: для V1 и для V2. Это быстро разрастается и ломает читаемость. Гораздо проще: сначала привести всё к актуальному DTO (V2), и только потом применять один набор правил.
Ошибка №3: “Мягко пропустили” и никому не сказали.
Пропуск битых записей без отчёта превращается в тихую потерю данных. Пользователь видит, что часть книг исчезла, но не понимает почему. Даже если вы не показываете подробности в UI прямо сейчас, вы обязаны сохранить хотя бы список issues и их количество, чтобы можно было объяснить, что произошло.
Ошибка №4: Дубликаты id игнорируются.
Если не проверять уникальность, то хранилище становится непредсказуемым: операции “обновить по id” и “удалить по id” начинают зависеть от того, какая запись “случайно последняя”. Это тот случай, когда система выглядит рабочей до первого серьёзного кейса, а потом внезапно превращается в детектив.
Ошибка №5: Слишком агрессивная “автопочинка” данных.
Иногда хочется “исправить всё автоматически”: пустой title заменить на "Untitled", год 0 заменить на 2000, и так далее. Проблема в том, что вы начинаете придумывать данные за пользователя, а это может быть хуже ошибки. Если уж делаете дефолты, они должны быть осознанными, детерминированными и обязательно отражаться в отчёте, иначе получится «тихий ремонт реальности».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ