На протяжении многих лет традиционным подходом было включение библиотек Alamofire (AFNetworking) в состав iOS приложения для осуществления RESTful запросов к бэкенду. И если в первой половине минувшего десятилетия это объяснялось использованием промышленных стандартов (а, фактически, никто не хотел заморачиваться с наборок сырых поделок, которые предоставила компания Apple из коробки), то в начале нынешнего десятилетия нет никаких аргументированных причин, чтоб ограничивать себя возможностями этой популярной библиотеки. А ограничения, во истину, колоссальные – с точки зрения RESTful – его работа – тривиальная и предсказуемая, но вот методы использования, обусловленные Clean архитектурой Боба Мартина – вызывают дикую головную боль почти к любого разработчика, который приходит на проект. Благие намерения отделить роутинг от сериализации / десериализации приводят к невероятному разрастанию классов и зависимостей слоев, не смотря на то, что изначально, Clean архитектура декларировала то, что она предназначена для того, чтоб избегать таких зависимостей. Однажды пришлось столкнуться с ситуацией - для того чтоб добавить Post запрос необходимо было внести изменение в 11 фалов проекта!

Apple тоже заметили эту порочную тенденцию, и вместе с реализацией асинхронности через await преложила свой подход с использованием комбаина. Вот только не учла ригидности человеческой психики – те кто раньше создавали десяток слоев для выполнения сетевых запросов, сейчас сократили их до 3-4, но создали при этом синглтонный менеджер, длинной на несколько десятков тысяч строк кода.

Разумеется так делают не все. Но, такой подход можно встретить «сплошь и рядом». С учетом идеи о самодокументируемости кода – это едва ли не худшее решение для коллективной разработки.

Вместе с тем, гибкий и удобный подход лежит на поверхности, и вполне доступен для понимания в большом количестве книг по паттернам программирования. Целесообразность его – оставим за скобками – евангелисты любой архитектуры автоматически становятся борцами против него, так как он не соответствует их представлениям о «Чистом Коде» и низводит некоторых идолов их религий. Однако, он реально удобен. Особенно, тем кто только входит в программирование.

В известной книге «Банды четырех» были описаны три стандартных паттерна - «Стратегия», «Машина состояний» и «Команда».  По сути, каждый из них выражал одну и ту же простую мысль - «делать что-то конкретное определенным образом». У «Стратегии» и «Машины состояний» отличались реализации, зато, были идентичные UML диаграммы. А вот с командой – наоборот – диаграммы отличаются сильно, зато реализация очень схожа. А принципе, ни «стратегию», ни «машину состояний» можно было бы не упоминать в рамках этого изложения, но если читатель хотя бы вскользь знаком с ними, то легко сориентируется в использовании REST запросов на основе команд – в принципе, перейти от команды к машине состояний или стратегии – это тривиальная задача – можно на пирамиду смотреть под разными углами, и видеть разные фигуры, но пирамида, при этом, по прежнему остается пирамидой.

Преимущество использование паттерна «Команда» описано во множестве источников, и нет никакой разумной необходимости повторять это здесь (не диссертацию пишем). Лишь оговоримся чего мы тим достичь идя наперекор «устоявшейся индустрии»:

  • писать как можно меньше кода;

  • весь код запроса должен быть изолирован – добавление новой команды не должно затрагивать другие команды ни на уровне файла, ни на уровне класса (вспоминаем принцип открыто-закрытой реализации);

  • код запроса должен быть легко доступен и легко читаем;

  • все изменения должны происходить в одном месте;

  • запросы должны выполняться асинхронно, и многопоточно.

  • память должна высвобождаться после завершения запроса и получения необходимых данных с севера.

Бонусом от паттерна «Команда» мы получаем следующие свойства:

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

  2. Интерфейс команды стандартизирован. Зная механизм работы одной команды - можно выполнить все остальные команды.

  3. Все команды, потенциально, могут быть размещены в любой из коллекций (массив, словарь, сет и т. д.) и выполнены из этой коллекции в произвольном порядке.

Общий минимальный алгоритм работы с командой сводится к следующему:

  1. Создать нужную команду.

  2. Передать в нее данные.

  3. Выполнить команду.

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

К примеру, мы хотим вызывать сетевой запрос из контроллера или модели данных (да, это дурной тон, с точки зрения некоторых евангелистов, но такой подход весьма популярен). При этом, для некоторых запросов сервер всего лишь отвечает кодом «200». Нет ошибки – нет проблемы. После выполнения запроса нам не нужно ничего делать. Или, в другом случае, сервер нам что-то возвращает. Но это что-то не должно отображаться во вью-контроллере, или использоваться вью-моделью. В этом случае команда может сохранить результат своей работы в базу данных. А вот база данных, выполнит необходимое уведомление UI. Причем, вся работа по запросу, получению ответа, сохранению в хранилище выполнится в фоновом потоке. В UI же пользователь получит данные только тогда, когда они будут готовы, причем, в потоке предназначенном для этого.

В мобильной разработке, в основном, применяются GET и POST запросы. Разумеется, другие типы запросов тоже можно реализовать, но принципиально, с точки зрения клиентского приложения, они не отличаются от POST запросов. А вот GET и POST традиционно отличаются в сценариях использования.

В GET запросах параметры передаются либо в пути самого запроса, либо в виде query параметров (все что идет за знаком «?» в URL). GET запрос, обычно, не имеет параметров в теле запроса.

POST наоборот – редко использует параметры в пути запроса или query параметры, зато очень часто передает данные в теле запроса.

Эти небольшие отличия приводят к тому, что создание команд для GET и POST запросов очень схоже, но не идентично. Отличия  – чисто традиционные. Могут найтись бэкенд-разработчики, которые ограничиваются каким-то одним типом запросов, или разные типы приводят к полностью идентичному виду. Кроме того, HTTP запросы имеют развитую систему заголовков, через которые передаются данные аутентификации. Как правило, они идентичны для одного и того же приложения или сервиса.

Соответственно, прослеживается схема построения команды.

GET:

  1. Создание класса команды.

  2. Установка пути ресурса (URL запроса).

  3. Установка query параметров.

  4. Выполнение запроса,

  5. Получение сериализованного ответа.

  6. Десериализация ответа.

  7. Сохраняем результат.

POST:

  1. Создание класса команды.

  2. Создание типа параметров

  3. Установка пути ресурса (URL запроса).

  4. Установка (задание) параметров.

  5. Выполнение запроса,

  6. Получение сериализованного ответа.

  7. Десериализация ответа.

  8. Сохраняем результат

Все очень похоже. 

Новички в разработке часто путаются с сериализацией и десериализацией и зачем оно нужно.

Десериализированный объект – это объект который находится в памяти Вашего устройства (телефона или компьютера) также как любой экземпляр класса – т. е. сугубо в двоичном виде.

Сериализированный объект – это объект, который прошел такую предварительную обработку, которая позволяет передать его по сети без потерь. Для этого, обычно, его вначале превращают в JSON, а потом JSON превращают в Data (т. е. в массив байт). При получении  – такой объект разворачивают в обратном порядке.

Чтоб упростить задачу программирования (ранее, в эпоху до Swift 4 это была нетривиальная процедура) в настоящее время используются протоколы Encodable и Decodable. Сочетание обоих протоколов обозначается Codable.

Другими словами, то что мы отправляем должно соответствовать протоколу Encodable, а то что получаем - Decodable. Если не хотим задумываться о том, что нам нужно – используем Codable.

GET:

import Foundation

class ExampleGetRequest : NetworkBase {
    
    // MARK: - Types

    // MARK: - Variables
    override var command: String { return "get?test=\(self.arg)" }
    private (set) var arg = ""
    private (set) var result:GetResponse?
    
    func request(arg: String) {
        self.arg = arg
        self.request(type:.GET)
    }
    
    override func receiveData(data:Data) {
        if let response:GetResponse = self.parse(data: data) {
            self.result = response
            print(response)
        }
    }
}

POST:

import Foundation

class ExamplePostRequest : NetworkBase {
    
    // MARK: - Types
    struct Params: Encodable {
        let arg: String
        let name: String
        let age: Int
    }
    // MARK: - Variables
    override var command: String { return "post" }
    private (set) var result:PostResponse?
    
    func request(arg: String, name: String, age: Int) {
        let params = Params(arg: arg, name: name, age: age)
        self.request(type:.POST, params: params)
    }
    
    override func receiveData(data:Data) {
        if let response:PostResponse = self.parse(data: data) {
            self.result = response
            print(response)
        }
    }
}

Вся магия происходит в базовом классе. Теперь рассмотрим как эти команды можно использовать.

В демонстрационном приложении команды вызываются в трех вариантах:

  1. GET без обработки результата.

  2. GET с обработкой результата.

  3. POST с обработкой результата.

    func shortGetRequest() {
        let param = "SHORT get request completed"
        ExampleGetRequest().request(arg: param)
        self.result = ""
    }
    func getRequest() {
        let param = "GET request with callback completed"
        ExampleGetRequest().callback() {[weak self] request in
            guard let request = request as? ExampleGetRequest else { return }
            guard let self else { return }
            main {
                self.result = request.result?.args["test"] ?? ""
            }
        }.request(arg: param)
    }
    func postRequest() {
        let desc = "POST request with callback completed"
        ExamplePostRequest().callback() {[weak self] request in
            guard let request = request as? ExamplePostRequest else { return }
            guard let self else { return }
            guard let name = request.result?.data.name,
                  let age  = request.result?.data.age,
                  let desc = request.result?.data.arg else { return }
            main {
                self.result = "\(name), \(age)\n\(desc)"
            }
        }.request(arg: desc, name: "John", age: 44)
    }

Само по себе API довольно бесполезное – это API предназначенное для тестирования Postman.  Каждая из реализованных команд возвращает то, что мы отправляем на сервер. API позволяет отладить классы, которы мы используем в приложении.

Полученные данные с сервера команды выводят в консоль после десериализации. Для случаев, когда мы делаем обработку команды в функции обратного вызова – мы обновляем наш UI полученными данными.

Формально, паттерн «команда» требует унифицированный подход для активации команды. Но при этом, согласно требований паттерна, в команду, предварительно, нужно передать данные. После чего уже можно передавать сам объект команды. И у нас такая возможность сохраняется. Но, как правило, это редко требуется в строгом соответствии с требованием паттерна. Мы немного упрощаем себе задачу тем, что активируем команду тем же методом, которым передаем данные в команду. И по той же причине, мы не храним данные запроса, а размещаем их перед самой активацией. Но, потенциально эта возможность присутствует, и мы можем ей воспользоваться, если появится в этом потребность.

Ответ в callback нам возвращается от абстрактной команды. Но мы всегда можем ее привести в конкретному виду через рефлексию. И уже после этого можно извлечь полученные от сервера данные.

Хотелось бы обратить внимание, что данные в callback приходят в бэкграунд потоке. Потому, если из callback осуществляется взаимодействие с пользовательским интерфейсом, следует обязательно перейти в главный поток.

Важным обстоятельством является то, что к моменту получения данных класс в котором происходит вызов команды может уже не существовать. В таком случае обращение к объектам класса может вызвать креш приложения. Поэтому, в обязательном порядке нужно делать проверку weak замыкания:

If let self else { return } // swift 5.7 до безобразия упрощает такую проверку.

Ответы от сервера довольно тривиальные, и легко парсятся, за исключением внутренней структуры Headers. Для Get и Post запроса были созданы структуры ответов. И обе структуры содержат объект Headers. Сложность последней заключается в том, что в Swift нельзя использовать поля с теми названиями, которые приходят в ответе.Для этого нужно их перемапить. И протокол Codable позволяет сделать реализацию этого маппинга. 

import Foundation

struct Headers: Codable {
    let host: String
    let accept: String
    let aEncoding: String
    
    enum CodingKeys: String, CodingKey {
        case host
        case accept
        case acceptEncoding = "accept-encoding"
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(host, forKey: .host)
        try container.encode(accept, forKey: .accept)
        try container.encode(aEncoding, forKey: .acceptEncoding)
      }
      init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
          host = try container.decode(String.self, forKey: .host)
          accept = try container.decode(String.self, forKey: .accept)
          aEncoding = try container.decode(String.self, forKey: .acceptEncoding)
      }
}

Поле «accept-encoding» позволяет увидеть еще одну важную особенность работы команды – если поставить пробел внутри строкового представления, то код скомпилируется, но при запросе с сервера данных – произойдет ошибка. Уведомление об ошибке появится в консоли, а так же, будет выведено сообщение, с каким именно полем возникла проблема.

Бывает довольно полезно узнать как долго выполнялся запрос к серверу. Для этого к консоль выводится время, затраченное командой от момента запроса, до обратного вызова. Это чисто утилитарная функция, не несущая особого смысла. Точно так же можно добавить длительность сохранения  данных, если Вы используете CoreData. Но. Как правило, оно составляет микросекунды, и ни на что в рамках клиентского приложения не влияет.

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

Исходный код доступен на GitHub.

Обсудить можно на телеграмм канале.

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