JavaRush /Курсы /Swift SELF /Команда fetch: пайпл...

Команда fetch: пайплайн от parse до save

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

1. Что на самом деле делает fetch

Когда новичок слышит «команда fetch», мозг рисует простую картинку: “ага, значит мы делаем запрос, печатаем ответ и всё”. Это нормальная фантаазия, примерно как мечта «в пятницу вечером быстро починю баг на 5 минут» (спойлер: нет). На практике fetch — это сценарий: последовательность шагов, где каждый шаг имеет чёткий вход/выход и понятную точку, где всё может пойти не так.

Важно зафиксировать мысль: наш CLI — это не набор разрозненных функций, а маленький «конвейер». У fetch есть логика до сети (валидация и сборка URLRequest), логика после сети (маппинг DTO → Domain), и логика сохранения (обновить репозиторий и обязательно сохранить изменения).

Схема сценария выглядит так:

flowchart TD
    A[parse args] --> B["Command.fetch(id)"]
    B --> C[Endpoint builder]
    C --> D[URLRequest]
    D --> E[ApiClient.fetch]
    E --> F[BookDTO]
    F --> G[map DTO → Book]
    G --> H[repo.upsert]
    H --> I[repo.save]

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

2. Command как источник правды для id

Когда код разрастается, появляется крайне токсичная привычка: “айдишник где-то лежит”. Он может лежать в глобальной переменной, в поле класса, быть захвачен замыканием, приехать из readLine() в середине сценария, или (любимый вариант) быть «случайно доступен» в скоупе. Это делает поведение команды непредсказуемым.

В нашем курсе правило очень простое: аргументы команды живут внутри Command. Значит, id должен извлекаться через pattern matching: case .fetch(let id).

Pattern matching по enum с associated values — это базовый и очень “свифтовый” способ писать такие вещи; на уровне языка это буквально ожидаемый стиль использования enum, и вы будете постоянно видеть похожий switch с case let ... в реальных кодовых базах.

enum Command {
    case fetch(id: Int)
    case help
}

func extractFetchID(from command: Command) -> Int? {
    switch command {
    case .fetch(let id):
        return id
    case .help:
        return nil
    }
}

Обратите внимание на психологию: после такого кода у вас в голове фиксируется «id появился из команды». Это маленькая вещь, но она резко снижает количество багов вида “а почему он тянет id от прошлого запуска?”.

3. parse: аргументы CLI → Command.fetch(id:)

Парсинг — это «пограничник» нашего приложения: он впускает внутрь только то, что похоже на валидную команду. Здесь важно не переусложнять: наша цель не написать bash, а сделать предсказуемый формат ввода, который можно объяснить одним абзацем человеку и одной функцией компилятору.

Мы считаем, что пользователь запускает CLI примерно так:

swift run LibraryCLI fetch 42

Значит, мы получаем массив аргументов, где "fetch" — имя команды, а "42"id.

Пример (упрощённо, без детальных ошибок парсинга):

func parseCommand(_ args: [String]) -> Command? {
    guard args.count >= 2 else { return .help }

    let name = args[0]
    let second = args[1]

    if name == "fetch", let id = Int(second) {
        return .fetch(id: id)
    }
    return .help
}

Здесь мы сознательно не углубляемся в UX‑ошибки (что печатать пользователю, какой exit code возвращать и т.д.) — это отдельная тема. Сейчас достаточно, что после parse у нас типизированная команда, а не «сырые строки».

4. Сборка запроса: endpoint builder и URLRequest

Когда студент впервые пишет сетевой код, он почти неизбежно делает так:

let url = URL(string: "https://api.site.com/v1/books/" + "\(id)" + "?a=b")!

И это ровно тот момент, когда Swift вежливо спрашивает: “Ты уверен?” — а студент отвечает !, как будто это заклинание «отстань». Не делайте так. Мы уже взрослые. У нас теперь есть ответственность, ипотека, и URLComponents.

Мы выносим сборку URL и URLRequest в отдельный builder (endpoint). Это даёт две выгоды: сценарий fetch остаётся читаемым (там нет склейки строк), и ошибки сборки запроса становятся явным типом ошибок.

Кстати, URLComponents — value type из Foundation, то есть его можно безопасно копировать/мутировать как значение (внутри он реализован эффективно, с copy-on-write‑идеей).

import Foundation

enum EndpointBuildError: Error {
    case invalidBaseURL
    case invalidURLComponents
}

enum BooksEndpoint {
    case details(id: Int)

    func makeRequest(baseURL: URL) throws -> URLRequest {
        guard let base = URLComponents(url: baseURL, resolvingAgainstBaseURL: false),
              let scheme = base.scheme, let host = base.host else {
            throw EndpointBuildError.invalidBaseURL
        }

        var c = URLComponents()
        c.scheme = scheme
        c.host = host

        switch self {
        case .details(let id):
            c.path = "/v1/books/\(id)"
        }

        guard let url = c.url else { throw EndpointBuildError.invalidURLComponents }

        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        return request
    }
}

Обратите внимание на дизайн: id достаётся из self внутри switch, а не приходит «откуда-то». Это продолжение идеи “источник правды” — но уже на уровне endpoint.

5. После сети: DTO, домен и сохранение

ApiClient.fetch(...): сеть ничего не знает про CLI и репозиторий

Сетевой слой часто ломают одной невинной фразой: “давайте прямо из ApiClient будем сохранять в файл”. После этого у вас API‑клиент внезапно начинает зависеть от формата хранения, путей, FileManager, логгера, и превращается в комбайн “на все случаи жизни”. Такой код потом нельзя тестировать без ритуальных танцев.

Правильнее: ApiClient принимает URLRequest и возвращает DTO (или бросает ошибку). Он не должен знать, что такое BookRepository, и точно не должен знать, что такое команда fetch.

Псевдокод/схема (зависит от ваших модулей):

import Foundation

protocol ApiClient {
    func fetch<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T
}

Здесь важно прочувствовать контракт: request in → decoded DTO out. Если вы держите этот контракт чистым, всё остальное проще тестировать и развивать.

DTO → Domain: «переводчик», который не стесняется быть скучным

DTO (то, что приходит из JSON) почти никогда не совпадает 1‑в‑1 с доменной моделью. Иногда у DTO больше полей, иногда меньше, иногда названия другие, иногда приходят опционалы, иногда сервер шлёт null, потому что «ну а почему бы и нет».

Поэтому маппинг должен быть отдельным шагом. Да, это кажется «лишним кодом», но это тот самый “лишний код”, который спасает вас от ситуации “мы поменяли API и сломали всё приложение”.

struct BookDTO: Decodable {
    let id: Int
    let title: String
    let author: String?
}

struct Book {
    let id: Int
    let title: String
    let author: String
}

extension Book {
    init(dto: BookDTO) {
        self.id = dto.id
        self.title = dto.title
        self.author = dto.author ?? "Unknown" // дефолт на уровне домена
    }
}

Заметьте, мы приняли решение: если author == nil, в домене будет "Unknown". Это не “магия”, это домашнее правило вашего приложения. И оно должно жить в домене/маппинге, а не размазываться по всему проекту.

Update + Save: почему save() — критичный шаг

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

Тут есть два типичных соблазна.

Первый: “а давайте save() делать try?, чтобы не мешало”. Это почти всегда ошибка: если запись не произошла, пользователь думает, что всё успешно, а данные исчезают. Второй соблазн: сохранять несколько раз “по пути”, после каждого изменения. Это и медленнее, и опаснее в плане согласованности.

Пример (минимальная идея “применили → один save”):

protocol BookRepository {
    func upsert(_ book: Book)
    func save() throws
}

func updateAndSave(book: Book, repo: BookRepository) throws {
    repo.upsert(book)
    try repo.save()
}

Это выглядит банально — и это хорошо. Банальный код часто живёт дольше модных архитектурных слов.

6. Собираем всё вместе: runFetch(...)

Самое приятное в правильной архитектуре — момент, когда вы собираете шаги в цепочку и видите, что она читается как текст: “взяли id → собрали request → получили dto → сделали book → обновили repo → сохранили”.

Очень важно: id мы достаём строго из Command через pattern matching case .fetch(let id). Это требование дня: никаких “id вне scope”.

Псевдокод/схема (потому что конкретные типы NetworkError, реализация ApiClient и репо у вас уже есть в проекте):

import Foundation

func runFetch(
    command: Command,
    baseURL: URL,
    api: ApiClient,
    repo: BookRepository
) async throws {
    guard case .fetch(let id) = command else { return }

    let request = try BooksEndpoint.details(id: id).makeRequest(baseURL: baseURL)
    let dto = try await api.fetch(request, as: BookDTO.self)

    let book = Book(dto: dto)
    repo.upsert(book)
    try repo.save()
}

Здесь стоит заметить важный стилевой момент: у сценария одна понятная точка отказа на каждом шаге (try, try await, try). Мы не прячем ошибки, не делаем try?, не превращаем fetch в “вроде работает”.

Если вы используете SwiftPM, то точка входа обычно находится в Sources/main.swift (или в @main‑типе), и именно там вы зовёте parse, создаёте зависимости и запускаете сценарий.

7. Кто за что отвечает в fetch

Чтобы закрепить, полезно держать маленькую таблицу, которая отвечает на вопрос “где какой код должен жить”. Это помогает не превращать main.swift в «Войну и мир», а ApiClient — в «Войну и мир, но про сеть».

Шаг Вход Выход Где живёт Типичная ошибка
parse
[String]
Command
CLI хранить аргументы в глобальных переменных
extract id
Command
Int
application/service брать id “из воздуха”, не из case .fetch(let id)
build request
baseURL + id
URLRequest
endpoint builder склеивать URL строками и ставить !
fetch
URLRequest
BookDTO
network (ApiClient) смешивать сеть и storage
map
BookDTO
Book
domain mapping тянуть DTO в домен “как есть”
update
Book
изменение repo storage/repo писать в файл из сети
save
repo state запись на диск storage/repo try? save() и “вроде норм”

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

Ошибка №1: id живёт отдельной переменной, а не внутри Command.
Такой код обычно сначала “работает”, а потом внезапно начинает брать id из прошлого запуска, из другого кейса, из тестового значения или просто из неправильного места. Лечится просто: id всегда извлекается только через case .fetch(let id) и нигде больше.

Ошибка №2: сборка URL строковой конкатенацией и принудительный !.
Это превращает баги в «бомбы с таймером»: приложение падает не там, где вы ошиблись, а там, где URL(string:) вернул nil. Endpoint builder с URLComponents и throws делает проблему явной и управляемой, а URLComponents при этом нормально живёт как value type.

Ошибка №3: ApiClient начинает “знать” про репозиторий, файлы и CLI.
В этот момент тестирование превращается в боль, а код — в кашу зависимостей. Держите контракт сетевого слоя узким: URLRequestDTO.

Ошибка №4: DTO используется как доменная модель “и так сойдёт”.
Первые два дня кажется, что это экономит время. На третий день API меняется, и вы внезапно чините весь проект, потому что author стал optional или поле переименовали. Делайте маппинг явным: он дешевле, чем хаос.

Ошибка №5: save() вызывается через try? или вообще игнорируется.
Если сохранение не произошло — это значимая ошибка сценария. Пользователь ожидает, что fetch обновил библиотеку. “Тихое” падение сохранения — это как сделать “Сохранить файл” кнопкой, которая иногда просто притворяется.

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