Cómo aislar la lógica de interacción cliente-servidor en aplicaciones iOS

Publicado: 2022-03-11

Hoy en día, la mayoría de las aplicaciones móviles dependen en gran medida de las interacciones cliente-servidor. Esto no solo significa que pueden descargar la mayoría de sus tareas pesadas a los servidores back-end, sino que también permite que estas aplicaciones móviles ofrezcan todo tipo de características y funcionalidades que solo pueden estar disponibles a través de Internet.

Los servidores back-end generalmente están diseñados para ofrecer sus servicios a través de API RESTful. Para aplicaciones más simples, a menudo nos sentimos tentados a crear código espagueti; mezclar código que invoca la API con el resto de la lógica de la aplicación. Sin embargo, a medida que las aplicaciones se vuelven más complejas y manejan más y más API, puede convertirse en una molestia interactuar con estas API de una manera no estructurada y no planificada.

Mantenga el código de su aplicación iOS ordenado con un módulo de red de cliente REST bien diseñado.

Mantenga el código de su aplicación iOS ordenado con un módulo de red de cliente REST bien diseñado.
Pío

Este artículo analiza un enfoque arquitectónico para crear un módulo de red de cliente REST limpio para aplicaciones de iOS que le permite mantener toda la lógica de interacción cliente-servidor aislada del resto del código de su aplicación.

Aplicaciones Cliente-Servidor

Una interacción cliente-servidor típica se parece a esto:

  1. Un usuario realiza alguna acción (p. ej., tocar algún botón o realizar algún otro gesto en la pantalla).
  2. La aplicación prepara y envía una solicitud HTTP/REST en respuesta a la acción del usuario.
  3. El servidor procesa la solicitud y responde en consecuencia a la solicitud.
  4. La aplicación recibe la respuesta y actualiza la interfaz de usuario en función de ella.

A simple vista, el proceso general puede parecer simple, pero tenemos que pensar en los detalles.

Incluso suponiendo que una API de servidor back-end funcione como se anuncia (¡lo cual no siempre es el caso!), a menudo puede estar mal diseñada, lo que la hace ineficiente o incluso difícil de usar. Una molestia común es que todas las llamadas a la API requieren que la persona que llama proporcione la misma información de forma redundante (por ejemplo, cómo se formatean los datos de la solicitud, un token de acceso que el servidor puede usar para identificar al usuario que ha iniciado sesión actualmente, etc.).

Es posible que las aplicaciones móviles también necesiten utilizar varios servidores back-end al mismo tiempo para diferentes propósitos. Un servidor puede, por ejemplo, estar dedicado a la autenticación de usuarios, mientras que otro se ocupa solo de recopilar análisis.

Además, un cliente REST típico deberá hacer mucho más que simplemente invocar API remotas. La capacidad de cancelar solicitudes pendientes, o un enfoque limpio y manejable para manejar errores, son ejemplos de funcionalidades que deben integrarse en cualquier aplicación móvil robusta.

Una visión general de la arquitectura

El núcleo de nuestro cliente REST se basará en los siguientes componentes:

  • Modelos: Clases que describen los modelos de datos de nuestra aplicación, reflejando la estructura de los datos recibidos o enviados a los servidores back-end.
  • Analizadores: responsables de decodificar las respuestas del servidor y producir objetos modelo.
  • Errores: Objetos para representar respuestas erróneas del servidor.
  • Cliente: envía solicitudes a servidores backend y recibe respuestas.
  • Servicios: Administrar operaciones vinculadas lógicamente (p. ej., autenticación, administración de datos relacionados con el usuario, análisis, etc.).

Así es como cada uno de estos componentes interactuará entre sí:

Las flechas del 1 al 10 en la imagen anterior muestran una secuencia ideal de operaciones entre la aplicación que invoca un servicio y el servicio que finalmente devuelve los datos solicitados como un objeto modelo. Cada componente de ese flujo tiene una función específica que garantiza la separación de preocupaciones dentro del módulo.

Implementación

Implementaremos nuestro cliente REST como parte de nuestra aplicación de red social imaginaria en la que cargaremos una lista de los amigos del usuario actualmente conectado. Asumiremos que nuestro servidor remoto usa JSON para las respuestas.

Comencemos implementando nuestros modelos y analizadores.

De JSON sin procesar a objetos modelo

Nuestro primer modelo, User , define la estructura de información para cualquier usuario de la red social. Para simplificar las cosas, solo incluiremos campos que sean absolutamente necesarios para este tutorial (en una aplicación real, la estructura normalmente tendría muchas más propiedades).

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

Dado que recibiremos todos los datos del usuario del servidor back-end a través de su API, necesitamos una forma de analizar la respuesta de la API en un objeto User válido. Para hacer esto, agregaremos un constructor a User que acepte un objeto JSON analizado ( Dictionary ) como parámetro. Definiremos nuestro objeto JSON como un tipo con alias:

 typealias JSON = [String: Any]

Luego agregaremos la función constructora a nuestra estructura de User de la siguiente manera:

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

Para conservar el constructor predeterminado original de User , agregamos el constructor a través de una extensión en el tipo de User .

A continuación, para crear un objeto User a partir de una respuesta de API sin procesar, debemos realizar los siguientes dos pasos:

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

Manejo de errores simplificado

Definiremos un tipo para representar diferentes errores que pueden ocurrir al intentar interactuar con los servidores backend. Podemos dividir todos estos errores en tres categorías básicas:

  • Sin conectividad a Internet
  • Errores que se informaron como parte de la respuesta (por ejemplo, errores de validación, derechos de acceso insuficientes, etc.)
  • Los errores que el servidor no informa como parte de la respuesta (por ejemplo, bloqueo del servidor, tiempo de espera de las respuestas, etc.)

Podemos definir nuestros objetos de error como un tipo de enumeración. Y mientras estamos en eso, es una buena idea hacer que nuestro tipo ServiceError se ajuste al protocolo Error . Esto nos permitirá usar y manejar estos valores de error usando mecanismos estándar proporcionados por Swift (como usar throw para lanzar un error).

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

A diferencia noInternetConnection y other errores, el error personalizado tiene un valor asociado. Esto nos permitirá usar la respuesta de error del servidor como un valor asociado para el error en sí mismo, lo que le dará más contexto al error.

Ahora, agreguemos una propiedad errorDescription a la enumeración ServiceError para que los errores sean más descriptivos. Agregaremos mensajes codificados para noInternetConnection y other errores y usaremos el valor asociado como mensaje para errores 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 } } }

Solo hay una cosa más que debemos implementar en nuestra enumeración ServiceError . En el caso de un error custom , debemos transformar los datos JSON del servidor en un objeto de error. Para hacer esto, usamos el mismo enfoque que usamos en el caso de los modelos:

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

Cerrar la brecha entre la aplicación y el servidor backend

El componente del cliente será un intermediario entre la aplicación y el servidor backend. Es un componente crítico que definirá cómo se comunicarán la aplicación y el servidor, pero no sabrá nada sobre los modelos de datos y sus estructuras. El cliente será responsable de invocar direcciones URL específicas con los parámetros proporcionados y devolver los datos JSON entrantes analizados como objetos 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 } }

Examinemos lo que está sucediendo en el código anterior...

Primero, declaramos un tipo de enumeración, RequestMethod , que describe cuatro métodos HTTP comunes. Estos son algunos de los métodos utilizados en las API REST.

La clase WebClient contiene la propiedad baseURL que se usará para resolver todas las URL relativas que reciba. En caso de que nuestra aplicación necesite interactuar con varios servidores, podemos crear varias instancias de WebClient , cada una con un valor diferente para baseURL .

El Cliente tiene un solo método de load , que toma una ruta relativa a baseURL como parámetro, método de solicitud, parámetros de solicitud y cierre de finalización. El cierre de finalización se invoca con el JSON analizado y ServiceError como parámetros. Por ahora, el método anterior carece de una implementación, a la que llegaremos en breve.

Antes de implementar el método de load , necesitamos una forma de crear una URL a partir de toda la información disponible para el método. Extenderemos la clase URL para este propósito:

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

Aquí simplemente agregamos la ruta a la URL base. Para los métodos HTTP GET y DELETE, también agregamos los parámetros de consulta a la cadena de URL.

A continuación, debemos poder crear instancias de URLRequest a partir de parámetros dados. Para ello haremos algo similar a lo que hicimos para 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 } } }

Aquí, primero creamos una URL usando el constructor de la extensión. Luego, inicializamos una instancia de URLRequest con esta URL , configuramos algunos encabezados HTTP según sea necesario y luego, en el caso de los métodos HTTP POST o PUT, agregamos parámetros al cuerpo de la solicitud.

Ahora que hemos cubierto todos los requisitos previos, podemos implementar el método 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 } }

El método de load anterior realiza los siguientes pasos:

  1. Consultar disponibilidad de la conexión a Internet. Si la conectividad a Internet no está disponible, llamamos al cierre de finalización inmediatamente con el error noInternetConnection como parámetro. (Nota: la Reachability en el código es una clase personalizada, que utiliza uno de los enfoques comunes para verificar la conexión a Internet).
  2. Agregar parámetros comunes. . Esto puede incluir parámetros comunes, como un token de aplicación o una identificación de usuario.
  3. Cree el objeto URLRequest , utilizando el constructor de la extensión.
  4. Envía la solicitud al servidor. Usamos el objeto URLSession para enviar datos al servidor.
  5. Analizar los datos entrantes. Cuando el servidor responde, primero analizamos la carga útil de la respuesta en un objeto JSON mediante JSONSerialization . Luego verificamos el código de estado de la respuesta. Si es un código de éxito (es decir, en el rango entre 200 y 299), llamamos al cierre de finalización con el objeto JSON. De lo contrario, transformamos el objeto JSON en un objeto ServiceError y llamamos al cierre de finalización con ese objeto de error.

Definición de servicios para operaciones vinculadas lógicamente

En el caso de nuestra aplicación, necesitamos un servicio que se ocupe de las tareas relacionadas con los amigos de un usuario. Para ello, creamos una clase FriendsService . Idealmente, una clase como esta estará a cargo de operaciones como obtener una lista de amigos, agregar un nuevo amigo, eliminar un amigo, agrupar a algunos amigos en una categoría, etc. Para simplificar este tutorial, implementaremos solo un método :

 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 clase FriendsService contiene una propiedad de client de tipo WebClient . Se inicializa con la URL base del servidor remoto que se encarga de gestionar amigos. Como se mencionó anteriormente, en otras clases de servicio, podemos tener una instancia diferente de WebClient inicializada con una URL diferente si es necesario.

En el caso de una aplicación que funciona con un solo servidor, a la clase WebClient se le puede dar un constructor que se inicialice con la URL de ese servidor:

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

El método loadFriends , cuando se invoca, prepara todos los parámetros necesarios y utiliza la instancia de WebClient de FriendService para realizar una solicitud de API. Después de recibir la respuesta del servidor a través de WebClient , transforma el objeto JSON en modelos User y llama al cierre de finalización con ellos como parámetro.

Un uso típico de FriendService puede parecerse a lo siguiente:

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

En el ejemplo anterior, asumimos que la función friendsButtonTapped se invoca cada vez que el usuario toca un botón destinado a mostrarles una lista de sus amigos en la red. También mantenemos una referencia a la tarea en la propiedad friendsTask para que podamos cancelar la solicitud en cualquier momento llamando a friendsTask?.cancel() .

Esto nos permite tener un mayor control del ciclo de vida de las solicitudes pendientes, permitiéndonos terminarlas cuando determinamos que se han vuelto irrelevantes.

Conclusión

En este artículo, he compartido una arquitectura simple de un módulo de red para su aplicación de iOS que es trivial de implementar y puede adaptarse a las complejas necesidades de red de la mayoría de las aplicaciones de iOS. Sin embargo, la conclusión clave de esto es que un cliente REST correctamente diseñado y los componentes que lo acompañan, que están aislados del resto de la lógica de su aplicación, pueden ayudar a mantener simple el código de interacción cliente-servidor de su aplicación, incluso cuando la aplicación en sí misma se vuelve cada vez más compleja. .

Espero que este artículo le resulte útil para crear su próxima aplicación para iOS. Puede encontrar el código fuente de este módulo de red en GitHub. Echa un vistazo al código, bifurcalo, cámbialo, juega con él.

Si encuentra alguna otra arquitectura más preferible para usted y su proyecto, comparta los detalles en la sección de comentarios a continuación.

Relacionado: Simplificación del uso de la API RESTful y la persistencia de datos en iOS con Mantle y Realm