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 — в «Войну и мир, но про сеть».
| Шаг | Вход | Выход | Где живёт | Типичная ошибка |
|---|---|---|---|---|
|
|
|
CLI | хранить аргументы в глобальных переменных |
| extract id | |
|
application/service | брать id “из воздуха”, не из case .fetch(let id) |
| build request | |
|
endpoint builder | склеивать URL строками и ставить ! |
|
|
|
network (ApiClient) | смешивать сеть и storage |
|
|
|
domain mapping | тянуть DTO в домен “как есть” |
|
|
изменение repo | storage/repo | писать в файл из сети |
|
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.
В этот момент тестирование превращается в боль, а код — в кашу зависимостей. Держите контракт сетевого слоя узким: URLRequest → DTO.
Ошибка №4: DTO используется как доменная модель “и так сойдёт”.
Первые два дня кажется, что это экономит время. На третий день API меняется, и вы внезапно чините весь проект, потому что author стал optional или поле переименовали. Делайте маппинг явным: он дешевле, чем хаос.
Ошибка №5: save() вызывается через try? или вообще игнорируется.
Если сохранение не произошло — это значимая ошибка сценария. Пользователь ожидает, что fetch обновил библиотеку. “Тихое” падение сохранения — это как сделать “Сохранить файл” кнопкой, которая иногда просто притворяется.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ