📜 ⬆️ ⬇️

Abstraction of the network layer using "strategies"

From all my previous implementations of the network layer, there was an impression that there is still room for growth. This publication aims to provide one of the architectural solutions for building the network layer of the application. This is not about the next way to use the next network framework.


Part 1. A look at existing approaches


To start, the Artsy application is taken from the publication 21 Amazing Open Source iOS Apps Written in Swift . It uses the popular Moya framework, on the basis of which the entire network layer is built. I will note a number of major flaws that I have encountered in this project and I often meet in other applications and publications.


Repetition Response Chain Repetitions


let endpoint: ArtsyAPI = ArtsyAPI.activeAuctions provider.request(endpoint) .filterSuccessfulStatusCodes() .mapJSON() .mapTo(arrayOf: Sale.self) 

The developer has identified a logical chain with this code, in which the response to the activeAuctions query is converted into an array of Sale objects. When you reuse this query in another ViewModel or ViewController, the developer will have to copy the query along with the response conversion chain. To avoid copying duplicate conversion logic, the request and the response can be linked with some kind of contract that will be described exactly once.


A large number of dependencies


Frequently, Alamofire , Moya and other frameworks are used to work with the network. Ideally, the application should be minimally dependent on these frameworks. If in search for Artsy repository you type import Moya , you can see dozens of matches. If suddenly the project decides to abandon the use of Moya - a lot of code will have to refactor.


It is not difficult to estimate how much each project depends on the network framework, if you remove this dependency and try to modify the application to a healthy state.


General query manager class


A possible way out of the situation with dependencies will be to create a special class, which will be one to know about frameworks and about all possible ways to get data from the network. These methods will be described by functions with strictly typed input and output parameters, which in turn will be the contract mentioned above and will help to cope with the problem of repeating response transformation chains . This approach is also quite common. Its practical application can also be found in apps from the list of 21 iOS Apps Written in Swift . For example, in the DesignerNewsApp application. This class looks like this:


 struct DesignerNewsService { static func storiesForSection(..., response: ([Story]) -> ()) { // parameters Alamofire.request(...).response { _ in // parsing } } static func loginWithEmail(..., response: (token: String?) -> ()) { // parameters Alamofire.request(...).response { _ in // parsing } } } 

This approach also has disadvantages. The number of responsibilities assigned to this class is more than required by the principle of sole responsibility. It will have to be changed when changing the way requests are executed (replacing Alamofire ), when changing the parsing framework, when changing request parameters. In addition, such a class can grow into a god object or be used as a singleton with all the ensuing consequences.


Do you know the feeling of despondency when you need to integrate a project with another RESTful API? This is when once again you need to create some APIManager and fill it with Alamofire requests ... (link)

Part 2. Strategy-based approach


Considering all the shortcomings described in the 1st part of the publication, I formulated for myself a number of requirements for the future layer of work with the network:



What happened in the end:


Basic Network Layer Protocols


The ApiTarget protocol defines all the data that is needed to form a request (parameters, path, method ..., etc.)


 protocol ApiTarget { var parameters: [String : String] { get } } 

The generic ApiResponseConvertible protocol determines how to convert the resulting object (in this case, Data ) into an object of the associated type .


 protocol ApiResponseConvertible { associatedtype ResultType func map(data: Data) throws -> ResultType } 

The ApiService protocol defines the way requests are sent. Typically, a function declared in a protocol accepts a closure containing the response object and possible errors. In the current implementation, the function returns Observable - an object of the RxSwift reactive framework.


 protocol ApiService: class { func request<T>(with target: T) -> Observable<T.ResultType> where T: ApiResponseConvertible, T: ApiTarget } 

Strategies


I call the strategy the contract mentioned at the beginning of the publication, which links together several types of data. The strategy is a protocol and in the simplest case looks like this:


 protocol Strategy { associatedtype ObjectType associatedtype ResultType } 

For the needs of the network layer, the strategy should be able to create an object that can be passed to an instance of the class corresponding to the ApiService protocol. Add an object creation function to the ApiStrategy protocol.


 protocol ApiStrategy { associatedtype ObjectType associatedtype ResultType static func target(with object: ObjectType) -> AnyTarget<ResultType> } 

The introduction of the new universal structure AnyTarget is due to the fact that we cannot use the generalized ApiResponseConvertible protocol as the type of the object returned by the function, because the protocol has an associated type .


 struct AnyTarget<T>: ApiResponseConvertible, ApiTarget { private let _map: (Data) throws -> T let parameters: [String : String] init<U>(with target: U) where U: ApiResponseConvertible, U: ApiTarget, U.ResultType == T { _map = target.map parameters = target.parameters } func map(data: Data) throws -> T { return try _map(data) } } 

Here is the most primitive implementation of the strategy:


 struct SimpleStrategy: ApiStrategy { typealias ObjectType = Int typealias ResultType = String static func target(with object: Int) -> AnyTarget<String> { let target = Target(value: object) return AnyTarget(with: target) } } private struct Target { let value: Int } extension Target: ApiTarget { var parameters: [String : String] { return [:] } } extension Target: ApiResponseConvertible { public func map(data: Data) throws -> String { return "\(value)" // map value from data } } 

It should be noted that the structure of Target is private, because it will not be used outside the file. It is needed only to initialize the universal structure of AnyTarget .


The object conversion also takes place within the file, so ApiService will not know anything about the tools used during parsing.


Using strategies and service


 let service: ApiService = ... let target = SimpleStrategy.target(with: ...) let request = service.request(with: target) 

The strategy will tell you which object is needed to make the request and which object will be output. Everything is strictly typed by the strategy and there is no need to specify types as in the case of universal functions.


ApiService implementation


As you can see, in this approach, the network framework remained outside the basic logic of building the service. At first, it can not be used at all. For example, if in the implementation of the map function of the ApiResponseConvertible protocol to return a mock-object, then the service can be quite a primitive class:


 class MockService: ApiService { func request<T>(with target: T) -> Observable<T.ResultType> where T : ApiResponseConvertible, T : ApiTarget { return Observable .just(Data()) .map({ [map = target.map] (data) -> T.ResultType in return try map(data) }) } } 

The test implementation and use of the ApiService protocol based on the real Moya network framework can be seen on the spoiler:


ApiService + Moya + Implementation
 public extension Api { public class Service { public enum Kind { case failing(Api.Error) case normal case test } let kind: Api.Service.Kind let logs: Bool fileprivate lazy var provider: MoyaProvider<Target> = self.getProvider() public init(kind: Api.Service.Kind, logs: Bool) { self.kind = kind self.logs = logs } fileprivate func getProvider() -> MoyaProvider<Target> { return MoyaProvider<Target>( stubClosure: stubClosure, plugins: plugins ) } private var plugins: [PluginType] { return logs ? [RequestPluginType()] : [] } private func stubClosure(_ target: Target) -> Moya.StubBehavior { switch kind { case .failing, .normal: return Moya.StubBehavior.never case .test: return Moya.StubBehavior.immediate } } } } extension Api.Service: ApiService { public func dispose() { // } public func request<T>(headers: [Api.Header: String], scheduler: ImmediateSchedulerType, target: T) -> Observable<T.ResultType> where T: ApiResponseConvertible, T: ApiTarget { switch kind { case .failing(let error): return Observable.error(error) default: return Observable .just((), scheduler: scheduler) .map({ [weak self] _ -> MoyaProvider<Target>? in return self?.provider }) .filterNil() .flatMap({ [headers, target] provider -> Observable<Moya.Response> in let api = Target(headers: headers, target: target) return provider.rx .request(api) .asObservable() }) .map({ [map = target.map] (response: Moya.Response) -> T.ResultType in switch response.statusCode { case 200: return try map(response.data) case 401: throw Api.Error.invalidToken case 404: do { let json: JSON = try response.data.materialize() let message: String = try json["ErrorMessage"].materialize() throw Api.Error.failedWithMessage(message) } catch let error { if case .some(let error) = error as? Api.Error, case .failedWithMessage = error { throw error } else { throw Api.Error.failedWithMessage(nil) } } case 500: throw Api.Error.serverInteralError case 501: throw Api.Error.appUpdateRequired default: throw Api.Error.unknown(nil) } }) .catchError({ (error) -> Observable<T.ResultType> in switch error as? Api.Error { case .some(let error): return Observable.error(error) default: let error = Api.Error.unknown(error) return Observable.error(error) } }) } } } 

ApiService + Moya + Usage
 func observableRequest(_ observableCancel: Observable<Void>, _ observableTextPrepared: Observable<String>) -> Observable<Result<Objects, Api.Error>> { let factoryApiService = base.factoryApiService let factoryIndicator = base.factoryIndicator let factorySchedulerConcurrent = base.factorySchedulerConcurrent return observableTextPrepared .observeOn(base.factorySchedulerConcurrent()) .flatMapLatest(observableCancel: observableCancel, observableFactory: { (text) -> Observable<Result<Objects, Api.Error>> in return Observable .using(factoryApiService) { (service: Api.Service) -> Observable<Result<Objects, Api.Error>> in let object = Api.Request.Categories.Name(text: text) let target = Api.Strategy.Categories.Auto.target(with: object) let headers = [Api.Header.authorization: ""] let request = service .request(headers: headers, scheduler: factorySchedulerConcurrent(), target: target) .map({ Objects(text: text, manual: true, objects: $0) }) .map({ Result<Objects, Api.Error>(value: $0) }) .shareReplayLatestWhileConnected() switch factoryIndicator() { case .some(let activityIndicator): return request.trackActivity(activityIndicator) default: return request } } .catchError({ (error) -> Observable<Result<Objects, Api.Error>> in switch error as? Api.Error { case .some(let error): return Observable.just(Result<Objects, Api.Error>(error: error)) default: return Observable.just(Result<Objects, Api.Error>(error: Api.Error.unknown(nil))) } }) }) .observeOn(base.factorySchedulerConcurrent()) .shareReplayLatestWhileConnected() } 

Conclusion


The resulting network layer can successfully exist without strategies. Likewise, strategies can be applied to other goals and objectives. Their joint use made the network layer understandable and easy to use.


')

Source: https://habr.com/ru/post/338380/


All Articles