Tutoriales

Combine (I): URLSession

Usando el nuevo framework de Swift para llamadas de red

Actualizado a Xcode 11

URLSession es la librería estándar en Swift que normalmente usamos cuando queramos hacer una llamada de red. Tal como está estructurada hoy día, básicamente usamos un singleton en la misma, llamado shared, que lo que permite es coger una sesión de red compartida (ya iniciada) y que tiene los parámetros normales de funcionamiento.

Probando con URLSession de forma tradicional

Si hacemos una prueba, usando el método convencional, el flujo de trabajo sería el siguiente:

  • Tengo una URL como tipo URL.
  • Creo una sesión de datos en el singleton URLSession.shared usando el método dataTask que tiene dos implementaciones: con un parámetro URLo un parámetro URLRequest que usaríamos para una llamada más compleja a una API restFUL, por ejemplo, donde modificaríamos las cabeceras y el cuerpo de llamada (por ejemplo, para hacer una llamada de tipo POST).
  • A ese método le inyectamos un closure (un bloque de código) que recibirá tres parámetros: un tipo de dato Data, otro URLResponse y un último de tipo Error. Todos opcionales.
  • En Data viene el cuerpo de la respuesta que hemos tenido del servidor, en URLResponse las cabeceras de dicha respuesta y en Error cualquier posible error provocado en la conexión y que no haya permitido recuperar los datos en la llamada.
  • El tipo URLResponse no tiene el valor statusCode que nos interesa para saber qué respuesta ha dado el servicio como respuesta HTTP. Así que tenemos que transformarlo de forma opcional a HTTPURLResponse.
  • Tenemos que inyectar dicho closure con el proceso de la llamada y actuar en consecuencia.

Vamos a suponer un ejemplo en que quiero recuperar un JSON de la red, de una dirección de una API de prueba en https://my-json-server.typicode.com/typicode/demo/posts. Esta llamada me devolverá un pequeño array con 3 registros, donde la estructura de datos sería un id de tipo Int y un title de tipo String. De forma que tendríamos este struct preparado para la serialización:

struct Posts:Codable {
     let id:Int
     let title:String
 }

Tendríamos que hacer la llamada y si todo es correcto y la respuesta del status code de la petición es 200 (operación OK), entonces hacer una serialización de los datos en el tipo Data al tipo del struct Posts.

let session = URLSession.shared
 session.dataTask(with: url) { data, response, error in
     guard let data = data, let response = response as? HTTPURLResponse, error == nil else {
         if let error = error {
             print("Error en la conexión \(error.localizedDescription)")
         }
         return
     }
     if response.statusCode == 200 {
         let decoder = JSONDecoder()
         do {
             let posts = try decoder.decode([Posts].self, from: data)
             for post in posts {
                 print(post.id, post.title)
             }
         } catch {
             print("Error en la serialización \(error.localizedDescription)")
         }
     }
 }.resume()

Ese sería el ejemplo convencional, y dada mi experiencia, si este bloque lo metemos tal cual dentro de un bloque de código estándar, puede crear confusión que el código del closure se ejecuta en otro momento diferente a la propia llamada.

La programación asíncrona es un paradigma difícil de entender para muchos programadores, sobre todo por el momento en que se empieza a dividir el código en momentos donde se lanza algo o no y tenemos varias llamadas asíncronas funcionando a varios niveles.

Combine, la API declarativa para procesar valores en el tiempo

Combine es una de las más interesantes incorporaciones a todas las nuevas versiones beta presentadas en la WWDC 2019 y que verán la luz para el público en septiembre con el lanzamiento de iOS 13, macOS Catalina, watchOS 6, tvOS 13 y el nuevo iPadOS 13.

A grandes rasgos, Combine es la solución de programación reactiva de Apple para tratar, por pasos, la programación asíncrona de forma declarativa y poder gestionar sus solicitudes de datos y los operadores que transforman los datos desde su recepción hasta su entrega.

Se compone de tres elementos: el publicador o Publisher que publica resultados de forma asíncrona, el suscriptor o Suscriber que se suscribe a los contenidos que el publicador presenta y los operadores u Operators que son operaciones basadas en muchas de las higher order functions o funciones de rango más alto de la programación funcional. Como map, flatMap, filter, reduce y alguna que otra nueva que ha incorporado para casos concretos.

El flujo convencional de Combine es que un Publisher tiene dentro un Subscribe que recibe los datos cuando algo se publica. Y entre uno y otro colocamos operadores que transformen y preparen los datos. Podemos crear clases o structs que usen estos datos a través de protocolos, creando nuestros propios datos de tipo Publisher y configurar la gestión asíncrona.

Pero lo importante (y por donde vamos a empezar) es que el sistema ya ha creado publicadores para nosotros con todo configurado para algunas de las funciones más comunes: como el uso del patrón KVO (Key-Value Observing), las notificaciones de la clase NotificationCenter, cualquier tipo de callback y el que vamos a ver aquí: la gestión de URLSession.

Aplicando Combine a una llamada de URLSession con dataTaskPublisher

Cuando creamos una URLSesssion tenemos una nueva operación que no es dataTask como ya hemos visto, a la que hay que enviar una URL o una URLRequest además de un closure.

Ahora tenemos un nuevo método que devuelve un Publishercompuesto por los datos de la llamada y la respuesta: el error ya lo gestiona él. Si llamada diera algún error, el publicador daría error y cortaría el hilo.

Hacemos una constante que llamaremos publisher que tendrá la siguiente llamada:

let publisher = URLSession.shared
     .dataTaskPublisher(for: url)

Nada más. Si miramos el tipo de publisher veremos que es de tipo URLSession.DataTaskPublisher. Este tipo de dato es un struct que tiene internamente un tipo genérico Output que guarda los datos de la llamada de red si ha sido correcta, y un Failure en caso de error. Pero lo importante es que tiene un buen número de operadores al estar conformado con Publisher que nos permiten trabajar o gestionar los datos por pasos.

No vamos a entrar aquí en detalle, pero tenemos infinidad de operaciones que nos permiten gestionar errores, evitar duplicados en las llamadas, controlar que las llamadas consecutivas de red de un mismo Publisher se espacien en el tiempo un mínimo. Podemos consultarlas en la documentación oficial de Apple, pulsando aquí.

Nosotros vamos a centrarnos en tres de los operadores más importantes, los tres que normalmente usaremos. El primero será tryMap que realiza una operación map de transformación con la posibilidad de devolver un throw con un error y que gestiona la entrada de la respuesta y los datos (nuestro Data y URLResponse pero ya fuera del opcional).

.tryMap {
         guard let response = $1 as? HTTPURLResponse, response.statusCode == 200 else {
             throw NetworkErrors.BadContent
         }
         return $0
     }

Concatenamos esta llamada a la anterior de dataTaskPublisher y nos permitirá recoger y transformar la respuesta. En caso que la convierta a HTTPURLResponse y el statusCode sea 200 sigue y devuelve los datos ya fuera del opcional (que están en $0). Si no, devuelve un error de una enumeración que hemos creado.

enum NetworkErrors:Error {
     case BadContent
 }

Este return de los datos hace que la respuesta que ahora tenemos es solo los datos, fuera del opcional. Pero esos datos queremos serializarlos con JSONDecoder, así que podríamos hacer otro tryMap o incluso podíamos haber serializado dentro del que ya hemos hecho. Pero vamos a usar un operador decode que se encargará él solo. Este operador solo necesita la instancia del decoder y el tipo de dato a transformar.

.decode(type: [Posts].self, decoder: JSONDecoder())

Ahora el resultado de nuestro flujo ya no es un Data, es un array de Posts porque hemos serializado el contenido (si todo ha ido bien).

Cuando ya tenemos el dato transformado como queremos, llamados al método sink que es algo así como el paso final de nuestro flujo, donde vamos a hacer lo que queríamos con los datos ya recuperados y transformados por los distintos operadores.

El método sink tiene debe tener la siguiente implementación: debemos usar dos closures: uno de completado que recibirá el resultado de la operación dentro de la promesa del publicador y otro que nos dará el valor recibido.

En realidad esté método convierte el Publisher en un Suscriber, de forma que nos suscribimos a los resultados de nuestro publicador para recuperar sus resultados.

El dato que recibe el primer closure para completion es el estado de la operación que será una enumeración con los posibles valores .failure o .finished. Si es .failure, lleva dentro como valor asociado a la enumeración el fallo en sí que se ha producido en nuestra petición. Si es .finished, simplemente todo ha ido bien. Nada más. En el segundo closure, receiveValue, nos entra un valor del tipo último que devolvió el último operador. En nuestro caso, el último operador fue el .decode que devuelve un array de [Posts].

  .sink(receiveCompletion: { completion in
         switch completion {
            case .failure(let failure):
             print("Error \(failure)")
            case .finished:
            print("Hecho")
         }
     }, receiveValue: {
         for post in $0 {
             print(post.id, post.title)
         }
     })

De esta forma tenemos, escalonadamente, nuestra llamada de red de forma asíncrona, procesada paso a paso por operadores y devuelta. Y por cierto, no hay que poner resume() (que a mí siempre se me olvida).

De forma que el flujo completo, equivalente al que hemos visto en la primera parte usando el método tradicional, sería el siguiente:

let publisher = URLSession.shared
     .dataTaskPublisher(for: url)
     .tryMap {
         guard let response = $1 as? HTTPURLResponse, response.statusCode == 200 else {
             throw NetworkErrors.BadContent
         }
         return $0
     }
     .decode(type: [Posts].self, decoder: JSONDecoder())
     .sink(receiveCompletion: { completion in
         switch completion {
            case .failure(let failure):
             print("Error \(failure)")
            case .finished:
            print("Hecho")
         }
     }, receiveValue: {
         for post in $0 {
             print(post.id, post.title)
         }
     })

Mucho más claro y elegante, a mi modo de ver. La programación funcional es así de elegante y clara.

No olvidéis importar Combine en vuestro código si queréis probarlo, que por cierto, esta prueba está hecha en un Playground. ¿Qué os parecido? Dadnos vuestra opinión en los comentarios y compartir el tutorial con el mundo. Un saludo y Good Apple Coding.

Etiquetas

Julio César Fernández

Analista, consultor y periodista tecnológico, desarrollador, empresario, productor audiovisual, actor de doblaje e ingeniero de vídeo y audio.
Botón volver arriba
Cerrar
Cerrar