Índice de contenidos
- Introducción
- Entorno
- Funcionamiento básico
- Estructuras de datos anidadas
- Nullabilidad
- Decodificación/Codificación personalizada
- Class vs Struct
- Configuración del JSONDecoder/encoder
- Codificando/Decodificando fechas
- Estructuras de datos heterogéneas
- Arrays con elementos no decodificables
- 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:
{ "name": "Tokyo", "lat": 35.6894989, "lon": 139.6917114 }
Para mapearla lo haríamos de la siguiente manera
struct City: Codable { var name: String var lat: Double var lon: Double }
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:
struct City: Codable { var name: String var lat: Double var lon: Double enum CodingKeys: String, CodingKey { case name case lat case lon } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) name = try container.decode(String.self, forKey: .name) lat = try container.decode(Double.self, forKey: .lat) lon = try container.decode(Double.self, forKey: .lon) } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(name, forKey: .name) try container.encode(lat, forKey: .lat) try container.encode(lon, forKey: .lon) } }
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:
import Foundation let json = """ { "name": "Tokyo", "lat": 35.6894989, "lon": 139.6917114 } """ let data = json.data(using: .utf8)! struct City: Codable { var name: String var lat: Double var lon: Double } let decoder = JSONDecoder() let city = try decoder.decode(City.self, from: data) print(city.name) // Will print "Tokyo" print(city.lat) // Will print "35.6894989" print(city.lon) // Will print "139.6917114" let encoder = JSONEncoder() data = try encoder.encode(city) print(String(data: data, encoding: .utf8)!) // Regenerates original JSON
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:
{ "id": "1", "name": "Interstellar", "duration": 169, "genre": "SCI_FI" }
Podemos mapearlo como:
struct Movie: Codable { enum Genre: String, Codable { case sciFi = "SCI_FI" case comedy = "COMEDI" case action = "ACTION" case drama = "DRAMA" case other = "OTHER" } var id: String var name: String var duration: Int var genre: Genre }
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:
{ "name": "Japan", "cities": [ { "name": "Tokyo", "coordinates": { "lat": 35.6894989, "lon": 139.6917114 } }, { "name": "Kyoto", "coordinates": { "lat": 35.0210686, "lon": 135.7538452 } }, { "name": "Osaka", "coordinates": { "lat": 34.6937408, "lon": 135.502182 } } ] }
Para mapear esta estructura podríamos crear los siguientes struct:
struct Country: Codable { var name: String var cities: [City] } struct City: Codable { var name: String var coordinates: Coordinates } struct Coordinates: Codable { var lat: Double var lon: Double }
Y para decodificar la respuesta solo necesitaríamos hacerlo al tipo raíz
let country = try decoder.decode(Country.self, from: data)
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:
struct City: Codable { var name: String var lat: Double var lon: Double enum CityKeys: String, CodingKey { case name case lat case lon } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CityKeys.self) name = try container.decode(String.self, forKey: .name) lat = try container.decode(Double.self, forKey: .lat) lon = try container.decode(Double.self, forKey: .lon) } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CityKeys.self) try container.encode(name, forKey: .name) try container.encode(lat, forKey: .lat) try container.encode(lon, forKey: .lon) } }
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 ?)
{ "n": "Tokyo", "l": 35.6894989, "lo": 139.6917114 }
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»
struct City: Codable { var name: String var lat: Double var lon: Double enum CodingKeys: String, CodingKey { case name = "n" case lat = "l" case lon = "lo" } }
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í:
{ "id": "1", "description": "Machine1", "enabled": 0, }
Podemos cambiar ese enabled a un valor Bool:
struct Machine: Codable { var id: String var description: String var enabled: Bool enum CodingKeys: String, CodingKey { case id case description case enabled } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) description = try container.decode(String.self, forKey: .description) enabled = try container.decode(Int.self, forKey: .enabled) == 0 } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(description, forKey: .description) try container.encode(enabled ? 1 : 0, forKey: .enabled) } }
En el caso de decodificar casos opcionales, también tenemos el siguiente helper:
struct Foo: Codable { var bar: String? init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) bar = try container.decodeIfPresent(String.self, forKey: .bar) } }
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:
struct Foo: Codable { var bar: String? init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if container.contains(.bar) { bar = try container.decode(String?.self, forKey: .bar) } } }
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.
struct Foo: Codable { var bar: String? init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) bar = try? container.decodeIfPresent(String.self, forKey: .bar) } }
7. Class vs Struct
Hasta ahora hemos estado trabajando con structs, aunque también tenemos la posibilidad de usar clases.
class Foo: Codable { var bar: String? required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) bar = try? container.decodeIfPresent(String.self, forKey: .bar) } }
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.
class MyClass: Codable { var bar: String } class MyChildClass: MyClass { var baz: String enum CodingKeys: String, CodingKey { case baz } required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) baz = try container.decode(String.self, forKey: .baz) try super.init(from: decoder) } }
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):
struct MyStruct: Codable { var bar: String } struct MyComplexStruct: Codable { var baz: String var myStruct: MyStruct enum CodingKeys: String, CodingKey { case baz } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) baz = try container.decode(String.self, forKey: .baz) myStruct = try MyStruct(from: decoder) } }
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
let decoder = JSONDecoder() let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" decoder.dateDecodingStrategy = .formatted(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:
struct MyStruct: Codable { var date: Date init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let dateString = try container.decode(String.self, forKey: .date) (...) self.date = <converted dateString to date> } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(<converted date to appropiate format>, forKey: .date) } }
- Si sabemos cuales son los N formatos que usan las respuestas de nuestros servicios, podemos usar el siguiente truco
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.locale = Locale(identifier: "en_US_POSIX") dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" let dateFormatter2 = DateFormatter() dateFormatter2.locale = Locale(identifier: "en_US_POSIX") dateFormatter2.timeZone = TimeZone(secondsFromGMT: 0) dateFormatter2.dateFormat = "yyyy-MM-dd" (...) for formatter in [dateFormatter,dateFormatter2,(...)] { if let date = formatter.date(from: dateStr) { return date } } // Set a fallback date or throw an exception to abort decoding, whatever best suits your project return Date.distantPast }
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:
[ { "title": "Jurassic Park", "type": "MOVIE", "published": 1993, "duration": 127, "director": "Steven Spielberg", "music": "John Williams" }, { "title": "Jurassic Park", "type": "BOOK", "published": 1990, "pages": 400, "writter": "Michael Crichton" }, { "title": "Back In Black", "type": "SONG", "album": "Back In Black", "artist": "AC/DC", "duration": 256 } ]
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:
struct Media: Codable { enum ItemType: String, Codable { case movie = "MOVIE" case book = "BOOK" case song = "SONG" } var title: String var type: ItemType var published: Int var duration: Int? var director: String? var music: String? var pages: Int? var writter: String? var album: String? var artist: String? }
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»:
struct Media: Codable { enum ItemType: Codable { enum TypeKey: CodingKey { case type } case movie(MovieMedia) case book(BookMedia) case song(SongMedia) case unknown(String) init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: TypeKey.self) let type = try container.decode(String.self, forKey: .type) switch type { case "MOVIE": self = .movie(try MovieMedia(from: decoder)) case "BOOK": self = .book(try BookMedia(from: decoder)) case "SONG": self = .song(try SongMedia(from: decoder)) default: self = .unknown(type) } } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: TypeKey.self) var type = "" switch self { case .movie(let item): type = "MOVIE" try item.encode(to: encoder) case .book(let item): type = "BOOK" try item.encode(to: encoder) case .song(let item): type = "SONG" try item.encode(to: encoder) case .unknown(let unknownType): type = unknownType } try container.encode(type, forKey: .type) } } var title: String var type: ItemType var published: Int enum CodingKeys: String, CodingKey { case title case published } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) title = try container.decode(String.self, forKey: .title) published = try container.decode(Int.self, forKey: .published) type = try ItemType(from: decoder) } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(title, forKey: .title) try container.encode(published, forKey: .published) try type.encode(to: encoder) } } struct MovieMedia: Codable { var duration: Int var director: String var music: String } struct BookMedia: Codable { var pages: Int var writter: String } struct SongMedia: Codable { var duration: Int var album: String var artist: String }
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:
let json = #"[{"title":"Jurassic Park","type":"MOVIE","published":1993,"duration":127,"director":"Steven Spielberg","music":"John Williams"},{"title":"Jurassic Park","type":"BOOK","published":1990,"pages":400,"writter":"Michael Crichton"},{"title":"Back In Black","type":"SONG","published":1980,"album":"Back In Black","artist":"AC/DC","duration":256}]"# let data = json.data(using: .utf8)! let media = try JSONDecoder().decode([Media].self, from: data) media.forEach { media in switch media.type { case .movie(let media): print(media) case .book(let media): print(media) case .song(let media): print(media) default: break } } let encodedMedia = String(data: try JSONEncoder().encode(media), encoding: .utf8)
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:
{ "country": "Japan", "cities": [ { "name": "Tokyo", "coordinates": { "lat": 35.6894989, "lon": 139.6917114 } }, { "name": "Kyoto", "coordinates": { "lat": 35.0210686, "lon": 135.7538452 } }, { "name": null, "coordinates": { "lat": 34.6937408, "lon": 135.502182 } } ] }
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.
@propertyWrapper struct FailableCodableArray<T:Codable>: Codable { private struct EmptyCodable: Codable {} var wrappedValue: [T] init(from decoder: Decoder) throws { wrappedValue = [] var container = try decoder.unkeyedContainer() while !container.isAtEnd { if let item = try? container.decode(T.self) { wrappedValue.append(item) } else { // Needed to make nestedContainer iterator increase (doesn't increase on failed decoding), preventing infinite loop let _ = try container.decode(EmptyCodable.self) } } } }
Con este property wrapper, para el caso concreto del apartado 4, bastaría con modificar la estructura de datos «Country»:
struct Country: Codable { var name: String @FailableCodableArray var cities: [City] }
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.