Пишем свой сетевой слой на Swift: протокол-ориентированный подход

Автор оригинала: Malcolm Kumwenda
  • Перевод
  • Tutorial


Сейчас практически 100% приложений используют работу с сетью, поэтому вопрос организации и использования сетевого слоя встает перед каждым. Есть два основных подхода к решению этой проблемы, это либо использование сторонних библиотек, либо собственная реализация сетевого слоя. В данной статье мы рассмотрим именно второй вариант, причем попробуем реализовать сетевой слой с использованием всех последних возможностей языка, применяя протоколы и перечисления. Это избавит проект от лишних зависимостей в виде дополнительных библиотек. Те, кто хоть раз видел Moya, сразу узнают множество схожих деталей в реализации и использовании, так оно и есть, только в этот раз мы все сделаем своими руками, не трогая Moya и Alamofire.


В этом руководстве мы рассмотрим как реализовать сетевой слой на чистом Swift, без использования каких-либо сторонних библиотек. После того как вы познакомитесь с этой статьей, ваш код станет

  • протокол-ориентированным
  • простым в использовании
  • простым в применении
  • типобезопасным
  • для конечных точек(endpoints) будут использоваться перечисления


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



Написав просто router.request(. и используя всю мощь перечислений мы увидим все варианты возможных запросов и их параметров.

Сначала, немного о структуре проекта

Всякий раз когда вы создаете что-то новое, и для того чтобы иметь возможность в будущем легко во всем разобраться, очень важно правильно все организовать и структурировать. Я придерживаюсь убеждения, что правильно организованная структура папок — важная деталь при построении архитектуры приложения. Для того чтобы у нас все было правильно разложено по папкам давайте создадим их заранее. Так будет выглядеть общая структура папок в проекте:



EndPointType Protocol

В первую очередь нам необходимо определить наш EndPointType протокол. В этом протоколе будет находиться вся необходимая информация для конфигурации запроса. Что такое запрос(endpoint)? По сути это URLRequest со всеми сопутствующими компонентами, такими как заголовки, параметры запроса, тело запроса. Протокол EndPointType — это самая важная часть нашей реализации сетевого слоя. Давайте создадим файл и назовем его EndPointType. Поместите этот файл в папку Service(не в папку EndPoint, почему — будет понятно чуть позже)



HTTP Protocols

Наш EndPointType содержит в себе несколько протоколов, которые необходимы нам для создания запроса. Давайте посмотрим, что это за протоколы.

HTTPMethod

Создайте файл, назовите его HTTPMethod и поместите в папку Service. Это перечисление будет использоваться для установки HTTP-метода нашего запроса.



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



В следующем разделе мы обсудим Parameters и то как мы будем с ними работать

HTTPHeaders

HTTPHeaders это просто typealias для словаря. Вы можете создать его вверху вашего HTTPTask файла.

public typealias HTTPHeaders = [String:String]


Parameters & Encoding

Создайте файл, назовите его ParameterEncoding и поместите в папку Encoding. Создадим typealias для Parameters, это опять будет обычный словарь. Мы делаем это для того чтобы код выглядел понятнее и был читаемым.

public typealias Parameters = [String:Any]


Далее, определим протокол ParameterEncoder с единственной функцией encode. У метода encode есть два параметра: inout URLRequest и Parameters. INOUT это ключевое слово в Swift, которое определяет параметр функции как ссылку. Обычно параметры передаются в функцию как значения. Когда вы пишете inout перед параметром функции в вызове, вы определяете этот параметр как ссылочный тип. Чтобы узнать больше о inout-аргументах, вы можете перейти по этой ссылке. Если же вкратце, то inout позволяет изменять значение самой переменной, которая была передана в функцию, а не просто получать ее значение в параметре и работать с ним внутри функции. Протокол ParameterEncoder будет реализовываться в JSONParameterEncoder и в URLPameterEncoder.

public protocol ParameterEncoder {
 static func encode(urlRequest: inout URLRequest, with parameters: Parameters) throws
}


ParameterEncoder содержит единственную функцию, задача которой кодировать параметры. Этот метод может выдавать ошибку, которую необходимо обрабатывать, поэтому мы используем throw.

Также может оказаться полезным выдавать не стандартные ошибки, а кастомизированные. Всегда довольно трудно расшифровать то, что вам выдает Xcode. Когда у вас все ошибки кастомизированы и описаны, вы всегда точно знаете что произошло. Чтобы это сделать давайте определим перечисление, которое наследует от Error.



Создайте файл, назовите его URLParameterEncoder и поместите в папку Encoding.



Этот код берет список параметров, преобразует и форматирует их для использования в качестве параметров URL. Как вы знаете, некоторые символы запрещено использовать в URL. Параметры также разделяются символом "&", следовательно мы должны позаботиться об этом. Мы также должны задать дефолтное значение заголовкам, если они не установлены в запросе.

Это та часть кода, которую предполагается покрывать unit-тестами. Построение URL-запроса является ключевым моментом, иначе мы можем спровоцировать множество ненужных ошибок. Если вы используете открытый API, вы явно не захотите использовать весь возможный объем запросов на провалившиеся тесты. Если у вас есть желание узнать больше о Unit-тестах, вы можете начать с этой статьи.

JSONParameterEncoder

Создайте файл, назовите его JSONParameterEncoder и поместите в папку Encoding.



Все аналогично как и в случае с URLParameter, просто здесь мы преобразуем параметры для JSON и опять же добавляем параметры, определяющие кодировку «application/json», в хедер.

NetworkRouter

Создайте файл, назовите его NetworkRouter и поместите в папку Service. Начнем с определения typealias для замыкания.

public typealias NetworkRouterCompletion = (_ data: Data?,_ response: URLResponse?,_ error: Error?)->()


Далее определим протокол NetworkRouter.



У NetworkRouter есть EndPoint, который он использует для запросов, и как только запрос завершается, результат этого запроса передается в замыкание NetworkRouterCompletion. В протоколе также есть функция cancel, которая может использоваться для прерывания длительных запросов загрузки и выгрузки. Мы также использовали здесь associatedtype, потому что мы хотим чтобы наш Routerподдерживал любой тип EndPointType. Без использования associatedtype роутеру бы пришлось иметь какой-то конкретный тип реализующий EndPointType. Если вы хотите узнать больше об associatedtype, то можете прочитать данную статью.

Router

Создайте файл, назовите его Router и поместите в папку Service. Мы объявляем private переменную типа URLSessionTask. На ней и будет лежать вся работа. Мы делаем ее private, потому что не хотим чтобы кто-то извне мог изменить ее.



Request

Тут мы создаем URLSession с помощью URLSession.shared, это самый простой способ создания. Но помните, что этот способ не единственный. Можно использовать и более сложные конфигурации URLSession, которые могут менять ее поведение. Больше об этом в данной статье.

Запрос создается с помощью вызова функции buildRequest Вызов функции обернут в do-try-catch, потому что функции кодировки внутри buildRequest могут выдавать исключения. В completion передаются response, data и error.



Build Request

Мы создаем наш запрос с помощью функции buildRequest. Эта функция отвечает за всю жизненно важную работу в нашем сетевом слое. По сути, конвертирует EndPointType в URLRequest. И как только EndPoint превращается в запрос, мы можем передать его в session. Здесь происходит много всего, поэтому давайте разберем по методам. Сначала разберем метод buildRequest:

1. Мы инициализируем переменную запроса URLRequest. Задаем в ней наш базовый URL-адрес и добавляем к нему путь конкретного запроса, который будет использоваться.

2. Присваиваем request.httpMethod http-метод из нашего EndPoint.

3. Создаем блок do-try-catch, потому что наши кодировщики могут выдавать ошибку. Создавая один большой блок do-try-catch мы избавляем себя от необходимости создавать отдельный блок для каждого try.

4. В switch проверяем route.task.

5. В зависимости от вида task вызываем соответствующий кодировщик.



Configure Parameters

Создайте функцию configureParameters в Router.



Эта функция отвечает за преобразование наших параметров запроса. Поскольку наш API предполагает использование bodyParameters в виде JSON и URLParameters преобразованными в формат URL, то мы просто передаем соответствующие параметры в соответствующие функции преобразования, которые мы описывали в начале статьи. Если же вы используете API, который включает в себя различные виды кодировки, то в этом случае я бы рекомендовал дополнить HTTPTask дополнительным перечислением с типом кодировки. В этом перечислении должны содержаться все возможные виды кодировок. После этого в configureParameters добавьте еще один аргумент с данным перечислением. В зависимости от его значения переключитесь с помощью switch и произведите нужную вам кодировку.

Add Additional Headers

Создайте функцию addAdditionalHeaders в Router.



Просто добавьте все необходимые заголовки в запрос.

Cancel

Функция cancel будет выглядеть довольно просто:



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

Теперь давайте попробуем использовать наш сетевой слой на реальном примере. Мы подключимся к TheMovieDB чтобы получить данные для нашего приложения.

MovieEndPoint

Создадим файл MovieEndPoint и поместим его в папку EndPoint. MovieEndPoint это то же самое что
и TargetType в Moya. Здесь мы реализуем вместо него свой собственный EndPointType. Статью, описывающую как использовать Moya на аналогичном примере вы можете найти по этой ссылке.

import Foundation


enum NetworkEnvironment {
    case qa
    case production
    case staging
}

public enum MovieApi {
    case recommended(id:Int)
    case popular(page:Int)
    case newMovies(page:Int)
    case video(id:Int)
}

extension MovieApi: EndPointType {
    
    var environmentBaseURL : String {
        switch NetworkManager.environment {
        case .production: return "https://api.themoviedb.org/3/movie/"
        case .qa: return "https://qa.themoviedb.org/3/movie/"
        case .staging: return "https://staging.themoviedb.org/3/movie/"
        }
    }
    
    var baseURL: URL {
        guard let url = URL(string: environmentBaseURL) else { fatalError("baseURL could not be configured.")}
        return url
    }
    
    var path: String {
        switch self {
        case .recommended(let id):
            return "\(id)/recommendations"
        case .popular:
            return "popular"
        case .newMovies:
            return "now_playing"
        case .video(let id):
            return "\(id)/videos"
        }
    }
    
    var httpMethod: HTTPMethod {
        return .get
    }
    
    var task: HTTPTask {
        switch self {
        case .newMovies(let page):
            return .requestParameters(bodyParameters: nil,
                                      urlParameters: ["page":page,
                                                      "api_key":NetworkManager.MovieAPIKey])
        default:
            return .request
        }
    }
    
    var headers: HTTPHeaders? {
        return nil
    }
}


MovieModel

Для парсинга модели данных MovieModel и JSON в модель используется протокол Decodable. Разместите данный файл в папку Model.

Заметка: для более подробного знакомства с протоколами Codable, Decodable и Encodable вы можете прочитать другую мою статью, где подробно описаны все особенности работы с ними.

import Foundation

struct MovieApiResponse {
    let page: Int
    let numberOfResults: Int
    let numberOfPages: Int
    let movies: [Movie]
}

extension MovieApiResponse: Decodable {
    
    private enum MovieApiResponseCodingKeys: String, CodingKey {
        case page
        case numberOfResults = "total_results"
        case numberOfPages = "total_pages"
        case movies = "results"
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: MovieApiResponseCodingKeys.self)
        
        page = try container.decode(Int.self, forKey: .page)
        numberOfResults = try container.decode(Int.self, forKey: .numberOfResults)
        numberOfPages = try container.decode(Int.self, forKey: .numberOfPages)
        movies = try container.decode([Movie].self, forKey: .movies)
        
    }
}


struct Movie {
    let id: Int
    let posterPath: String
    let backdrop: String
    let title: String
    let releaseDate: String
    let rating: Double
    let overview: String
}

extension Movie: Decodable {
    
    enum MovieCodingKeys: String, CodingKey {
        case id
        case posterPath = "poster_path"
        case backdrop = "backdrop_path"
        case title
        case releaseDate = "release_date"
        case rating = "vote_average"
        case overview
    }
    
    
    init(from decoder: Decoder) throws {
        let movieContainer = try decoder.container(keyedBy: MovieCodingKeys.self)
        
        id = try movieContainer.decode(Int.self, forKey: .id)
        posterPath = try movieContainer.decode(String.self, forKey: .posterPath)
        backdrop = try movieContainer.decode(String.self, forKey: .backdrop)
        title = try movieContainer.decode(String.self, forKey: .title)
        releaseDate = try movieContainer.decode(String.self, forKey: .releaseDate)
        rating = try movieContainer.decode(Double.self, forKey: .rating)
        overview = try movieContainer.decode(String.self, forKey: .overview)
    }
}


NetworkManager

Создайте файл NetworkManager в папке Manager. На текущий момент NetworkManager содержит в себе только два статических свойства: ключ API и перечисление, описывающее тип сервера для подключения. NetworkManager также содержит Router, который имеет тип MovieApi.



Network Response

Создайте перечисление NetworkResponse в NetworkManager.



Мы используем это перечисление при обработке ответов на запросы и будем выводить соответствующее сообщение.

Result

Создайте перечисление Result в NetworkManager.



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

Обработка ответов на запрос

Создайте функцию handleNetworkResponse. Эта функция принимает один аргумент, типа HTTPResponse и возвращает Result.



В этой функции мы в зависимости от полученного statusCode из HTTPResponse возвращаем сообщение об ошибке, либо признак успешного запроса. Обычно код в диапазоне 200..299 означает успех.

Делаем сетевой запрос

Итак, мы сделали все, чтобы начать использовать наш сетевой слой, давайте попробуем сделать запрос.

Мы будем запрашивать список новых фильмов. Создайте функцию и назовите ее getNewMovies.



Давайте разберем все по шагам:

1. Мы определяем метод getNewMovies с двумя аргументами: номер страницы пагинации и completion handler, который возвращает опциональный массив моделей Movie, либо опциональную ошибку.

2. Вызываем Router. Передаем номер страницы и обрабатываем completion в замыкании.

3. URLSession возвращает ошибку если нет сети или не получилось сделать запрос по какой-либо причине. Обратите внимание, что это не ошибка API, такие ошибки происходят на клиенте и происходят обычно из-за плохого качества интернет-соединения.

4. Нам необходимо привести наш response к HTTPURLResponse, потому что нам надо получить доступ к свойству statusCode.

5. Объявляем result и инициализируем его с помощью метода handleNetworkResponse

6. Success означает что запрос прошел успешно и мы получили ожидаемый ответ. Затем мы проверяем, пришли ли с ответом данные, и если нет, то просто завершаем метод через return.

7. Если же ответ приходит с данными, то необходимо распарсить полученные данные в модель. После этого передаем полученный массив моделей в completion.

8. В случае ошибки просто передаем ошибку в completion.

Все, вот так работает наш собственный сетевой слой на чистом Swift, без использования каких-либо зависимостей в виде сторонних подов и библиотек. Для того чтобы сделать тестовый api-запрос получения списка фильмов создадим MainViewController cо свойством NetworkManager и вызовем через него метод getNewMovies.

 class MainViewController: UIViewController {
    
    var networkManager: NetworkManager!
    
    init(networkManager: NetworkManager) {
        super.init(nibName: nil, bundle: nil)
        self.networkManager = networkManager
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .green
        networkManager.getNewMovies(page: 1) { movies, error in
            if let error = error {
                print(error)
            }
            if let movies = movies {
                print(movies)
            }
        }
    }
}


Небольшой бонус

У вас были ситуации в Xcode, когда вы не понимали что за плейсхолдер используется в конкретном месте? Например посмотрим на код, который мы сейчас написали для Router.



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



Заключение

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

Исходный код вы можете найти в этом репозитории.

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

А как обычно организуете свой сетевой слой вы?

Поделиться публикацией

Комментарии 11

    0
    забавно, мой ученик тот же апи использует. И тут даже слишком много кода.
    Почитайте пожалуйста про парсинг json и camel Case.
      0
      Про парсинг я уже сам писал вот здесь, там кстати есть и про CamelCase и про snake
      +1
      Десериализацию стоит вынести в дженерик метод чтобы при создании очередного эндпоинта всё не копипастить из router.request.
      0
      `NSURLSession` современный, гибкий, высокоуровневый, удобный, документированный, развивающийся API. Не вижу смысла использовать что-то ещё поверх него. Вам реально кажется таким полезным вместо `«POST»` писать `.POST`? Или `.count(5)` настолько лучше, чем `URLQueryItem(parameter: «count», value: «5»)`, несмотря на то, что теперь чтобы добавить новый параметр в вызов, нужно два места в коде менять?

      При этом все эти украшательства искусственно ограничивают доступные возможности. Потом эти возможности либо а) не используются б) обрастают дублирующими «своими» API-обёртками.

      Вместо просто использования стандартных API, разработчики, приходя на такой проект, вынуждены разбираться в особенностях очередного «фреймворка».
        0
        Вы правда думаете, что запрос такого вида, как тот что ниже, будет проще для использования в коде чем одна строчка с хорошо читаемым форматом?

        Да, я специально немного утрировал пример, можно создать функции для get и post запросов с входными параметрами, можно сделать функции для каждого конкретного запроса и т.д., но чем больше вы будете пытаться сделать слой универсальным, гибким и простым в использовании, тем больше вы будете приближаться к чему-то похожему на эту статью. В больших проектах вызовы запросов встречаются в сотнях мест, и даже небольшое сокращение кода при работе с запросами и более понятный и читаемый синтаксис могут принести огромную пользу. Вносить изменения в такой слой тоже, наоборот, легче, вам надо например поменять стандартный хедер в get-запросах, тип кодировки или еще что-то подобное, вы его поменяете всего в одном месте, никаких проблем. И эти «украшательства» абсолютно ничем не ограничивают возможности, их никто не отнимал, добавляйте что хотите. И для новых разработчиков такой «фреймворк» не принесет никакого неудобства, наоборот, пользоваться им проще некуда, написать его непросто, а пользоваться им уж точно не сложно + такой подход защитит от самодеятельности этих самых новых разработчиков, в то время как дав им возможность реализовать запросы через URLSession в том виде в каком они хотят однозначно приведет к тому что каждый реализует как бог пошлет. Жить безусловно можно и с URLSession, но так — намного удобнее.

        let config = URLSessionConfiguration.default
        
        let session = URLSession(configuration: config)
        
        let url = URL(string: "https://httpbin.org/anything")!
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = "POST"
        
        // your post request data
        let postDict : [String: Any] = ["name": "axel",
                                        "favorite_animal": "fox"]
        
        guard let postData = try? JSONSerialization.data(withJSONObject: postDict, options: []) else {
            return
        }
        
        urlRequest.httpBody = postData
        
        let task = session.dataTask(with: urlRequest) { data, response, error in
        	
            // ensure there is no error for this HTTP response
            guard error == nil else {
                print ("error: \(error!)")
                return
            }
        	
            // ensure there is data returned from this HTTP response
            guard let content = data else {	
                print("No data")
                return
            }
        	
            // serialise the data / NSData object into Dictionary [String : Any]
            guard let json = (try? JSONSerialization.jsonObject(with: content, options: JSONSerialization.ReadingOptions.mutableContainers)) as? [String: Any] else {
                print("Not containing JSON")
                return
            }
        	
            print("gotten json response dictionary is \n \(json)")
            // update UI using the response here
        }
        
        // execute the HTTP request
        task.resume()

          0

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

        0
        По построению такой «роутер» поддерживает отмену только последнего запроса, причём это не очевидно, это может вылиться в ужасно непонятные ошибки в работе
          0
          В каждой функции типа getNewMovies будет происходить одно и то же. Поэтому, данную обработку ответа к конкретному типу я бы вынес тоже в сетевой слой. Ну либо в слой поверх сетевого (тут каждому уж свое). Вот, почитать: habr.com/ru/post/338380
            0
            Почему-то есть ощущение, что Alamofire переписан «немного другими словами»
              0
              скорее уж Moya

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

            Самое читаемое