Введение.

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

  • Классический способ подразумевает получение данных через параметр-замыкание в то время как сейчас предпочтительным является асинхронный результат. Начиная с iOS15 в 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 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
  let warehouseID: String?
  let deliveryType: String?
  let filterStatus: String?

  init(
    type: MarketplaceSegment,
    order: MarketplaceSorting,
    warehouseID: String? = nil,
    deliveryType: DeliveryType? = nil,
    filterStatus: ArchiveStatus? = nil
  ) {
      self.type = type.rawValue
      self.order = order.rawValue
      self.timezoneOffset = TimeZone.current.secondsFromGMT() / 3600
      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"

Комментарии (0)