Trois façons d'utiliser Jackson pour JSON avec Java

May 07, 2020
Rédigé par

Trois façons d'utiliser Jackson pour JSON avec Java

Si vous avez l'habitude d'un langage de type statique comme Java, travailler avec du JSON peut s'avérer difficile. JSON n'a pas de définition de type et ne dispose pas de certaines fonctionnalités qui pourraient être utiles : il n'y a que des chaînes de caractères, des nombres, des booléens et des null. Alors, pour stocker d'autres types d'éléments (comme une date ou une heure), nous sommes obligés d'utiliser une convention basée sur des chaînes. Malgré ses lacunes, JSON est le format le plus courant pour les API sur le Web. Nous avons donc vraiment besoin de trouver un moyen de travailler avec dans Java.

Jackson est l'une des bibliothèques Java pour JSON les plus courantes et c'est celle que j'utilise le plus fréquemment. Pour cet article, j'ai choisi un document JSON assez complexe et trois requêtes que j'aimerais effectuer à l'aide de Jackson. Je vais comparer trois approches différentes :

  1. Modèle d'arborescence
  2. Liaison de données
  3. Requêtes de chemin d'accès

La totalité du code utilisé dans cet article se trouve dans ce répertoire. Il fonctionne avec Java 8 et ses versions ultérieures.

Autres bibliothèques Java pour travailler avec JSON

Pour travailler avec JSON, les librairies Java les plus populaires sont Jackson et Gson, selon les taux d'utilisation dans Maven Central et les notes GitHub. Dans cet article, je vais utiliser Jackson. Vous pouvez également vous référer à un article équivalent avec des exemples de code Gson ici.

Vous pouvez jeter un œil à des exemples de dépendance Jackson ici.

Exemples de données et de questions

Pour trouver des exemples de données, j'ai lu l'article que Tilde a écrit récemment : 7 API intéressantes dont vous ignoriez avoir besoin. J'ai choisi l'API de NeoWs (Near Earth Object Web Service ou service Web des objets proches de la Terre) parmi les API de la NASA. Cette API est gérée par l'équipe très brillamment nommée SpaceRocks.

La requête de l'API de flux NeoWs renvoie la liste de tous les astéroïdes qui se rapprocheront le plus de la Terre au cours des 7 prochains jours. Je vais vous montrer comment répondre aux questions suivantes dans Java :

  • Combien y en a-t-il ?
    Vous pouvez trouver cette information en examinant la clé element_count à la racine de l'objet JSON.
  • Combien d'entre eux sont potentiellement dangereux ?
    Il faut faire une boucle avec chaque NEO (objet proche de la Terre) et vérifier la clé is_potentially_hazardous_asteroid, qui est une valeur booléenne dans JSON. (Spoiler ! Ce n'est pas zéro.)
  • Quel est le nom et la vitesse de l'objet proche de la Terre le plus rapide ?
    Encore une fois, nous devons faire une boucle, mais cette fois, les objets sont plus complexes. Nous devons également être conscients que les vitesses sont stockées sous forme de chaînes de caractères et non de chiffres, par exemple : "kilometers_per_second": "6.076659807". C'est assez courant dans les documents JSON car cela évite les problèmes de précision avec les nombres très petits ou très grands.

Un modèle d'arborescence JSON

Jackson vous permet de lire le JSON sous forme de modèle d'arborescence : des objets Java qui représentent des objets, des séries et des valeurs JSON. Ces objets peuvent s'appeler JsonNode ou JsonArray, et ils sont fournis par Jackson.

Avantages :

  • Vous n'aurez pas besoin de créer vous-même des classes supplémentaires
  • Jackson peut réaliser des contraintes de type implicite et explicite pour vous

Inconvénients :

  • Votre code, qui opère avec les objets du modèle d'arborescence Jackson, peut être dense
  • Il est très tentant de combiner le code Jackson à la logique de l'application, mais cela risque de compliquer la lecture et le test de votre code

Exemples de modèles d'arborescence Jackson

Jackson utilise une classe appelée ObjectMapper comme point d'entrée principal. En général, je crée un nouvel ObjectMapper au démarrage de l'application et comme les instances d'ObjectMapper sont thread-safe, on peut le traiter comme un singleton.

Un ObjectMapper peut lire le code JSON à partir de diverses sources à l'aide d'une méthode readTree surchargée. Ici, j'ai fourni une chaîne. readTree renvoie un JsonNode qui représente la racine du document JSON. Les instances JsonNode peuvent être des JsonObjects, des JsonArrays ou toute une variété de nœuds de « valeur », comme TextNode ou IntNode.

Voici le code permettant d'analyser une chaîne JSON en JsonNode :

ObjectMapper mapper = new ObjectMapper();
JsonNode neoJsonNode = mapper.readTree(SourceData.asString());

[ce code dans le répertoire d'exemple]

Combien y a-t-il de NEO ?

Il faut trouver la clé element_count  dans le JsonNode et la renvoyer sous la forme int. Le code se lit plutôt bien :

int getNeoCount(JsonNode neoJsonNode) {
   return neoJsonNode
       .get("element_count")
       .asInt();
}

[ce code dans le répertoire d'exemple]

Gestion des erreurs : sans element_count , .get("element_count") renvoie null et il y a une exception NullPointerException avec .asInt(). Si vous utilisez .path  plutôt que .get, Jackson peut utiliser le modèle d'objet Null pour éviter ce type d'exception. Dans les deux cas, vous devrez corriger les erreurs. Pour ma part, j'ai tendance à utiliser .get.

Combien d'astéroïdes potentiellement dangereux y a-t-il cette semaine ?

Pour cette question, je m'attendais à ce que la réponse soit zéro. En réalité, c'est 19. Mais je ne panique pas pour autant (pour l'instant). Pour calculer cela à partir de la racine JsonNode, nous devons :

  • Itérer tous les NEO - il existe une liste de ces éléments pour chaque date, nous aurons donc besoin d'une boucle imbriquée
  • Incrémenter un compteur si le champ is_potentially_hazardous_asteroid est true.

Voici le code :

int getPotentiallyHazardousAsteroidCount(JsonNode neoJsonNode) {
   int potentiallyHazardousAsteroidCount = 0;
   JsonNode nearEarthObjects = neoJsonNode.path("near_earth_objects");
   for (JsonNode neoClosestApproachDate : nearEarthObjects) {
       for (JsonNode neo : neoClosestApproachDate) {
           if (neo.get("is_potentially_hazardous_asteroid").asBoolean()) {
               potentiallyHazardousAsteroidCount += 1;
           }
       }
   }
   return potentiallyHazardousAsteroidCount;
}

[ce code dans le répertoire d'exemple]

C'est un peu spécial : Jackson ne nous permet pas directement d'utiliser l'API Streams, donc j'ai recours à une boucle imbriquée. asBoolean renvoie la valeur des champs booléens dans JSON, mais peut également être appelé sur d'autres types :

  • les nœuds numériques (numeric nodes) seront résolus comme true s'ils n'étaient pas égaux à zéro
  • les nœuds de texte (text nodes) sont true si la valeur est "true"

Quel est le nom et la vitesse du NEO le plus rapide ?

La méthode de recherche et d'itération de chacun des NEO est la même que dans l'exemple précédent, mais la vitesse de chaque NEO est imbriquée sous plusieurs niveaux, vous devez donc les parcourir pour choisir la valeur kilometers_per_second.

"close_approach_data": [

 {
    ...
     "relative_velocity": {
         "kilometers_per_second": "6.076659807",
         "kilometers_per_hour": "21875.9753053124",
         "miles_per_hour": "13592.8803223482"
   },
  ...
 }
]

J'ai créé une petite classe appelée NeoNameAndSpeed pour contenir les deux valeurs. Cela peut devenir un record par la suite. Le code crée l'un de ces objets de la manière suivante :

NeoNameAndSpeed getFastestNEO(JsonNode neoJsonNode) {
   NeoNameAndSpeed fastestNEO = null;
   JsonNode nearEarthObjects = neoJsonNode.path("near_earth_objects");
   for (JsonNode neoClosestApproachDate : nearEarthObjects) {
       for (JsonNode neo : neoClosestApproachDate) {
           double speed = neo
               .get("close_approach_data")
               .get(0)
               .get("relative_velocity")
               .get("kilometers_per_second")
               .asDouble();
           if ( fastestNEO == null ||  speed > fastestNEO.speed ){
               fastestNEO = new NeoNameAndSpeed(neo.get("name").asText(), speed);
           }
       }
   }
   return fastestNEO;
}

[ce code dans le répertoire d'exemple]

Même si les vitesses sont stockées sous forme de chaînes dans JSON, je pourrais appeler .asDouble() ‌- Jackson est suffisamment intelligent pour appeler Double.parseDouble  à ma place.

Liaison de données (data biding) JSON aux classes personnalisées

Si vous avez des requêtes de données plus complexes, ou si vous devez créer des objets à partir de JSON pour les transmettre à un autre code, le modèle d'arborescence n'est pas adapté. Jackson propose un autre mode de fonctionnement appelé liaison de données, qui permet de décomposer directement JSON en objets de votre propre design. Par défaut, Spring MVC utilise Jackson de cette manière lorsque vous acceptez ou renvoyez des objets à partir de vos contrôleurs Web.

Avantages :

  • La conversion JSON en objets est simple
  • La lecture de valeurs à partir d'objets peut utiliser n'importe quelle API Java
  • Les objets sont indépendants de Jackson et peuvent donc être utilisés dans d'autres contextes
  • Le mapping est personnalisable avec des Modules Jackson

Inconvénients :

  • Travail en amont : vous devez créer des classes dont la structure correspond aux objets JSON, puis demander à Jackson de lire votre JSON dans ces objets

Introduction à la liaison de données (data binding)

Voici un exemple simple basé sur un petit sous-ensemble de NEO JSON :

{
 "id": "54016476",
 "name": "(2020 GR1)",
 "closeApproachDate": "2020-04-12",
}

Imaginons une classe capable de contenir les données comme suit :

class NeoSummaryDetails {
    public int id;
    public String name;
    public LocalDate closeApproachDate;
}

Jackson est presque prêt à l'emploi pour mapper des objets JSON et faire correspondre des objets similaires. Il gère assez bien le fait que l'objet int id  soit une chaîne, mais il a besoin d'aide pour convertir la chaîne 2020-04-12  en objet LocalDate. Il réalise cela à l'aide d'un module personnalisé, qui définit un mappage JSON pour les types d'objets personnalisés.

Liaison de données Jackson : styles personnalisés

Pour le mapping LocalDate, Jackson fournit une dépendance. Ajoutez ceci à votre projet et configurez votre ObjectMapper comme suit :

ObjectMapper objectMapper = new ObjectMapper()
       .registerModule(new JavaTimeModule());

[ce code dans le répertoire d'exemple]

Liaison de données Jackson : noms de champs personnalisés

Vous avez peut-être remarqué que j'ai utilisé closeApproachDate dans mon exemple JSON ci-dessus, alors que les données de la NASA utilisent close_approach_date. J'ai fait cela parce que Jackson utilisera les capacités de réflexion de Java pour faire correspondre les clés JSON aux noms de champ Java (et ils doivent parfaitement correspondre).

La plupart du temps, vous ne pouvez pas modifier votre JSON : il provient généralement d'une API que vous ne contrôlez pas. Mais vous voulez tout de même éviter que des champs soient écrits en snake_case dans vos classes Java. C'est réalisable grâce à une annotation sur le champ closeApproachDate :

@JsonProperty("close_approach_date")
public LocalDate closeApproachDate;

[ce code dans le répertoire d'exemple]

Création d'objets personnalisés avec JsonSchema2Pojo

Vous êtes probablement en train de vous dire que tout cela demande beaucoup de temps. Changement de noms de champ, lecture et écriture personnalisées… Sans oublier le nombre de classes que vous devrez peut-être créer. Eh bien, vous avez raison ! Mais ne vous inquiétez pas, il existe un excellent outil capable de créer les classes à votre place.

JsonSchema2Pojo peut se baser sur un schéma JSON ou, plus utile encore, sur un document JSON afin de générer des classes correspondantes pour vous. Il comprend les annotations de Jackson et propose de nombreuses options, même si les valeurs par défaut sont déjà bien adaptées. En général, il fait 90 % de mon travail à ma place, mais les classes ont souvent besoin d'être peaufinées une fois générées.

Pour l'utiliser pour ce projet, j'ai supprimé tous les NEO sauf un, et j'ai sélectionné les options suivantes :

Capture d'écran - options à sélectionner dans JsonSchema2Pojo

[code généré dans le répertoire d'exemple]

Données stockées dans les clés et les valeurs

Le JSON de NeoWS possède une fonctionnalité un peu particulière (mais pas inhabituelle) : certaines données sont stockées dans les clés plutôt que dans les valeurs des objets JSON. Le mappage near_earth_objects comporte des clés qui sont des dates. Cela pose un problème, car les dates ne seront pas toujours les mêmes, et bien sûr, jsonschema2pojo ne le sait pas. Il a créé un champ appelé _20200412. Pour résoudre ce problème, j'ai changé le nom de la classe _20200412 en NeoDetails et le type de nearEarthObjects est devenu Map<String, List<NeoDetails>> (à consulter ici). Je peux alors supprimer la classe NearEarthObjects qui n'est plus utilisée.

J'ai également modifié les types de numbers-in-strings de String en double et j'ai ajouté LocalDate là où c'était nécessaire.

Liaison de données Jackson pour l'API NEO

Avec les classes générées par JsonSchema2Pojo, l'ensemble du volumineux document JSON peut être lu avec :

NeoWsDataJackson neoWsDataJackson = new ObjectMapper()
   .registerModule(new JavaTimeModule())
   .readValue(SourceData.asString(), NeoWsDataJackson.class);

Trouver les données voulues

Maintenant que nous disposons d'anciens objets Java simples, nous pouvons tirer parti de l'accès aux champs et de l'API Streams pour trouver les données dont nous avons besoin :

System.out.println("NEO count: " + neoWsData.elementCount);
System.out.println("Potentially hazardous asteroids: " +
   neoWsData.nearEarthObjects.values()
       .stream().flatMap(Collection::stream) // this converts a Collection of Collections of objects into a single stream
       .filter(neo -> neo.isPotentiallyHazardousAsteroid)
       .count());
NeoDetails fastestNeo = neoWsData.nearEarthObjects.values()
   .stream().flatMap(Collection::stream)
   .max( Comparator.comparing( neo -> neo.closeApproachData.get(0).relativeVelocity.kilometersPerSecond ))
   .get();
System.out.println(String.format("Fastest NEO is: %s at %f km/sec",
   fastestNeo.name,
   fastestNeo.closeApproachData.get(0).relativeVelocity.kilometersPerSecond));

[ce code dans le répertoire d'exemple]

Ce code est plus compatible avec le langage Java, et il n'y a pas du tout de Jackson dedans, ce qui facilite les tests individuels de cette version. Si vous travaillez souvent avec le même format JSON, l'investissement dans la création de classes en vaut probablement la peine.

Requêtes de chemin d'accès depuis JSON à l'aide de JsonPointer

Avec Jackson, vous pouvez également utiliser un pointeur JSON. C'est un moyen compact de faire référence à une valeur unique spécifique dans un document JSON :

JsonNode node = objectMapper.readTree(SourceData.asString());
JsonPointer pointer = JsonPointer.compile("/element_count");
System.out.println("NEO count: " + node.at(pointer).asText());

[ce code dans le répertoire d'exemple]

Les pointeurs JSON ne peuvent pointer que vers une seule valeur. Vous ne pouvez pas agréger ou utiliser des caractères génériques, ils sont donc plutôt limités.

Résumé des différentes façons d'utiliser Jackson

Pour les requêtes simples, le modèle d'arborescence peut vous être très utile, mais vous devrez probablement combiner l'analyse JSON et la logique d'application, ce qui peut rendre le test et la maintenance plus compliqués.

Pour extraire une valeur unique d'un document JSON, vous pouvez envisager d'utiliser un pointeur JSON, mais le code est à peine plus simple que le modèle d'arborescence, alors je ne le fais presque jamais.

Pour les requêtes plus complexes, et en particulier lorsque votre analyse JSON fait partie d'une application plus importante, je recommande la liaison de données. C'est généralement plus facile à long terme, puisque JsonSchema2Pojo se charge d'une bonne partie du travail pour vous.

Quelles sont vos méthodes de travail préférées avec JSON pour Java ? Partagez-les sur mon Twitter @MaximumGilliard, ou par e-mail à mgilliard@twilio.com. J'ai hâte de voir ce que {"you": "build"}.