Идеальный REST-клиент для iOS

от автора

Введение

В подавляющем большинстве современных мобильных приложений используется сетевой обмен данными. Обладая обширным опытом сетевого взаимодействия в крупных компаниях (банки, маркетплейсы и т.п.), хотим поделиться опытом построения идеального, с нашей точки зрения, сетевого клиента для 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».


ссылка на оригинал статьи https://habr.com/ru/articles/779206/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *