Skip to content

Instantly share code, notes, and snippets.

@chebur
Last active Aug 2, 2017
Embed
What would you like to do?

Ниже описана архитектура сетевого слоя, которая была разработана и усовершенствована на основании опыта многих проектов. Ее легко покрывать юнит тестами, модифицировать, конфигурировать и расширять под свои нужды.

Ответ на пост @kean Api Client

Запросы

Если клиент будет заниматься сериализацей, валидацией и маппингом, то нельзя будет сконфигурировать запрос так, чтобы он как-то отличался от всех остальных запросов. А это нужно довольно часто. Поэтому все эти задачи надо инкапсулировать в запрос.

Есть три протокола, которые описывают эти задачи:

  • сериализация запроса (map struct to NSURLRequest)
  • валидация ответа (is (URLResponse, NSData) tuple valid)
  • маппинг ответа (map NSData to struct)
/// 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 и добавить в него кастомные заголовки.

Маппинг JSON

Как было сказано выше, для маппинга существует протокол 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(...)
@kean
Copy link

kean commented Aug 1, 2017

Я сейчас столкнулся с вопросом в своем аналоге. Допустим у меня есть сервис, который использует UrlRequestManaging и мне нужно протестировать этот сервис. Как написать стаб для UrlRequestManaging? Причем так, чтобы я мог проставить стабы под конкретные запросы.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment