Pull to refresh
46.67
WBTECH
Технологический фундамент Wildberries

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

Level of difficultyMedium
Reading time8 min
Views2.5K

Введение

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

Tags:
Hubs:
Total votes 2: ↑2 and ↓0+2
Comments0

Articles

Information

Website
www.wildberries.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия