Cum să izolați logica interacțiunii client-server în aplicațiile iOS

Publicat: 2022-03-11

În zilele noastre, majoritatea aplicațiilor mobile se bazează în mare măsură pe interacțiunile client-server. Acest lucru nu înseamnă doar că își pot descărca majoritatea sarcinilor grele pe serverele de back-end, dar le permite și acestor aplicații mobile să ofere tot felul de caracteristici și funcționalități care pot fi puse la dispoziție numai prin internet.

Serverele back-end sunt de obicei proiectate pentru a-și oferi serviciile prin intermediul API-urilor RESTful. Pentru aplicații mai simple, adesea ne simțim tentați să obținem prin crearea unui cod spaghetti; cod de amestecare care invocă API-ul cu restul logicii aplicației. Cu toate acestea, pe măsură ce aplicațiile devin complexe și se confruntă cu tot mai multe API-uri, poate deveni o pacoste să interacționați cu aceste API-uri într-un mod nestructurat, neplanificat.

Păstrați codul aplicației iOS fără dezordine cu un modul de rețea client REST bine conceput.

Păstrați codul aplicației iOS fără dezordine cu un modul de rețea client REST bine conceput.
Tweet

Acest articol discută o abordare arhitecturală pentru construirea unui modul de rețea client REST curat pentru aplicațiile iOS, care vă permite să păstrați toată logica de interacțiune client-server izolată de restul codului aplicației.

Aplicații client-server

O interacțiune tipică client-server arată cam așa:

  1. Un utilizator efectuează o anumită acțiune (de exemplu, atingerea unui buton sau efectuarea unui alt gest pe ecran).
  2. Aplicația pregătește și trimite o solicitare HTTP/REST ca răspuns la acțiunea utilizatorului.
  3. Serverul procesează cererea și răspunde în consecință aplicației.
  4. Aplicația primește răspunsul și actualizează interfața cu utilizatorul pe baza acestuia.

La o privire rapidă, procesul general poate părea simplu, dar trebuie să ne gândim la detalii.

Chiar și presupunând că un API de server backend funcționează așa cum este anunțat (ceea ce nu este întotdeauna cazul!), acesta poate fi adesea proiectat prost, făcându-l ineficient, sau chiar dificil de utilizat. O supărare comună este că toate apelurile către API-ul necesită ca apelantul să furnizeze în mod redundant aceleași informații (de exemplu, modul în care sunt formatate datele de solicitare, un token de acces pe care serverul îl poate folosi pentru a identifica utilizatorul conectat în prezent și așa mai departe).

Este posibil ca aplicațiile mobile să necesite, de asemenea, să utilizeze mai multe servere back-end simultan în scopuri diferite. Un server poate, de exemplu, să fie dedicat autentificării utilizatorilor, în timp ce altul se ocupă doar de colectarea analizelor.

În plus, un client REST obișnuit va trebui să facă mult mai mult decât să invoce API-uri la distanță. Capacitatea de a anula cererile în așteptare sau o abordare curată și gestionabilă pentru gestionarea erorilor sunt exemple de funcționalitate care trebuie încorporate în orice aplicație mobilă robustă.

O privire de ansamblu asupra arhitecturii

Nucleul clientului nostru REST va fi construit pe următoarele componente:

  • Modele: clase care descriu modelele de date ale aplicației noastre, reflectând structura datelor primite de la sau trimise către serverele backend.
  • Analizoare: Responsabili pentru decodarea răspunsurilor serverului și producerea obiectelor model.
  • Erori: obiecte care reprezintă răspunsuri eronate ale serverului.
  • Client: trimite cereri către serverele backend și primește răspunsuri.
  • Servicii: Gestionați operațiunile legate logic (de exemplu, autentificarea, gestionarea datelor legate de utilizatori, analize etc.).

Acesta este modul în care fiecare dintre aceste componente va interacționa între ele:

Săgețile de la 1 la 10 din imaginea de mai sus arată o secvență ideală de operații între aplicația care invocă un serviciu și serviciul care în cele din urmă returnează datele solicitate ca obiect model. Fiecare componentă din acel flux are un rol specific care asigură separarea preocupărilor în cadrul modulului.

Implementarea

Vom implementa clientul nostru REST ca parte a aplicației noastre imaginare de rețea socială în care vom încărca o listă cu prietenii utilizatorilor conectați în prezent. Vom presupune că serverul nostru la distanță folosește JSON pentru răspunsuri.

Să începem prin a ne implementa modelele și analizatorii.

De la JSON brut la obiecte model

Primul nostru model, User , definește structura informațiilor pentru orice utilizator al rețelei sociale. Pentru a menține lucrurile simple, vom include doar câmpuri care sunt absolut necesare pentru acest tutorial (într-o aplicație reală, structura ar avea de obicei mult mai multe proprietăți).

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

Deoarece vom primi toate datele utilizatorului de la serverul backend prin intermediul API-ului său, avem nevoie de o modalitate de a analiza răspunsul API într-un obiect User valid. Pentru a face acest lucru, vom adăuga un constructor la User care acceptă un obiect JSON analizat ( Dictionary ) ca parametru. Vom defini obiectul nostru JSON ca tip alias:

 typealias JSON = [String: Any]

Vom adăuga apoi funcția constructor la structura noastră User , după cum urmează:

 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 } }

Pentru a păstra constructorul implicit original al User , adăugăm constructorul printr-o extensie pe tipul de User .

Apoi, pentru a crea un obiect User dintr-un răspuns API brut, trebuie să parcurgem următorii doi pași:

 // 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)

Gestionarea eficientă a erorilor

Vom defini un tip care să reprezinte diferite erori care pot apărea atunci când încercați să interacționați cu serverele backend. Putem împărți toate aceste erori în trei categorii de bază:

  • Fără conexiune la internet
  • Erori care au fost raportate ca parte a răspunsului (de exemplu, erori de validare, drepturi de acces insuficiente etc.)
  • Erori pe care serverul nu le raportează ca parte a răspunsului (de exemplu, blocarea serverului, expirarea răspunsurilor etc.)

Putem defini obiectele noastre de eroare ca tip de enumerare. Și în timp ce suntem la asta, este o idee bună să facem ca tipul ServiceError să fie conform protocolului Error . Acest lucru ne va permite să folosim și să gestionăm aceste valori de eroare folosind mecanisme standard furnizate de Swift (cum ar fi folosirea throw pentru a arunca o eroare).

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

Spre deosebire noInternetConnection și other erori, eroarea personalizată are o valoare asociată acesteia. Acest lucru ne va permite să folosim răspunsul la eroare de la server ca valoare asociată erorii în sine, oferind astfel erorii mai mult context.

Acum, să adăugăm o proprietate errorDescription la enumerarea ServiceError pentru a face erorile mai descriptive. Vom adăuga mesaje hardcoded pentru noInternetConnection și other erori și vom folosi valoarea asociată ca mesaj pentru erori 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 } } }

Mai este un lucru pe care trebuie să îl implementăm în enumerarea ServiceError . În cazul unei erori custom , trebuie să transformăm datele JSON ale serverului într-un obiect de eroare. Pentru a face acest lucru, folosim aceeași abordare pe care am folosit-o în cazul modelelor:

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

Reducerea decalajului dintre aplicație și serverul de backend

Componenta client va fi un intermediar între aplicație și serverul de backend. Este o componentă critică care va defini modul în care aplicația și serverul vor comunica, dar nu va ști nimic despre modelele de date și structurile acestora. Clientul va fi responsabil pentru invocarea adreselor URL specifice cu parametrii furnizați și pentru returnarea datelor JSON de intrare analizate ca obiecte 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 } }

Să examinăm ce se întâmplă în codul de mai sus...

În primul rând, am declarat un tip de enumerare, RequestMethod , care descrie patru metode HTTP comune. Acestea se numără printre metodele utilizate în API-urile REST.

Clasa WebClient conține proprietatea baseURL care va fi folosită pentru a rezolva toate URL-urile relative pe care le primește. În cazul în care aplicația noastră trebuie să interacționeze cu mai multe servere, putem crea mai multe instanțe de WebClient , fiecare cu o valoare diferită pentru baseURL .

Clientul are o singură metodă load , care ia o cale relativă la baseURL -ul de bază ca parametru, metodă de solicitare, parametri de solicitare și închidere de finalizare. Închiderea finalizării este invocată cu JSON analizat și ServiceError ca parametri. Deocamdată, metodei de mai sus îi lipsește o implementare, la care vom ajunge în curând.

Înainte de a implementa metoda de load , avem nevoie de o modalitate de a crea o URL din toate informațiile disponibile pentru metodă. Vom extinde clasa URL în acest scop:

 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! } }

Aici pur și simplu adăugăm calea către adresa URL de bază. Pentru metodele HTTP GET și DELETE, adăugăm și parametrii de interogare la șirul URL.

În continuare, trebuie să putem crea instanțe de URLRequest din parametrii dați. Pentru a face acest lucru, vom face ceva similar cu ceea ce am făcut pentru 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 } } }

Aici, mai întâi creăm o URL folosind constructorul din extensie. Apoi inițializam o instanță URLRequest cu această URL , setăm câteva antete HTTP după cum este necesar și apoi, în cazul metodelor HTTP POST sau PUT, adăugăm parametri la corpul solicitării.

Acum că am acoperit toate cerințele preliminare, putem implementa metoda de 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 } }

Metoda load de mai sus realizează următorii pași:

  1. Verificați disponibilitatea conexiunii la internet. Dacă conectivitatea la internet nu este disponibilă, numim imediat închiderea finalizării fără eroare noInternetConnection ca parametru. (Notă: Reachability în cod este o clasă personalizată, care utilizează una dintre abordările comune pentru a verifica conexiunea la Internet.)
  2. Adăugați parametri comuni. . Aceasta poate include parametri comuni, cum ar fi un simbol al aplicației sau un ID de utilizator.
  3. Creați obiectul URLRequest , folosind constructorul din extensie.
  4. Trimiteți cererea către server. Folosim obiectul URLSession pentru a trimite date către server.
  5. Analizați datele primite. Când serverul răspunde, mai întâi analizăm sarcina de răspuns într-un obiect JSON folosind JSONSerialization . Apoi verificăm codul de stare al răspunsului. Dacă este un cod de succes (adică, în intervalul între 200 și 299), numim închiderea completării cu obiectul JSON. În caz contrar, transformăm obiectul JSON într-un obiect ServiceError și apelăm închiderea de finalizare cu acel obiect de eroare.

Definirea serviciilor pentru operațiuni legate logic

În cazul aplicației noastre, avem nevoie de un serviciu care să se ocupe de sarcini legate de prietenii unui utilizator. Pentru aceasta, creăm o clasă FriendsService . În mod ideal, o clasă ca aceasta va fi responsabilă de operațiuni precum obținerea unei liste de prieteni, adăugarea unui nou prieten, eliminarea unui prieten, gruparea unor prieteni într-o categorie etc. Pentru simplitate, în acest tutorial, vom implementa o singură metodă. :

 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) } } }

Clasa FriendsService conține o proprietate client de tip WebClient . Este inițializat cu adresa URL de bază a serverului de la distanță care se ocupă de gestionarea prietenilor. După cum sa menționat anterior, în alte clase de servicii, putem avea o instanță diferită de WebClient inițializată cu o adresă URL diferită, dacă este necesar.

În cazul unei aplicații care funcționează cu un singur server, clasei WebClient i se poate da un constructor care se inițializează cu adresa URL a serverului respectiv:

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

Metoda loadFriends , atunci când este invocată, pregătește toți parametrii necesari și folosește instanța FriendService a WebClient pentru a face o solicitare API. După ce primește răspunsul de la server prin WebClient , transformă obiectul JSON în modele de User și apelează închiderea completării cu acestea ca parametru.

O utilizare tipică a FriendService poate arăta cam așa:

 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 } } } }

În exemplul de mai sus, presupunem că funcția friendsButtonTapped este invocată ori de câte ori utilizatorul atinge un buton destinat să le arate o listă cu prietenii lor din rețea. De asemenea, păstrăm o referință la sarcină în proprietatea friendsTask , astfel încât să putem anula solicitarea în orice moment apelând friendsTask?.cancel() .

Acest lucru ne permite să avem un control mai mare asupra ciclului de viață al cererilor în așteptare, permițându-ne să le încheiem atunci când stabilim că au devenit irelevante.

Concluzie

În acest articol, am împărtășit o arhitectură simplă a unui modul de rețea pentru aplicația dvs. iOS, care este atât banal de implementat, cât și poate fi adaptat la nevoile complexe de rețea ale majorității aplicațiilor iOS. Cu toate acestea, concluzia cheie din aceasta este că un client REST proiectat corespunzător și componentele care îl însoțesc - care sunt izolate de restul logicii aplicației - pot ajuta la menținerea codului de interacțiune client-server al aplicației dvs. simplu, chiar dacă aplicația în sine devine din ce în ce mai complexă. .

Sper că veți găsi acest articol util în construirea următoarei aplicații iOS. Puteți găsi codul sursă al acestui modul de rețea pe GitHub. Verificați codul, bifurcați-l, schimbați-l, jucați-vă cu el.

Dacă găsiți o altă arhitectură mai preferată pentru dvs. și proiectul dvs., vă rugăm să împărtășiți detaliile în secțiunea de comentarii de mai jos.

Înrudit : Simplificarea utilizării API-ului RESTful și a persistenței datelor pe iOS cu Mantle și Realm