1. Асинхронность сетевых запросов
Если вы только начинаете, то самая естественная мечта звучит так: «Я вызову fetchBooks(), она вернёт мне массив книг, и я пойду дальше». Мечта прекрасная, как «код без багов в пятницу вечером». Но сеть работает медленно и непредсказуемо: сервер может отвечать 50 мс, 5 секунд или вообще не отвечать. Если сделать запрос «синхронно», программа просто зависнет, ожидая ответа.
Поэтому в Swift (и вообще почти везде) классический сетевой API устроен так: вы запускаете запрос сейчас, а результат получаете позже — через функцию‑коллбек, то есть completion handler. Это и есть callback‑модель: «я вернусь к тебе, честно-честно».
Чтобы это ощущалось не мистикой, а инженерией, держите простую ментальную картинку:
flowchart LR
A[Ваш код] -->|создаёт задачу| B[URLSession]
B -->|уходит в сеть| C[Интернет и сервер]
C -->|ответ / ошибка| B
B -->|вызывает completion handler| A
Сигнатура dataTask и параметры completion handler
Самая важная строка этой лекции — это сигнатура completion handler у URLSession.dataTask. В учебном смысле она ценнее, чем мотивационные цитаты.
В Swift она выглядит так (упрощённо по смыслу, но в точности по ключевым частям):
// Идея сигнатуры из документации/эволюции Swift:
func dataTask(
with url: URL,
completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void
) -> URLSessionDataTask
То есть completion handler получает три значения, и каждое из них — Optional:
- Data? — тело ответа (байты). Может быть nil.
- URLResponse? — метаданные ответа. Может быть nil.
- Error? — ошибка транспорта (например, нет сети). Может быть nil.
Здесь важно не запутаться: HTTP‑ошибка (например, 404) — это чаще всего не Error. Это «успешно полученный ответ», просто со статус‑кодом, который означает «неуспех» на уровне протокола. А Error — это обычно проблема «мы вообще не смогли нормально сходить».
Чтобы мозг не пытался хранить это в виде каши, можно смотреть на это через маленькую таблицу:
| Что пришло | Тип | Что означает по-человечески |
|---|---|---|
| Тело | |
«Вот байты, попробуй интерпретировать» |
| Ответ | |
«Вот информация об ответе (включая статус‑код, но не всегда доступен напрямую)» |
| Ошибка | |
«Я не смог выполнить запрос: сеть, DNS, таймаут, TLS и т.п.» |
2. @escaping в completion handler
Когда вы видите @escaping, компилятор не пытается испортить вам настроение. Он пытается защитить вас от ситуации «замыкание будет вызвано позже, а вы думали — сейчас».
В Swift замыкания по умолчанию не escaping. Это означает: функция гарантирует, что замыкание будет использовано (вызвано) внутри этой функции, пока она ещё выполняется. Как только функция завершилась — замыкание больше нигде не хранится.
Но URLSession устроен иначе. Вы вызываете dataTask(...), функция сразу возвращается, а completion handler будет вызван потом, когда сеть ответит. Значит, completion handler «убегает наружу», то есть escape.
Сначала маленький пример без сети, чтобы прочувствовать идею на сухом, но честном коде.
Не-escaping: вызвали и сразу закончили
func runNow(_ action: () -> Void) {
action()
}
runNow {
print("Я выполняюсь прямо сейчас") // Я выполняюсь прямо сейчас
}
Здесь action не нужно помечать @escaping, потому что оно не переживает конец runNow.
Escaping: сохранили на потом
var pendingActions: [() -> Void] = []
func storeForLater(_ action: @escaping () -> Void) {
pendingActions.append(action)
}
storeForLater {
print("Я выполнюсь позже") // (пока ничего не печатает)
}
pendingActions.first?() // Я выполнюсь позже
Вот почему нужен @escaping: мы сохранили замыкание в массив и вызвали позже, уже после завершения storeForLater.
То же самое делает URLSession
URLSession внутри себя делает примерно «храни completion handler где‑то в задаче, а когда всё закончится — вызови». Поэтому completion handler и помечен как @escaping. Это встроенная часть контракта API, а не декоративный атрибут.
3. resume(): задача не стартует сама
С URLSession есть одна особенно частая ловушка: вы думаете, что «я создал запрос — значит он пошёл». А он не пошёл.
dataTask возвращает объект URLSessionDataTask. Это «задача», но она в состоянии паузы, пока вы не вызовете resume().
Давайте сделаем минимальный пример. Он компилируется, но важное замечание: в CLI‑приложении (как наш LibraryCLI) программа может успеть завершиться раньше, чем сеть ответит. Эту проблему мы решим в следующем разделе.
import Foundation
guard let url = URL(string: "https://example.com") else {
fatalError("Invalid constant URL")
}
let task = URLSession.shared.dataTask(with: url) { data, response, error in
print("Callback called!") // Callback called! (если программа доживёт)
print("Bytes:", data?.count ?? 0)
}
task.resume() // Без этой строки не произойдёт вообще ничего.
Ментальная модель простая:
sequenceDiagram
participant You as Ваш код
participant Session as URLSession
participant Task as URLSessionDataTask
participant Net as Сеть
You->>Session: dataTask(with:completion:)
Session-->>You: возвращает Task (ещё не запущена)
You->>Task: resume()
Task->>Net: выполняет запрос
Net-->>Task: ответ/ошибка
Task-->>You: вызывает completion handler
Если вы забыли resume(), то вы создали «контейнер для запроса», но не запустили его. Это как купить билет на поезд и остаться дома.
4. Completion handler: базовый порядок проверок
В completion handler легко написать код, который «иногда работает». Это опасная разновидность работоспособности: она хуже, чем «всегда не работает», потому что заставляет сомневаться в реальности.
Хотя полную красивую модель ошибок мы будем собирать в следующей лекции, уже сейчас нужно запомнить базовый порядок мыслей внутри completion handler. Мы не обязаны делать идеальную архитектуру, но обязаны не путать причины неуспеха.
Начнём с каркаса:
import Foundation
func handleResponse(data: Data?, response: URLResponse?, error: Error?) {
if let error {
print("Transport error:", error)
return
}
guard let httpResponse = response as? HTTPURLResponse else {
print("Not an HTTP response")
return
}
print("Status code:", httpResponse.statusCode)
let bodySize = data?.count ?? 0
print("Body bytes:", bodySize)
}
Здесь важно сразу две вещи.
Во-первых, мы проверяем error первым. Если транспортная ошибка есть, обсуждать статус‑код бессмысленно: возможно, ответа вообще нет.
Во-вторых, мы приводим URLResponse к HTTPURLResponse через as?, а не через as!. Потому что as! — это «я клянусь, что это HTTP». В сетевом коде клятвы обычно заканчиваются так же, как и большинство клятв в пятницу вечером: «почему оно упало».
Про статус‑код мы пока говорим минимально: мы его печатаем. В следующей лекции мы превратим это в аккуратный контракт (чтобы вызывающий код не гадал по print-ам, что случилось). Но даже сейчас вы должны чувствовать: statusCode — это не «дополнительная опция», это часть ответа.
5. CLI-практика: дождаться callback и сделать PingClient
В iOS‑приложении (или серверном приложении) обычно есть «жизнь» процесса: event loop крутится, и callback успевает выполниться.
А CLI‑программа часто работает так: выполнила top‑level код — и завершилась. Поэтому вы можете увидеть ситуацию:
- запрос запустили,
- resume() вызвали,
- а completion handler так и не сработал, потому что процесс уже закончился.
Чтобы учебно дождаться ответа (не делая пока полноценную архитектуру), удобно использовать DispatchSemaphore с таймаутом. Да, это уже слегка пахнет синхронизацией, но мы используем это как «костыль‑демонстрацию», а не как стиль жизни.
Как учебно дождаться ответа в CLI
import Foundation
let semaphore = DispatchSemaphore(value: 0)
guard let url = URL(string: "https://example.com") else {
fatalError("Invalid constant URL")
}
URLSession.shared.dataTask(with: url) { data, response, error in
print("Got response, bytes:", data?.count ?? 0)
semaphore.signal()
}.resume()
let timeout = DispatchTime.now() + 5
if semaphore.wait(timeout: timeout) == .timedOut {
print("Timed out waiting for response") // Timed out waiting for response (если не успели)
}
Здесь смысл очень простой: «подожди максимум 5 секунд, иначе выведи понятное сообщение». Мы не делаем из этого идеальный сетевой слой, мы просто гарантируем, что программа не завершится мгновенно.
Мини-встраивание в LibraryCLI: PingClient на callbacks
Мы продолжаем развивать наш учебный CLI‑проект. Сейчас нам нужна очень маленькая утилита: проверить, что мы умеем сделать HTTP‑GET и получить ответ. Это будет наш «первый сетевой шаг», который позже станет кирпичиком для более серьёзных команд.
Сделаем тип PingClient, который:
- принимает URLRequest (чтобы использовать то, что мы уже собрали в прошлой лекции),
- запускает dataTask,
- возвращает результат через completion handler.
Пока не делаем «правильный Result и NetworkError» — это будет следующая лекция. Здесь мы тренируем именно механику callbacks и @escaping.
import Foundation
struct PingClient {
func ping(request: URLRequest, completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
URLSession.shared.dataTask(with: request) { data, response, error in
completion(data, response, error)
}.resume()
}
}
Обратите внимание на @escaping у completion. Мы обязаны поставить этот атрибут, потому что внутри ping мы передаём completion дальше, в URLSession, а он вызовет его позже.
Теперь пример использования (условно в main.swift, очень упрощённо и без полноценного CLI‑парсинга):
import Foundation
let semaphore = DispatchSemaphore(value: 0)
guard let url = URL(string: "https://example.com") else {
fatalError("Invalid constant URL")
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
let client = PingClient()
client.ping(request: request) { data, response, error in
if let error {
print("Ping failed:", error)
semaphore.signal()
return
}
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
print("Ping status:", code) // например: Ping status: 200
print("Bytes:", data?.count ?? 0)
semaphore.signal()
}
_ = semaphore.wait(timeout: .now() + 5)
Это уже ощущается как «реальная» сетевуха:
- запрос собрали,
- клиент выполнил,
- результат пришёл в замыкание,
- программа дождалась (не вечно).
Да, это ещё не архитектура мечты. Но это честная, минимальная основа: вы понимаете, где вы находитесь во времени, и почему @escaping не случайный.
6. Типичные ошибки
Ошибка №1: забыть вызвать resume().
Это самый популярный «баг‑невидимка»: компилятор молчит, код выглядит логично, но запрос не уходит. В голове должно быть правило: dataTask(...) создаёт задачу, а resume() запускает её. Если нет resume() — вы просто создали объект.
Ошибка №2: считать, что код после dataTask выполнится «после запроса».
Completion handler вызовется позже, поэтому строки после dataTask(...).resume() выполнятся сразу. Это приводит к странным эффектам: вы печатаете «Готово», а потом только начинается запрос. Лечится одним способом: всё, что зависит от результата, живёт внутри callback’а (или в следующей лекции — внутри Result-контракта).
Ошибка №3: проверять data и игнорировать error.
Иногда пишут «если data не nil — значит успех». На практике при ошибках data может быть nil, а error будет содержать настоящую причину. И наоборот: при HTTP‑ошибке data может быть, но это не «успешный результат». Правильная привычка: сначала error, затем response (приведение к HTTPURLResponse), затем статус‑код, затем работа с data.
Ошибка №4: делать response as! HTTPURLResponse.
Такой код превращает сетевую нестабильность в краш программы. as! — это контракт «я уверен». В сети уверенность обычно не награда, а источник новых впечатлений. Используйте as? и обрабатывайте ситуацию, когда ответ не удалось интерпретировать как HTTP.
Ошибка №5: в CLI не дождаться completion handler и решить, что «интернет не работает».
Если вы тестируете это в командной строке, программа может завершиться раньше, чем придёт ответ. Это не проблема сети, это проблема жизненного цикла процесса. Для учебных примеров используйте ожидание через семафор с таймаутом, чтобы результат успел напечататься и при этом приложение не зависало навечно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ