So isolieren Sie die Client-Server-Interaktionslogik in iOS-Anwendungen
Veröffentlicht: 2022-03-11Heutzutage verlassen sich die meisten mobilen Anwendungen stark auf Client-Server-Interaktionen. Dies bedeutet nicht nur, dass sie die meisten ihrer schweren Aufgaben auf Back-End-Server auslagern können, sondern ermöglicht es diesen mobilen Anwendungen auch, alle Arten von Features und Funktionen anzubieten, die nur über das Internet verfügbar gemacht werden können.
Back-End-Server sind normalerweise so konzipiert, dass sie ihre Dienste über RESTful-APIs anbieten. Bei einfacheren Anwendungen fühlen wir uns oft versucht, Spaghetti-Code zu erstellen; Mischen von Code, der die API aufruft, mit dem Rest der Anwendungslogik. Da Anwendungen jedoch komplexer werden und mit immer mehr APIs arbeiten, kann es lästig werden, mit diesen APIs auf unstrukturierte, ungeplante Weise zu interagieren.
Dieser Artikel beschreibt einen architektonischen Ansatz zum Erstellen eines sauberen REST-Client-Netzwerkmoduls für iOS-Anwendungen, das es Ihnen ermöglicht, Ihre gesamte Client-Server-Interaktionslogik vom Rest Ihres Anwendungscodes isoliert zu halten.
Client-Server-Anwendungen
Eine typische Client-Server-Interaktion sieht etwa so aus:
- Ein Benutzer führt eine Aktion durch (z. B. Tippen auf eine Schaltfläche oder Ausführen einer anderen Geste auf dem Bildschirm).
- Die Anwendung bereitet eine HTTP/REST-Anforderung vor und sendet sie als Antwort auf die Benutzeraktion.
- Der Server verarbeitet die Anfrage und antwortet entsprechend auf die Anwendung.
- Die Anwendung empfängt die Antwort und aktualisiert die Benutzeroberfläche darauf basierend.
Auf den ersten Blick mag der Gesamtprozess einfach aussehen, aber wir müssen an die Details denken.
Selbst wenn angenommen wird, dass eine Backend-Server-API wie angekündigt funktioniert (was nicht immer der Fall ist!), kann sie oft schlecht gestaltet sein, was ihre Verwendung ineffizient oder sogar schwierig macht. Ein häufiges Ärgernis besteht darin, dass alle Aufrufe der API vom Aufrufer verlangen, dieselben Informationen redundant bereitzustellen (z. B. wie Anforderungsdaten formatiert sind, ein Zugriffstoken, das der Server verwenden kann, um den aktuell angemeldeten Benutzer zu identifizieren, und so weiter).
Mobile Anwendungen müssen möglicherweise auch mehrere Back-End-Server gleichzeitig für verschiedene Zwecke verwenden. Ein Server kann beispielsweise der Benutzerauthentifizierung gewidmet sein, während ein anderer sich nur mit dem Sammeln von Analysen befasst.
Darüber hinaus muss ein typischer REST-Client viel mehr tun, als nur Remote-APIs aufzurufen. Die Möglichkeit, ausstehende Anfragen zu stornieren, oder ein sauberer und überschaubarer Ansatz zur Fehlerbehandlung sind Beispiele für Funktionen, die in jede robuste mobile Anwendung integriert werden müssen.
Ein Überblick über die Architektur
Der Kern unseres REST-Clients wird auf diesen folgenden Komponenten aufbauen:
- Modelle: Klassen, die die Datenmodelle unserer Anwendung beschreiben und die Struktur der Daten widerspiegeln, die von den Backend-Servern empfangen oder an diese gesendet werden.
- Parser: Verantwortlich für die Dekodierung von Serverantworten und die Erstellung von Modellobjekten.
- Fehler: Objekte zur Darstellung fehlerhafter Serverantworten.
- Client: Sendet Anfragen an Backend-Server und empfängt Antworten.
- Dienste: Verwalten Sie logisch verknüpfte Vorgänge (z. B. Authentifizierung, Verwaltung benutzerbezogener Daten, Analysen usw.).
So interagieren alle diese Komponenten miteinander:
Die Pfeile 1 bis 10 im obigen Bild zeigen eine ideale Abfolge von Vorgängen zwischen der Anwendung, die einen Dienst aufruft, und dem Dienst, der schließlich die angeforderten Daten als Modellobjekt zurückgibt. Jede Komponente in diesem Ablauf hat eine bestimmte Rolle, die die Trennung von Bedenken innerhalb des Moduls sicherstellt.
Implementierung
Wir implementieren unseren REST-Client als Teil unserer imaginären Anwendung für soziale Netzwerke, in die wir eine Liste der Freunde des aktuell angemeldeten Benutzers laden. Wir gehen davon aus, dass unser Remote-Server JSON für Antworten verwendet.
Beginnen wir mit der Implementierung unserer Modelle und Parser.
Von Raw JSON zu Modellobjekten
Unser erstes Modell, User , definiert die Informationsstruktur für jeden Benutzer des sozialen Netzwerks. Der Einfachheit halber nehmen wir nur Felder auf, die für dieses Tutorial absolut notwendig sind (in einer realen Anwendung hätte die Struktur normalerweise viel mehr Eigenschaften).
struct User { var id: String var email: String? var name: String? } Da wir alle Benutzerdaten vom Backend-Server über seine API erhalten, brauchen wir eine Möglichkeit, die API-Antwort in ein gültiges User zu parsen. Dazu fügen wir User einen Konstruktor hinzu, der ein geparstes JSON-Objekt ( Dictionary ) als Parameter akzeptiert. Wir definieren unser JSON-Objekt als Alias-Typ:
typealias JSON = [String: Any] Wir werden dann die Konstruktorfunktion wie folgt zu unserer User hinzufügen:
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 } } Um den ursprünglichen Standardkonstruktor von User , fügen wir den Konstruktor über eine Erweiterung des Typs User hinzu.
Um als Nächstes ein User aus einer rohen API-Antwort zu erstellen, müssen wir die folgenden zwei Schritte ausführen:
// 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)Optimierte Fehlerbehandlung
Wir werden einen Typ definieren, um verschiedene Fehler darzustellen, die auftreten können, wenn versucht wird, mit den Back-End-Servern zu interagieren. Wir können alle diese Fehler in drei grundlegende Kategorien einteilen:
- Keine Internetverbindung
- Fehler, die als Teil der Antwort gemeldet wurden (z. B. Validierungsfehler, unzureichende Zugriffsrechte usw.)
- Fehler, die der Server nicht als Teil der Antwort meldet (z. B. Serverabsturz, Zeitüberschreitung bei Antworten usw.)
Wir können unsere Fehlerobjekte als Aufzählungstyp definieren. Und wenn wir schon dabei sind, ist es eine gute Idee, unseren ServiceError Typ an das Error -Protokoll anzupassen. Dadurch können wir diese Fehlerwerte mithilfe von Standardmechanismen verwenden und behandeln, die von Swift bereitgestellt werden (z. B. die Verwendung von throw zum Auslösen eines Fehlers).
enum ServiceError: Error { case noInternetConnection case custom(String) case other } Im Gegensatz zu noInternetConnection und other Fehlern ist dem benutzerdefinierten Fehler ein Wert zugeordnet. Dadurch können wir die Fehlerantwort des Servers als zugeordneten Wert für den Fehler selbst verwenden und dem Fehler dadurch mehr Kontext geben.
Nun fügen wir der ServiceError -Enumeration eine errorDescription -Eigenschaft hinzu, um die Fehler aussagekräftiger zu machen. Wir fügen hartcodierte Nachrichten für noInternetConnection und other Fehler hinzu und verwenden den zugehörigen Wert als Nachricht für custom Fehler.
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 } } } Es gibt nur noch eine Sache, die wir in unserer ServiceError Enumeration implementieren müssen. Im Falle eines custom Fehlers müssen wir die Server-JSON-Daten in ein Fehlerobjekt umwandeln. Dazu verwenden wir den gleichen Ansatz wie bei den Modellen:
extension ServiceError { init(json: JSON) { if let message = json["message"] as? String { self = .custom(message) } else { self = .other } } }Überbrückung der Lücke zwischen Anwendung und Backend-Server
Die Client-Komponente fungiert als Vermittler zwischen der Anwendung und dem Back-End-Server. Es ist eine kritische Komponente, die definiert, wie die Anwendung und der Server kommunizieren, aber nichts über die Datenmodelle und ihre Strukturen weiß. Der Client ist dafür verantwortlich, bestimmte URLs mit bereitgestellten Parametern aufzurufen und eingehende JSON-Daten zurückzugeben, die als JSON-Objekte analysiert wurden.
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 } }Lassen Sie uns untersuchen, was im obigen Code passiert ...

Zuerst haben wir einen Aufzählungstyp, RequestMethod , deklariert, der vier gängige HTTP-Methoden beschreibt. Diese gehören zu den Methoden, die in REST-APIs verwendet werden.
Die WebClient -Klasse enthält die baseURL Eigenschaft, die zum Auflösen aller empfangenen relativen URLs verwendet wird. Falls unsere Anwendung mit mehreren Servern interagieren muss, können wir mehrere Instanzen von WebClient mit jeweils einem anderen Wert für baseURL .
Der Client hat eine einzelne Methode load , die einen Pfad relativ zu baseURL als Parameter, Anforderungsmethode, Anforderungsparameter und Abschlussabschluss annimmt. Der Abschlussabschluss wird mit dem geparsten JSON und ServiceError als Parameter aufgerufen. Im Moment fehlt der obigen Methode eine Implementierung, auf die wir in Kürze zurückkommen werden.
Vor der Implementierung der load benötigen wir eine Möglichkeit, eine URL aus allen Informationen zu erstellen, die der Methode zur Verfügung stehen. Dazu erweitern wir die URL -Klasse:
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! } }Hier fügen wir einfach den Pfad zur Basis-URL hinzu. Bei GET- und DELETE-HTTP-Methoden fügen wir auch die Abfrageparameter zum URL-String hinzu.
Als nächstes müssen wir in der Lage sein, Instanzen von URLRequest aus gegebenen Parametern zu erstellen. Dazu machen wir etwas Ähnliches wie für 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 } } } Hier erstellen wir zunächst eine URL mit dem Konstruktor aus der Erweiterung. Dann initialisieren wir eine Instanz von URLRequest mit dieser URL , setzen ein paar HTTP-Header nach Bedarf und fügen dann im Falle von POST- oder PUT-HTTP-Methoden Parameter zum Anforderungstext hinzu.
Nachdem wir nun alle Voraussetzungen erfüllt haben, können wir die load implementieren:
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 } } Die load oben führt die folgenden Schritte aus:
- Überprüfen Sie die Verfügbarkeit der Internetverbindung. Wenn keine Internetverbindung verfügbar ist, rufen wir den Abschlussabschluss sofort mit dem Fehler
noInternetConnectionals Parameter auf. (Hinweis:Reachabilityim Code ist eine benutzerdefinierte Klasse, die einen der gängigen Ansätze zum Überprüfen der Internetverbindung verwendet.) - Fügen Sie gemeinsame Parameter hinzu. . Dazu können allgemeine Parameter wie ein Anwendungstoken oder eine Benutzer-ID gehören.
- Erstellen Sie das
URLRequestObjekt mit dem Konstruktor der Erweiterung. - Senden Sie die Anfrage an den Server. Wir verwenden das
URLSessionObjekt, um Daten an den Server zu senden. - Analysieren Sie eingehende Daten. Wenn der Server antwortet, parsen wir zuerst die Antwortnutzlast in ein JSON-Objekt mit
JSONSerialization. Dann prüfen wir den Statuscode der Antwort. Wenn es sich um einen Erfolgscode handelt (dh im Bereich zwischen 200 und 299), rufen wir den Abschlussabschluss mit dem JSON-Objekt auf. Andernfalls wandeln wir das JSON-Objekt in einServiceErrorObjekt um und rufen den Abschlussabschluss mit diesem Fehlerobjekt auf.
Definieren von Diensten für logisch verknüpfte Operationen
Im Fall unserer Anwendung benötigen wir einen Dienst, der sich um Aufgaben im Zusammenhang mit Freunden eines Benutzers kümmert. Dazu erstellen wir eine FriendsService -Klasse. Idealerweise ist eine solche Klasse für Vorgänge wie das Abrufen einer Freundesliste, das Hinzufügen eines neuen Freundes, das Entfernen eines Freundes, das Gruppieren einiger Freunde in einer Kategorie usw. zuständig. Der Einfachheit halber implementieren wir in diesem Tutorial nur eine Methode :
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) } } } Die FriendsService -Klasse enthält eine client vom Typ WebClient . Es wird mit der Basis-URL des Remote-Servers initialisiert, der für die Verwaltung von Freunden zuständig ist. Wie bereits erwähnt, können wir in anderen Dienstklassen bei Bedarf eine andere Instanz von WebClient mit einer anderen URL initialisieren.
Im Falle einer Anwendung, die nur mit einem Server funktioniert, kann der WebClient -Klasse ein Konstruktor gegeben werden, der mit der URL dieses Servers initialisiert wird:
final class WebClient { // ... init() { self.baseUrl = "https://your_server_base_url" } // ... } Wenn die Methode loadFriends aufgerufen wird, bereitet sie alle erforderlichen Parameter vor und verwendet die Instanz von FriendService von WebClient , um eine API-Anfrage zu stellen. Nachdem es die Antwort vom Server über den WebClient hat, wandelt es das JSON-Objekt in User um und ruft den Abschlussabschluss mit ihnen als Parameter auf.
Eine typische Verwendung des FriendService kann wie folgt aussehen:
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 } } } } Im obigen Beispiel gehen wir davon aus, dass die Funktion friendsButtonTapped immer dann aufgerufen wird, wenn der Benutzer auf eine Schaltfläche tippt, die ihm eine Liste seiner Freunde im Netzwerk anzeigen soll. Wir behalten auch einen Verweis auf die Aufgabe in der Eigenschaft friendsTask bei, sodass wir die Anfrage jederzeit abbrechen können, indem wir friendsTask?.cancel() aufrufen.
Dadurch haben wir eine bessere Kontrolle über den Lebenszyklus ausstehender Anfragen und können sie beenden, wenn wir feststellen, dass sie irrelevant geworden sind.
Fazit
In diesem Artikel habe ich eine einfache Architektur eines Netzwerkmoduls für Ihre iOS-Anwendung vorgestellt, die sowohl einfach zu implementieren ist als auch an die komplizierten Netzwerkanforderungen der meisten iOS-Anwendungen angepasst werden kann. Die wichtigste Erkenntnis daraus ist jedoch, dass ein richtig entworfener REST-Client und die dazugehörigen Komponenten – die von der restlichen Anwendungslogik isoliert sind – dazu beitragen können, den Client-Server-Interaktionscode Ihrer Anwendung einfach zu halten, selbst wenn die Anwendung selbst immer komplexer wird .
Ich hoffe, Sie finden diesen Artikel hilfreich beim Erstellen Ihrer nächsten iOS-Anwendung. Den Quellcode dieses Netzwerkmoduls finden Sie auf GitHub. Überprüfen Sie den Code, forken Sie ihn, ändern Sie ihn, spielen Sie damit.
Wenn Sie eine andere Architektur für sich und Ihr Projekt vorzuziehen finden, teilen Sie die Details bitte im Kommentarbereich unten mit.
