JavaRush /Курсы /Swift SELF /Разделение ответственности: encode/decode

Разделение ответственности: encode/decode

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

1. Почему не стоит смешивать всё в одном load()

Когда пишешь первый файловый репозиторий, очень хочется сделать так: в load() прочитать файл, тут же декодировать, тут же превратить в доменные типы, тут же что-нибудь поправить, и заодно вывести print("Loaded!") для настроения. Это ощущается как «я молодец, оно работает», но дальше начинается взрослая жизнь: формат JSON меняется, путь к файлу становится настраиваемым, появляются новые поля, а ошибки нужно нормально показывать пользователю.

Проблема тут не в том, что «так нельзя по правилам чистого кода». Проблема в том, что вы сами себе усложняете жизнь: один метод начинает знать слишком много. Он знает про файловую систему, про JSON, про структуру DTO, про доменные правила и про то, как реагировать на ошибки. И каждый следующий шаг (например, поменять форматирование JSON или заменить способ записи) превращается в мини-операцию на мозге без наркоза.

Давайте посмотрим на типичный «монолитный» антипример (он компилируется, но в долгую — вредный):

import Foundation

final class BadRepository {
    private let fileURL: URL

    init(fileURL: URL) {
        self.fileURL = fileURL
    }

    func loadTitles() throws -> [String] {
        let data = try Data(contentsOf: fileURL)
        let dto = try JSONDecoder().decode([String].self, from: data)
        return dto
    }
}

Он даже выглядит прилично. Но как только вместо [String] у вас появится LibraryFileDTO, а потом ещё schemaVersion, а потом маппинг в Book, а потом нормальные ошибки — метод раздуется, и вы начнёте бояться его трогать (а это плохой признак: код, которого боятся, обычно мстит).

2. Конвейер преобразований: URLDataDTO ↔ Domain

Чтобы перестать смешивать обязанности, полезно мыслить не «репозиторий читает JSON», а «репозиторий запускает конвейер преобразований». На входе конвейера у нас лежит URL файла, на выходе — доменные сущности. Где-то посередине обязательно будет Data, а рядом с ним — Codable-DTO, потому что JSON сам по себе в домен обычно тащить не хочется.

В Swift-сообществе давно укоренилась идея разделять шаги сериализации на отдельные этапы: сначала получить представление, удобное для кодирования, потом превратить его в байты, потом обратно байты в валидированное представление и лишь затем — в доменную сущность.

Давайте зафиксируем схему. Её удобно держать в голове как «трубу»:

flowchart LR
    A[URL файла] --> B[Data]
    B --> C[DTO: Codable]
    C --> D[Domain-модель]

И отдельно два направления:

flowchart TD
    subgraph LOAD["load()"]
        L1[URL] --> L2[read Data] --> L3[decode DTO] --> L4[map to Domain]
    end
flowchart TD
    subgraph SAVE["save()"]
        S1[Domain] --> S2[map to DTO] --> S3[encode Data] --> S4[write URL]
    end

Главная мысль сегодняшней лекции: шаг “decode/encode” не должен быть внутри шага “read/write”. Это разные задачи, и им полезно жить в разных типах.

3. Домен и DTO формата файла

Мини-домен: Book и BookID

Перед тем как делить обязанности, нам нужно договориться о минимальных типах, вокруг которых будет крутиться пример. Мы продолжаем учебный CLI-проект «библиотека»: храним книги и их названия. Модель будет нарочно простой — сегодня мы тренируем архитектуру, а не богатство предметной области.

import Foundation

struct BookID: Hashable, Codable {
    let rawValue: UUID
}

struct Book: Codable {
    let id: BookID
    var title: String
}

Обратите внимание на деталь: хотя Book и Codable, мы всё равно будем кодировать не его напрямую, а DTO формата файла. Это кажется странным до тех пор, пока вы не попробуете поменять формат хранения хоть раз. После первой же миграции вы начнёте уважать DTO как уважали бы огнетушитель: пока не нужен — кажется лишним, когда нужен — поздно спорить.

DTO формата файла: «как лежит на диске»

DTO — это договор с файлом. Доменные типы — это договор с предметной областью. И эти два договора не обязаны совпадать. В файле удобно хранить UUID как обычную строку/UUID, в домене удобно хранить BookID. В файле нам нужен schemaVersion, в домене он обычно не нужен как часть Book.

Поэтому делаем два DTO: один для книги, второй — корневой контейнер файла.

import Foundation

struct BookDTO: Codable {
    var id: UUID
    var title: String
}

struct LibraryFileDTO: Codable {
    var schemaVersion: Int
    var items: [BookDTO]
}

И вот здесь важный момент по теме лекции: DTO не должен знать ничего про пути, файлы, директории и где это хранится. DTO — это просто структура данных, которую удобно кодировать и декодировать.

Маппинг DTO ↔ Domain: отдельный «переводчик»

Когда вы впервые слышите «маппинг», хочется закатить глаза: «ну это же одна строчка, зачем отдельная сущность». Но здесь та же история, что с ремнями безопасности: один раз пристегнулся — кажется неудобно, один раз не пристегнулся — неудобно становится в другом смысле.

Мы делаем маппинг как расширение BookDTO. Это удобно: DTO остаётся DTO, а преобразование — рядом, но не смешивается с чтением/записью.

import Foundation

extension BookDTO {
    init(domain: Book) {
        self.id = domain.id.rawValue
        self.title = domain.title
    }

    func toDomain() -> Book {
        Book(id: BookID(rawValue: id), title: title)
    }
}

На этом шаге мы всё ещё не трогаем JSON и не трогаем файловую систему. Мы просто умеем сказать: «в домене у нас так, а в файле — так».

4. Два компонента вместо одного: кодек и файловый слой

JSON-кодек: DTO ↔ Data, без URL и папок

Теперь — ключевой герой лекции: кодек. Его задача — превращать DTO в байты (Data) и обратно. Всё. Он не должен знать, где лежит файл, существует ли директория, какие права у пользователя и как называется ваш проект. Он живёт в мире «вот тебе структура — вот тебе JSON-байты».

Сделаем LibraryJSONCodec.

import Foundation

struct LibraryJSONCodec {
    private let encoder: JSONEncoder
    private let decoder: JSONDecoder

    init() {
        let e = JSONEncoder()
        e.outputFormatting = [.prettyPrinted, .sortedKeys]
        self.encoder = e
        self.decoder = JSONDecoder()
    }

    func encode(_ dto: LibraryFileDTO) throws -> Data {
        try encoder.encode(dto)
    }

    func decode(_ data: Data) throws -> LibraryFileDTO {
        try decoder.decode(LibraryFileDTO.self, from: data)
    }
}

Почему это хорошо?

  • Кодек можно тестировать без файлов вообще: дали DTO → получили Data → проверили, что декодируется обратно.
  • И наоборот, можно тестировать файловый слой без JSON: дали Data → записали → прочитали обратно.

Это и есть практический смысл разделения ответственности, а не «красивые слова».

FileStore: URLData, без Codable и JSONDecoder

Вторая половина разделения — файловый ввод/вывод. И тут мы делаем симметричный ход: файловый слой умеет читать и писать байты, но не знает ничего про LibraryFileDTO.

Самый простой вариант выглядит так:

import Foundation

struct FileStore {
    func read(from url: URL) throws -> Data {
        try Data(contentsOf: url)
    }

    func write(_ data: Data, to url: URL) throws {
        try data.write(to: url)
    }
}

Да, мы уже обсуждали, что для надёжной записи позже мы захотим «temp → replace» и, возможно, backup. Но обратите внимание: это всё равно останется обязанностью файлового слоя, а не кодека. Кодек вообще не должен подозревать, что его байты куда-то записывают — он «в вакууме» делает JSON.

И это очень освобождает мозг: если вам нужно улучшить стратегию записи, вы не трогаете JSON-код и DTO. Вы улучшаете только FileStore.

5. Репозиторий-оркестратор: связываем шаги в load/save

Каркас репозитория и зависимости

Теперь собираем всё вместе. Репозиторий — это тот, кто знает, в каком порядке запускать шаги, и где источник правды в памяти. Но он не должен сам «уметь читать файлы» и «уметь кодировать JSON». Он должен использовать FileStore и LibraryJSONCodec.

Сделаем репозиторий, который хранит книги в памяти как словарь. Сегодня мы не реализуем оптимизации поиска и индекс — нам важно, чтобы жизненный цикл load/save был чистым и понятным.

import Foundation

enum RepositoryError: Error {
    case unsupportedSchemaVersion(Int)
}

final class JSONFileBookRepository {
    private let fileURL: URL
    private let store: FileStore
    private let codec: LibraryJSONCodec

    private var booksByID: [BookID: Book] = [:]

    init(fileURL: URL, store: FileStore, codec: LibraryJSONCodec) {
        self.fileURL = fileURL
        self.store = store
        self.codec = codec
    }
}

Заметьте приятную вещь: зависимости передаются через init. Это не «сложная DI-архитектура», это просто честный способ сказать: репозиторий не обязан создавать кодек и файловый слой сам.

load(): read Data → decode DTO → map to Domain

Сейчас мы напишем load() как последовательность простых шагов. Каждый шаг маленький, и поэтому ошибки легче понимать: если упали на чтении — проблема с файлом, если упали на декодировании — проблема с JSON, если упали на версии схемы — проблема совместимости.

import Foundation

extension JSONFileBookRepository {
    func load() throws {
        let data = try store.read(from: fileURL)
        let dto = try codec.decode(data)

        guard dto.schemaVersion == 1 else {
            throw RepositoryError.unsupportedSchemaVersion(dto.schemaVersion)
        }

        let books = dto.items.map { $0.toDomain() }
        self.booksByID = Dictionary(uniqueKeysWithValues: books.map { ($0.id, $0) })
    }
}

Да, тут есть одна «плотная» строка со словарём. Если она выглядит страшно — это нормально. Страх проходит, когда вы понимаете идею: мы хотим быстро построить booksByID, потому что это удобно как источник правды.

Если хотите сделать более «пошагово» (иногда так читается легче), можно так:

import Foundation

extension JSONFileBookRepository {
    func replaceState(with books: [Book]) {
        var dict: [BookID: Book] = [:]
        for book in books {
            dict[book.id] = book
        }
        self.booksByID = dict
    }
}

И тогда в load() будет чуть меньше «магии».

save(): map to DTO → encode Data → write URL

С save() логика зеркальная. Важно: репозиторий сохраняет текущее in-memory состояние. Он не обязан «угадывать», что изменилось, и не обязан смешивать сохранение с командами add/update/remove. Мы отдельно обсуждали, что семантика «изменили в памяти» и «сохранили на диск» должна быть явной.

import Foundation

extension JSONFileBookRepository {
    func save() throws {
        let items = booksByID.values
            .map { BookDTO(domain: $0) }
            .sorted { $0.title < $1.title }

        let dto = LibraryFileDTO(schemaVersion: 1, items: items)
        let data = try codec.encode(dto)

        try store.write(data, to: fileURL)
    }
}

Почему сортировка тут допустима? Потому что это не «доменные правила», а удобство формата файла: человеку проще смотреть на JSON, где книги идут в стабильном порядке. И, главное, сортировка не нарушает доменную модель. Если вам это кажется лишним — можно убрать. Я не обижусь. Компилятор тоже.

Мини-демо: добавили → save()load()

Чтобы ощутить, что всё связалось, добавим мини-методы для работы с памятью. Здесь мы не углубляемся в полный контракт репозитория (он был раньше), а просто сделаем пару операций, чтобы можно было «пощупать» цикл.

import Foundation

extension JSONFileBookRepository {
    func add(title: String) {
        let id = BookID(rawValue: UUID())
        booksByID[id] = Book(id: id, title: title)
    }

    func allBooks() -> [Book] {
        Array(booksByID.values)
    }
}

И маленький top-level пример (в CLI-утилите это временно, чисто для проверки руками):

import Foundation

let url = URL(fileURLWithPath: "library.json")
let repo = JSONFileBookRepository(fileURL: url, store: FileStore(), codec: LibraryJSONCodec())

repo.add(title: "Swift для тех, кто устал")
try repo.save()

try repo.load()
print(repo.allBooks().count) // 1

Если вы запустите это в окружении, где рядом можно писать файл, вы увидите 1. И это самое приятное «1» в мире: оно означает, что разделение слоёв не сломало нам жизнь, а наоборот — сделало её аккуратнее.

6. Что мы выиграли: тестируемость и локальные изменения

Сейчас будет важный момент, который новички часто недооценивают. Разделение ответственности — это не про красоту. Это про то, что изменения становятся локальными.

  • Если вам нужно поменять форматирование JSON (например, убрать prettyPrinted, потому что файл огромный), вы трогаете только LibraryJSONCodec. Репозиторий вообще не заметит.
  • Если вам нужно поменять стратегию записи (например, записывать через временный файл и замену), вы трогаете только FileStore. Кодек вообще не заметит.
  • Если вам нужно поменять доменную модель (например, добавить author), вы меняете доменные типы и маппинг DTO↔Domain. И, что важно, вы решаете отдельно: меняется ли формат файла. Если да — меняете DTO. Если нет — DTO можно оставить.

Это похоже на кухню: нож режет, плита нагревает, а повар решает последовательность. Можно, конечно, пытаться резать ножом и одновременно жарить на нём котлету, но тогда вы получите очень странный нож и ещё более странную котлету.

7. Типичные ошибки

Ошибка №1: кодек начинает «знать про файлы».
Очень соблазнительно добавить в LibraryJSONCodec метод decode(from url: URL) и внутри сделать Data(contentsOf:). Кажется, что это «удобно», но вы моментально склеиваете два слоя в один, и потом не можете протестировать JSON-декодирование без файлов. Держите железное правило: кодек работает только с Data.

Ошибка №2: файловый слой начинает «знать про DTO».
Иногда делают FileStore.save(dto: LibraryFileDTO, to: URL) и внутри кодируют JSON. Это симметричная ошибка: вы больше не можете улучшать файловую запись независимо от JSON. Файловый слой должен быть туповатым (в хорошем смысле): прочитал байты, записал байты.

Ошибка №3: репозиторий превращается в «бог-объект».
Даже при наличии FileStore и кодека можно написать load() на 80 строк, где репозиторий ещё и «чинит битые данные», и «делает миграцию», и «строит индекс», и «логирует», и «печатает пользователю подсказку». Проблема не в количестве строк, а в количестве обязанностей. Если вы чувствуете, что load() становится страшным, обычно это сигнал: вы смешали уровни абстракции.

Ошибка №4: print() внутри хранения.
Это особенно коварно: кажется, что один print("Saved") никому не мешает. Но потом вы захотите использовать репозиторий в другом режиме (например, в тестах или в другом CLI-выводе), и внезапно репозиторий начнёт «болтать» в консоль там, где вы этого не просили. Репозиторий возвращает ошибки и данные; общение с пользователем живёт уровнем выше.

Ошибка №5: «раз у меня Domain тоже Codable, значит DTO не нужно».
Это рабочая стратегия ровно до первой серьёзной правки модели. Потом вам нужно либо поддерживать старые файлы, либо мигрировать, либо хранить schemaVersion. DTO — это ваш способ сохранить свободу: менять домен, не ломая формат файла, или менять файл, не таща это напрямую в домен.

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