Come isolare la logica di interazione client-server nelle applicazioni iOS
Pubblicato: 2022-03-11Al giorno d'oggi, la maggior parte delle applicazioni mobili si basa fortemente sulle interazioni client-server. Questo non solo significa che possono scaricare la maggior parte delle loro attività pesanti sui server back-end, ma consente anche a queste applicazioni mobili di offrire tutti i tipi di caratteristiche e funzionalità che possono essere rese disponibili solo tramite Internet.
I server back-end sono generalmente progettati per offrire i propri servizi tramite API RESTful. Per applicazioni più semplici, ci sentiamo spesso tentati di creare codice spaghetti; combinazione di codice che richiama l'API con il resto della logica dell'applicazione. Tuttavia, poiché le applicazioni diventano complesse e gestiscono un numero sempre maggiore di API, può diventare una seccatura interagire con queste API in modo non strutturato e non pianificato.
Questo articolo illustra un approccio architetturale per la creazione di un modulo di rete client REST pulito per applicazioni iOS che consente di mantenere tutta la logica di interazione client-server isolata dal resto del codice dell'applicazione.
Applicazioni client-server
Una tipica interazione client-server è simile a questa:
- Un utente esegue alcune azioni (ad esempio, toccando un pulsante o eseguendo qualche altro gesto sullo schermo).
- L'applicazione prepara e invia una richiesta HTTP/REST in risposta all'azione dell'utente.
- Il server elabora la richiesta e risponde di conseguenza all'applicazione.
- L'applicazione riceve la risposta e aggiorna l'interfaccia utente in base ad essa.
A prima vista, il processo generale può sembrare semplice, ma dobbiamo pensare ai dettagli.
Anche supponendo che un'API del server back-end funzioni come pubblicizzato (cosa che non è sempre così!), spesso può essere mal progettata rendendola inefficiente o addirittura difficile da usare. Un inconveniente comune è che tutte le chiamate all'API richiedono che il chiamante fornisca in modo ridondante le stesse informazioni (ad esempio, come vengono formattati i dati della richiesta, un token di accesso che il server può utilizzare per identificare l'utente attualmente connesso e così via).
Le applicazioni mobili potrebbero anche dover utilizzare più server back-end contemporaneamente per scopi diversi. Un server può, ad esempio, essere dedicato all'autenticazione dell'utente mentre un altro si occupa solo della raccolta di analisi.
Inoltre, un tipico client REST dovrà fare molto di più che richiamare API remote. La possibilità di annullare le richieste in sospeso o un approccio pulito e gestibile alla gestione degli errori sono esempi di funzionalità che devono essere integrate in qualsiasi robusta applicazione mobile.
Una panoramica dell'architettura
Il nucleo del nostro client REST sarà costruito su questi componenti seguenti:
- Modelli: classi che descrivono i modelli di dati della nostra applicazione, riflettendo la struttura dei dati ricevuti o inviati ai server di back-end.
- Parser: responsabili della decodifica delle risposte del server e della produzione di oggetti modello.
- Errori: oggetti per rappresentare risposte errate del server.
- Client: invia le richieste ai server back-end e riceve le risposte.
- Servizi: gestione delle operazioni collegate logicamente (ad es. autenticazione, gestione dei dati relativi agli utenti, analisi, ecc.).
Ecco come ciascuno di questi componenti interagirà tra loro:
Le frecce da 1 a 10 nell'immagine sopra mostrano una sequenza ideale di operazioni tra l'applicazione che invoca un servizio e il servizio che eventualmente restituisce i dati richiesti come oggetto modello. Ogni componente in quel flusso ha un ruolo specifico che garantisce la separazione delle preoccupazioni all'interno del modulo.
Implementazione
Implementeremo il nostro client REST come parte della nostra immaginaria applicazione di social network in cui caricheremo un elenco degli amici dell'utente attualmente connesso. Assumiamo che il nostro server remoto utilizzi JSON per le risposte.
Iniziamo implementando i nostri modelli e parser.
Da Raw JSON a Model Objects
Il nostro primo modello, User , definisce la struttura delle informazioni per qualsiasi utente del social network. Per semplificare le cose, includeremo solo i campi assolutamente necessari per questo tutorial (in un'applicazione reale, la struttura avrebbe in genere molte più proprietà).
struct User { var id: String var email: String? var name: String? } Poiché riceveremo tutti i dati utente dal server back-end tramite la sua API, abbiamo bisogno di un modo per analizzare la risposta API in un oggetto User valido. Per fare ciò, aggiungeremo un costruttore a User che accetta un oggetto JSON analizzato ( Dictionary ) come parametro. Definiremo il nostro oggetto JSON come un tipo con alias:
typealias JSON = [String: Any] Aggiungeremo quindi la funzione di costruzione alla nostra struttura User come segue:
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 } } Per preservare il costruttore predefinito originale di User , aggiungiamo il costruttore tramite un'estensione sul tipo User .
Successivamente, per creare un oggetto User da una risposta API grezza, è necessario eseguire i due passaggi seguenti:
// 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)Gestione semplificata degli errori
Definiremo un tipo per rappresentare diversi errori che possono verificarsi quando si tenta di interagire con i server di backend. Possiamo dividere tutti questi errori in tre categorie di base:
- Nessuna connettività Internet
- Errori segnalati come parte della risposta (es. errori di convalida, diritti di accesso insufficienti, ecc.)
- Errori che il server non riesce a segnalare come parte della risposta (ad es. crash del server, timeout delle risposte, ecc.)
Possiamo definire i nostri oggetti di errore come un tipo di enumerazione. E già che ci siamo, è una buona idea rendere il nostro tipo ServiceError conforme al protocollo Error . Ciò ci consentirà di utilizzare e gestire questi valori di errore utilizzando i meccanismi standard forniti da Swift (come l'utilizzo di throw per generare un errore).
enum ServiceError: Error { case noInternetConnection case custom(String) case other } A differenza noInternetConnection e other errori, l'errore personalizzato ha un valore ad esso associato. Ciò ci consentirà di utilizzare la risposta all'errore dal server come valore associato per l'errore stesso, fornendo così all'errore più contesto.
Ora aggiungiamo una proprietà errorDescription all'enumerazione ServiceError per rendere gli errori più descrittivi. Aggiungeremo messaggi hardcoded per noInternetConnection e other errori e utilizzeremo il valore associato come messaggio per gli errori 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 } } } C'è solo un'altra cosa che dobbiamo implementare nella nostra enumerazione ServiceError . Nel caso di un errore custom , è necessario trasformare i dati JSON del server in un oggetto di errore. Per fare ciò, utilizziamo lo stesso approccio che abbiamo utilizzato nel caso dei modelli:
extension ServiceError { init(json: JSON) { if let message = json["message"] as? String { self = .custom(message) } else { self = .other } } }Colmare il divario tra l'applicazione e il server di backend
Il componente client fungerà da intermediario tra l'applicazione e il server back-end. È un componente critico che definirà il modo in cui l'applicazione e il server comunicheranno, ma non saprà nulla dei modelli di dati e delle loro strutture. Il client sarà responsabile del richiamo di URL specifici con i parametri forniti e della restituzione dei dati JSON in ingresso analizzati come oggetti 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 } }Esaminiamo cosa sta succedendo nel codice sopra...

Innanzitutto, abbiamo dichiarato un tipo di enumerazione, RequestMethod , che descrive quattro metodi HTTP comuni. Questi sono tra i metodi utilizzati nelle API REST.
La classe WebClient contiene la proprietà baseURL che verrà utilizzata per risolvere tutti gli URL relativi che riceve. Nel caso in cui la nostra applicazione debba interagire con più server, possiamo creare più istanze di WebClient ciascuna con un valore diverso per baseURL .
Il client ha un unico metodo load , che accetta un percorso relativo a baseURL come parametro, metodo di richiesta, parametri di richiesta e chiusura del completamento. La chiusura di completamento viene richiamata con JSON e ServiceError analizzati come parametri. Per ora, il metodo di cui sopra manca di un'implementazione, di cui parleremo a breve.
Prima di implementare il metodo di load , abbiamo bisogno di un modo per creare un URL da tutte le informazioni disponibili per il metodo. Estenderemo la classe URL per questo scopo:
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! } }Qui aggiungiamo semplicemente il percorso all'URL di base. Per i metodi HTTP GET e DELETE, aggiungiamo anche i parametri della query alla stringa URL.
Successivamente, dobbiamo essere in grado di creare istanze di URLRequest da determinati parametri. Per fare ciò faremo qualcosa di simile a quello che abbiamo fatto per 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 } } } Qui, creiamo prima un URL usando il costruttore dell'estensione. Quindi inizializziamo un'istanza di URLRequest con questo URL , impostiamo alcune intestazioni HTTP secondo necessità e quindi, in caso di metodi POST o PUT HTTP, aggiungiamo parametri al corpo della richiesta.
Ora che abbiamo coperto tutti i prerequisiti, possiamo implementare il metodo di 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 } } Il metodo load precedente esegue i seguenti passaggi:
- Verifica la disponibilità della connessione Internet. Se la connettività Internet non è disponibile, chiamiamo la chiusura del completamento immediatamente con l'errore
noInternetConnectioncome parametro. (Nota: laReachabilitynel codice è una classe personalizzata, che utilizza uno degli approcci comuni per controllare la connessione a Internet.) - Aggiungi parametri comuni. . Questo può includere parametri comuni come un token dell'applicazione o un ID utente.
- Crea l'oggetto
URLRequest, usando il costruttore dell'estensione. - Invia la richiesta al server. Usiamo l'oggetto
URLSessionper inviare dati al server. - Analizza i dati in entrata. Quando il server risponde, analizziamo prima il payload della risposta in un oggetto JSON utilizzando
JSONSerialization. Quindi controlliamo il codice di stato della risposta. Se è un codice di successo (vale a dire, nell'intervallo tra 200 e 299), chiamiamo la chiusura di completamento con l'oggetto JSON. In caso contrario, trasformiamo l'oggetto JSON in un oggettoServiceErrore chiamiamo la chiusura di completamento con quell'oggetto di errore.
Definizione di servizi per operazioni collegate logicamente
Nel caso della nostra applicazione, abbiamo bisogno di un servizio che si occuperà delle attività relative agli amici di un utente. Per questo, creiamo una classe FriendsService . Idealmente, una classe come questa sarà responsabile di operazioni come ottenere un elenco di amici, aggiungere un nuovo amico, rimuovere un amico, raggruppare alcuni amici in una categoria, ecc. Per semplicità in questo tutorial implementeremo un solo metodo :
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) } } } La classe FriendsService contiene una proprietà client di tipo WebClient . Viene inizializzato con l'URL di base del server remoto che si occupa della gestione degli amici. Come accennato in precedenza, in altre classi di servizio, possiamo avere un'istanza diversa di WebClient inizializzata con un URL diverso, se necessario.
Nel caso di un'applicazione che funziona con un solo server, alla classe WebClient può essere assegnato un costruttore che inizializza con l'URL di quel server:
final class WebClient { // ... init() { self.baseUrl = "https://your_server_base_url" } // ... } Il metodo loadFriends , quando invocato, prepara tutti i parametri necessari e utilizza l'istanza di WebClient di FriendService per effettuare una richiesta API. Dopo aver ricevuto la risposta dal server tramite WebClient , trasforma l'oggetto JSON in modelli User e chiama la chiusura di completamento con essi come parametro.
Un uso tipico di FriendService potrebbe essere simile al seguente:
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 } } } } Nell'esempio precedente, assumiamo che la funzione friendsButtonTapped venga invocata ogni volta che l'utente tocca un pulsante destinato a mostrare loro un elenco dei propri amici nella rete. Manteniamo anche un riferimento all'attività nella proprietà friendsTask in modo da poter annullare la richiesta in qualsiasi momento chiamando friendsTask?.cancel() .
Questo ci consente di avere un maggiore controllo sul ciclo di vita delle richieste in sospeso, consentendoci di terminarle quando determiniamo che sono diventate irrilevanti.
Conclusione
In questo articolo, ho condiviso una semplice architettura di un modulo di rete per la tua applicazione iOS che è allo stesso tempo banale da implementare e può essere adattata alle complesse esigenze di rete della maggior parte delle applicazioni iOS. Tuttavia, il punto chiave di ciò è che un client REST adeguatamente progettato e i componenti che lo accompagnano, che sono isolati dal resto della logica dell'applicazione, possono aiutare a mantenere semplice il codice di interazione client-server dell'applicazione, anche se l'applicazione stessa diventa sempre più complessa .
Spero che questo articolo ti sia utile per creare la tua prossima applicazione iOS. Puoi trovare il codice sorgente di questo modulo di rete su GitHub. Controlla il codice, esegui il fork, cambialo, giocaci.
Se trovi qualche altra architettura più preferibile per te e il tuo progetto, condividi i dettagli nella sezione commenti qui sotto.
