.key, value: String(describing:

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

В днешно време повечето мобилни приложения разчитат до голяма степен на взаимодействието клиент-сървър. Това не само означава, че те могат да разтоварят по-голямата част от тежките си задачи на back-end сървъри, но също така позволява на тези мобилни приложения да предлагат всякакви функции и функционалност, които могат да бъдат достъпни само чрез интернет.

Back-end сървърите обикновено са проектирани да предлагат своите услуги чрез RESTful API . За по-прости приложения често се изкушаваме да създадем код за спагети; смесване на код, който извиква API с останалата част от логиката на приложението. Въпреки това, тъй като приложенията се усложняват и се справят с все повече и повече API, може да се окаже неприятно да се взаимодейства с тези API по неструктуриран, непланиран начин.

Запазете кода на приложението си за iOS безпроблемно с добре проектиран REST клиентски мрежов модул.



Запазете кода на приложението си за iOS безпроблемно с добре проектиран REST клиентски мрежов модул. Tweet

Тази статия разглежда архитектурен подход за изграждане на чист REST клиентски мрежов модул за iOS приложения което ви позволява да запазите цялата си логика за взаимодействие клиент-сървър изолирана от останалата част от кода на вашето приложение.

Приложения клиент-сървър

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

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

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

Дори да приемем, че API за бекенд сървър работи както е рекламирано (което е не винаги е така!), той често може да бъде лошо проектиран, което го прави неефективен или дори труден за използване. Една често срещана неприятност е, че всички извиквания към API изискват повикващият да предоставя излишно една и съща информация (напр. Как се форматират данните на заявката, маркер за достъп, който сървърът може да използва, за да идентифицира влезлия в момента потребител и т.н.).

Мобилните приложения може да се наложи да използват едновременно множество задни сървъри едновременно за различни цели. Например един сървър може да бъде посветен на удостоверяване на потребителя, докато друг се занимава само със събиране на анализи.

Освен това, типичният REST клиент ще трябва да направи нещо повече от просто извикване на отдалечени API. Възможността за отмяна на чакащи заявки или чист и управляем подход за обработка на грешки са примери за функционалност, която трябва да бъде вградена във всяко стабилно мобилно приложение.

Преглед на архитектурата

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

Ето как всеки от тези компоненти ще взаимодейства помежду си:

Стрелките от 1 до 10 в изображението по-горе показват идеална последователност от операции между приложението, извикващо услуга, и услугата, която в крайна сметка връща исканите данни като обект на модел. Всеки компонент в този поток има специфична роля за осигуряване разделяне на опасенията в рамките на модула.

Изпълнение

Ще внедрим нашия REST клиент като част от нашето въображаемо приложение за социална мрежа, в което ще заредим списък с влезлите в момента приятели на потребителя. Ще приемем, че нашият отдалечен сървър използва JSON за отговори.

Нека започнем с внедряването на нашите модели и парсери.

От Raw JSON до Model Objects

Първият ни модел, 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 изброяване, за да направят грешките по-описателни. Ще добавим твърдо кодирани съобщения за 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 адрес. За GET и DELETE HTTP методи, ние също добавяме параметрите на заявката към низа на 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 заглавки, ако е необходимо, и след това в случай на POST или PUT HTTP методи, добавете параметри към тялото на заявката.

След като покрихме всички предпоставки, можем да приложим 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 метод, когато бъде извикан, подготвя всички необходими параметри и използва FriendService екземпляра на WebClient за да направите заявка за 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 .value)) } default: break } self = components.url! } }

Тук просто добавяме пътя към основния URL адрес. За GET и DELETE HTTP методи, ние също добавяме параметрите на заявката към низа на 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 заглавки, ако е необходимо, и след това в случай на POST или PUT HTTP методи, добавете параметри към тялото на заявката.

След като покрихме всички предпоставки, можем да приложим 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 метод, когато бъде извикан, подготвя всички необходими параметри и използва FriendService екземпляра на WebClient за да направите заявка за 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