Comment isoler la logique d'interaction client-serveur dans les applications iOS
Publié: 2022-03-11De nos jours, la plupart des applications mobiles dépendent fortement des interactions client-serveur. Cela signifie non seulement qu'ils peuvent décharger la plupart de leurs tâches lourdes sur des serveurs principaux, mais cela permet également à ces applications mobiles d'offrir toutes sortes de fonctionnalités qui ne peuvent être mises à disposition que via Internet.
Les serveurs principaux sont généralement conçus pour offrir leurs services via des API RESTful. Pour des applications plus simples, nous sommes souvent tentés de créer du code spaghetti ; mélanger le code qui invoque l'API avec le reste de la logique de l'application. Cependant, à mesure que les applications deviennent complexes et traitent de plus en plus d'API, il peut devenir gênant d'interagir avec ces API de manière non structurée et non planifiée.
Cet article décrit une approche architecturale pour la création d'un module de mise en réseau client REST propre pour les applications iOS qui vous permet de garder toute votre logique d'interaction client-serveur isolée du reste de votre code d'application.
Applications client-serveur
Une interaction client-serveur typique ressemble à ceci :
- Un utilisateur effectue une action (par exemple, appuyer sur un bouton ou effectuer un autre geste sur l'écran).
- L'application prépare et envoie une requête HTTP/REST en réponse à l'action de l'utilisateur.
- Le serveur traite la demande et répond en conséquence à l'application.
- L'application reçoit la réponse et met à jour l'interface utilisateur en fonction de celle-ci.
À première vue, le processus global peut sembler simple, mais nous devons penser aux détails.
Même en supposant qu'une API de serveur principal fonctionne comme annoncé (ce qui n'est pas toujours le cas !), elle peut souvent être mal conçue, ce qui la rend inefficace, voire difficile à utiliser. Un inconvénient courant est que tous les appels à l'API exigent que l'appelant fournisse les mêmes informations de manière redondante (par exemple, comment les données de la demande sont formatées, un jeton d'accès que le serveur peut utiliser pour identifier l'utilisateur actuellement connecté, etc.).
Les applications mobiles peuvent également avoir besoin d'utiliser plusieurs serveurs principaux simultanément à des fins différentes. Un serveur peut, par exemple, être dédié à l'authentification des utilisateurs tandis qu'un autre ne s'occupe que de la collecte d'analyses.
De plus, un client REST typique devra faire bien plus que simplement invoquer des API distantes. La possibilité d'annuler les demandes en attente ou une approche propre et gérable de la gestion des erreurs sont des exemples de fonctionnalités qui doivent être intégrées à toute application mobile robuste.
Un aperçu de l'architecture
Le cœur de notre client REST sera construit sur les composants suivants :
- Modèles : classes qui décrivent les modèles de données de notre application, reflétant la structure des données reçues ou envoyées aux serveurs principaux.
- Analyseurs : responsables du décodage des réponses du serveur et de la production d'objets modèles.
- Erreurs : Objets pour représenter les réponses erronées du serveur.
- Client : envoie des requêtes aux serveurs principaux et reçoit des réponses.
- Services : gérez les opérations logiquement liées (par exemple, l'authentification, la gestion des données relatives aux utilisateurs, l'analyse, etc.).
Voici comment chacun de ces composants va interagir les uns avec les autres :
Les flèches 1 à 10 dans l'image ci-dessus montrent une séquence idéale d'opérations entre l'application appelant un service et le service renvoyant finalement les données demandées en tant qu'objet modèle. Chaque composant de ce flux a un rôle spécifique assurant la séparation des préoccupations au sein du module.
Mise en œuvre
Nous implémenterons notre client REST dans le cadre de notre application de réseau social imaginaire dans laquelle nous chargerons une liste des amis de l'utilisateur actuellement connecté. Nous supposerons que notre serveur distant utilise JSON pour les réponses.
Commençons par implémenter nos modèles et analyseurs.
Du JSON brut aux objets de modèle
Notre premier modèle, User , définit la structure des informations pour tout utilisateur du réseau social. Pour simplifier les choses, nous n'inclurons que les champs qui sont absolument nécessaires pour ce tutoriel (dans une application réelle, la structure aurait généralement beaucoup plus de propriétés).
struct User { var id: String var email: String? var name: String? } Étant donné que nous recevrons toutes les données utilisateur du serveur principal via son API, nous avons besoin d'un moyen d'analyser la réponse de l'API dans un objet User valide. Pour ce faire, nous allons ajouter un constructeur à User qui accepte un objet JSON analysé ( Dictionary ) en tant que paramètre. Nous allons définir notre objet JSON comme un type alias :
typealias JSON = [String: Any] Nous ajouterons ensuite la fonction constructeur à notre structure User comme suit :
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 } } Pour conserver le constructeur par défaut d'origine de User , nous ajoutons le constructeur via une extension sur le type User .
Ensuite, pour créer un objet User à partir d'une réponse API brute, nous devons effectuer les deux étapes suivantes :
// 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)Gestion simplifiée des erreurs
Nous allons définir un type pour représenter les différentes erreurs qui peuvent survenir lors d'une tentative d'interaction avec les serveurs principaux. Nous pouvons diviser toutes ces erreurs en trois catégories de base :
- Pas de connectivité Internet
- Les erreurs signalées dans le cadre de la réponse (par exemple, erreurs de validation, droits d'accès insuffisants, etc.)
- Les erreurs que le serveur ne signale pas dans le cadre de la réponse (par exemple, plantage du serveur, dépassement du délai de réponse, etc.)
Nous pouvons définir nos objets d'erreur comme un type d'énumération. Et pendant que nous y sommes, c'est une bonne idée de rendre notre type ServiceError conforme au protocole Error . Cela nous permettra d'utiliser et de gérer ces valeurs d'erreur à l'aide des mécanismes standard fournis par Swift (comme l'utilisation de throw pour générer une erreur).
enum ServiceError: Error { case noInternetConnection case custom(String) case other } Contrairement à noInternetConnection et à other erreurs, l'erreur personnalisée est associée à une valeur. Cela nous permettra d'utiliser la réponse d'erreur du serveur comme valeur associée pour l'erreur elle-même, donnant ainsi plus de contexte à l'erreur.
Ajoutons maintenant une propriété errorDescription à l'énumération ServiceError pour rendre les erreurs plus descriptives. Nous ajouterons des messages codés en dur pour la noInternetConnection et other erreurs et utiliserons la valeur associée comme message pour les erreurs 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 } } } Il nous reste encore une chose à implémenter dans notre énumération ServiceError . Dans le cas d'une erreur custom , nous devons transformer les données JSON du serveur en un objet d'erreur. Pour ce faire, nous utilisons la même approche que nous avons utilisée dans le cas des modèles :
extension ServiceError { init(json: JSON) { if let message = json["message"] as? String { self = .custom(message) } else { self = .other } } }Combler le fossé entre l'application et le serveur principal
Le composant client sera un intermédiaire entre l'application et le serveur principal. C'est un composant critique qui définira comment l'application et le serveur communiqueront, mais il ne saura rien des modèles de données et de leurs structures. Le client sera chargé d'invoquer des URL spécifiques avec les paramètres fournis et de renvoyer les données JSON entrantes analysées en tant qu'objets 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 } }Examinons ce qui se passe dans le code ci-dessus…

Tout d'abord, nous avons déclaré un type d'énumération, RequestMethod , qui décrit quatre méthodes HTTP courantes. Ce sont parmi les méthodes utilisées dans les API REST.
La classe WebClient contient la propriété baseURL qui sera utilisée pour résoudre toutes les URL relatives qu'elle reçoit. Dans le cas où notre application doit interagir avec plusieurs serveurs, nous pouvons créer plusieurs instances de WebClient chacune avec une valeur différente pour baseURL .
Le client a une seule méthode load , qui prend un chemin relatif à baseURL comme paramètre, méthode de requête, paramètres de requête et fermeture d'achèvement. La fermeture d'achèvement est invoquée avec le JSON analysé et ServiceError comme paramètres. Pour l'instant, la méthode ci-dessus n'a pas d'implémentation, sur laquelle nous reviendrons sous peu.
Avant d'implémenter la méthode load , nous avons besoin d'un moyen de créer une URL à partir de toutes les informations disponibles pour la méthode. Nous allons étendre la classe URL à cet effet :
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! } }Ici, nous ajoutons simplement le chemin à l'URL de base. Pour les méthodes HTTP GET et DELETE, nous ajoutons également les paramètres de requête à la chaîne d'URL.
Ensuite, nous devons pouvoir créer des instances de URLRequest à partir de paramètres donnés. Pour ce faire, nous allons faire quelque chose de similaire à ce que nous avons fait pour 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 } } } Ici, nous créons d'abord une URL en utilisant le constructeur de l'extension. Ensuite, nous initialisons une instance de URLRequest avec cette URL , définissons quelques en-têtes HTTP si nécessaire, puis dans le cas des méthodes POST ou PUT HTTP, ajoutons des paramètres au corps de la requête.
Maintenant que nous avons couvert tous les prérequis, nous pouvons implémenter la méthode 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 } } La méthode load ci-dessus effectue les étapes suivantes :
- Vérifiez la disponibilité de la connexion Internet. Si la connectivité Internet n'est pas disponible, nous appelons immédiatement la fermeture d'achèvement avec l'erreur
noInternetConnectioncomme paramètre. (Remarque : l'Reachabilitydans le code est une classe personnalisée, qui utilise l'une des approches courantes pour vérifier la connexion Internet.) - Ajoutez des paramètres communs. . Cela peut inclure des paramètres communs tels qu'un jeton d'application ou un ID utilisateur.
- Créez l'objet
URLRequesten utilisant le constructeur de l'extension. - Envoyez la requête au serveur. Nous utilisons l'objet
URLSessionpour envoyer des données au serveur. - Analyser les données entrantes. Lorsque le serveur répond, nous analysons d'abord la charge utile de la réponse dans un objet JSON à l'aide
JSONSerialization. Ensuite, nous vérifions le code d'état de la réponse. S'il s'agit d'un code de réussite (c'est-à-dire compris entre 200 et 299), nous appelons la clôture de complétion avec l'objet JSON. Sinon, nous transformons l'objet JSON en un objetServiceErroret appelons la fermeture d'achèvement avec cet objet d'erreur.
Définition des services pour les opérations logiquement liées
Dans le cas de notre application, nous avons besoin d'un service qui s'occupera des tâches liées aux amis d'un utilisateur. Pour cela, nous créons une classe FriendsService . Idéalement, une classe comme celle-ci sera en charge d'opérations telles que l'obtention d'une liste d'amis, l'ajout d'un nouvel ami, la suppression d'un ami, le regroupement de certains amis dans une catégorie, etc. Pour simplifier dans ce tutoriel, nous n'implémenterons qu'une seule méthode :
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 contient une propriété client de type WebClient . Il est initialisé avec l'URL de base du serveur distant qui s'occupe de la gestion des amis. Comme mentionné précédemment, dans d'autres classes de service, nous pouvons avoir une instance différente de WebClient initialisée avec une URL différente si nécessaire.
Dans le cas d'une application qui fonctionne avec un seul serveur, la classe WebClient peut recevoir un constructeur qui s'initialise avec l'URL de ce serveur :
final class WebClient { // ... init() { self.baseUrl = "https://your_server_base_url" } // ... } La méthode loadFriends , lorsqu'elle est invoquée, prépare tous les paramètres nécessaires et utilise l'instance WebClient de FriendService pour effectuer une requête API. Après avoir reçu la réponse du serveur via le WebClient , il transforme l'objet JSON en modèles User et appelle la fermeture d'achèvement avec eux comme paramètre.
Une utilisation typique de FriendService peut ressembler à ceci :
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 } } } } Dans l'exemple ci-dessus, nous supposons que la fonction friendsButtonTapped est invoquée chaque fois que l'utilisateur appuie sur un bouton destiné à lui montrer une liste de ses amis sur le réseau. Nous gardons également une référence à la tâche dans la propriété friendsTask afin que nous puissions annuler la demande à tout moment en appelant friendsTask?.cancel() .
Cela nous permet d'avoir un meilleur contrôle du cycle de vie des demandes en attente, nous permettant de les terminer lorsque nous déterminons qu'elles sont devenues inutiles.
Conclusion
Dans cet article, j'ai partagé une architecture simple d'un module réseau pour votre application iOS qui est à la fois simple à mettre en œuvre et peut être adaptée aux besoins réseau complexes de la plupart des applications iOS. Cependant, l'essentiel à retenir est qu'un client REST correctement conçu et les composants qui l'accompagnent - qui sont isolés du reste de la logique de votre application - peuvent aider à garder le code d'interaction client-serveur de votre application simple, même si l'application elle-même devient de plus en plus complexe. .
J'espère que vous trouverez cet article utile pour créer votre prochaine application iOS. Vous pouvez trouver le code source de ce module de mise en réseau sur GitHub. Découvrez le code, bifurquez-le, modifiez-le, jouez avec.
Si vous trouvez une autre architecture plus préférable pour vous et votre projet, veuillez partager les détails dans la section commentaires ci-dessous.
