Cliente de red usando Combine, Genericos y Codables

2
994
  1. Introducción
  2. Entorno
  3. El cliente de la red
  4. Los modelos Codable
  5. Creando y cancelando HTTP petición
  6. Encadenamiento de peticiones
  7. Conclusiones
  8. Referencias

Introducción

Hacer peticiones HTTP es una de las primeras cosas que debes aprender al comenzar el desarrollo de iOS. Tanto si creas el cliente desde cero como si usas Alamofire u otra librería, a menudo terminas con un código complejo. Especialmente, cuando se trata de encadenar peticiones, ejecutarlas en paralelo o cancelarlas. El framework Combine ya nos proporciona todas las herramientas que necesitamos para escribir una capa de red concisa. En este artículo implementaremos un cliente de la red basado en promesas mediante el uso de las API de Swift 5: Codables, URLSession y el framework Combine. Para probar nuestra capa de red, practicaremos con varios ejemplos del mundo real que consultan la API REST de Marvel y sincronizan las peticiones HTTP en cadena y en paralelo.

Entorno

Este tutorial está escrito usando el siguiente entorno:

      • Hardware: MacBook Pro 15’ (2,5 GHz Intel Core i7, 16GB DDR3, mediados de 2015)
      • Sistema operativo: macOS Catalina 10.15
      • Versiones del software:
        • Xcode: 11
        • iOS SDK: 13.4

Es importante saber que para probar SwiftUI se necesita instalar Mac OS Catalina 10.15 y Xcode 11.

El cliente de la red

La forma tradicional

Hacer una petición HTTP en Swift es bastante fácil, puedes usar la URLSession incorporada con una tarea de datos simple. Por supuesto, es posible que desees verificar el código de estado y si todo está bien, puedes analizar su respuesta JSON utilizando el objeto JSONDecoder de Foundation.

let task = URLSession.shared.dataTask(with: url) { data, response, error in
    if let error = error {
        fatalError("Error: \(error.localizedDescription)")
    }
    guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
        fatalError("Error: invalid HTTP response code")
    }
    guard let data = data else {
        fatalError("Error: missing response data")
    }

    do {
        let decoder = JSONDecoder()
        let posts = try decoder.decode([Post].self, from: data)
        print(posts.map { $0.title })
    }
    catch {
        print("Error: \(error.localizedDescription)")
    }
}
task.resume()

Data tasks y Combine framework

Ahora, como podemos ver, el enfoque tradicional «basado en bloques» es bueno, pero ¿podemos hacer algo mejor aquí? ¿Sabes cómo describir todo como una cadena, como solíamos hacer esto con promesas en Javascript? A partir de iOS13 con la ayuda del increíble framework Combine, ¡puedes ir mucho más allá! 😃

private var cancellable: AnyCancellable?
//1                                  
self.cancellable = URLSession.shared.dataTaskPublisher(for: url) //2
.map { $0.data } //3
.decode(type: [Post].self, decoder: JSONDecoder()) //4
.replaceError(with: []) //5
.eraseToAnyPublisher()  //6
.sink(receiveValue: { posts in  //7
    print(posts.count)
})
//...
self.cancellable?.cancel() //8

Me encanta cómo el código «se explica»:

  1. Primero guardamos el publisher en la variable cancellable
  2. Luego creamos un nuevo objeto de publisher
  3. Mapeamos la respuesta, solo nos importa la parte de datos (ignoramos los errores)
  4. Decodificamos el contenido de los datos usando un JSONDecoder
  5. Si algo sale mal, creamos nuestro error (o un array vacío)
  6. Borramos la complejidad de los tipos de publishers a un simple AnyPublisher
  7. Usamos el operador sink para mostrar información sobre el valor final
  8. Opcional: puedes cancelar tu petición de red en cualquier momento

Combine es el framework declarativo Swift para procesar valores a lo largo del tiempo. Impone el paradigma funcional reactivo de la programación, que es diferente del orientado a objetos que prevalece en la comunidad de desarrollo de iOS.

Con todo lo que ya sabemos creamos un cliente HTTP basado en promesas. Cumple y configura las peticiones pasándole un único objeto URLRequest. El cliente transforma automáticamente los datos JSON en un valor codificable y devuelve una instancia de AnyPublisher:

public enum APIError: Error {
    case internalError
    case decodingError
    case serverError(code: Int, message: String)
}

func send<T: Decodable>(_ request: URLRequest, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher<T, APIError> {
        return URLSession.shared
         .dataTaskPublisher(for: request) //1
         .mapError{ APIError.serverError(code: $0.errorCode, 
                    message: $0.localizedDescription) } //2
         .map { $0.data } //3
         .decode(type: T.self, decoder: decoder) //4
         .print() //5
         .mapError { _ in APIError.decodingError } //6
         .receive(on: DispatchQueue.main) //7
         .eraseToAnyPublisher() //8
    }
  1. Usamos el API de URLSession, para que nos devuelva el publisher que a su vez emite la respuesta en forma de (data: Data, response: URLResponse).
  2. Convierte cualquier fallo del publisher anterior en un nuevo error.
  3. Transforma todos los elementos del publisher anterior con una closura proporcionada. En nuestro caso sacamos el property data que contiene los datos de la respuesta.
  4. Decodifica la salida del flujo ascendente utilizando un TopLevelDecoder especificado. Por ejemplo, use JSONDecoder. En nuestro caso usamos por defecto JSONDecoder.
  5. Imprime mensajes de logging para todos los eventos de publisher.
  6. Si ocurre un error con decodificación, creamos nuestro error especifico.
  7. Especifica el scheduler en el que recibimos los elementos del publisher. En nuestro caso usamos main queue.
  8. Borramos el tipo de publisher y devolvemos una instancia de AnyPublisher.

Los modelos Codable

Muchas tareas de programación implican enviar datos a través de una conexión de red, guardar datos en el disco o enviar datos a API y servicios. Estas tareas a menudo requieren que los datos se codifiquen y decodifiquen desde y hacia un formato intermedio mientras se transfieren los datos.
La biblioteca estándar Swift tiene una manera estandarizada para la codificación y decodificación de datos. Puedes adoptarla implementando los protocolos Codable y Decodable en sus tipos. La adopción de estos protocolos permite que las implementaciones de los protocolos Encoder y Decoder tomen sus datos y los codifiquen o decodifiquen desde y hacia una representación externa como JSON o la lista de propiedades. Para admitir tanto la codificación como la decodificación, tienes que declarar la conformidad con Codable, que combina los protocolos Encodable y Decodable.

La forma más sencilla de hacer que un tipo sea codificable es declarar sus propiedades utilizando tipos que ya son codificables. Estos tipos incluyen tipos de biblioteca estándar como String, Int y Double; y tipos de Foundation como Date, Data y URL. Cualquier tipo cuyas propiedades son codificables se ajusta automáticamente a Codable simplemente declarando esa conformidad.
Consideramos una estructura Landmark que almacena el nombre y el año de fundación:

struct Landmark {
    var name: String
    var foundingYear: Int
}

Agregar Codable a la lista de herencia para Landmark activa una conformidad automática que satisface todos los requisitos de protocolo de Encodable y Decodable:

struct Landmark: Codable {
    var name: String
    var foundingYear: Int
    
    // Landmark ahora soporta los métodos de Codable init(from:) and encode(to:), 
    // aunque no están escritos explícitamente
}

La adopción de Codable en sus propios tipos permite serializarlos desde y hacia cualquiera de los formatos de datos integrados, y cualquier formato proporcionado por codificadores y decodificadores personalizados. Por ejemplo, la estructura de Landmark se puede codificar utilizando las clases PropertyListEncoder y JSONEncoder, aunque Landmark en sí no contiene código para manejar específicamente listas de propiedades o JSON.

En el caso de Marvel API tenemos una API muy compleja con un montón de campos y se requiere mucho tiempo para describir todos los campos de la API. Para simplificar el proceso podemos usar los servicios como Quicktype. Este servicio configura las estructuras o las clases de Codable para nosotros. Y así podemos ahorrar mucho tiempo.

// MARK: - CharacterWrapper
struct CharacterResponse: Codable {
    let code: Int?
    let status: String?
    let copyright: String?
    let attributionText: String?
    let attributionHTML: String?
    let etag: String?
    var data: DataClass?
}

// MARK: - DataClass
struct DataClass: Codable {
    let offset, limit, total, count: Int?
    var results: [Character]?
}
...

A veces el output de este servicio no es de todo preciso y tenemos que corregirlo pero de todas maneras nos ayuda bastante.

Creando y cancelando HTTP petición

A lo largo del artículo, trabajaremos con la API REST de Marvel. Comencemos declarando un espacio de nombres para ello:

extension URL{
    
    static private let baseURL = "https://gateway.marvel.com/"
    
    private enum Endpoint: String {
        case characters = "v1/public/characters"
        case comics = "v1/public/comics"
    }

}

Luego vamos a implementar los endpoints para pedir la lista de caracteres y la lista de cómics.

static func characters(_ characterId: String? = nil, limit: Int, offset: Int) -> URL {
        var endPoint = Endpoint.characters.rawValue
        var pageParams = ""
        if let _ = characterId {
            endPoint = endPoint + "/\(characterId!)"
        } else {
            pageParams = "&limit=\(limit)&offset=\(offset)"
        }
        return URL(string: "\(baseURL)\(endPoint)?apikey=\(Secret.publicKey)&hash=\(Secret.md5)&ts=\(Secret.ts)\(pageParams)")!
    }
    
    static func comics(_ comicId: String? = nil) -> URL{
        var endPoint = Endpoint.comics.rawValue
        if let _ = comicId {
            endPoint = endPoint + "/\(comicId!)"
        }
        return URL(string: "\(baseURL)\(endPoint)?apikey=\(Secret.publicKey)&hash=\(Secret.md5)&ts=\(Secret.ts)")!
        
    }

Y finalmente definimos el método para enviar las peticiones a través de nuestro cliente. Tenemos que usar la propiedad dateDecodingStrategy de JSONDecoder en nuestro caso, porque el formato de la fecha en la API de Marvel es distinto.

static func buildRequest(for url: URL, method: HTTPMethod) -> URLRequest {

        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        request.allHTTPHeaderFields = ["Content-Type": "application/json"
        return request
    }


static func send<T: Decodable>(_ url: URL, method: HTTPMethod) -> AnyPublisher<T, APIError> {
                
        let request = buildRequest(for: url, method: method)
        
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
            let container = try decoder.singleValueContainer()
            let dateStr = try container.decode(String.self)
            
            let dateFormatter = DateFormatter()
            dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
            dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
            
            if let date = dateFormatter.date(from: dateStr) {
                return date
            } else {
                return Date.distantPast
            }
        })
        
        return networkService.send(request, decoder)
            .eraseToAnyPublisher()
    }

Encadenamiento de peticiones

Otra tarea común es ejecutar las peticiones una por una. Por ejemplo, busquemos los caracteres de los cómics y luego extraemos la información sobre el primer cómic.

//1
let characters = MarvelAPI.characters(page: 0).map{ $0.data?.results} 
//2        
let firstComic = characters.compactMap {$0.first?.comics?.items?.first}
//3     
let loadFirstComic = firstComic.flatMap { comic in
    MarvelAPI.comics(comicId: comic.id)
}
//4
let token = loadFirstComic.sink(receiveCompletion: { _ in },
                        receiveValue: { print($0) })
  1. Pedimos la lista de todos los caracteres.
  2. Conseguimos el primer cómic de la lista.
  3. Encadenamos dos peticiones con la ayuda de Combine. El operador flatMap transforma un publisher, que devuelve el primer cómic, en uno nuevo, que devuelve toda la información de este cómic.
  4. Suscribimos a la cadena de peticiones. Aquí es donde realmente se lanzan las peticiones.

Si vas a ejecutar este código, verás la información en la consola de depuración. Tomemos un momento para apreciar lo fácil que fue. El código es muy fácil de leer. Además, se escala bien si vamos a agregar más peticiones a la cadena.

Cuando las peticiones  HTTP son independientes entre sí, podemos ejecutarlas en paralelo y combinar sus resultados. Esto acelera el proceso, en comparación con el encadenamiento, ya que el tiempo de carga total es igual al de la petición más lenta.

En esta sección, enumeraremos los caracteres y los cómics de Marvel en paralelo. Pero antes de hacer eso, hagamos un pequeño refactorizador.

let characters = MarvelAPI.characters() //1
let comics = MarvelAPI.comics() //2
//3        
let token = Publishers.Zip(characters,comics).sink(
            receiveCompletion: {_ in }, receiveValue: { (characters, comics) in
                print(characters, comics) })
  1. La petición de los caracteres.
  2. La petición de los cómics.
  3. Creamos y lanzamos la petición combinada. Estamos utilizando el publisher Zip de Combine, que espera hasta que se completen ambas peticiones y luego entrega sus resultados como una tupla.

Conclusiones

Combine es un framework increíble, puede hacer mucho, pero definitivamente tiene cierta curva de aprendizaje. Lamentablemente, solo puedes usarlo si está apuntando a iOS13 o superior (esto significa que tienes como mínimo un año completo para aprender cada bit del framework), así que no lo pienses dos veces antes de empezar a aprender esta nueva tecnología.

También debes tener en cuenta que actualmente no existe un publisher de tareas de carga y descarga, pero puedes hacer tu propia solución hasta que Apple publique oficialmente algo. 🤞

Realmente me encanta cómo Apple implementó algunos conceptos de programación reactiva y espero que con el tiempo la programación reactiva sea la principal manera de hacer las aplicaciones iOS.

Referencias

  1. Introducing Combine
  2. Combine in practice
  3. Modern Networking in Swift 5 with URLSession, Combine and Codable

 

2 COMENTARIOS

  1. Muy buen artículo que está genial para ver de un vistazo el potencial de este framework, lastima lo de iOS 13 pero es posible que cuando salga iOS 14 ya se pueda forzar a las empresas a utilizar esta versión

    • Gracias por tu feedback. Espero que sea así. 😉 Combine simplifica muchísimas cosas que antes eran complicadas de hacer. Es solo cuestión de tiempo.;)

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad