Как изолировать логику взаимодействия клиент-сервер в iOS-приложениях

Опубликовано: 2022-03-11

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

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

Поддерживайте порядок в коде приложения iOS с помощью хорошо разработанного клиентского сетевого модуля REST.

Поддерживайте порядок в коде приложения iOS с помощью хорошо разработанного клиентского сетевого модуля REST.
Твитнуть

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

Клиент-серверные приложения

Типичное взаимодействие клиент-сервер выглядит примерно так:

  1. Пользователь выполняет какое-либо действие (например, нажимает на какую-либо кнопку или выполняет какой-либо другой жест на экране).
  2. Приложение подготавливает и отправляет запрос HTTP/REST в ответ на действие пользователя.
  3. Сервер обрабатывает запрос и отвечает приложению соответствующим образом.
  4. Приложение получает ответ и на его основе обновляет пользовательский интерфейс.

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

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

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

Кроме того, типичному REST-клиенту нужно будет делать гораздо больше, чем просто вызывать удаленные API. Возможность отмены ожидающих запросов или простой и управляемый подход к обработке ошибок — это примеры функций, которые должны быть встроены в любое надежное мобильное приложение.

Обзор архитектуры

Ядро нашего клиента REST будет построено на следующих компонентах:

  • Модели: классы, описывающие модели данных нашего приложения, отражающие структуру данных, полученных от внутренних серверов или отправленных на них.
  • Парсеры: отвечают за декодирование ответов сервера и создание объектов модели.
  • Ошибки: объекты для представления ошибочных ответов сервера.
  • Клиент: отправляет запросы на внутренние серверы и получает ответы.
  • Службы: управляйте логически связанными операциями (например, аутентификацией, управлением пользовательскими данными, аналитикой и т. д.).

Вот как каждый из этих компонентов будет взаимодействовать друг с другом:

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

Реализация

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

Давайте начнем с реализации наших моделей и парсеров.

От необработанного JSON к объектам модели

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

 struct User { var id: String var email: String? var name: String? }

Поскольку мы будем получать все пользовательские данные с внутреннего сервера через его API, нам нужен способ преобразовать ответ API в допустимый объект User . Для этого мы добавим в User конструктор, принимающий в качестве параметра разобранный JSON-объект ( Dictionary ). Мы определим наш объект JSON как тип с псевдонимом:

 typealias JSON = [String: Any]

Затем мы добавим функцию конструктора в нашу структуру User следующим образом:

 extension User { init?(json: JSON) { guard let id = json["id"] as? String else { return nil } self.id = id self.email = json["email"] as? String self.name = json["name"] as? String } }

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

Затем, чтобы создать объект User из необработанного ответа API, нам нужно выполнить следующие два шага:

 // Transform raw JSON data to parsed JSON object using JSONSerializer (part of standard library) let userObject = (try? JSONSerialization.jsonObject(with: data, options: [])) as? JSON // Create an instance of `User` structure from parsed JSON object let user = userObject.flatMap(User.init)

Оптимизированная обработка ошибок

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

  • Нет подключения к Интернету
  • Ошибки, о которых сообщается как часть ответа (например, ошибки проверки, недостаточные права доступа и т. д.)
  • Ошибки, о которых сервер не сообщает как часть ответа (например, сбой сервера, превышение времени ответа и т. д.)

Мы можем определить наши объекты ошибок как тип перечисления. И пока мы этим занимаемся, было бы неплохо привести наш тип ServiceError в соответствие с протоколом Error . Это позволит нам использовать и обрабатывать эти значения ошибок, используя стандартные механизмы, предоставляемые Swift (например, использование throw для выдачи ошибки).

 enum ServiceError: Error { case noInternetConnection case custom(String) case other }

В отличие от noInternetConnection и other ошибок, пользовательская ошибка имеет связанное с ней значение. Это позволит нам использовать ответ сервера об ошибке в качестве ассоциированного значения для самой ошибки, тем самым придавая ошибке больше контекста.

Теперь давайте добавим свойство errorDescription в ServiceError ServiceError, чтобы сделать ошибки более описательными. Мы добавим жестко закодированные сообщения для noInternetConnection и other ошибок и будем использовать связанное значение в качестве сообщения для custom ошибок.

 extension ServiceError: LocalizedError { var errorDescription: String? { switch self { case .noInternetConnection: return "No Internet connection" case .other: return "Something went wrong" case .custom(let message): return message } } }

Есть еще одна вещь, которую нам нужно реализовать в нашем перечислении ServiceError . В случае custom ошибки нам необходимо преобразовать данные JSON сервера в объект ошибки. Для этого мы используем тот же подход, который мы использовали в случае с моделями:

 extension ServiceError { init(json: JSON) { if let message = json["message"] as? String { self = .custom(message) } else { self = .other } } }

Преодоление разрыва между приложением и внутренним сервером

Клиентский компонент будет посредником между приложением и внутренним сервером. Это критически важный компонент, который определяет, как приложение будет взаимодействовать с сервером, но ничего не знает о моделях данных и их структурах. Клиент будет нести ответственность за вызов определенных URL-адресов с предоставленными параметрами и возврат входящих данных JSON, проанализированных как объекты JSON.

 enum RequestMethod: String { case get = "GET" case post = "POST" case put = "PUT" case delete = "DELETE" } final class WebClient { private var baseUrl: String init(baseUrl: String) { self.baseUrl = baseUrl } func load(path: String, method: RequestMethod, params: JSON, completion: @escaping (Any?, ServiceError?) -> ()) -> URLSessionDataTask? { // TODO: Add implementation } }

Давайте посмотрим, что происходит в приведенном выше коде…

Во-первых, мы объявили тип перечисления RequestMethod , который описывает четыре общих метода HTTP. Это одни из методов, используемых в REST API.

Класс WebClient содержит свойство baseURL , которое будет использоваться для разрешения всех относительных URL-адресов, которые он получает. Если нашему приложению необходимо взаимодействовать с несколькими серверами, мы можем создать несколько экземпляров WebClient с разными значениями baseURL .

Клиент имеет единственный метод load , который принимает путь относительно baseURL в качестве параметра, метод запроса, параметры запроса и закрытие завершения. Закрытие завершения вызывается с проанализированным JSON и ServiceError в качестве параметров. На данный момент описанному выше методу не хватает реализации, к которой мы вскоре вернемся.

Прежде чем реализовать метод load , нам нужен способ создать URL -адрес из всей информации, доступной для метода. Для этой цели мы расширим класс URL :

 extension URL { init(baseUrl: String, path: String, params: JSON, method: RequestMethod) { var components = URLComponents(string: baseUrl)! components.path += path switch method { case .get, .delete: components.queryItems = params.map { URLQueryItem(name: $0.key, value: String(describing: $0.value)) } default: break } self = components.url! } }

Здесь мы просто добавляем путь к базовому URL. Для HTTP-методов GET и DELETE мы также добавляем параметры запроса в строку URL.

Далее нам нужно иметь возможность создавать экземпляры URLRequest из заданных параметров. Для этого мы сделаем что-то похожее на то, что мы сделали для URL :

 extension URLRequest { init(baseUrl: String, path: String, method: RequestMethod, params: JSON) { let url = URL(baseUrl: baseUrl, path: path, params: params, method: method) self.init(url: url) httpMethod = method.rawValue setValue("application/json", forHTTPHeaderField: "Accept") setValue("application/json", forHTTPHeaderField: "Content-Type") switch method { case .post, .put: httpBody = try! JSONSerialization.data(withJSONObject: params, options: []) default: break } } }

Здесь мы сначала создаем URL -адрес с помощью конструктора из расширения. Затем мы инициализируем экземпляр URLRequest с этим URL -адресом, при необходимости устанавливаем несколько заголовков HTTP, а затем, в случае HTTP-методов POST или PUT, добавляем параметры в тело запроса.

Теперь, когда мы выполнили все предварительные требования, мы можем реализовать метод load :

 final class WebClient { private var baseUrl: String init(baseUrl: String) { self.baseUrl = baseUrl } func load(path: String, method: RequestMethod, params: JSON, completion: @escaping (Any?, ServiceError?) -> ()) -> URLSessionDataTask? { // Checking internet connection availability if !Reachability.isConnectedToNetwork() { completion(nil, ServiceError.noInternetConnection) return nil } // Adding common parameters var parameters = params if let token = KeychainWrapper.itemForKey("application_token") { parameters["token"] = token } // Creating the URLRequest object let request = URLRequest(baseUrl: baseUrl, path: path, method: method, params: params) // Sending request to the server. let task = URLSession.shared.dataTask(with: request) { data, response, error in // Parsing incoming data var object: Any? = nil if let data = data { object = try? JSONSerialization.jsonObject(with: data, options: []) } if let httpResponse = response as? HTTPURLResponse, (200..<300) ~= httpResponse.statusCode { completion(object, nil) } else { let error = (object as? JSON).flatMap(ServiceError.init) ?? ServiceError.other completion(nil, error) } } task.resume() return task } }

Приведенный выше метод load выполняет следующие шаги:

  1. Проверить наличие интернет-соединения. Если подключение к Интернету недоступно, мы немедленно вызываем завершение закрытия с ошибкой noInternetConnection в качестве параметра. (Примечание. Reachability в коде — это настраиваемый класс, который использует один из распространенных подходов для проверки интернет-соединения.)
  2. Добавьте общие параметры. . Это может включать общие параметры, такие как токен приложения или идентификатор пользователя.
  3. Создайте объект URLRequest , используя конструктор из расширения.
  4. Отправить запрос на сервер. Мы используем объект URLSession для отправки данных на сервер.
  5. Разбирать входящие данные. Когда сервер отвечает, мы сначала анализируем полезную нагрузку ответа в объект JSON, используя JSONSerialization . Затем мы проверяем код состояния ответа. Если это код успеха (то есть в диапазоне от 200 до 299), мы вызываем завершение закрытия с помощью объекта JSON. В противном случае мы преобразуем объект JSON в объект ServiceError и вызываем завершение закрытия с этим объектом ошибки.

Определение служб для логически связанных операций

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

 final class FriendsService { private let client = WebClient(baseUrl: "https://your_server_host/api/v1") @discardableResult func loadFriends(forUser user: User, completion: @escaping ([User]?, ServiceError?) -> ()) -> URLSessionDataTask? { let params: JSON = ["user_id": user.id] return client.load(path: "/friends", method: .get, params: params) { result, error in let dictionaries = result as? [JSON] completion(dictionaries?.flatMap(User.init), error) } } }

Класс FriendsService содержит client свойство типа WebClient . Он инициализируется базовым URL-адресом удаленного сервера, который отвечает за управление друзьями. Как упоминалось ранее, в других классах обслуживания мы можем инициализировать другой экземпляр WebClient с другим URL-адресом, если это необходимо.

В случае приложения, которое работает только с одним сервером, классу WebClient можно предоставить конструктор, который инициализируется URL-адресом этого сервера:

 final class WebClient { // ... init() { self.baseUrl = "https://your_server_base_url" } // ... }

Метод loadFriends при вызове подготавливает все необходимые параметры и использует экземпляр WebClient FriendService для выполнения запроса API. Получив ответ от сервера через WebClient , он преобразует объект JSON в модели User и вызывает замыкание завершения с ними в качестве параметра.

Типичное использование FriendService может выглядеть примерно так:

 let friendsTask: URLSessionDataTask! let activityIndicator: UIActivityIndicatorView! var friends: [User] = [] func friendsButtonTapped() { friendsTask?.cancel() //Cancel previous loading task. activityIndicator.startAnimating() //Show loading indicator friendsTask = FriendsService().loadFriends(forUser: currentUser) {[weak self] friends, error in DispatchQueue.main.async { self?.activityIndicator.stopAnimating() //Stop loading indicators if let error = error { print(error.localizedDescription) //Handle service error } else if let friends = friends { self?.friends = friends //Update friends property self?.updateUI() //Update user interface } } } }

В приведенном выше примере мы предполагаем, что функция friendsButtonTapped вызывается всякий раз, когда пользователь нажимает кнопку, предназначенную для отображения списка его друзей в сети. Мы также сохраняем ссылку на задачу в свойстве friendsTask , чтобы мы могли отменить запрос в любое время, вызвав friendsTask?.cancel() .

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

Заключение

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

Я надеюсь, что вы найдете эту статью полезной при создании вашего следующего приложения для iOS. Вы можете найти исходный код этого сетевого модуля на GitHub. Проверьте код, разветвите его, измените его, поиграйте с ним.

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

Связанный: Упрощение использования RESTful API и сохранения данных на iOS с помощью Mantle и Realm