JavaRush /Курсы /Swift SELF /Валидация при загрузке: “битая запись” — что делаем

Валидация при загрузке: “битая запись” — что делаем

Swift SELF
60 уровень , 2 лекция
Открыта

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. Мы специально держим их простыми, чтобы код был понятным и не превращался в бюрократию.

Поле Где живёт Правило (инвариант) Почему важно
id
домен непустой (после trim) и уникальный в файле id — это «паспорт» книги
title
домен непустой (после trim) иначе книга неотличима от призрака
year
домен либо отсутствует, либо в разумном диапазоне год нужен как данные, а не как фантастика
tags
домен/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-команд.

Ключевой принцип: валидируем на входе в домен, то есть при маппинге DTODomain. Тогда домен живёт с гарантией “внутри всё корректно”.

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, и так далее. Проблема в том, что вы начинаете придумывать данные за пользователя, а это может быть хуже ошибки. Если уж делаете дефолты, они должны быть осознанными, детерминированными и обязательно отражаться в отчёте, иначе получится «тихий ремонт реальности».

1
Задача
Swift SELF, 60 уровень, 2 лекция
Недоступна
NonEmptyText для “человеческого” текста
NonEmptyText для “человеческого” текста
1
Задача
Swift SELF, 60 уровень, 2 лекция
Недоступна
Отчёт о валидации через LoadIssue
Отчёт о валидации через LoadIssue
1
Задача
Swift SELF, 60 уровень, 2 лекция
Недоступна
Мягкая загрузка списка книг: DTO → validation → domain + issues
Мягкая загрузка списка книг: DTO → validation → domain + issues
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ