Я хочу рассказать, как можно построить достаточно гибкий сетевой слой.
Обратите внимание, это архитектура сетевого слоя приложения, а не реализация работы с сетью. Фреймворк для работы с сетью вы можете использовать любой.
Вот как это будет выглядеть в итоге:
import UIKit
class ViewController: UIViewController {
let service: WallPostable = BasicWallAPI()
@IBOutlet weak var textField: UITextField!
@IBAction func postAction() {
service.postWall(with: textField.text!)
}
}
Итак, как выглядит работа с API для конечного пользователя (я имею в виду программиста, который использует реализацию слоя):
Немного раскроем ящик API:
Как я вижу ящик Send:
Мы формируем запрос. Обычно запросы имеют некоторые одинаковые хэдеры и чтобы не прописывать их в каждом запросе, мы подготавливаем запросы в блоке «Request Preparation». Далее полноценно собранный запрос мы отправляем, используя удобный для нас фреймворк (В моем примере я использую нативный фреймворк от Apple). Отправить запрос всегда самое простое, все сложное начинается при получении ответа.
Как я вижу ящик Handle:
Пришедший ответ мы проверяем на успешность выполнения, далее в случае успешного ответа мы преобразуем его в модель понятную для приложения и отдаем. Если запрос выполнился не успешно, то мы отдаем его в блок обработки ошибки: он достает код ошибки, сообщение если оно имеется, реагирует на все это как мы ему говорим, формирует ошибку понятную для приложения и отдает.
Сетевой слой, который я реализую разделяет каждое действие и инкапсулирует его в соответствующем типе, который будет реализовывать эту свою единственную обязанность. Связаны между собой эти типы будут по протоколам.
Итак, что нам нужно:
- Запрос
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)
svanichkin
24.02.2018 11:51+1Подскажите, а в чём конкретно заключается гибкость данного примера?
Isa_Aliev Автор
24.02.2018 12:07-1В том, что мы можем легко изменять реализацию того или иного этапа, реализовав нужный протокол и инжектировав реализацию в сетевой модуль. Обычно реализация взаимодействия с серверным API ложится на один большой класс, который делает все, тут за каждый этап отвечает отдельный объект.
svanichkin
24.02.2018 12:09+1Ну во перрвых почему вы решили что «Обычно реализация взаимодействия с серверным API ложится на один большой класс»? Это далеко не так. Если это единственное что подразумевает гибкость, то я разочарован.
vitaliygozhenko
24.02.2018 16:01А чем ваша реализация лучше Alamofire?
Isa_Aliev Автор
24.02.2018 16:26Alamofire это фреймворк для работы с сетью, а не архитектурный слой. С сетью мы работаем в рамках слоя, ваш вопрос не корректный, в начале статьи обращено на это внимание.
Gentlee
Хосспаде, будни быдлокодера велосипедостроителя.