
Введение
В подавляющем большинстве современных мобильных приложений используется сетевой обмен данными. Обладая обширным опытом сетевого взаимодействия в крупных компаниях (банки, маркетплейсы и т.п.), хотим поделиться опытом построения идеального, с нашей точки зрения, сетевого клиента для iOS.
В нашем представлении идеальный REST-клиент обеспечивает:
сетевые запросы в одну строчку в большинстве случаев;
асинхронность (с iOS 13.0);
гибкость (возможность широкой настройки);
компактность реализации —это значит, что код клиента легко понять и, в случае обнаружения недостатка, доработать под свои нужды. В нашем случае код клиента умещается в ~300 строк, протокол с расширениями в ~200 строк и два инструментальных файла по 30 строк. Всего 4 файла: суммарно < 600 строк.
Пример нашего типичного сетевого запроса (внутри некоего класса):
struct Warehouse: Decodable { let id: Int let title: String } func fetchWarehouses() async throws -> [Warehouse] { try await userSession.httpClient.get(url: userSession.api("warehouses")) }
Описание сущности userSession будет далее.
Постановка задачи
Дано:
стандартный класс URLSession.
Требуется:
построить REST-клиент поверх HTTP c обменом данными в формате JSON;
обеспечить возможность автоматической реакции на некоторые ошибки, чтобы не реагировать на них вручную в каждом обработчике сетевого запроса. Это свойство называется Request Retrier. Оно полезно в случае, когда с сервера в ответе на любой запрос может прилететь приоритетное прерывание, без которого дальнейшая работа невозможна (например, обновление пользовательского соглашения об оказываемых услугах которое необходимо принять);
обеспечить возможность извлекать из сетевых ответов общую информацию и в некоторых случаях кидать исключения. Это свойство называется Response Validator. Случается, что сервер, в случае ошибки запроса, присылает не 400-е значения ошибки, а 200 и, вместо ожидаемого ответа, информацию об ошибке запроса. Именно для обработки таких ситуаций и применяется ResponseValidator;
Решение
Предварительные заме��ания
URLSession, обладая обширными возможностями и универсальностью, по нашему мнению, обладает и некоторыми недостатками. Перечислим их:
классический способ подразумевает получение данных через параметр-замыкание в то время, как предпочтительным в текущий момент является асинхронный результат. Начиная с iOS 15, в URLSession появилась поддержка асинхронности. Наше решение работает начиная с iOS13;
let task = URLSession.shared.dataTask(with: url) {(data, response, error) in guard let data = data else { return } print(String(data: data, encoding: .utf8)!) } task.resume()
возвращаемый результат представляет собой класс типа Data и URLResponse, которые надо вручную анализировать и разбирать для получения целевых Decodable-структур DTO (Data Transfer Object) — структур обмена данными или получения информации о транспортной ошибке;
так же в запросе надо указывать параметры заголовка и класть в них всякую техническую информацию вроде токенов аутентификации (к которым, разумеется, должен быть доступ в месте сетевого вызова). Можно (и нужно) эту информацию положить один раз в URLSessionConfiguration перед инициализацией URLSession. Но можно про это и не знать, и каждый раз указывать это в сетевом запросе. Да, URLSession настолько гибкий, что можно один и тот же результат получать несколькими различными способами, и часто разработчики идут самым прямолинейным путем.
Cуществующие open source решения
Ограничимся рассмотрением самого популярного фреймворка — Alamofire.
Плюсы:
популярный фреймворк, хорошо подходит для простых проектов.
Минусы:
заявленная универсальность приводит к громоздким конструкциям «в несколько строк»
// Automatic String to URL conversion, Swift concurrency support, and automatic retry. let response = await AF.request("https://httpbin.org/get", interceptor: .retryPolicy) // Automatic HTTP Basic Auth. .authenticate(username: "user", password: "pass") // Caching customization. .cacheResponse(using: .cache) // Redirect customization. .redirect(using: .follow) // Validate response code and Content-Type. .validate() // Produce a cURL command for the request. .cURLDescription { description in print(description) } // Automatic Decodable support with background parsing. .serializingDecodable(DecodableType.self) // Await the full response with metrics and a parsed body. .response // Detailed response description for easy debugging. debugPrint(response)
большой объём кода самой библиотеки (300 Кб). Хочется иметь представление о всём коде, который мы используем. В случае Alamofire — это довольно затруднительно;
иногда отсутствует поддержка полезных фич (etag). Её очень долго не было, и рекомендация поддержки была прекрасна — «не используйте etag». В последних версиях исправили.
миграция — только одна версия Alamofire на проект. Мажорные версии существенно отличаются. Миграция в большом проекте из многих модулей, которые ведут разные команды, представляется затруднительной. Особенно затруднительна миграция при смене парадигм сетевых запросов (синхронная с замыканиями / асинхронная), поскольку эта смена влечет переделку всего сервисного кода приложения;
проблема «дл��нной истории» разработки. Alamofire разрабатывается многие годы, за которые в нём накопилось огромное количество разного редко используемого функционала, что привело к существенной громоздкости библиотеки. Новые особенности там появляются в довольно неуклюжем виде. Как в асинхронном примере выше. Сравните с самым первым примером кода.
Наше решение
Сетевой запрос является естественной асинхронной операцией. С появлением в iOS 13 асинхронных вызовов, они идеально подошли для реализации сетевого обмена. Сетевой клиент у нас реализован в два уровня: протокол и реализация.
Протокол
Дизайн протокола отражает желательный характер сетевых запросов. То есть, GET, POST, PUT, DELETE, PATCH — HTTP-запросы. На первый взгляд, тут присутствует некоторая похожесть методов, но при использовании это выглядит максимально естественно и удобно. Помимо этого, протокол позволяет замокать реализацию для написания модульных-тестов.
/// Асинхронный HTTP клиент public protocol AsyncHttpClient { var session: URLSession { get } /// GET HTTP method func get<Target: Decodable>( url: URL, parameters: [String: Any], tuners: [AsyncHttp.RequestTuners.Keys: AsyncHttp.RequestTuners] ) async throws -> Target /// POST HTTP method func post<Body: Encodable, Target: Decodable>( url: URL, body: Body, tuners: [AsyncHttp.RequestTuners.Keys: AsyncHttp.RequestTuners] ) async throws -> Target // Полностью аналогичные POST методы PUT, DELETE, PATCH // ... }
Протокол AsyncHttpClient дополняет расширение, которое позволяет:
не указывать параметры методов кроме URL;
не возвращать значение для всех методов кроме GET.
В каждом методе протокола AsyncHttpClient присутствует опциональный набор тюнеров, который позволяет в случае необходимости как угодно настроить любой запрос. В этом проявляется максимальная гибкость нашего решения. Если оно поверх URLSession/URLRequest, значит должен быть опциональный доступ и к URLSession и к URLRequest.
/// Тюнеры запросов public enum AsyncHttpRequestTuners { /// Тюнер запроса - позволяет как угодно настроить запрос case request((inout URLRequest) -> Void) /// Тюнер ответа - позволяет валидировать и извлекать данные из заголовка ответа case response((HTTPURLResponse) throws -> Void) /// Тюнер кодера. Позволяет настраивать кодер case encoder((inout JSONEncoder) -> Void) /// Тюнер декодера. Позволяет настраивать декодер case decoder((inout JSONDecoder) -> Void) public enum Keys { case request case encoder case decoder } }
Пример использования тюнера:
func fetchCounters() async throws -> Counters { try await network.get( url: api("counters"), tuners: [ .request: .request { (request: inout URLRequest) in request.timeoutInterval = 15 } ] ) }
Обратим внимание на параметр метода get parameters: [String: Any]. Для удобства кодирования параметров поставляется протокол CompactDictionaryRepresentable, который транслирует swift-структуру в формат [String: Any]. При этом все опциональные отсутствующие поля игнорируются.
Пример:
// Некоторые перечисления опущены struct TasksExcelRequest: CompactDictionaryRepresentable { let type: String let order: String let timezoneOffset: Int = TimeZone.current.secondsFromGMT() / 3600 let warehouseID: String? let deliveryType: String? let filterStatus: String? init( type: MarketplaceSegment, order: MarketplaceSorting, warehouseID: String? = nil, deliveryType: DeliveryType? = nil, filterStatus: TasksStatus? = nil ) { self.type = type.rawValue self.order = order.rawValue self.warehouseID = warehouseID self.deliveryType = deliveryType?.rawValue self.filterStatus = filterStatus?.rawValue } } struct MarketplaceFile: Decodable { let data: Data let mimeType: String let name: String } func fetchExcel(tasks request: TasksExcelRequest) async throws -> MarketplaceFile { try await httpClient.get( url: userSession.userSession.api("tasks/excel"), parameters: request.compactDictionaryRepresentation ) }
Метод get поддерживает etag. Для этого кодирование параметров в URL query всегда происходит в одном и том же порядке.
В случае недостаточности функционала, имеется доступ к базовой URLSession чтобы иметь возможность использовать её функционал.
Реализация.
Функционал HTTP-клиента реализует класс
public class AsyncHttpJsonClient: AsyncHttpClient { public init( configuration: URLSessionConfiguration = URLSessionConfiguration.default, requestRetrier: AsyncHttpRequestRetrier? = nil, responseValidator: AsyncHttpResponseValidator? = nil, dateFormatter: DateFormatter = ISO8601DateFormatterEx() ) // ... }
Он конфигурируется URLSessionConfiguration где должны быть настроены параметры аутентификации. При смене параметров аутентификации подразумевается пересоздание всего HTTP-клиента.
Дополнительно можно указать обработчик исключений (requestRetrier), проверщик ответов (responseValidator) и формировщик дат.
/// Протокол специальной реакции на некоторые ошибки public protocol AsyncHttpRequestRetrier { func shouldRetry(request: URLRequest, error: Error) async -> Bool } /// Протокол проверки ответов и генерации специфических ошибок public protocol AsyncHttpResponseValidator { func validate(response: HTTPURLResponse, data: Data?) throws }
К сожалению, стандартный формировщик дат ISO8601DateFormatter обладает недостатками, по-этому в пакет включен расширенный формировщик дат, позволяющий работать с временными поясами и дробными секундами.
В случае, если responseValidator кинет исключение, то requestRetrier тоже вызовется.
Подробно описывать код сетевого клиента представляется излишним. Его технологическая часть составляет примерно 100 строк. Ссылка на код находится в ссылках.
Архитектурные замечания.
В дополнение к сетевому клиенту рекомендуется создать класс UserSession, который будет представлять пользовательскую сессию.
Например:
enum ApiVersion: String { case v1 case v2 case v3 case v4 } protocol UserSessionProtocol: AnyObject { var httpClient: AsyncHttpClient { get } func endpoint(for target: ApiVersion) -> URL func logout() async // Dependancy Injecting. func locate(service: Any.Type) -> Any? } extension UserSessionProtocol { func baseUrl() -> URL { baseUrl(for: .v1) } func api(_ name: String, version: ApiVersion = .v1) -> URL { endpoint(for: version).appendingPathComponent(name) } func api(_ name: String, version: ApiVersion = .v1, appending pathComponents: [String]) -> URL { api(name, version: version).appendingPathComponent(pathComponents.joined(separator: "/")) } func get<T>(service: T.Type) -> T { // Разрешаем форскаст потому что предполагается, что искомые сервисы всегда будут зарегистрированы // при создании сессии. locate(service: service) as! T } }
Реализация опущена, т.к. она незначительна для нашей статьи. Именно такую сессию использует код из примера 1.
Также рекомендуется вместо непосредственного обращения к сессии (и сетевому клиенту) из бизнес-логики приложения использовать промежуточный сервисный сло��. Он изолирует сущности бизнес логики от сущностей сетевого транспорта и исправляет транспортные артефакты вроде таких:
struct Model<Model: Decodable>: Decodable { let model: Model } struct PaymentsResponse: Decodable { let payments: [Payment] } struct Payment: Decodable { let id: Int let date: Date let amount: Double } protocol ReportsWorker { //... func fetchPayments() async throws -> [Payment] //... } class ReportsService: ReportsWorker { //... func fetchPayments() async throws -> [Payment] { // С сервера целевая информация приходит обернутая в несколько слоёв // Извлекаем данные из обёрток // Кроме того, даты с сервера прилетают неудобном формате. Применяем тюнер let response: Transport.Model<PaymentsResponse> = try await userSession.asyncHttpClient.get( url: userSession.endpoint().appendingPathComponent("getPayments"), tuners: [ .decoder: .decoder { (decoder: inout JSONDecoder) in decoder.dateDecodingStrategy = .formatted( { DateFormatter(format: "yyyy-MM-dd") }()) } ] ) return response.model.payments } // ... }
Для наглядности, визуализируем рекомендованную архитектуру сервисного слоя:

Заключение.
В данной статье мы описали реализацию идеального, по нашему мнению, сетевого клиента на iOS (и прочих AppleOS), которая отличается удобством использования, функциональной гибкостью и компактностью реализации. Надеемся, эта статья окажется полезной для читателей. Пишите комментарии, задавайте вопросы.
Полезные ссылки:
Код асинхронного сетевого клиента на GitHub.
Моя статья "Идеальный наблюдатель на Swift".
