Formats et méthodes de sérialisation REST

Les services Web sont devenus prépondérants dans les architectures techniques actuelles, les notions de micro-services et de services API-first en sont l’exemple parfait. Bien souvent lors de la création de ces services, nous ne réfléchissons que très peu au format d’échange, nous avons tendance à utiliser des messages sérialisés en JSON ou en XML par simplicité et habitude. Néanmoins, la multiplication des services Web qu’une entreprise possède et la communication entre ces derniers peut rapidement créer des situations problématiques lors de montées en version ou de création d’un nouveau service. Il faut donc prendre en compte plusieurs facteurs afin de déterminer la solution technique à utiliser pour la création d’un service :

  • la facilité d’utilisation,
  • la flexibilité,
  • le support (communauté ou entreprise),
  • la performance.

Il faut en effet être capable de mettre rapidement la solution en place, de pouvoir l’utiliser avec différents langages selon le contexte, de disposer d’une communauté suffisamment large soutenant le produit (ou une entreprise) et être performant afin de répondre le plus rapidement possible.

Dans cet article nous allons parler des différentes autres possibilités qui s’offrent à vous lors du choix du format de sérialisation de vos services Web et comment choisir le format correspondant à vos besoins.

Pourquoi changer ?

Il existe de très nombreux formats de sérialisation : JSON, SOAP, XML-RPC, Protocol Buffers, Avro, Apache Thrift, etc. Historiquement, les premiers schémas utilisés dans les applications de service (à grande échelle) sont apparus avec des formats SGML (via DTD), XML (via XSL), etc. Le protocole SOAP par exemple permet de valider les données transitant, mais il pose un problème de lourdeur de création et d’utilisation de l’application. En effet, la rigidité et la verbosité du XML entraîne des surcoûts au niveau du développement. Notamment au niveau des tests où la construction des requête peut nécessiter l’utilisation de programme extérieurs comme “SOAP UI” par exemple. C’est une des raisons qui a entraîné l’ascension du JSON, bien plus clair, mais qui oublie un peu l’aspect validation. Le JSON, très largement utilisé dans les échanges entre services, a permis de mettre en lumière de nombreuses solutions de sérialisation légères et sécurisantes. Pour toutes ces raisons, nous n’étudierons pas le format XML dans cet article. On peut alors se poser la question du changement de format.

Les contrats de service…

Lors de la création d’un service Web utilisant des données plus ou moins complexes, il est nécessaire de définir les types d’objets échangés. Bien souvent, il faut créer les classes représentant ces objets et c’est directement en lisant le code source que l’on obtient des informations sur leur contenu. L’utilisation de schémas permet de représenter les données transitantes de manière simple et compréhensible par le plus grand nombre. C’est un moyen efficace de savoir quel objet est obtenu ou attendu par n’importe quel utilisateur du service. On peut ainsi définir les objets transitants de façon plus structurée et donner plus de sens à la donnée tout en facilitant la compréhension.

On peut aussi parler des fonction de définition de contraintes à appliquer sur chaque champ. Ces contraintes portent sur plusieurs aspects comme le type, le nombre d’occurrences ou les cardinalités et les restrictions de champs obligatoires. On peut ainsi définir des modèles de données structurées et les valider au runtime sans avoir à écrire des lignes de code dans chaque application (sérialisation / désérialisation des données). On évite l’écriture de code de validation et on s’abstrait encore un peu plus du langage de programmation pour permettre au plus grand nombre de consommer ou produire pour notre service Web.

Le JSON dispose d’un système de schéma avec json-schema par exemple. Mais cette solution est très basique et ne procure pas de solution de gestion de contrat de service avancé.

Et leur usage avancé

Ainsi de nombreuses autres fonctionnalités accompagnent l’utilisation des contrats de service avec certains nouveaux formats de sérialisation. Il existe par exemple des mécanismes d’enregistrement de schémas pour ne pas avoir à dupliquer les schémas de chaque côté (client / serveur) du service et ainsi faciliter encore plus leur partage et leur utilisation.

De même les formats comme Avro permettent de générer des classes d’accès aux données s’adaptant au schéma ce qui peut simplifier l’utilisation.

La plupart des technologies que nous allons décrire disposent d’un système d’évolution de schéma. Ce système permet par exemple de définir de nouveaux champs attendus par un service Web, pour ajouter une fonctionnalité par exemple, sans impacter les client du service. Cela élimine tous les codes basés sur une version donnée et notamment les imbrications de “if” permettant de traiter chaque version. Cela permet de limiter les modifications bloquantes sur les types des champs ou encore le respect des contraintes entre les différentes évolutions d’un même schéma. En effet, la plupart du temps, un champ marqué comme requis ne peut pas devenir optionnel dans une nouvelle version du schéma (à moins de changer complètement le type de retour). Un utilisateur d’une ancienne version du schéma sera toujours en capacité d’envoyer des données grâce à cette contrainte. C’est ce qu’on appelle de la rétro-compatibilité. C’est donc un outil très utile mais aussi dangereux : il ne faut pas tout déclarer en required sous peine d’obtenir un schéma peu flexible ! Par exemple, si on ajoute un champ required, il faut que tous les utilisateurs l’aient préalablement inclus pour éviter une erreur de validation de schéma (cette fonctionnalité a d’ailleurs disparue dans proto3).

Mise en place des solutions

En se basant sur la partie précédente nous allons donc comparer trois formats actuels : le JSON, Protocol Buffers et enfin Avro. Pour comparer les différentes méthodes, l’idée est simple. On va utiliser deux applications Java, une serveur et une cliente, exposant quelques métriques pour la performance. Nous mesurerons principalement les temps d’exécution à divers instants de l’application et la taille des messages HTTP. Nous utiliserons Spring Boot pour ces applications et le message envoyé sera une map contenant un type complexe (des personnes).

protocols_app

On a ajouté un filtre sur les requêtes HTTP permettant de récupérer la taille du contenu.

@ConfigurationpublicclassFilter{/** * Filter used to add various header to http responses. * We are particularly interested by the content-length. */@Beanpublic FilterRegistrationBean filterRegistrationBean(){ FilterRegistrationBean filterBean =new FilterRegistrationBean(); filterBean.setFilter(new ShallowEtagHeaderFilter()); filterBean.setUrlPatterns(Collections.singletonList("*"));return filterBean;}}
On utilisera la même application (en modifiant l’utilisation du protocole) afin de valider les différences entre les solutions.

JSON

Commençons par la solution la plus conventionnelle pour la création d’une API, l’utilisation du JSON. Aujourd’hui le JSON est roi et ce pour plusieurs raisons :

  • Facilement lisible pour les humains et très facile à mettre en place,
  • Multi-langage / plateforme, actuellement des parseurs sont disponibles pour la grande majorité des langages de programmation.

Pour retourner des valeurs sous forme de JSON il suffit de créer un objet Java. Par défaut dans Spring Boot c’est le Jackson2HttpMessageConverter qui est utilisé pour sérialiser l’objet JSON pour le message HTTP.

Voici le contrôleur permettant de retourner du JSON:

@RestControllerpublicclassController{private ObjectBuilder objectBuilder;@AutowiredpublicController(ObjectBuilder objectBuilder){this.objectBuilder= objectBuilder;}@RequestMapping(value ="/json", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)public Map<String, JsonPerson> getJsonPeople(@RequestParam(required =false) Integer size){return objectBuilder.jsonBuilder(size);}}
Comme on peut le constater c’est on ne peut plus simple et c’est pourquoi on ne creuse souvent pas plus loin. Mais qu’en est-il des aspects performances ?

Protocol Buffer

Protocol buffer est à l’origine un protocole développé par Google. Il permet de définir des structures de message ou schémas et de les utiliser pour créer du contenu binaire de façon optimisée (le plus léger possible). L’application cliente devra disposer du schéma pour récupérer le contenu original. Voici le schéma .proto concernant notre modèle de test :

optionjava_package="fgarcia.test.protocols.protobuf"; optionjava_outer_classname="ContentProtos"; messagePerson { requiredstringfirstName=1; requiredstringlastName=2; requiredstringaddress=3; requiredint32age=4; repeatedstringmoreInfo=5; } messageMapEntry { requiredstringkey=1; required Person value=2; } messagePeopleList { repeated MapEntry entry=1; }
Le format d’un fichier .proto est très simple. Il ressemble à la déclaration d’une structure en C par exemple. Il suffit de spécifier le package Java et le nom de la classe qui contiendra le code généré et de spécifier le format des messages. Des mots clés tel que “required”, “optional” ou “repeated” permettent de donner des contraintes sur les schémas (attention à la rétrocompatibilité). Ici on a recréé la structure de notre Map sous forme de schéma Protobuf.

Des plugins Maven existent pour permettre de générer les classes correspondant aux schémas et les classes générées permettent de créer des instances à l’aide du pattern builder :

/** * Create a single protobuf Person. */private ContentProtos.PersoncreateProtoPerson(int i){return ContentProtos.Person.newBuilder().setFirstName("Foo "+ i).setLastName("Bar "+ i).setAge(i).setAddress(i +" bar street Paris").addAllMoreInfo(Arrays.asList("foo","bar","babar","foofoo")).build();}
Il suffit ensuite de configurer RestTemplate afin qu’il puisse sérialiser / désérialiser les messages. C’est assez simple car la librairie protobuf-java intègre le ProtobuffHttpMessageConverter.
@Beanpublic RestTemplate restTemplate(){ RestTemplate restTemplate =new RestTemplate(); restTemplate.getMessageConverters().add(protobufHttpMessageConverter()); restTemplate.getMessageConverters().add(avroHttpMessageConverter());return restTemplate;}@Beanpublic ProtobufHttpMessageConverter protobufHttpMessageConverter(){returnnewProtobufHttpMessageConverter();}

Avro

Avro, à l’instar de Protocol Buffer, utilise des schémas pour convertir le texte en contenu binaire (le plus léger possible). Ce protocole est maintenu et développé par la fondation Apache et connaît un grand essor. Il possède la même philosophie que Protocol Buffer. Contrairement à ce dernier, il permet néanmoins de se passer de la génération de code pour les langages non typés et il permet de schématiser des appels RPC… Il est utilisé par divers acteurs importants, par exemple par la solution Confluent encapsulant le broker Kafka.

La création de schéma est cette fois uniquement utile pour les langages fortement typés. C’est le cas du Java, il faut donc définir un schéma et utiliser un outil de génération de classes.

Le format de schéma est encore une fois assez simple. Il correspond en fait à un JSON décrivant le schéma. Cela permet de faciliter l’utilisation des schémas avec des librairies existantes.

[{ "namespace": "fgarcia.test.protocols.avro", "type": "record", "name": "Person", "fields": [ {"name": "firstName", "type": "string"}, {"name": "lastName", "type": "string"}, {"name": "address", "type": "string"}, {"name": "age", "type": "int"}, {"name": "moreInfo", "type": {"type": "array", "items": "string"}} ] }, { "namespace": "fgarcia.test.protocols.avro", "type": "record", "name": "PeopleList", "fields": [ {"name": "items", "type": { "type": "map", "values": "fgarcia.test.protocols.avro.Person"} }] }]
On peut remarquer l’utilisation du type “record” ou encore “map” pour définir des formats de contenu et l’utilisation de types simples pour décrire les valeurs que prendront chacun des champs. Les contraintes d’obligation de champs sont faites en fonction des types de ces derniers, si “null” est indiqué alors le champ est optionnel. Après génération du code à l’aide d’un plugin Maven, on peut créer des objets Avro à l’aide soit du pattern builder pour une validation automatique et une initialisation des variables ou bien de manière classique avec un constructeur.
/*** Create a single avro Person.*/private Person createAvroPerson(int i){return Person.newBuilder().setFirstName("Foo "+ i).setLastName("Bar "+ i).setAge(i).setAddress(i +" bar street Paris").setMoreInfo(Arrays.asList("foo","bar","babar","foofoo")).build();}

RestTemplate ne fournissant pas la compatibilité avec Avro, nous avons dû créer la classe AvroHttpMessageConverter. Implémentation de AbstractHttpMessageConverter, elle convertit les objets java en messages HTTP au format Avro. Cette implémentation est disponible sur GitHub.

Comparatif

La facilité d’utilisation

Comme vu dans la partie précédente, c’est de loin le JSON qui est le plus simple à mettre en place. Il est utilisé de façon native avec Spring Boot et ne nécessite pas de code spécifique. Protobuf et Avro requièrent par contre quelques modifications au niveau sérialisation et désérialisation. Elles sont néanmoins assez simples à mettre en oeuvre. Il existe aussi des librairies permettant de faciliter la conversion de types (binaire => Avro par exemple) et qui réduisent la nécessité d’utiliser la génération de code. Bijection (librairie open source développée par Twitter) rentre par exemple dans cette catégorie.

La flexibilité et support

En terme de flexibilité, c’est ex æquo pour les différents formats présentés. Ils sont tous compatibles avec les langages de programmation majeurs, parfois même avec des fonctionnalités de prise en compte de schéma automatique avec les langages non typés par exemple.

Concernant le support, le JSON est universel et est le format par défaut sur Spring Boot, il est donc largement supporté par la communauté. On peut en dire de même pour Avro qui est maintenu par la fondation Apache et dont la dernière version date de mai 2016. Enfin Protobuf, historiquement créé par Google est aujourd’hui sous licence libre et vient d’être releasé en version 3.0.0.

Utilisation des contrats de service

Nous disions précédemment que les trois formats peuvent définir des contrats de service mais avec une granularité différente. Pour le JSON c’est un support basique avec définition et validation des données. …… Pour Protobuf et Avro ce sont des solutions complètes. On dispose des fonctionnalités de base auxquelles on peut ajouter un schéma registry, un système de versionning poussé et des classes d’accès simples générées à partir des schémas. Il existe même un framework pour effectuer du RPC avec Protobuf.

Performances

Sur 100 requêtes contenant 1000 objets dans la map, nous obtenons les valeurs moyennes suivantes :

JSON

JSON
(GZIP)

Protobuf

Protobuf
(GZIP)

Avro

Avro
(GZIP)

Temps serveur (ms)339,9663,5289,2293,32241,0488,21
Temps client (ms)6,528,4321,6824,6410,2912
Temps total (ms)346,4871,93310,9117,96251,33100,21
Taille (kilobytes)132.374.663.9
Deux jeux de tests ont été effectués : un avec compression GZIP et un sans. Commençons par analyser le cas sans compression.

Pour le JSON on remarque donc que les messages sont assez volumineux et que le traitement total (temps serveur plus temps client) est d’environ 346ms. Les messages sont les plus volumineux de nos tests et le temps de traitement côté serveur est très long. Par contre le temps de désérialisation et le traitement côté client sont très courts. C’est donc le facteur taille des messages qui conditionne le plus le temps serveur pour le JSON.

Pour Protobuf on remarque que les messages sont un peu moins volumineux et que le traitement total est légèrement plus court (310ms). La taille des messages influe sur le temps de traitement côté serveur qui est plus rapide mais la désérialisation côté client est en revanche bien plus longue (quasiment 4 fois plus long que le JSON). C’est donc un facteur à prendre en compte, à savoir si augmenter le traitement du client est acceptable.

Avro crée les documents les plus petits et dispose du temps de traitement total le plus faible (251ms). Le temps de traitement côté client est plus long que pour le JSON mais le temps de transfert serveur est bien plus court. C’est donc la solution la plus performante de ce comparatif. Elle peut être très intéressante dans le cadre d’applications mobiles en Javascript par exemple car elle réduit grandement la taille des objets transférés, ce qui est fondamental pour les connections mobiles.

Si l’on prend en compte la compression GZIP, tout bascule. Le JSON profite énormément de la compression des données pour une très légère augmentation du coût de désérialisation. Ce gain est bien plus significatif que pour les autres formats car la taille de l’objet permet d’avoir un réel impact sur le coût réseau. Il est important pour les autres formats mais ces derniers ont déjà optimisé leurs contenus binaire, l’impact est donc moindre.

Ainsi en terme de performance pure on obtient le classement suivant :

  1. Json Gzippé
  2. Avro Gzippé
  3. Protobuf Gzippé
  4. Avro
  5. Protobuf
  6. Json

Conclusion

On peut dire qu’il n’y a pas de solution miracle. Il faut utiliser le bon outil en fonction du besoin. Le JSON est un format qui a fait ses preuves et qui a la grande force d’être très lisible, et ce directement au sein du navigateur. Ce format est quasi parfait pour tous les points mais est très basique dans son utilisation et peut montrer des limites dans des entreprises de taille importantes ou la multiplication des services demande l’utilisation de fonctions avancés de contrats de service.

Protobuf contrairement à la sérialisation du JSON envoie du binaire, sa lisibilité est donc réduite. Il est dépendant d’un schéma et cela lui confère de bien meilleures performances que le JSON (en non Gzippé) même s’il est assez lourd au niveau désérialisation (côté client). C’est un facteur à prendre à compte pour le choix du format.

Pour finir, Avro pousse un peu plus loin que Protobuf avec des schémas écrits en JSON pour la lisibilité et la facilité d’utilisation. Cela semble être la meilleure alternative si l’on décide de s’éloigner du JSON pour gagner en industrialisation et en fiabilité en perdant le moins de performance et en sacrifiant un peu de simplicité et de lisibilité (format binaire).

On ne connait pas encore les formats qui vont emmerger dans le futur, Protobuf et Avro permettent de s’affranchir du langage et du format pour la transition des données et est notamment utile pour la communication dans des systèmes distribués (comme  HDFS par exemple). Protobuf et Avro entrent aussi en résonance avec l’émergence des technologies d’enregistrement de schémas et de validation des applications de services. Ce sont des outils qui permettent de mutualiser les schémas des protocoles afin qu’ils soient utilisés par différentes équipes, avec différents langages de programmation. Ainsi on valide les données transitant et on évite les incohérences lors de mises à jour de contenu (les schema-registry assurent la rétrocompatibilité). Le changement de format de données permet donc d’apporter plus que la performance. Comme avec SOAP par le passé on dispose donc maintenant de moyens de garder un aspect validation du format de contenu avec des performances aussi bonnes voire meilleures que le standard JSON. Même si ce n’est pas le cas dans les exemples de l’article, il existe des dizaines de formats de sérialisation, plus ou moins connus ou supportés, il est néanmoins peu intéressant d’en comparer des centaines. Vous pouvez tout de même trouver des comparatifs de performance (uniquement) ici par exemple.

L’ensemble du code source est disponible ici : https://github.com/garciafl/protocols

Sources: