Практически любое мобильное приложение взаимодействует с серверами через их API. Перед разработчиком в таком случае стоит задача реализовать сетевой слой своего приложения. Провайдеры того или иного API разрабатывают его интерфейс, зачастую, одинаково, но бывает и так, что API имеет свою специфику. Например, API Вконтакте при какой-либо ошибке в обращении к их методам не отображает это в статус коде ответа, а отображает это в самом теле ответа как JSON по ключу «error»: то есть, во-первых, вы не поймете по статус коду прошел ли запрос удачно, а во-вторых, не узнаете, какая произошла ошибка пока не измените логику обработки ответа. Таким образом, перед разработчиком лежит задача реализации достаточно гибкого слоя, контроль над которым можно осуществлять на разных этапах работы с сервером.

Я хочу рассказать, как можно построить достаточно гибкий сетевой слой.

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

Вот как это будет выглядеть в итоге:

import UIKit

class ViewController: UIViewController {

    let service: WallPostable = BasicWallAPI()
    
    @IBOutlet weak var textField: UITextField!

    @IBAction func postAction() {
        service.postWall(with: textField.text!)
    }
}

Итак, как выглядит работа с API для конечного пользователя (я имею в виду программиста, который использует реализацию слоя):

image

Немного раскроем ящик API:

image

Как я вижу ящик Send:

image

Мы формируем запрос. Обычно запросы имеют некоторые одинаковые хэдеры и чтобы не прописывать их в каждом запросе, мы подготавливаем запросы в блоке «Request Preparation». Далее полноценно собранный запрос мы отправляем, используя удобный для нас фреймворк (В моем примере я использую нативный фреймворк от Apple). Отправить запрос всегда самое простое, все сложное начинается при получении ответа.

Как я вижу ящик Handle:

image

Пришедший ответ мы проверяем на успешность выполнения, далее в случае успешного ответа мы преобразуем его в модель понятную для приложения и отдаем. Если запрос выполнился не успешно, то мы отдаем его в блок обработки ошибки: он достает код ошибки, сообщение если оно имеется, реагирует на все это как мы ему говорим, формирует ошибку понятную для приложения и отдает.

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

Итак, что нам нужно:

  • Запрос

    enum HTTPMethod: String {
        case GET
        case POST
        case PUT
        case DELETE
    }
    
    protocol HTTPRequestRepresentable {
        var path: String { get set }
        var httpMethod: HTTPMethod { get }
        var parameters: JSON? { get set }
        var headerFields: [String: String]? { get set }
        var bodyString: String? { get set }
    }
    
    extension HTTPRequestRepresentable {
        func urlRequest() -> URLRequest? {
            guard var urlComponents = URLComponents(string: self.path) else {
                return nil
            }
            
            if let parametersJSON = self.parameters {
                var queryItems = [URLQueryItem]()
                for (key, value) in parametersJSON {
                    queryItems.append(URLQueryItem(name: key, value: value as? String))
                }
                urlComponents.queryItems = queryItems
            }
            
            guard let url = urlComponents.url else {
                return nil
            }
            
            var urlRequest = URLRequest(url: url)
            urlRequest.httpMethod = self.httpMethod.rawValue
            urlRequest.allHTTPHeaderFields = headerFields
            if let body = bodyString {
                urlRequest.httpBody = body.data(using: .utf8)
            }
            
            return urlRequest
        }
    }
    
  • Отправитель запроса, который будет описан позже
  • Обработчик ответа

    
    enum Result<T, E> {
        case Value(T)
        case Error(E)
    }
    
    protocol ResponseHandler {
        associatedtype ResultType
        associatedtype ErrorType
        
        func handleResponse(_ response: ResponseRepresentable, completion: (Result<ResultType, ErrorType>) -> ())
    }
    
  • Модель, в которую мы хотим преобразовать ответ
    Этот слой построен с учетом прекрасной возможности в Swift 4, которая предоставила нам такой протокол как Decodable, при реализации которого ваша модель будет самостоятельно (почти) создана из пришедшего ответа.

Отправитель запроса нуждается в:

  • Подготовителе запроса

    protocol RequestPreparator {
        func prepareRequest(_ request: inout HTTPRequestRepresentable)
    }
    

Обработчик ответа в свою очередь нуждается в:

  • Валидаторе ответа (проверяет ответ на успешность)
    protocol SuccessResponseChecker {
        func isSuccessResponse(_ response: ResponseRepresentable) -> Bool
    }
    
  • Обработчике неудачного ответа

    protocol ErrorHandler {
        var errorCodeHandler: ErrorCodeHandler { get set }
        
        func handleError(_ error: ErrorRepresentable)
    }
    
  • Преобразователе ответа в модель

    protocol DecodingProcessor {
        associatedtype DecodingResult
        
        func decodeFrom(_ data: Data) throws -> DecodingResult
    }
    


Теперь об отправителе запроса. Им в нашем случае будет являться сервис, которому передается запрос, на нем лежит обязанность отправить его и затем передать ответ обработчику ответа.

protocol Service {
    associatedtype ResultType: Decodable
    associatedtype ErrorType: ErrorRepresentable
    
    typealias SuccessHandlerBlock = (ResultType) -> ()
    typealias FailureHandlerBlock = (ErrorType) -> ()
    
    var request: HTTPRequestRepresentable? { get set }
    var responseHandler: HTTPResponseHandler<ResultType, ErrorType>? { get set }
  
    func sendRequest() -> Self?
}

Себя мы будем возвращать для реализации Method Chaining'а.

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

Нередко бывает так, что для более чем одного запроса при ошибке приходит один и тот же код, но означает он для них совершенно разное. Когда сервис — один большой объект, который все сам и обрабатывает, возникают проблемы, ведущие к «костылям» и неизбежному росту класса и уменьшению его красоты. В данном случае, если вам надо как-то по своему отреагировать на ошибку, вы просто реализуете протокол обработчика ошибки и инжектируете его в обработчика ответа: ничего менять в других местах вы не будете, вы расширите систему, а не модифицируете ее, что есть хороший пример Open-Close принципа с точки зрения системы. Все объекты выполняют одну свою роль: Single Responsibility Principle. Выполнение остальных принципов, думаю очевидно.

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

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

Итак, вот как выглядит наш сервис, отправляющий запрос:

final class BaseService<T: Decodable, E: ErrorRepresentable>: Service {
    typealias ResultType = T
    typealias ErrorType = E
    
    var responseHandler: HTTPResponseHandler<T, E>? = HTTPResponseHandler<T, E>()
    var request: HTTPRequestRepresentable?

    var successHandler: SuccessHandlerBlock?
    var failureHandler: FailureHandlerBlock?
    var noneHandler: (() -> ())?
    
    var requestPreparator: RequestPreparator? = BaseRequestPreparator()
    
    private var session: URLSession {
        let session = URLSession(configuration: URLSessionConfiguration.default, delegate: nil, delegateQueue: nil)
        
        return session
    }
    
    @discardableResult
    func sendRequest() -> BaseService<T, E>? {
        guard var request = request else {
            return nil
        }
        
        requestPreparator?.prepareRequest(&request)
        
        guard let urlRequest = request.urlRequest() else {
            return nil
        }

        session.dataTask(with: urlRequest) { [weak self] (data, response, error) in
            let response = BaseResponse(data: data, response: response, error: error)
            
            self?.responseHandler?.handleResponse(response, completion: { [weak self] (result) in
                switch result {
                case let .Value(model):
                    self?.processSuccess(model)
                case let .Error(error):
                    self?.processError(error)
                }
            })
        }.resume()
        
        return self
    }
    
    @discardableResult
    func onSucces(_ success: @escaping SuccessHandlerBlock) -> BaseService<T, E> {
        successHandler = success
        
        return self
    }
    
    @discardableResult
    func onFailure(_ failure: @escaping FailureHandlerBlock) -> BaseService<T, E> {
        failureHandler = failure
        
        return self
    }
    
    private func processSuccess(_ model: T) {
        successHandler?(model)
        successHandler = nil
    }

    private func processError(_ error: E) {
        failureHandler?(error)
        failureHandler = nil
    }
}

Как видно, все что он делает это подготовка запроса, отправка его с помощью Apple'вского фреймворка и передача ответа на обработку обработчику ответов.

Как вы поняли, параметр обобщенного класса T — это тип итоговой модели, а E — тип ошибки. Знание этих типов в большей степени нужно обработчику ответа, взглянем на него:

class HTTPResponseHandler<T: Decodable, E: ErrorRepresentable>: ResponseHandler {
    typealias ResultType = T
    typealias ErrorType = E
    
    private var isResponseRepresentSimpleType: Bool {
        return
            T.self == Int.self ||
            T.self == String.self ||
            T.self == Double.self ||
            T.self == Float.self
    }
    
    var errorHandler: ErrorHandler = BaseErrorHandler()
    var successResponseChecker: SuccessResponseChecker = BaseSuccessResponseChecker()
    var decodingProcessor = ModelDecodingProcessor<T>()
    var nestedModelGetter: NestedModelGetter?
    
    func handleResponse(_ response: ResponseRepresentable, completion: (Result<T, E>) -> ()) {
        if successResponseChecker.isSuccessResponse(response) {
            processSuccessResponse(response, completion: completion)
        } else {
            processFailureResponse(response, completion: completion)
        }
    }
    
    private func processSuccessResponse(_ response: ResponseRepresentable, completion: (Result<T, E>) -> ()) {
        guard var data = response.data else {
            return
        }
       
       //Часть кода удалена при копировании в статью, чтобы не отвлекать от основной реализации

        guard let result = try? decodingProcessor.decodeFrom(data) else {
            completion(Result.Error(E(ProcessingErrorType.modelProcessingError)))
            
            return
        }
        
        completion(.Value(result))
    }
    
    private func simpleTypeUsingNestedModelGetter(from data: Data) -> T? {
        let getter = nestedModelGetter!
        
        guard let escapedModelJSON = try? getter.getFrom(data) else {
            return nil
        }
        
        guard let result = escapedModelJSON[getter.escapedModelKey] as? T else {
            return nil
        }
        
        return result
    }
    
    
    private func processFailureResponse(_ response: ResponseRepresentable, completion: (Result<T, E>) -> ()) {
        let error = E(response)
        completion(.Error(error))
        errorHandler.handleError(error)
    }
    
}

Я специально удалил некоторые строки, чтобы не отвлекать от основной реализации. Что делают эти удаленные строки? Я реализовал возможность извлечения вложенной модели, если остально в ответе вам не нужно. Такое бывает, например в случае, когда вы запрашиваете стенку ВК и он возвращает что-то вроде:

["response": {
"items": [{"id":1, "text": "some"}, {"id":2, "text": "awesome"}],
"count": 231
}]

а вам нужны только items, в таком случае вы можете дать обработчику ответа NestedModelGetter, который имеет проперти keyPath в виде «response.items», и он вытащит для вас вашу модель. Также в моей реализации можно достать count, то есть примитивный тип, для которого вы конечно должны дать объект, который data в этот тип преобразует.

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

Также видно, что у обработчика ответа есть валидатор успешности и в случае неуспешного ответа мы отдаем ответ обработчику ошибок.

Приведу пример модели поста стены, чтобы было понятно, как она дает себя собрать из ответа:

struct WallItem: Decodable {
    var id: Int
    var text: String
}

Модели просто реализуют протокол Decodable, и, конечно, если ключи в JSON не совпадают с именами у модели, необходимо также добавить в модель CodingKeys. Об этом всем можно почитать в документации Apple.

Также приведу пример валидатора успешного запроса для API VK:

struct VKAPISuccessChecker: SuccessResponseChecker {
    let jsonSerializer = JSONSerializer()
    
    func isSuccessResponse(_ response: ResponseRepresentable) -> Bool {
        guard let httpResponse = response.response as? HTTPURLResponse else {
            return false
        }
        
        let isSuccesAccordingToStatusCode = Range(uncheckedBounds: (200, 300)).contains(httpResponse.statusCode)
        
        guard let data = response.data else {
            return false
        }
        
        guard let json = try? jsonSerializer.serialize(data) else {
            return false
        }
        
        return isSuccesAccordingToStatusCode && !json.keys.contains("error")
    }
}

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

let service = BaseService<T, VKAPIError>()
service.request = request
        
let responseHandler = HTTPResponseHandler<T, VKAPIError>()
responseHandler.nestedModelGetter = nestedModelGetter
responseHandler.successResponseChecker = VKAPISuccessChecker()
        
if let decodingProcessor = decodingProcessor {
   responseHandler.decodingProcessor = decodingProcessor
}
        
service.responseHandler = responseHandler
        
return service


Немного об ошибках. Все типы ошибок реализуют протокол:
protocol ErrorRepresentable {
    var message: String? { get set }
    var errorCode: Int? { get set }
    var type: ErrorType { get set }
    
    init(_ type: ErrorType)
    init(_ response: ResponseRepresentable)
}

protocol ErrorType {
    var rawValue: String { get }
}


ErrorType реализуют у меня enum'ы. Взглянем на структуры ошибки ВК:

enum VKAPIErrorType: String, ErrorType {
    case invalidAccessToken
    case unknownError
}

struct VKAPIError: ErrorRepresentable {
    var errorCode: Int?
    var message: String?
    var type: ErrorType = VKAPIErrorType.unknownError
    
    init(_ type: ErrorType) {
        self.type = type
    }
    
    init(_ response: ResponseRepresentable) {
        guard let data = response.data else {
            return
        }
        
        let jsonSerializer = JSONSerializer()
        guard let dataJSON = try? jsonSerializer.serialize(data),
            let errorJSON = dataJSON["error"] as? JSON else {
            return
        }

        errorCode = errorJSON["error_code"] as? Int
        message = errorJSON["error_msg"] as? String
        
        guard let code = errorCode else {
            return
        }
        
        switch code {
        case 5:
            type = VKAPIErrorType.invalidAccessToken
        default:
            type = VKAPIErrorType.unknownError
        }
    }
}


Думаю, никакому адекватному разработчику не хотелось бы прописывать сборку сервиса в каждом контроллере. Мне тоже, поэтому завернем процесс сборки в Builder:

protocol APIBuilder {
    associatedtype ResultType: Decodable
    associatedtype ErrorType: ErrorRepresentable
    
    func buildAPI(for request: HTTPRequestRepresentable,
    decodingProcessor: ModelDecodingProcessor<ResultType>?,
    nestedModelGetter: NestedModelGetter?) -> BaseService<ResultType, ErrorType>
}

class VKAPIBuilder<T: Decodable>: APIBuilder {
    typealias ResultType = T
    typealias ErrorType = VKAPIError
    
    func buildAPI(for request: HTTPRequestRepresentable,
             decodingProcessor: ModelDecodingProcessor<T>? = nil,
             nestedModelGetter: NestedModelGetter? = nil) -> BaseService<T, VKAPIError> {
        let service = BaseService<T, VKAPIError>()
        service.request = request
        
        let responseHandler = HTTPResponseHandler<T, VKAPIError>()
        responseHandler.nestedModelGetter = nestedModelGetter
        responseHandler.successResponseChecker = VKAPISuccessChecker()
        
        if let decodingProcessor = decodingProcessor {
            responseHandler.decodingProcessor = decodingProcessor
        }
        
        service.responseHandler = responseHandler
        
        return service
    }
}


Теперь завернем все это в отдельный объект, который будет реализовывать следующие протоколы:

protocol WallGettable {
    func getWall(completion: @escaping (Wall) -> ())
}

protocol WallPostable {
    func postWall(with message: String)
}

typealias WallAPI = WallGettable & WallPostable

А вот его реализация:

class BasicWallAPI: WallAPI {
    lazy var getWallService: BaseService<Wall, VKAPIError> = {
        let apiBuilder = VKAPIBuilder<Wall>()
        
        return apiBuilder.buildAPI(for: GETWallRequest(),
                                    nestedModelGetter: ResponseModelGetter.wallResponse)
    }()
    
    lazy var postService: BaseService<[String: [String: Int]], VKAPIError> = {
        let service = BaseService<[String: [String: Int]], VKAPIError>()
        
        return service
    }()

    func getWall(completion: @escaping (Wall) -> ()) {
        _ = getWallService.sendRequest()?.onSucces({ (wall) in
            completion(wall)
        })
    }

    func postWall(with message: String) {
        postService.request = POSTWallRequest(message: message)
        _ = postService.sendRequest()
    }
}

Вот мы и пришли к результату, который был показан в начале статьи. Приятным плюсом является реализация принципа Separation of Concerns, то есть контроллер будет в состоянии вызвать только те методы, которые ему необходимы и не будет знать ни о чем другом.

Пример использования


Давайте реализуем также метод для получения количества записей на стене, то есть значения поля count.

Расширим протокол WallGettable:

protocol WallGettable {
    func getWall(completion: @escaping (Wall) -> ())
    func getWallItemsCount(completion: @escaping (Int) -> ())
}


Реализуем новый метод в нашей структуре:
        lazy var getWallItemsCountService: BaseService<Int, VKAPIError> = {
        let apiBuilder = VKAPIBuilder<Int>()
        
        return apiBuilder.buildAPI(for: GETWallRequest(), decodingProcessor: IntDecodingProcessor(), nestedModelGetter: ResponseModelGetter.wallResponseCount)
    }()
    

    func getWallItemsCount(completion: @escaping (Int) -> ()) {
        _ = getWallItemsCountService.sendRequest()?.onSucces({ (count) in
            completion(count)
        })
    }

Что из себя представляет ResponseModelGetter?

enum ResponseModelGetter: String, NestedModelGetter {
    case wallResponse = "response"
    case wallResponseItems = "response.items"
    case wallResponseCount = "response.count"
    case wallResponseFirstText = "response.items.text"
    
    var keyPath: String {
        return self.rawValue
    }
}

Вызовем новый метод:

    override func viewDidLoad() {
        super.viewDidLoad()
        
        service.getWallItemsCount { (count) in
            print("Wall items count is \(count)")
        }
    }

Получим вывод в консоль:

Wall items count is 1650

Конечно, необязательно оборачивать API всю эту логику, но это удобно, красиво и правильно (главное не свалить все методы API в один класс). При тестировании слоя на API ВК не забудьте вставить свой access_token (куда его прописывать найдете в ReqestPreparator'ах).

Детали реализации вы можете посмотреть в проекте, доступном по ссылке.

Если вам понравилась реализация, не пожалейте для нее звезды.

Комментарии (6)


  1. Gentlee
    24.02.2018 07:03
    -1

    Хосспаде, будни быдлокодера велосипедостроителя.


  1. svanichkin
    24.02.2018 11:51
    +1

    Подскажите, а в чём конкретно заключается гибкость данного примера?


    1. Isa_Aliev Автор
      24.02.2018 12:07
      -1

      В том, что мы можем легко изменять реализацию того или иного этапа, реализовав нужный протокол и инжектировав реализацию в сетевой модуль. Обычно реализация взаимодействия с серверным API ложится на один большой класс, который делает все, тут за каждый этап отвечает отдельный объект.


      1. svanichkin
        24.02.2018 12:09
        +1

        Ну во перрвых почему вы решили что «Обычно реализация взаимодействия с серверным API ложится на один большой класс»? Это далеко не так. Если это единственное что подразумевает гибкость, то я разочарован.


  1. vitaliygozhenko
    24.02.2018 16:01

    А чем ваша реализация лучше Alamofire?


    1. Isa_Aliev Автор
      24.02.2018 16:26

      Alamofire это фреймворк для работы с сетью, а не архитектурный слой. С сетью мы работаем в рамках слоя, ваш вопрос не корректный, в начале статьи обращено на это внимание.