Swift: Codables a fondo

0
5229

Índice de contenidos

  1. Introducción
  2. Entorno
  3. Funcionamiento básico
  4. Estructuras de datos anidadas
  5. Nullabilidad
  6. Decodificación/Codificación personalizada
  7. Class vs Struct
  8. Configuración del JSONDecoder/encoder
  9. Codificando/Decodificando fechas
  10. Estructuras de datos heterogéneas
  11. Arrays con elementos no decodificables
  12. Conclusiones

1. Introducción

Con la release 5.0 de Swift se introdujo en el lenguaje una utilidad a la que nombraron «Codables» que hace más fácil mapear a objetos distintos formatos, inicialmente JSON y PropertyLists de una forma sencilla. Esto hace que, por ejemplo ya no sea tan necesario recurrir a librerías de terceros (Mantle, ObjectMapper, etc) para poder trabajar con JSON de una forma mas cómoda.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 15’ (2.5 GHz Intel Core i7, 16 GB 1600 MHz DDR3)
  • Sistema Operativo: Mac OS X Catalina 10.15.3
  • Xcode 11.3.1
  • iOS 13.4
  • Swift 5.1

3. Funcionamiento básico

Supongamos que tenemos la siguiente respuesta JSON:

Para mapearla lo haríamos de la siguiente manera

Es importante remarcar que todos los atributos de la estructura de datos deben conformar el protocolo Codable, de nos ser así ya se encargará el compilador de recordárnoslo. Realmente, esto hará que el compilador nos genere esto por nosotros:

Más adelante veremos porque esto es importante.

Para probarlo en un Playground por ejemplo, con este pequeño fragmento de código podemos tanto decodificar cómo codificar nuestro objeto:

Aquí cabe mencionar que podemos optar porque nuestro objeto sólo se pueda codificar o decodificar, haciendo que en vez de adoptar el protocolo Codable adopte Decodable o Encodable (de hecho literalmente Codable es un alias «public typealias Codable = Decodable & Encodable»). Personalmente y tras la experiencia en varios proyectos mi recomendación personal es que siempre que se pueda adoptar Codable, por ejemplo, nos puede interesar persistir esas respuestas, y si solo se adopta Decodable no podríamos hacerlo.

También podemos utilizar enums para mejorar nuestras estructuras de datos, por ejemplo, si tenemos este JSON que representa una película:

Podemos mapearlo como:

En el caso de que el género viniese como un código numérico (hay gente muy rara ahí fuera…) bastaría con cambiar a enum Genre: Int, Codable e indicar los códigos de cada género de forma análoga.

4. Estructuras de datos anidadas

Imaginemos una respuesta más compleja, como cualquiera que vamos a encontrar en el Mundo Real ©. Por ejemplo cambiando un poco nuestra respuesta anterior:

Para mapear esta estructura podríamos crear los siguientes struct:

Y para decodificar la respuesta solo necesitaríamos hacerlo al tipo raíz

Aquí es importante tener en cuenta que todos los objetos de la jerarquía deben adoptar los mismos protocolos (por ejemplo no podríamos hacer que Coordinates sea solo Decodable o Encodable, el compilador nos daría un error).

El decoder/encoder irá llamando recursivamente a los métodos init/encode de cada uno de los objetos para parsear los datos y construir la jerarquía de objetos.

5. Nullabilidad

Una ventaja de los Codables es que tienen en cuenta la nullabilidad. Si en la jerarquía de clases de nuestro Codable en algún punto se incumple el contrato de nullabilidad fallará la decodificación de la respuesta completa. Por ejemplo, en nuestra respuesta del apartado anterior, en caso de que en la respuesta JSON, basta con que una de las ciudades tenga el campo «name» a nil para que cuando intentemos decodificar la respuesta lance una excepción.

Por una parte la ventaja es clara, puesto que la nullabilidad va marcada por el contrato nuestro código no puede crashear en runtime al manejar una respuesta. Por otra parte puede llegar a dar problemas si no está correctamente especificada y documentada la nullabilidad del API rest (imaginad el caso anterior, con miles de ciudades y que falle la decodificación porque una tiene el campo «name» a null).

6. Decodificación/Codificación personalizada

En ocasiones es posible que tengamos que recurrir a implementar exactamente como decodificar y/o codificar los datos. Por defecto, el compilador sintetizará la implementación de las CodingKeys y los metodos init(from decoder: Decoder) y func encode(to encoder: Encoder) (según corresponda dependiendo si es Decodable, Encodable o ambos). Es más, si implementamos ambos métodos el compilador tampoco sintetizará las CodingKeys y tendremos que declararlas nosotros.

En cuanto a las CodingKeys, siempre y cuando estemos implementando nosotros mismos tanto la codificación como la decodificación, no tienen porqué llamarse así siempre, podríamos declarar perfectamente el ejemplo anterior de City de la siguiente manera:

En cuanto a los CodingKeys, sirven para declarar que keys existen el las estructuras de datos. ¿Por qué podríamos necesitar implementarlos nosotros mismo? Bueno, imaginemos que estamos trabajando con una API un tanto peculiar y nos devuelve los datos de la siguiente manera (niños, no hagáis esto en casa 🙏)

En este caso por no queremos tener en nuestro código algo así, por lo que recurrimos a las CodingKeys para hacer la «traducción»

Con esto el decoder/encoder hará la traducción por nosotros.

Por otra parta, implementando los métodos para decodificar/codificar los datos, podemos controlar exactamente cómo decodificar/codificar los datos. Por ejemplo, con un JSON así:

Podemos cambiar ese enabled a un valor Bool:

En el caso de decodificar casos opcionales, también tenemos el siguiente helper:

En este caso si «bar» fuese null o directamente no estuviese contenido en la respuesta lo seteará a nil, y sería similar a implementar lo siguiente:

Cabe mencionar que en ambos casos, que si «bar» no fuese null pero tampoco fuese un String, fallaría la decodificación de la respuesta completa ya que se estaría violando el contrato. Si nuestra intención es que algún atributo que no pueda decodificarse por ser del tipo incorrecto se establezca a nil en vez de lanzar una excepción habría que modificar ligeramente la decodificación.

7. Class vs Struct

Hasta ahora hemos estado trabajando con structs, aunque también tenemos la posibilidad de usar clases.

La ventaja de usar structs es que estos son inmutables, y esa inmutabilidad es muy deseada de cara a utilizar respuestas, sobre todo con el fin de evitar errores y efectos colaterales si se modifica por descuido. Como desventaja está que no podemos usar herencia. Aun así, usar la herencia en el caso de los Codables es un tanto complejo, ya que nos fuerza a implementar absolutamente todo en la clase derivada, ya que no será capaz de sintetizar correctamentamente el código.

Otra aproximación es usar structs y usar composición como alternativa a la herencia, aqui ya dependerá del caso concreto y de las preferencias de cada uno (aunque en ambos casos nos va a tocar implementar la decodificación a mano):

8. Configuración del JSONDecoder/encoder

En función de la fuente de datos que estemos usando, puede que el decoder/encoder nos ofrezca la posibilidad de configurar su comportamiento (en este tutorial nos estamos centrando en JSON, aunque en principio se puede implementar un encoder/decoder a cualquier tipo de formato, podría ser XML).

En el caso de JSON, hay algunas cosas muy útiles que nos da la posibilidad de configurar.

  • Imaginemos que la respuesta JSON de un API rest nos vienen los keys en snake_keys (muy habitual con backends en Python por ejemplo). Mediante el atributo keyDecoding/EncodingStrategy podemos decirle que nos lo pase a came case (respuestas) o que nos lo pase a snake_case (peticiones). Esto nos evita tener que declarar manualmente las CodingKeys y su «traducción»
  • Fechas: Podemos configurar el atributo dateDecoding/EncodingStrategy. Por defecto intentará decodificar la fecha como el número de segundos desde el 1 de Enero de 2001 (WTF!). Este puede ser uno de los quebraderos de cabeza más grande, tanto que tiene su propio apartado.

Se puden consultar la documentacion de Apple en para ver otros comportamientos se pueden modificar
https://developer.apple.com/documentation/foundation/jsondecoder
https://developer.apple.com/documentation/foundation/jsonencoder

9. Codificando/Decodificando fechas

Como he avanzado en el apartado anterior, decodificar o codificar fechas (tipo Date) puede ser un dolor. Si tenemos mucha suerte y nuestro API rest utiliza siempre el mismo formato de fecha, tenemos la opción de establecer el atributo dateDecoding/EncodingStrategy. Aquí tenemos dos opciones interesantes:

  • iso8601: Formato de fecha estándar, tiene este formato «2020-03-24T12:00:00Z»
  • Otro formato, por ejemplo, el que usa javaScript al usar JSON.stringify, haciendo uso de un DateFormatter

Si no tenemos suerte, y el API rest es una idiosincracia de formatos de fecha en el cual en la misma respuesta usa hasta dos o tres formatos de fecha distintos que despertarán los instintos asesinos más primitivos del desarrollador de front (si, he visto cosas que jamás creeríais…), podemos optar por alguna de estas soluciones:

  • Manejar los casos concretos en la decodificación/codificación de la propia respuesta:

  • Si sabemos cuales son los N formatos que usan las respuestas de nuestros servicios, podemos usar el siguiente truco

Esto nos vale para la decodificación, para la codificación tendremos que valorar si la estructura de datos se va a utilizar para realizar la request (en cuyo caso habría que codificarlo en dicho formato de fecha) o bien si solo necesitamos que sea encodable para poder persistir los datos, de forma que lo suyo es usar el formato de fecha que menos informaciíon pierda (sería un tiro en el pie utilizar el formato de año, mes día si en la respuesta original nos ha venido con la hora).

10. Estructuras de datos heterogéneas

Puede darse el caso de que tengamos que lidiar con una respuesta JSON de este tipo:

Aquí tenemos varias opciones, podemos implementar una estructura de datos donde los campos comunes (title, type y published) sean no opcionales y el resto sean todos de tipo opcional:

Sería perfectamente válida, aunque habría que lidiar con el manejo de los opcionales, y también tiene la desventaja de que si la estructura JSON no cumple el contrato no se invalidaría la respuesta lanzando una excepción al decodificar (por ejemplo, un elemento de tipo «SONG» que no tuviese «artist»). Esto se puede paliar implementando nuestra propia decodificación/codificación que tenga en cuenta el «type» y actue en consecuencia.

Otra opción es usar herencia, con los pros y contras explicados en el punto 7.

Existe una última opción, es algo más compleja aunque permite seguir utilizando structs y a la vez evitar el problema de la nullabilidad obligatoria. Antes hemos visto que se pueden utilizar enums en las estructuras de datos, esto admite una vuelta de tuerca más. Podemos utilizar un enum con un tipo agregado para reemplazar el type por los distintos tipos de «Media»:

Podemos probar nuestra implementación en un Playground y ver como se manejaría la estructura de datos con el siguiente ejemplo de código:

Resultado

11. Arrays con elementos no decodificables

Hay una característica de los codables que puede ser particularmente molesta si bien tiene todo el sentido del mundo. Si en nuestra estructura de datos tenemos un array de N elementos, basta con que uno solo de esos N elementos lance una excepción para que invalide la respuesta completa.

Por ejemplo, imaginemos por un momento que el atributo «name» de las ciudades del apartado 4 tuviese valor null:

Esto provocaría directamente que al intentar decodificar la respuesta lanzase una excepción, probablemente mostrando el típico mensajito de marras al usuario de «Se ha producido un error» o el también clásico «Respuesta inesperada del servidor». Si además en vez de 3 ciudades fuesen 3000 apaga y vámonos…

Lo normal sería que la nullabilidad de la API estuviese especificada o al menos que se comportase de forma consistente y eso no pasase, pero ya sabemos como se las gasta el Mundo Real ©… Para esos casos hay varias formas de atacar el problema.

Una opción puede ser marcar como opcional ese atributo que ha provocado el fallo de decodificación. Lo malo de esto es que podemos acabar con el código lleno de «?» por todas partes, con lo cual deberíamos evitar esto a no ser que realmente sea un atributo que tenga sentido que no venga según que casos.

Otra opción posible es usar un truco posible con Swift 5.1, los Property Wrappers (similar a las anotaciones de Java o a los decoradores de Python). Esto daría para un tutorial, pero digamos que encapsulan un tipo de dato concreto de forma que desde fuera se sigue «viendo» como dicho tipo de dato pero se controla el acceso a dicho elemento. Se implementaría de la siguiente manera.

Con este property wrapper, para el caso concreto del apartado 4, bastaría con modificar la estructura de datos «Country»:

Si probasemos a decodificar la respuesta «mala» de las ciudades con esta modificación, podemos comprobar que funciona y que tiene solo 2 ciudades, ignorando aquella que no cumple el contrato.

12. Conclusiones

Los Codables fueron una de las mayores novedades de la versión 5 de Swift. En el caso de nuestros proyectos, nos ha permitido quitarnos todas dependencias de terceros para mapear los objetos y trabajar con ellos. Si bien es cierto que la curva de aprendizaje inicial es algo elevada al principio, y que todo funciona bien hasta que te encuentras con algún caso complicado, en general todos se pueden resolver de una manera u otra.

Además, el tema de la nullabilidad, si bien puede dar un poco de guerra, ayudan a que salgan a la superficie problemas que ni siquiera sabes que tenías. En nuestro caso el proyecto aún tenía gran parte de la base de código en Obj-C, y eso nos enmascaraba una gran cantidad de problemas con el patrón null object, que se lo «tragaba todo» y nos encontrábamos con pantallas completamente vacías porque el mapeo no funcionaba o nos venían a null cosas que no deberían venir nunca a null (seguro que a alguno le suena el «no, si esto nunca debería venir a null», hasta que un día vino…).

Por otra parte, conviene tener un mecanismo que nos permita tracear cualquier excepción que lance la codificación/decodificación, las cuales contienen información detallada de que ha sido lo que ha provocado la violación del contrato, para poder detectar posibles problemas de la integración de un API rest o comportamientos anómalos por parte de este.

Dejar respuesta

Please enter your comment!
Please enter your name here