Introduction à Codable

Mathieu Lanoy
app A•Z (ex 2 App à Z)
10 min readSep 21, 2017

--

Lors de la WWDC 2017, Apple a annoncé la nouvelle version de son langage, Swift 4. Cette dernière s’accompagne d’un typealias qui nous intéresse particulièrement: Codable.

Sa définition est particulière:

typealias Codable = Decodable & Encodable

Codable est donc une composition de protocoles, un type qui doit se conformer à tous les protocoles de sa liste. Il permet à la fois de convertir un type vers une représentation de données externes mais aussi de transformer des données en type que vous avez défini dans votre code.

Ainsi, vous pourrez décoder des données venant de services externes et les faire correspondre à vos classes et structures de vos projets, peu importe la source (JSON, XML, format propriétaire…).

Plusieurs solutions de correspondance de données existent déjà et, pour la plupart, gèrent facilement le format JSON que nous allons voir par la suite, mais ce n’est que depuis la dernière version de swift qu’Apple intègre dans leur API la possibilité d’encoder et de décoder ces formats.

Les bases

Codable fournit une implémentation par défaut. Dans la majorité des cas, vous n’aurez qu’à adopter ce protocole afin d’avoir un comportement adéquate rapidement.

Prenons un JSON d’exemple qui décrit une personne:

{
"firstname": "Paul",
"lastname": "Dupont",
"age": 36,
"country": "France"
}

Notre structure de données swift pourrait ressembler à:

enum Country : String {
case France
case Italy
case England
// ...
}

struct Person {
let firstname: String
let lastname: String
let age: Int
let country: Country
}

Pour convertir ce JSON en une instance de Person, nous devons marquer nos types comme adoptant le protocole Codable.

enum Country : String, Codable {
// ...
}

struct Person : Codable {
// ...
}

Il suffit ensuite de créer un Decoder:

let jsonData = jsonString.data(encoding: .utf8)!
let decoder = JSONDecoder()
let person = try! decoder.decode(Person.self, for: jsonData)

Et c’est tout! Nous avons transformé notre document JSON en une instance de Person. Aucune personnalisation en plus n’est nécessaire puisque les clefs du document et de notre objet sont identiques.

À noter toutefois que nous utilisons try! dans notre exemple, mais qu’une gestion complète des erreurs est à privilégier.

Ce dernier témoigne d’un cas simple, mais que faut-il faire si les clefs du document et les attributs de notre objet ne correspondent pas?

Personnalisation des clefs

Généralement, les API utilisent le snake-case (en minuscules et en séparant par des underscores) pour nommer les clefs, ce qui ne correspond pas aux conventions de nommage des propriétés du swift.

Nous devons personnaliser l’implémentation par défaut de Codable.

Les clefs sont gérées automatiquement par une énumération qui est générée à la compilation, CodingKeys. Cette énumération adopte CodingKey, qui définit la connexion entre une propriété et une valeur dans un format encodé.

Pour modifier ces clefs, nous allons implémenter notre propre énumération. Pour les cas où les clefs sont différentes de nos propriétés, nous pouvons fournir une nouvelle valeur pour cette clef. Modifions le JSON:

{
"first_name": "Paul",
"last_name": "Dupont"
// ...
}

Notre énumération à ajouter dans notre structure est:

struct Person : Codable {
// ...
enum CodingKeys : String, CodingKey {
case firstname = "first_name"
case lastname = "last_name"
case age
case country
}
}

Wrapper

Il arrive souvent que les APIs inclus une clef au plus haut niveau de la structure JSON afin que cette dernière représente un objet comme:

{
"persons": [ {...} ]
}

Pour représenter une telle structure en swift, nous pouvons créer un nouveau type pour la réponse:

struct PersonList : Codable {
let persons: [Person]
}

Comme notre propriété correspond bien à la clef et que Person est déjà Codable, cela fonctionnera.

Liste d’entités

Si l’API retourne un tableau comme élément initial, il suffit de décoder la structure JSON de la manière suivante:

let decoder = JSONDecoder()
let persons = try decoder.decode([Person].self, from: data)

La différence est que nous utilisons un tableau comme type. Array<T> est conforme à Codable tant que T est conforme à Codable.

Gérer les clefs Wrapper

Un autre scénario possible est que la structure JSON contienne un tableau où chaque objet est encapsulé avec une clef.

[
{
"person" : {
"id": "uuid12459078214",
"first_name": "Paul",
"last_name": "Dupont",
"age": 36,
"country": "France"
}
}
]

Vous pouvez très bien utiliser la même méthode pour capturer la clef avec un wrapper, mais un moyen plus facile est de découpler cette structure en visualisant les structures déjà implémentées.

[[String:Person]]

Ce qui peut se traduire par:

Array<Dictionary<String, Person>>

Comme pour les tableaux, Dictionary<K, T> est Decodable tant que K et T le sont.

let decoder = JSONDecoder()
let persons = try decoder.decode([[String:Person]].self, from: data)

Structure imbriquée

Parfois, les réponses des APIs ne sont pas aussi simple. Vous pouvez avoir plusieurs collections dans la réponse.

Par exemple:

{
"info": {
"page": 1,
"total_pages": 8,
"per_page": 10,
"total_number": 76
},
"companies": [
{
"id": 12097214,
"name": "Dupont and Co"
},
{
"id": 123007,
"name": "Martin Consulting"
}
]
}

Swift permet d’imbriquer des types et d’avoir une structure proche de ce qui nous est donné par les services externes lorsque l’on décode.

struct PagedCompanies : Codable {
struct Info : Codable {
let page: Int
let totalPages: Int
let perPage: Int
let totalNumber: Int
enum CodingKeys : String, CodingKey {
case page
case totalPages = "total_pages"
case perPage = "per_page"
case totalNumber = "total_number"
}
}

struct Company : Codable {
let id: Int
let name: String
}

let info: Info
let companies: [Company]
}

Un des principaux intérêts de cette approche est que vous pouvez bénéficier de plusieurs réponses pour le même type d’objets (une company ne possède qu’un id et un name dans cette réponse mais pas dans une autre). Comme la structure Company est imbriquée, vous pouvez avoir une autre structure Company imbriquée dans une autre réponse et qui encode ou décode une structure totalement différente.

Plus loin dans la personnalisation

Jusqu’à maintenant, nous nous sommes presque entièrement basés sur les implémentations par défaut d’Encodable et Decodable. Ils permettent de gérer la majorité des cas, mais vous allez potentiellement rencontrer des situations où vous voudriez avoir un contrôle plus important sur la façon d’encoder et de décoder les données.

Encoder manuellement

Pour commencer, nous allons voir comment encoder manuellement nos données.

extension Person {
func encode(to encoder: Encoder) throws {

}
}

Nous allons ajouter des propriétés à notre structure Person:

struct Person : Codable {
// ...
let createdAt: Date
let biography: String?

enum CodingKeys: String, CodingKey {
// ...
case createdAt = "created_at",
case biography
}
}

Dans cette méthode, nous devons récupérer un container depuis l’encoder et encoder les valeurs dans ce container.

un container peut être de différent types:

  • Keyed Container — fournit des valeurs par clef. C’est un dictionnaire.
  • Unkeyed Container — fournit des valeurs triées. C’est un tableau.
  • Single Value Container — Produit une valeur brute.

var container = encoder.container(keyedBy: CodingKeys.self)

Plusieurs choses à noter:

  • Le conteneur doit être modifiable car nous allons écrire dedans. La propriété doit être déclarée avec var
  • Nous devons spécifier les clefs (et donc la façon dont les clefs et les propriétés sont liées) afin qu’il puisse savoir quelles clefs nous pouvons encoder dans ce conteneur.

Nous allons encoder les valeurs dans le conteneur. Tous ces appels peuvent lever une exception, donc chaque appel doit commencer par un try:

try container.encode(firstname, forKey: .firstname)
try container.encode(lastname, forKey: .lastname)
try container.encode(age, forKey: .age)
try container.encode(country, forKey: .country)
try container.encode(createdAt, forKey: .createdAt)
try container.encode(biography, forKey: .biography)

Décoder manuellement

Décoder se traduit simplement par l’implémentation d’un constructeur.

extension Person {
init(from decoder: Decoder) throws {

}
}

De la même façon que pour encoder, nous avons besoin d’un container:

let container = try decoder.container(keyedBy: CodingKeys.self)

Nous pouvons décoder tous les types primitifs. Pour chacun des cas, nous devons spécifier le type attendu. Si le type ne correspond pas, une erreur DecodingError.TypeMismatch sera levée.

let firstname = try container.decode(String.self, forKey: .firstname)
let lastname = try container.decode(String.self, forKey: .lastname)
let country = try container.decode(Country.self,
forKey: .country)
let age = try container.decode(Int.self,
forKey: .style)
let createdAt = try container.decode(Date.self,
forKey: .createdAt)
let biography = try container.decodeIfPresent(String.self,
forKey: .biography)

Une fois que toutes ces variables sont définies, nous pouvons appeler le constructeur par défaut:

self.init(firstname: firstname,
lastname: lastname,
age: age,
country: country,
createdAt: createdAt,
biography: biography)

Uniformisation des structures

Codable est interessant, mais il peut arriver que la structure de données arrive avec des niveaux d’imbrication qui nous importent peu. Il y aurait peu d’interêt de déclarer des structures pour chaque niveaux d’imbrication du JSON.

Ajoutons des données à notre structure JSON:

{
"first_name": "Paul",
"last_name": "Dupont",
"misc": {
"height": 178,
"hair_color": blonde
}
// ...
}

Pour utiliser cette structure, nous allons devoir personnaliser l’implémentation des méthodes pour encoder et pour décoder.

Nous allons commencer par définir une énumération pour ces clefs imbriquées:

struct Person : Codable {
enum CodingKeys: String, CodingKey {
case firstname = "first_name"
case lastname = "last_name"
case age
case country
case createdAt = "created_at"
case biography
case misc // <-- NEW
}

enum MiscCodingKeys: String, CodingKey {
case height
case hairColor = "hair_color"
}
}

Lorsque nous encodons la valeur, la première chose à faire est de récupérer une référence vers le conteneur misc.

func encode(to encoder: Encoder) throws {
var container = encoder.container(
keyedBy: CodingKeys.self)

var misc = try container.nestedContainer(
keyedBy: MiscCodingKeys.self, forKey: .misc)
try misc.encode(height, forKey: .height)
try misc.encode(hairColor, forKey: .hairColor)

// ...

Pour décoder, nous faisons simplement l’inverse:

init(from decoder: Decoder) throws {
let container = try decoder.container(
keyedBy: CodingKeys.self)

let misc = try container.nestedContainer(
keyedBy: MiscCodingKeys.self, forKey: .misc)
let height = try info.decode(Int.self, forKey: .height)
let hairColor = try info.decode(String.self, forKey: .hairColor)

// ...
}

Performance

Lorsque nous parlons de traitement de données, il ne faut pas négliger les performances. Nous sommes la plupart du temps sur un environnement mobile, et malgré le fait que les derniers appareils en date possèdent des processeurs qui permettent d’effectuer ces traitements très rapidement, il est toujours avantageux d’effectuer ces opérations à-tire-d’aile. Décoder des données est un des aspects majeurs dans une application. Le scénario où plusieurs structures sont à transformer au même moment se rencontre souvent. Moins les performances sont bonnes pour le faire, plus le temps de processer les données est long.

Autres solutions

Avant Codable, il existait déjà des solutions de transformation de données externes en structures internes.

Historiquement, le premier à avoir vu le jour pour swift est la bibliothèque SwiftyJSON. La particularité de ce dernier est qu’il n’effectue aucune correspondance direct avec vos structures swift. C’est à vous de décrire les chemins pour atteindre les valeurs dans le flux que vous traitez. Vous n’avez aucun protocole à adopter. Il est à noter que SwiftyJSON est aussi intégrée à Alamofire via une extension.

D’autres sont sorties par la suite, et le fonctionnement se rapproche tous plus ou moins. il est nécessaire d’adopter un protocole et de décrire la façon dont les propriétés de vos structures ou classes sont liées au flux qui est traiter. Les principales bibliothèques qui fonctionnent de cette manière sont Marshal, Mapper (développée par Lyft), Genome

tests de performance

Un projet a émergé en mai 2016 qui teste les performances de toutes les bibliothèques de traitement du JSON. Ce projet, JSONShootout, calcule le temps qui est alloué par chaque bibliothèque pour transformer un fichier de 7Mo en objet natif. Le choix est fait de ne pas tester la transformation des dates car prenant trop de temps, peu importe la bibliothèque utilisée.

Les résultats nous sont donnés dans un graphique:

Nous pouvons clairement voir les tendances. Il apparait clairement que SwiftyJSON est lent dans le traitement de ce fichier, tandis que Marshal est la bibliothèque la plus rapide, 8 fois plus que SwiftyJSON.

Pour ce qui est de Codable, les résultats démontrent qu’il n’est le plus performant.

Il faut noter que ce test est conditionné. Il se fait sur un gros fichier. Pour des flux de données habituelles, la différence de performance est plus négligeable mais il permet d’avoir une idée de la vitesse de traitement de chacun.

Quand a SwiftyJSON, le test tente de créer un objet qui représente la totalité de la structure du fichier à transformer pour se rapprocher du comportement des autres bibliothèques alors que son utilisation permet de créer des structures avec des propriétés en lazy, ce qui permet de ne décoder les valeurs qu’une seule fois, et au moment de leur utilisation donc potentiellement, plus rapide que de la transformation complète.

Faut-il utiliser Codable?

Codable arrive pour combler un manque dans ce que proposait Apple. Comme nous avons pu le voir, les alternatives existent depuis le début du swift. Il est confortable de continuer à utiliser les bibliothèques historiques mais le passage à l’utilisation de Codable est-il justifié?

La réponse n’est pas aussi simple. Codable a des avantages non négligeables. Si vous devez utiliser une API simple dans l’architecture de ses réponses, vous aurez alors des structures et classes très peu verbeuses où vous n’aurez qu’à déclarer les propriétés et surement surcharger CodingKeys pour renommer les clefs de liaison.

Le problème principal est que la correspondance entre swift et votre structure de données est direct. Soit vous prenez le parti de calquer vos classes a la structure de données ce qui peut engendrer un nombre de classes et structures important, ce qui n’est pas optimal. Soit vous choisissez de modifier la structure de vos classes et structures, vous obligeant à surcharger les méthodes d’encodage et de décodage, le code pouvant devenir très vite verbeux donc facilitant les erreurs de votre part.

Si vous connaissez les services que vous allez utiliser, que les données ne sont pas complexes (pas d’imbrication profonde de dictionnaires par exemple) ou que chaque élément ne se décrit pas facilement par une structure, Codable n’est peut être pas la bonne solution. Votre code pourrait devenir difficilement maintenable, car verbeux, avec de l’uniformalisation de structures.

Étant un typealias de protocoles et donc un protocole fourni par Apple, la transformation de données est directement intégré dans plusieurs APIs. Il sera plus facile de transporter des objets Codable pour gérer le nouveau drag n drop sous iOS 11 par exemple. Vous n’aurez pas à implémenter une nouvelle méthode pour sérialiser ou désérialiser vos objets.

Toutes les solutions ne sont pas parfaites mais Codable est une possibilité qui reste viable et vous n’aurez pas à intégrer de bibliothèques externes à vos projets et tracter des dépendances.

Il faudra aussi observer ce que font les autres bibliothèques tierces maintenant qu’Apple fournit sa propre solution.

Comme toutes les nouvelles APIs, il est recommandé d’essayer de l’utiliser et se faire sa propre opinion sur le choix à faire concernant vos applications en cours ou futures.

--

--