Ниже описана архитектура сетевого слоя, которая была разработана и усовершенствована на основании опыта многих проектов. Ее легко покрывать юнит тестами, модифицировать, конфигурировать и расширять под свои нужды.
Ответ на пост @kean Api Client
Если клиент будет заниматься сериализацей, валидацией и маппингом, то нельзя будет сконфигурировать запрос так, чтобы он как-то отличался от всех остальных запросов. А это нужно довольно часто. Поэтому все эти задачи надо инкапсулировать в запрос.
Есть три протокола, которые описывают эти задачи:
- сериализация запроса (map
struct
toNSURLRequest
) - валидация ответа (is
(URLResponse, NSData)
tuple valid) - маппинг ответа (map
NSData
tostruct
)
/// Request serialization
public protocol UrlRequestConvertible {
func request(with baseUrl: URL) throws -> URLRequest
}
/// Response validation
public protocol UrlResponseValidatable {
func validate(_ response: URLResponse, data: Data?, error: Swift.Error?) throws
}
/// Response mapping
public protocol UrlResponseMappable {
associatedtype ResponseObject
func object(for response: URLResponse, data: Data?) throws -> ResponseObject
}
/// Combines all of theese tasks
public protocol UrlRequest : UrlRequestConvertible, UrlResponseValidatable, UrlResponseMappable {
}
Теперь можем объявить запрос, который соотвествует протоколу UrlRequest
public struct LoginRequest : UrlRequest {
var login: String
var password: String
public func request(with baseUrl: URL) throws -> URLRequest {
let url = baseUrl.appendingPathComponent("/login")
let parameters: [String: Any] =
["login": login,
"password": password]
return try HTTPRequestSerializer().request(.post, url: url, parameters: parameters)
}
public func object(for response: URLResponse, data: Data?) throws -> LoginResponse {
return try ResponseDictionaryMapper().object(for: response, data: data)
}
}
Публичный интерфейс объекта запроса максимально чист для того, кто будет его использовать. Его удобно тестировать. То есть можно написать тесты для валидации, маппинга и сериализации даже без клиента, не послав ни одного запроса. Также мы можем протестировать отдельно HTTPRequestSerializer
и ResponseDictionaryMapper
.
Объект, который соответствует протоколу UrlRequest
не должен содержать такие аттрибуты HTTP запроса как requestMethod
, requestParameters
итд, так как это имеет следующие недостатки:
- это будет дублирование функционала
NSHTTPURLRequest
и тогда это мало чем отличается от сабклассинга. - что если один и тот же запрос можно отправить через
PUT
иDELETE
? а параметры запроса сериализуются вbody
и/илиquery
? или формат параметров может бытьjson
и/илиquery
.
На все эти вопросы отвечает сам запрос, а сетевому клиенту нужно только получить NSURLRequest
и добавить в него кастомные заголовки.
Как было сказано выше, для маппинга существует протокол UrlResponseMappable
, который реализован во всех запросах, но сами запросы не занимаются маппингом, они использют для этого мапперы. Мапперы также поддерживают этот протокол:
class ResponseDictionaryMapper<T: Decodable> : UrlResponseMappable {
func object(for response: URLResponse, data: Data?) throws -> T {
guard let data = data else { throw JSONResponseMappingError.emptyData }
// mapping
let result: T!
do {
result = try JSONDecoder().decode(T.self, from: data)
} catch {
throw JSONResponseMappingError.mappingFailed(underlying: error)
}
return result
}
}
Задача сетевого клиента:
- получить из
UrlRequest
объектNSURLRequest
- добавить в
NSURLRequest
заголовки OAuth/Cookie - отправить модифицированный запрос
- обработать ошибки валидации/маппинга ответа
- переотправить запрос если надо итд.
Все эти задачи выполняются с помощью поведений Behaviors
(см http://khanlou.com/2017/01/request-behaviors/)
Коротко сетевой клиент можно описать протоколом:
public protocol UrlRequestManaging {
func execute<Request: UrlRequest>(_ request: Request) -> Single<Request.ResponseObject>
}
Тут важно отметить, что сетевой клиент не взаимодейсвует непосредственно с сетью. Он отправляет запрос NSURLRequest
не самостоятельно, а с помощью объекта UrlRequestExecuting
:
public protocol UrlRequestExecuting {
func execute(_ request: URLRequest, completion: @escaping (URLResponse, Data?, Error?) -> ()) -> Cancelable
}
Единственная ответственность этого объекта — отправить запрос и вернуть Data
в ответ (а также ssl пиннинг). К примеру, может быть AFNetworkingUrlRequestExecuting
, AlamofireUrlRequestExecuting
, SessionUrlRequestExecuting
Пример клиента:
class GithubUrlClient : UrlRequestManaging {
public init(executor: UrlRequestExecuting, sessionStorage: SessionStorage) {
self.executor = executor
self.sessionStorage = sessionStorage
}
public func execute<Request: UrlRequest>(_ request: Request) -> Single<Request.ResponseObject> {
...
// do all the serialzation/mapping/validation/resending/error handling/etc
...
}
}
Пример экзекютора:
public class AFNetworkingExecutor : UrlRequestExecuting {
public let baseUrl: URL
public let sessionManager: AFHTTPSessionManager
public var logger: Logger? // prints curl formatted request, can be disabled for RELEASE builds
public init(baseUrl: URL, securityPolicy: AFSecurityPolicy = .default()) {
self.baseUrl = baseUrl
let sessionConfiguration = URLSessionConfiguration.default
sessionConfiguration.httpShouldSetCookies = false
sessionManager = AFHTTPSessionManager(baseURL: baseUrl, sessionConfiguration: sessionConfiguration)
sessionManager.securityPolicy = securityPolicy
sessionManager.completionQueue = DispatchQueue.global()
sessionManager.responseSerializer = AFHTTPResponseSerializer()
sessionManager.responseSerializer.acceptableStatusCodes = nil
sessionManager.responseSerializer.acceptableContentTypes = nil
}
public func execute(_ request: URLRequest, completion: @escaping (URLResponse, Data?, Error?) -> ()) -> Cancelable {
let task = sessionManager.dataTask(with: request) { response, data, error in
completion(response, data as? Data, error)
}
logger?.debug("%@", request.curl(cookies: nil))
task.resume()
return Disposables.create {
task.suspend()
}
}
}
Запросы группируются в сервисы по смыслу. Например запросы аутентификации будут обслуживаться сервисом AuthService
, так что в итоге программисту ничего не придется знать про сетевую составляющую архитектуры:
class AuthService {
init(client: UrlRequestManaging, credentialsStorage: CredentialsStorage) {
//
}
func login(username: String, password: String) -> Single<LoginResponse> {
let request = LoginRequest(username: username, password: password)
return client.execute(request).do(onSuccess: {
// save credentials
self.credentialsStorage.save(username, password)
})
}
}
Сетевой слой собирается по кусочкам следующим образом:
let baseUrl = URL(string: "https://api.github.com")!
let executor = AFNetworkingRequestExecutor(baseUrl: baseUrl)
let client = UrlClient(executor: executor)
let service = AuthService(client: client)
let request = service.login(username: "chebur", password: "")
request.subscribe(...)
Я сейчас столкнулся с вопросом в своем аналоге. Допустим у меня есть сервис, который использует
UrlRequestManaging
и мне нужно протестировать этот сервис. Как написать стаб дляUrlRequestManaging
? Причем так, чтобы я мог проставить стабы под конкретные запросы.