Migration de librairie de sérialisation en JSON

Aurore de Amaral
Meetic
Published in
6 min readApr 5, 2023
Photo by Sebastian Brito on Unsplash

Voici le contexte de l’approche de test :

  • Un des microservices Scala de notre équipe utilisait deux librairies pour la sérialisation JSON
  • Ce microservice est un consumer de messages Kafka qui enrichit des données pour les indexer dans un ElasticSearch
  • Les messages Kafka contiennent du JSON et les requêtes HTTP ont une payload en JSON
  • Une des librairies utilisée dans le projet, spray-json, n’est plus maintenu activement (dernière version disponible fin 2021)
  • Il s’agissait de comparer différentes librairies pour la sérialisation JSON

Les choix qui ont été étudiés :

  • play-json (play-json) : qui est l’autre librairie utilisée dans le projet (336 ⭐)
  • jsoniter-scala (jsoniter) : qui est une librairie qui a de très bonnes performances (599 ⭐)
  • circe (circe) : qui est une librairie très utilisée dans l’écocystème Scala (2,4K ⭐)

TL;DR des solutions de migration

Voici un récapitulatif des résultats :

L’idée de cet article est de présenter quelques choix et donner un exemple pour orienter de futurs développeurs migrateurs 😉

Differences spray-json, play-json, jsoniter and circe

Une étape préparatoire

Une première étape permettant un refactoring plus efficace a été mis en place. Tous les tests sont partis d’une même branche git avec cette méthode contenant une métrique avec Kamon pour tester uniquement la latence de la sérialisation :

object MessageJSONCodec extends DefaultJsonProtocol {

private val histogram = Kamon
.timer("parsing-latency")
.withTag(this.getClass.getSimpleName, "latency")

def parseTo[T](json: String)(read: JsonReader[T]): Try[T] = {
val timer = histogram.start()
val monad = Try(json.parseJson.convertTo[T](read))
timer.stop()
monad
}

//Puis tous les implicit pour spray-json
implicit val memberAccountFormat = jsonFormat1(MemberAccount)
}

Plutôt que d’utiliser le parseJson dans chacune des classes qui sont utilisées.

La performance a été d’abord testée sur un kafka qui envoyait plusieurs batchs de tous les messages possible à partir d’une documentation (à jour on espère) de la liste de tous les topics/messages.Puis sur un message en particulier en envoyant de grandes quantités de messages à la fois.

Le consumer a été enfin testé en utilisant des tests manuels sur la recette avec les autres services à jour iso-prod.

Un peu d’exemples de code

play-json

La librairie crée par le framework Play! permet de sérialiser facilement du JSON avec des implicits de Reads ou Writes et ceux des types et classes communes à Scala sont déjà là. Plus qu’à le rajouter à nos classes, faire des règles de validation, et parcourir le chemin JSON.

Voici la réécriture de la méthode parseTo :

def parseTo[T](json: String)(read: Reads[T]): Try[T] = {
val timer = histogram.start()
val monad = Try(Json.parse(json).as[T](read))
timer.stop()
monad
}

//implicits
implicit val memberAccountFormat = Json.reads[MemberAccount]

On a deux cas particuliers qui nécessitent un implicit de Reads et Writes particulier :

  • Les OffsetDateTime ne sont pas toujours écrit avec le format standard ISO
  • Le cas des ids de membres sont, suivant les topics ou l’ancienneté du message, des Int ou des String de nombre

Le choix a été fait de rajouter ces implicits :

implicit def offsetDateTimeF: Format[OffsetDateTime] = new Format[OffsetDateTime] {
override def reads(json: JsValue): JsResult[OffsetDateTime] = {
Json
.fromJson[OffsetDateTime](json)(DefaultOffsetDateTimeReads)
.orElse(Json.fromJson[String](json).map(MagicDateTime.fromString))
}
override def writes(offset: OffsetDateTime): JsValue = Json.toJson(MagicDateTime.toString(offset))
}

val readIntFromString: Reads[Int] = implicitly[Reads[String]]
.map(x => Try(x.toInt))
.collect(JsonValidationError(Seq("Parsing error"))) { case Success(a) =>
a
}
val readInt: Reads[Int] = implicitly[Reads[Int]].orElse(readIntFromString)

//Utilisé comme ci-dessous
implicit def sessionStartDetailR: Reads[SessionStartDetail] = (
(__ \ "memberId").read[Int](readInt) and
(__ \ "eventDate").read[OffsetDateTime]
)(SessionStartDetail.apply _)

Concernant la validation de valeur, nous n’avons pas d’exemples dans le projet donc cette partie n’a pas été étudiée.

jsoniter-scala

Cette librairie apporte un sacré gain d’efficience et de sécurité, notamment car l’implémentation diffère beaucoup des autres solutions qui existent (avec la runtime reflection ou les AST par exemple). Cependant, avec jsoniter-scala il y avait plus de changement de code à faire, notamment car il est plus fastidieux d’écrire des équivalents aux Reads de play-json. Et il fallait aussi faire un travail supplémentaire en supprimant les deux librairies, spray-json et play-json

Voici la réécriture de la méthode parseTo :

def parseTo[T](json: String)(implicit codec: JsonValueCodec[T]): Try[T] = {
val timer = histogram.start()
val monad = Try(readFromString[T](json)(codec))
timer.stop()
monad
}

Et l’exemple avec les OffsetDateTime :

implicit val customCodecOfOffsetDateTime: JsonValueCodec[OffsetDateTime] = new JsonValueCodec[OffsetDateTime] {
override def decodeValue(in: JsonReader, default: OffsetDateTime): OffsetDateTime = {
val dateString = in.readString("")
Try(MagicDateTime.fromString(dateString))
.orElse(Try(OffsetDateTime.parse(dateString)))
.get
}

override def encodeValue(x: OffsetDateTime, out: JsonWriter): Unit = out.writeVal(MagicDateTime.toString(x))

override def nullValue: OffsetDateTime = null
}

Celui sur les ids des membres est plus complexe et dépend de la structure JSON (pas toujours identique pour les messages des différents topics ou les payloads HTTP). Le plus simple est de passer par une réécriture du code existant pour transformer toutes les classes ayant memberId: Int en un nouveau type memberId: ID (puis associer un custom codec à ce type).

De plus, comme recommandé dans la doc officielle, il vaut mieux rajouter un companion object pour chaque classe, rendant plus fastidieux le changement (et est considéré comme moins hexagonal friendly par l’équipe). Mais l’avantage de cette librairie, c’est qu’il n’est pas nécessaire de le faire pour les autres types utilisés dans la classe parent :

case class SessionStart(
...,
details: SessionStartDetail
)

case class SessionStartDetail(...)

object SessionStart {
import my.package.MessageJsonCodec.customCodecOfOffsetDateTime
implicit val codec: JsonValueCodec[SessionStart] = JsonCodecMaker.make
}
// et pas besoin d'un codec pour SessionStartDetail

Mais aussi rajouter des annotations (on doit passer par le parcours du path avec play-json) :

case class AppInstance(
@named("push_token") pushToken: Option[String],
@named("last_open_dt") lastOpenDate: OffsetDateTime
)

circe

Le dernier test portait sur Circe qui est une librairie plus proche du fonctionnement de spray-json , basé sur Cats et qui permet aussi pas mal de choses.

Voici la réécriture de la méthode parseTo :

def parseTo[T](json: String)(implicit read: Decoder[T]): Try[T] = {
val timer = histogram.start()
val monad = decode[T](json).fold(Failure(_), Success(_))
timer.stop()
monad
}

//Tous les implicits
implicit val sessionStartDetail: Decoder[SessionStartDetail] = deriveDecoder

Là aussi, il faut modifier les implicits, et plusieurs choix sont possibles, comme dans spray-json . La plus simple pour les ids de membres reste celle trouvée pour jsoniter-scala mais on peut néanmoins creuser l’utilisation des Cursor et HCursor bien que cette solution n’a pas été étudiée.

On peut utiliser très simplement des custom decoder pour les OffsetDateTime :

implicit val decodeOffsetDateTime: Decoder[OffsetDateTime] = Decoder.decodeString.emapTry { str =>
Try(MagicDateTime.fromString(str)).orElse(Try(OffsetDateTime.parse(str)))
}

implicit val encodeOffsetDateTime: Encoder[OffsetDateTime] = Encoder.encodeString.contramap[OffsetDateTime](MagicDateTime.toString)

Et aussi sérialiser avec changement de noms avec les forProductN (à peu près l’équivalent des annotations de jsoniter-scala , couplé avec les Cursor, permet de parcourir les arbres JSON)

Monitoring

Pour rappel, la performance a été d’abord testée sur un kafka qui envoyait 50 itérations de tous les messages possibles (une vingtaine) consommés par notre projet.

Voici le graphe que nous avions ressorti de nos tests automatisés avec Postman, avec, dans l’ordre de gauche à droite : spray-json, play-json, jsoniter-scala et circe

La valeur en ordonné est les millisecondes en temps médian, et en abscisse les secondes.

Latency among all different parsers

Un autre test de monitoring utilisant scalameter a été utilisé sur la sérialisation d’un message JSON particulier un nombre important de fois (10000). les nombres montrent d’autres résultats :

::Benchmark Json.parse::
cores: 12
hostname: my-Latitude-7430
name: OpenJDK 64-Bit Server VM
osArch: amd64
osName: Linux
vendor: Ubuntu
version: 11.0.17+8-post-Ubuntu-1ubuntu222.04
// spray-json
Parameters(sizes -> 10000): 49.372776 ms
// play-json
Parameters(sizes -> 10000): 29.218712 ms // score le plus "flat"
// jsoniter-scala
Parameters(sizes -> 10000): 68.455525 ms
// circe
Parameters(sizes -> 10000): 44.35046 ms

Conclusion

Les résultats concluent que play-json est un bon choix mais est légèrement plus lent à sérialiser que les autres solutions. Le choix entre jsoniter-scala ou circe devra donc porter sur d’autres critères, la facilité à migrer de spray ou celle de prise en main par les autres développeurs (notamment sur l’existant pour les projets que gèrent les équipes). circe semble être la solution la plus proche en terme de développement de par son utilisation présente si l’on met de côté tous les autres critères.

--

--

Aurore de Amaral
Meetic
Writer for

French developer for several years interested in new medias such as conversational experiences. Code in Scala and NodeJS