Ниже описана архитектура сетевого слоя, которая была разработана и усовершенствована на основании опыта многих проектов. Ее легко покрывать юнит тестами, модифицировать, конфигурировать и расширять под свои нужды.
Ответ на пост @kean Api Client
Если клиент будет заниматься сериализацей, валидацией и маппингом, то нельзя будет сконфигурировать запрос так, чтобы он как-то отличался от всех остальных запросов. А это нужно довольно часто. Поэтому все эти задачи надо инкапсулировать в запрос.
Есть три протокола, которые описывают эти задачи:
- сериализация запроса (map
structtoNSURLRequest) - валидация ответа (is
(URLResponse, NSData)tuple valid) - маппинг ответа (map
NSDatatostruct)
/// 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? Причем так, чтобы я мог проставить стабы под конкретные запросы.