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

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


    Часть 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()
    }

    Вывод


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

    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 4
    • 0
      Здравствуйте! Я — автор статьи про Moya, которую Вы процитировали в самом начале.
      Интересный подход, хотелось бы увидеть его в деле в виде какого-нибудь тестового проекта. Разумеется, я и сам могу накатать код, но код автора подхода тоже было бы интересно посмотреть :)
      • 0
        Накатать самому — хорошая позиция. Я ее сам придерживаюсь! Ведь гораздо легче понять материал если его попробовать использовать. Примеры кода в спойлерах в конце публикации не достаточно убедительны? (:
        • 0
          Моя позиция по этому вопросу следующая:
          1. Прежде чем что-то изучать, я смотрю как это работает в проекте, пусть даже тестовом. Это мне помогает понять, стоит ли вообще тратить время на изучение фреймворка/подхода. Не раз натыкался на вещи, которые выглядят интересно, но практическое применение в коде было не очень удачным. К слову, все мои статьи имеют тестовые проекты, в которых сразу можно опробовать подход/технологию.
          2. На хабре есть и начинающие программисты, которым зачастую бывает сложно применить фреймворк/подход в коде (как тимлид говорю). Им проще всего скачать проект, посмотреть как там это используется и применить уже у себя.

          Ну это так, пожелания. Вы вполне вольны их не учитывать. В любом случае — спасибо за статью!
          • 0
            Вот как раз ради начинающих разработчиков я и не пишу полную инструкцию к действиям. Я не хочу чтобы люди не задумываясь делали все по шаблону (как это делал я когда-то). Мне кажется, что лучше всего когда человек сам доходит до конца, даже если пройти ему надо лишь последнюю ступень. Это шаг помогает осознать не только как применять, но и почему подход получился именно таким.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.