Идеальный 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".