1. Асинхронність мережевих запитів
Якщо ви тільки починаєте, то найприродніша мрія звучить так: «Я викличу fetchBooks(), вона поверне мені масив книг, і я піду далі». Мрія чудова — як «код без багів у пʼятницю ввечері». Але мережа працює повільно й непередбачувано: сервер може відповісти за 50 мс, за 5 с або не відповісти взагалі. Якщо зробити запит синхронно, програма просто зависне, очікуючи на відповідь.
Тому у Swift (і взагалі майже скрізь) класичний мережевий API влаштований так: ви запускаєте запит зараз, а результат отримуєте пізніше — через функцію зворотного виклику, тобто completion handler. Це і є модель зворотного виклику: «я повернуся до вас, чесно-чесно».
Щоб це відчувалося не містикою, а інженерією, тримайте просту ментальну картинку:
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
Тобто обробник завершення отримує три значення, і кожне з них — 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("Недійсна URL-адреса")
}
let task = URLSession.shared.dataTask(with: url) { data, response, error in
print("Обробник завершення викликано!") // Обробник завершення викликано! (якщо програма ще працюватиме)
print("Байти:", 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("Помилка транспорту:", error)
return
}
guard let httpResponse = response as? HTTPURLResponse else {
print("Це не HTTP-відповідь")
return
}
print("Статус-код:", httpResponse.statusCode)
let bodySize = data?.count ?? 0
print("Байтів у тілі:", bodySize)
}
Тут важливі одразу дві речі.
По-перше, ми перевіряємо error першою. Якщо транспортна помилка є, обговорювати статус-код безглуздо: можливо, відповіді взагалі немає.
По-друге, ми приводимо URLResponse до HTTPURLResponse через as?, а не через as!. Бо as! — це «я клянусь, що це HTTP». У мережевому коді клятви зазвичай закінчуються так само, як і більшість клятв у пʼятницю ввечері: «чому воно впало».
Поки що про статус-код говоримо коротко: ми його просто друкуємо. У наступній лекції ми перетворимо це на акуратний контракт, щоб код, який викликає, не ворожив за print-ами, що сталося. Але навіть зараз ви маєте відчувати: statusCode — це не «додаткова опція», а частина відповіді.
5. CLI-практика: дочекатися callback і зробити PingClient
У iOS-застосунку або серверному застосунку зазвичай є «життя» процесу: цикл подій крутиться, і callback встигає виконатися.
А CLI-програма часто працює так: виконала код верхнього рівня — і завершилася. Тому ви можете побачити таку ситуацію:
- запит запустили,
- resume() викликали,
- а completion handler так і не спрацював, бо процес уже завершився.
Щоб навчально дочекатися відповіді (не будуючи поки повноцінну архітектуру), зручно використовувати DispatchSemaphore з таймаутом. Так, це трохи пахне синхронізацією, але ми використовуємо це як демонстраційний костиль, а не як стиль життя.
Як навчально дочекатися відповіді в CLI
import Foundation
let semaphore = DispatchSemaphore(value: 0)
guard let url = URL(string: "https://example.com") else {
fatalError("Недійсна URL-адреса")
}
URLSession.shared.dataTask(with: url) { data, response, error in
print("Отримано відповідь, байти:", data?.count ?? 0)
semaphore.signal()
}.resume()
let timeout = DispatchTime.now() + 5
if semaphore.wait(timeout: timeout) == .timedOut {
print("Вичерпано час очікування відповіді") // Вичерпано час очікування відповіді (якщо не встигли)
}
Тут сенс дуже простий: «почекати максимум 5 секунд, інакше вивести зрозуміле повідомлення». Ми не робимо з цього ідеальний мережевий шар. Ми лише гарантуємо, що програма не завершиться миттєво.
Мініінтеграція в LibraryCLI: PingClient на колбеках
Ми продовжуємо розвивати наш навчальний CLI-проєкт. Зараз нам потрібна дуже маленька утиліта: перевірити, що ми вміємо виконувати HTTP GET і отримувати відповідь. Це буде наш «перший мережевий крок», який пізніше стане цеглинкою для серйозніших команд.
Зробимо тип PingClient, який:
- приймає URLRequest (щоб використовувати те, що ми вже зібрали в минулій лекції),
- запускає dataTask,
- повертає результат через completion handler.
Поки що не створюємо «правильні Result і NetworkError» — це буде в наступній лекції. Тут ми тренуємо саме механіку колбеків і @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("Недійсна 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 не вдався:", error)
semaphore.signal()
return
}
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
print("Статус Ping:", code) // наприклад: Статус Ping: 200
print("Байт:", data?.count ?? 0)
semaphore.signal()
}
_ = semaphore.wait(timeout: .now() + 5)
Це вже відчувається як справжня мережева робота:
- запит зібрали,
- клієнт виконав його,
- результат прийшов у замикання,
- програма дочекалася його — але не вічно.
Так, це ще не архітектура мрії. Але це чесна, мінімальна основа: ви розумієте, де перебуваєте в часі, і чому @escaping тут не випадковий.
6. Типові помилки
Помилка №1: забути викликати resume().
Це найпопулярніший «невидимий баг»: компілятор мовчить, код виглядає логічно, але запит не йде. У голові має бути правило: dataTask(...) створює задачу, а resume() запускає її. Якщо немає resume() — ви просто створили об’єкт.
Помилка №2: вважати, що код після dataTask виконається «після запиту».
Completion handler викличуть пізніше, тому рядки після dataTask(...).resume() виконаються одразу. Це дає дивний ефект: ви друкуєте «Готово», а запит лише починається. Лікується це просто: усе, що залежить від результату, живе всередині колбека (або в наступній лекції — усередині контракту 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 і вирішити, що «інтернет не працює».
Якщо ви тестуєте це в командному рядку, програма може завершитися раніше, ніж надійде відповідь. Це не проблема мережі, а проблема життєвого циклу процесу. Для навчальних прикладів використовуйте очікування через семафор із таймаутом, щоб результат устиг надрукуватися, а застосунок не зависав вічно.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ