Typeclass derivation : faites éclore vos instances avec Magnolia

Vous faites du Scala ? Vous communiquez des données avec d'autres services (micro-services, big data, NoSQL...) ? Alors, vous aurez besoin de Magnolia.

Le format de donnée utilisé dans ce cadre est un point très sensible et correspond à des décisions qui vont être en partie contraintes par les performances, les frameworks utilisés... Vient alors la sérialisation et la désérialisation entre les données en mémoire et une représentation sous la forme d'une chaîne de caractères avec une syntaxe spécifique pour les formats lisibles (JSON, XML, CSV...) ou sous la forme d'une suite d'octets pour les formats binaires (Avro, Parquet, Protobuf...).

Sérialisation

Lors de cette phase de sérialisation, il y a différentes approches mise en oeuvre. Supposons que nous avons les case classes ci-dessous, représentant des personnes. Nous cherchons ici à convertir ces case classes en JSON.

case class Address(id: String, city: String)
case class Person(id: String, name: String, age: Int, address: Address)

L'approche de sérialisation la plus simple consiste à faire la conversion à la main. On prend chaque champ de Person qu'on insère dans une chaîne de caractères : s"""{"id": "${person.id}", "name": "${person.name}", ...}""". En terme de performance, c'est rapide. Mais, on a une potentielle répétition entre le nom du champ dans Person et son nom dans le JSON. De plus, avec une évolution du modèle, il faut se rappeler qu'il faut aussi modifier le template — ce qui n'est pas très fiable. Et puis, lorsque vous avez 200 champs par exemple dans votre entité, cette solution ne convient plus du tout. Vous avez enfin de quoi passer des heures de déboggage parce que vous avez oublié une , ou un ".

Une seconde approche consiste à utiliser la réflexion pour explorer la structuration de la donnée et d'en déterminer le structuration et l'élaboration du JSON en sortie. Il n'y a plus, dans ce cas, de répétition et l'approche suit naturellement les évolutions du modèle de donnée. Par contre, cette approche est réalisée au runtime et se traduit pas une réduction des performances de votre application, puisque que la phase d'exploration de la structure de donnée est relancée à chaque sérialisation.

Une troisième approche reprend l'approche précédente, seulement, l'exploration de la structure de donnée est faite une seule fois et va se traduire par la génération d'un code spécifique de conversion du modèle mémoire vers le format de sortie. Cette approche n'est coûteuse que lors de l'unique phase d'exploration et de génération de code. Donc en règle générale, dans un cadre statiquement typé, cette approche sera aussi performante voire plus performante que la version manuelle. Et si la phase d'exploration et de génération de code est faite à la compilation, typiquement avec un système de métaprogrammation avec des macros comme en Scala, cette phase ne vous coûtera plus rien au runtime.

Magnolia propose une telle approche.

Magnolia

Magnolia est un framework léger (à peine 1000 lignes) développé initialement par Jon Pretty en Scala. Magnolia permet de réaliser de la dérivation de typeclasse...

Wait! What?...

Nous avons vu la notion de typeclasse dans le précédent article. Il s'agit d'une fonctionnalité permettant de catégoriser différents types de données et de caractériser un comportement commun (ie. des opérations communes / polymorphes). Les typeclasses ne se basent pas sur l'héritage, mais sur la délégation pour se brancher sur l'implémentation qui convient. Pour déclarer une typeclasse, il faut de déclarer un trait générique, puis instancier ce trait pour des types particuliers.

Bien ! Mais si on crée des instances pour tous les types dont nous aurons besoin, on va vite se retrouver à ne faire que ça. On va aussi vite s'apercevoir qu'il y a une certaine répétition... C'est là qu'intervient la dérivation de typeclasse, qui consiste à générer automatiquement des instances pour des typeclasses données.

Cas d'utilisation : vers le JSON et au-delà

Partons de l'ADT ci-dessous (Algebraic Data Type - type de données algébrique - comprenez un ensemble de trait, case class et case object pour former un type composite) pour représenter des documents JSON.

sealed trait JsonValue
case class   JsonNumber(value: Double)                        extends JsonValue
case class   JsonString(value: String)                        extends JsonValue
case class   JsonBoolean(value: Boolean)                      extends JsonValue
case object  JsonNull                                         extends JsonValue
case class   JsonArray(value: List[JsonValue])                extends JsonValue
case class   JsonObject(value: List[(JsonString, JsonValue)]) extends JsonValue
sealed trait JsonValue
case class   JsonNumber(value: Double)                        extends JsonValue
case class   JsonString(value: String)                        extends JsonValue
case class   JsonBoolean(value: Boolean)                      extends JsonValue
case object  JsonNull                                         extends JsonValue
case class   JsonArray(value: List[JsonValue])                extends JsonValue
case class   JsonObject(value: List[JsonField])               extends JsonValue
case class   JsonField(name:String,value:JsonValue)

Ce qui donne le schéma ci-dessous, où un JsonValue est soit un JsonNumber, un JsonString, un JsonNull, un JsonArray ou un JsonObject.

Et pour des soucis de lisibilité, voici une fonction permettant de convertir des JsonValue en chaîne de caractères JSON.

def mkString(jsonValue: JsonValue): String =
  jsonValue match {
    case JsonNull        => "null"
    case JsonString(v)   => "\"" + v + "\""
    case JsonBoolean(v)  => s"$v"
    case JsonNumber(v)   => s"$v"
    case JsonArray(vs)   => vs.map(mkString).mkString("[", ", ", "]")
    case JsonObject(kvs) =>
      kvs
        .map { case (k, v) => s"${mkString(k)}: ${mkString(v)}" }
        .mkString("{", ", ", "}")
  }

Ce qui donne par exemple :

> mkString(JsonArray(JsonNumber(42), JsonString("all work and no play makes jack a dull boy")))
res0: String = [42.0, all work and no play makes jack a dull boy]

Notre objectif est de convertir n'importe quel type A en JsonValue. Nous créons pour cela une typeclasse ToJson.

trait ToJson[A] {
  def toJson(a: A): JsonValue
}

Afin de facilter l'utilisation de ToJson, nous allons associer la méthode d'extension toJson à tous les types A de catégorie ToJson.

implicit class ToJsonOps[A: ToJson](a: A) {
  def toJson: JsonValue = implicitly[ToJson[A]].toJson(a)
}

Ici, implicitly permet de trouver une instance implicite dont le type correspond au type passé en paramètre.

Nous créons à présent des instances de cette typeclasse pour des types de base.

implicit val intToJson:             ToJson[Int]     = a => JsonNumber(a.toDouble)
implicit val doubleToJson:          ToJson[Double]  = a => JsonNumber(a)
implicit val stringToJson:          ToJson[String]  = a => JsonString(a)
implicit val booleanToJson:         ToJson[Boolean] = a => JsonBooolean(a)
implicit def ListToJson[A: ToJson]: ToJson[List[A]] =
  a => JsonArray(a.map(_.toJson))
implicit def mapToJson[A: ToJson]: ToJson[List[(String, A)]] =
  a => JsonObject(a.map { case (k, v) => (JsonString(k), v.toJson) })

Ce qui donne :

> 42.toJson
res0: JsonValue = JsonNumber(42.0)

> "all work and no play makes jack a dull boy".toJson
res1: JsonValue = JsonString(all work and no play makes jack a dull boy)

> mkString(List("a" -> 1, "b" -> 3).toJson)
res2: String = {"a": 1.0, "b": 3.0}

> implicitly[ToJson[Int]].toJson(42)
res3: JsonValue = JsonNumber(42.0)

> 42L.toJson
<console>:19: error: value toJson is not a member of Long

> implicitly[ToJson[Long]].toJson(42L)
<console>:19: error: could not find implicit value for parameter e: ToJson[Long]

(Les deux derniers cas génèrent une erreur puisque nous n'avons pas déclarer d'instance implicite de type ToJson[Long] auparavant.)

Nous allons à présent dériver automatiquement ToJson pour toutes les case classes que nous pouvons créer. C'est là que nous allons utiliser Magnolia.

Magnolia se compose d'une macro et d'un ensemble d'interfaces pour réifier (ie. convertir en objet) les case classes et les sealed traits. Pour utiliser Magnolia, il faut bien évidemment importer le package correspondant et activer les macros (oui, les macros sont en Scala 2 et donc vouées à disparaître (d'où le experimental) ; nous verrons comment est-ce ça se passe pour les futures versions). Il faut ensuite déclarer notre typeclasse de la manière suivante :

import magnolia._
import scala.language.experimental.macros

type Typeclass[A] = ToJson[A]

Nous allons maintenant déclarer une fonction combine qui va permettre de donner un comportement lié à notre typeclasse sur n'importe quel case classe. Ici nous allons chercher à convertir des case classes en objet JSON.

def combine[A](cc: CaseClass[Typeclass, A]): Typeclass[A] =
  a => {
    val paramMap: List[(JsonString, JsonValue)] =
      cc.parameters
        .map(p => JsonString(p.label) -> p.typeclass.toJson(p.dereference(a)))
        .toList

    JsonObject(paramMap)
  }

À chaque fois que vous voyez Typeclass dans le code ci-dessus, il s'agit bien de ToJson derrière. Il est d'ailleurs possible de changer toutes les occurences de Typeclass par ToJson.

Dans notre case classe cc, passée en entrée de combine, nous allons plus particulièrement nous intéresser aux champs (ou paramètres). Pour chaque paramètre de la case classe, nous récupérons le nom du paramètre (p.label) converti en JsonString que nous associons à la valeur de ce paramètre, elle-même convertie en JSON. p.dereference(a) permet de récupérer la valeur associée au paramètre p dans l'instance a. p.typeclass permet de récupérer l'instance de la typeclasse associé au paramètre p. De fait, p.typeclass va retourner soit l'une des valeurs implicites déclarées plus haut si p correspond à un type de base, soit réaliser un appel récursif sur combine si p correspond à une case classe.

Pour rendre combine accessible, nous utilisons la macro gen de Magnolia de la manière suivante :

implicit def typeToJson[A]: Typeclass[A] = macro Magnolia.gen[A]

typeToJson est déclarée en tant que fonction (def). Ce qui permet d'utiliser la notation générique, où A représente n'importe quel type. Le fait de mettre implicit permet d'associer implicitement ToJson à toutes les case classes que nous rencontrons.

Avec les exemples de case classes Person et Address vu au début de l'article, nous obtenons :

> val toto =
  Person(
    id = "p1",
    name = "Toto",
    age = 32,
    address = Address("c1", "Paris"))
toto: Person = Person(p1, Toto, 32, Address(c1, Paris))

> mkString(toto.toJson)
res0: String = {"id": "p1", "name": "Toto", "age": 32.0, "address": {"id": "c1", "city": "Paris"}}

Conclusion

Il existe des alternatives à Magnolia pour réaliser de la dérivation de typeclasse. Il est possible de passer tout d'abord par Shapeless. Shapeless est un framework léger dédié à la programmation générique. Comme il est plus généraliste la dérivation de typeclasse peut se montrer plus verbeuse qu'avec Magnolia. Il est possible aussi de passer directement par les macros. Néanmoins, comme ce système est de plus bas niveau, il sera au mieux plus difficile de faire de la dérivation.

Avec la probable disparition des macros whitebox dans Scala 3 (type de macro intervenant avant la résolution des types), macros utilisées par Magnolia, la nouvelle version de Scala devrait adopter un outillage permettant de réaliser de la dérivation de typeclasse. Cet outillage tournera autour de nouveaux éléments de syntaxe et une partie spécifique dans la bibliothèque du SDK.

Pour terminer, Magnolia est notamment utilisé dans Scio, développé par Spotify. Scio est une API Scala pour Apache Beam et Google Cloud Dataflow. Julien Tournay a présenté une session ce sujet à ScalaIO 2018.

Projet contenant le code de l'article : https://github.com/univalence/blog-code/tree/master/typeclass_derivation