Введение: от базовых GET к промышленному HTTP-клиенту
Стандартный HTTP-запрос на Swift — это три строки кода с URLSession.shared.dataTask. Но в реальных проектах (загрузка файлов, авторизация с токенами, кеширование, ретраи) этого недостаточно. В этой статье мы разберем продвинутые техники создания HTTP-клиента на Swift для iOS и macOS: кастомные протоколы, управление сессиями, многопоточность через async/await и Combine, а также надежную обработку ошибок. Материал рассчитан на разработчиков, уже знакомых с основами URLSession.
Архитектура продвинутого HTTP-клиента
Промышленный клиент — это не просто функция запроса, а слой абстракции. Разделим его на три уровня:
- Transport Layer — низкоуровневая работа с URLSession, управление конфигурацией.
- Service Layer — сериализация/десериализация, обработка статус-кодов, ретраи.
- Application Layer — бизнес-логика, кеширование, авторизация.
Начнем с кастомизации Transport Layer через URLProtocol.
1. Кастомный URLProtocol для перехвата и модификации запросов
URLProtocol — мощный механизм для перехвата всех сетевых запросов в приложении. С его помощью можно добавить логирование, подпись запросов или мок-данные для тестирования.
import Foundation
class LoggingProtocol: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool {
// Перехватываем все HTTP/HTTPS запросы
return request.url?.scheme == "http" || request.url?.scheme == "https"
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
override func startLoading() {
print("📤 Запрос: \(request.httpMethod ?? "GET") \(request.url?.absoluteString ?? "")")
print("📦 Заголовки: \(request.allHTTPHeaderFields ?? [:])")
let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
guard let self = self else { return }
if let error = error {
print("❌ Ошибка: \(error.localizedDescription)")
self.client?.urlProtocol(self, didFailWithError: error)
} else if let data = data, let response = response {
print("✅ Ответ: \(response.expectedContentLength) байт")
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed)
self.client?.urlProtocol(self, didLoad: data)
}
self.client?.urlProtocolDidFinishLoading(self)
}
task.resume()
}
override func stopLoading() {
// Ничего не делаем, задача завершается сама
}
}
// Регистрация протокола
URLProtocol.registerClass(LoggingProtocol.self)
let config = URLSessionConfiguration.default
config.protocolClasses = [LoggingProtocol.self]
let session = URLSession(configuration: config)
Теперь все запросы через эту сессию будут логироваться. Аналогично можно добавить автоматическое добавление Bearer-токена или обработку 401 статуса.
2. Управление сессиями: кастомная конфигурация и многопоточность
Используйте разные URLSessionConfiguration для разных целей: .default для обычных запросов, .ephemeral для приватных данных (без кеша и куков), .background для фоновой загрузки.
let backgroundConfig = URLSessionConfiguration.background(withIdentifier: "com.pythonlib.background")
backgroundConfig.isDiscretionary = true
backgroundConfig.sessionSendsLaunchEvents = true
let backgroundSession = URLSession(configuration: backgroundConfig, delegate: self, delegateQueue: nil)
Для многопоточности используйте async/await (Swift 5.5+):
func performRequest(url: URL) async throws -> Data {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.invalidResponse
}
return data
}
// Использование
Task {
do {
let data = try await performRequest(url: url)
// Обработка данных
} catch {
print("Ошибка: \(error)")
}
}
Для Combine-подхода используйте dataTaskPublisher:
import Combine
var cancellables = Set<AnyCancellable>()
func fetchData(with url: URL) -> AnyPublisher<Data, Error> {
return URLSession.shared.dataTaskPublisher(for: url)
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.invalidResponse
}
return data
}
.eraseToAnyPublisher()
}
// Подписка
fetchData(with: url)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
// Обработка ошибок
}, receiveValue: { data in
// Обработка данных
})
.store(in: &cancellables)
3. Продвинутая обработка ошибок и ретраи
Сеть нестабильна. Добавим механизм повторных попыток с экспоненциальной задержкой (exponential backoff) и джиттером.
enum NetworkError: Error {
case invalidURL
case invalidResponse
case serverError(Int)
case decodingError
case noConnection
}
func performRequestWithRetry(url: URL, retries: Int = 3) async throws -> Data {
var lastError: Error?
for attempt in 0..<retries {
do {
let data = try await performRequest(url: url)
return data
} catch {
lastError = error
if attempt < retries - 1 {
// Экспоненциальная задержка: 2^attempt секунд + джиттер
let delay = Double(1 << attempt) + Double.random(in: 0...0.5)
print("⚠️ Попытка \(attempt + 1) не удалась, повтор через \(delay) сек")
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
}
}
throw lastError ?? NetworkError.noConnection
}
Для Combine-версии используйте retry оператор:
func fetchWithRetry(url: URL) -> AnyPublisher<Data, Error> {
return URLSession.shared.dataTaskPublisher(for: url)
.tryMap { data, response in
// проверка статуса
return data
}
.retry(3)
.eraseToAnyPublisher()
}
Не забывайте про кеширование: настройте URLCache для снижения нагрузки:
let cache = URLCache(memoryCapacity: 50_000