Абстракция сетевого слоя с применением «стратегий»

в 19:51, , рубрики: alamofire, DRY, iOS, moya, RxSwift, solid, srp, swift, xcode, разработка под iOS

От всех моих предыдущих реализаций сетевого слоя осталось впечатление, что есть еще куда расти. Данная публикация ставит целью привести один из вариантов архитектурного решения по построению сетевого слоя приложения. Речь пойдет не об очередном способе использования очередного сетевого фреймворка.

Часть 1. Взгляд на существующие подходы

Для начала из публикации 21 Amazing Open Source iOS Apps Written in Swift взято приложение Artsy. В нем используется популярный фреймворк Moya, на базе которого и построен весь сетевой слой. Отмечу ряд основных недостатков, которые встретил в данном проекте и часто встречаю в других приложениях и публикациях.

Повторы цепочек преобразования ответа

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

Разработчик этим кодом обозначил некую логическую цепочку, в которой ответ на запрос activeAuctions преобразуется в массив объектов Sale. При повторном использовании этого запроса в других ViewModel или ViewController разработчику придется копировать запрос вместе с цепочкой преобразования ответа. Чтобы избежать копирования повторяющейся логики преобразования, запрос и ответ можно связать неким контрактом, который будет описан ровно один раз.

Большое количество зависимостей

Часто для работы с сетью используются фреймворки Alamofire, Moya и др. В идеале приложение должно минимально зависеть от этих фреймворков. Если в поиске по репозитоию Artsy набрать import Moya, то можно увидеть десятки совпадений. Если вдруг проект решит отказаться от использования Moya — очень много кода придется рефакторить.

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

Общий класс менедежера запросов

Возможным выходом из ситуации с зависимостями будет создание специального класса, который будет один знать о фреймворках и обо всех возможных способах получить данные из сети. Эти способы будут описаны функциями со строго типизированными входящими и исходящими параметрами, что в свою очередь будет являться контрактом, упомянутом выше, и поможет справиться с проблемой повторов цепочек преобразования ответа. Такой подход тоже достаточно часто встречается. Его применение на практике можно также найти в приложениях из списка 21 Amazing Open Source iOS Apps Written in Swift. Например, в приложении DesignerNewsApp. Выглядит такой класс следующим образом:

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
        }
    }
}

У такого подхода также есть минусы. Количество обязанностей, возложенных на этот класс больше, чем того требует принцип единственной ответственности. Его придется менять при смене способа выполнения запросов (замена Alamofire), при смене фреймворка для парсинга, при изменении параметров запроса. Кроме того, такой класс может перерасти в god object или использоваться как singleton со всеми вытекающими последствиями.

Вам знакомо то чувство уныния, когда нужно интегрировать проект с очередным RESTful API? Это когда в очередной раз нужно создавать какой-нибудь APIManager и наполнять его Alamofire запросами… (ссылка)

Часть 2. Подход, основанный на стратегиях

Учитывая все недостатки, описанные в 1-й части публикации, я сформулировал для себя ряд требований к будущему слою работы с сетью:

  • Снизить зависимость от внешних сетевых фреймворков
  • Предусмотреть возможность быстро и легко заменять сетевые фреймворки между собой
  • Использовать по максимому универсальные классы/структуры и протоколы со связанными типами
  • Не допустить повтора цепочек преобразования и свести к минимуму повторяемость кода

Что получилось в итоге:

Базовые протоколы сетевого слоя

Протокол ApiTarget определяет все данные, которые необходимы для формирования запроса (параметры, путь, метод… и др.)

protocol ApiTarget {

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

Обобщенный протокол ApiResponseConvertible определяет способ преобразования полученного объекта (в данном случае Data) в объект связанного типа.

protocol ApiResponseConvertible {

    associatedtype ResultType

    func map(data: Data) throws -> ResultType
}

Протокол ApiService определяет способ отправки запросов. Обычно функция, объявленная в протоколе, принимает замыкание содержащее объект ответа и возможные ошибки. В текущей реализации функция возвращает Observable — объект реактивного фреймворка RxSwift.

protocol ApiService: class {

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

Стратегии

Стратегией я называю упомянутый в начале публикации контракт, который связывает между собой несколько типов данных. Стратегия является протоколом и выглядит в самом простом случае таким образом:

protocol Strategy {

    associatedtype ObjectType
    associatedtype ResultType
}

Для нужд сетевого слоя стратегия должна уметь создавать объект, который можно передать в экземпляр класса, соответствующего протоколу ApiService. Добавим функцию создания объекта в протокол ApiStrategy.

protocol ApiStrategy {

    associatedtype ObjectType
    associatedtype ResultType

    static func target(with object: ObjectType) -> AnyTarget<ResultType>
}

Введение новой универсальной структуры AnyTarget обусловленно тем, что мы не можем использовать обобщенный протокол ApiResponseConvertible в качестве типа возвращаемого функцией объекта, потому что у протокола есть связанный тип.

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)
    }
}

Вот так выглядит самая примитивная реализация стратегии:

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
    }
}

Стоит отметить, что структура Target является приватной, т.к. за пределами файла использоваться она не будет. Она нужна лишь для инициализации универсальной структуры AnyTarget.

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

Использование стратегий и сервиса

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

Стратегия подскажет, какой объект нужен для осуществления запроса и какой объект будет на выходе. Все строго типизировано стратегией и не требуется указывать типы как в случае с универсальными функциями.

Реализация ApiService

Как можно было заметить, в данном подходе сетевой фреймворк остался за пределами основной логики построения сервиса. На первых порах его можно не использовать совсем. Например, если в реализации функции map протокола ApiResponseConvertible возвращать mock-объект, то сервис может быть совсем примитивным классом:

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)
            })
    }
}

Тестовую реализацию и применение протокола ApiService на базе реального сетевого фреймворка Moya можно посмотреть спойлере:

ApiService + Moya + Реализация

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 + Использование

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()
}

Вывод

Полученный сетевой слой сможет успешно существовать и без стратегий. Точно так же стратегии можно использовать для других целей и задач. Совместное же их использование сделало использование сетевого слоя удобным и понятным.

Автор: iWheelBuy

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js