
Сейчас практически 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 подставит нужный тип.

Заключение
Теперь у нас есть реализация протокол-ориентированного сетевого слоя, которым очень просто пользоваться и который всегда можно настроить под свои нужды. Мы поняли его функционал и то, как работают все механизмы.
Исходный код вы можете найти в этом репозитории.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
А как обычно организуете свой сетевой слой вы?
37.21% Alamofire64
12.21% Moya21
2.33% Другая библиотека4
48.26% Свой сетевой слой без использования сторонних библиотек83
Проголосовали 172 пользователя. Воздержались 17 пользователей.