JavaRush /Курсы /Swift SELF /URLSession.dataTask и completion handler

URLSession.dataTask и completion handler

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

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 — это обычно проблема «мы вообще не смогли нормально сходить».

Чтобы мозг не пытался хранить это в виде каши, можно смотреть на это через маленькую таблицу:

Что пришло Тип Что означает по-человечески
Тело
Data?
«Вот байты, попробуй интерпретировать»
Ответ
URLResponse?
«Вот информация об ответе (включая статус‑код, но не всегда доступен напрямую)»
Ошибка
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 и решить, что «интернет не работает».
Если вы тестируете это в командной строке, программа может завершиться раньше, чем придёт ответ. Это не проблема сети, это проблема жизненного цикла процесса. Для учебных примеров используйте ожидание через семафор с таймаутом, чтобы результат успел напечататься и при этом приложение не зависало навечно.

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