1. Choix : readValue(...) ou readTree(...)
Pour un débutant, JSON semble être « une manière unique » de données : voici une chaîne, voici ObjectMapper, et puis transformons cela en Java « d'une manière ou d'une autre ». Mais Jackson propose en réalité deux stratégies populaires pour lire un même JSON : soit le convertir directement en DTO typé via readValue(...), soit le lire d'abord dans un arbre de nœuds (JsonNode) via readTree(...) et ensuite extraire les morceaux nécessaires manuellement. Ce choix ressemble à la façon dont vous lisez un livre : soit par la table des matières et les chapitres (structurellement), soit vous extrayez des citations par recherche (flexible, mais on peut rapidement transformer le tout en chaos).
Dans le contexte de ReadLater Starter, c'est important car le catalogue externe de livres n'est pas « notre » JSON. Il est étranger. Il peut être volumineux, parfois étrange, et parfois changeant. Nous devons choisir un style de travail avec lui pour que, plus tard, lorsque nous construirons le client en un artefact cohérent, le code reste lisible et prévisible.
Nous savons déjà transformer du raw JSON en un objet Java avec Jackson. Il faut maintenant répondre à une question plus pratique : quel style de lecture devons-nous adopter comme principal pour l'ensemble du client, et où devons-nous ne garder qu'une flexibilité ponctuelle. Cela déterminera où le contrat du fournisseur sera fixé, à quoi ressemblera le mapper et à quel point le projet supportera sereinement les changements de l'API externe.
Commençons par définir brièvement les deux approches de manière simple, puis comparons-les sur des exemples.
2. Typed DTO : un modèle rigide sans bureaucratie
Lorsque vous lisez JSON dans un DTO typé, vous décrivez à l'avance la forme des données dans une classe Java (dans notre cours, il s'agit le plus souvent d'un record). Vous dites en quelque sorte : « Je m'attends à ce que la réponse ressemble à ceci : une liste docs, chacun avec key, title, authors… ». Jackson fait le gros du travail à votre place : il fait correspondre les champs, vérifie les types, construit les objets. En conséquence, votre code devient plus court, et les erreurs apparaissent plus près de la source du problème (lors du mapping), plutôt que quelque part dans un NullPointerException inattendu trois méthodes plus loin.
Un point psychologique important : un DTO typé n'est pas du « sur-ingénierie ». Ce n'est pas de « l'architecture pour l'architecture ». Pour l'intégration avec une API externe, un DTO typé est un moyen de fixer le contrat que vous utilisez réellement. Et pas « dans votre tête », mais dans le code. Vous avez un type concret, et l'IDE/compilateur vous aident à ne pas oublier ce que vous attendez exactement.
Mini-exemple de DTO typé pour la recherche
Imaginons (simplifié) que le catalogue renvoie ce JSON :
{
"docs": [
{"key": "OL1M", "title": "Clean Code", "authors": ["Robert C. Martin"]}
]
}
On peut créer un DTO fournisseur pour cela. Il s'agit d'un DTO fournisseur, donc les noms des champs peuvent être « comme dans le JSON », même s'ils ne sont pas parfaits pour le style Java. Nous normaliserons plus tard : c'est le modèle interne du projet.
import java.util.List;
// DTO fournisseur : « format des données du fournisseur », les champs sont nommés comme dans le JSON.
record ProviderSearchResponse(List<ProviderBookDoc> docs) {}
// Élément interne du tableau docs : ce qui arrive réellement de l'API externe.
record ProviderBookDoc(String key, String title, List<String> authors) {}
Maintenant, la lecture :
import com.fasterxml.jackson.databind.ObjectMapper;
String json = """
{"docs":[{"key":"OL1M","title":"Clean Code","authors":["Robert C. Martin"]}]}
""";
ObjectMapper mapper = new ObjectMapper(); // Outil principal de Jackson pour le mapping JSON -> objets
ProviderSearchResponse response = mapper.readValue(json, ProviderSearchResponse.class); // DTO typé
// Ensuite, on travaille de manière typée : pas de chaînes magiques "docs"/"title" dans le code métier
System.out.println(response.docs().get(0).title()); // Clean Code
Notez un détail agréable : lorsque vous écrivez response.docs().get(0).title(), il n'y a pas de « chaînes magiques » "docs" et "title" dans tout le code. Tout vit au même endroit : dans le DTO. Oui, il faut écrire le DTO. Mais c'est un coût qui est presque toujours rentable si le champ n'est pas à usage unique.
Typed DTO : la voie principale pour ReadLater Starter
Dans notre projet, la réponse du catalogue n'est pas « pour regarder ». Elle est nécessaire pour :
1) extraire les champs stables (key, title, auteur),
2) les transformer en DTO normalisés du projet (par exemple, CatalogBookSearchItem),
3) afficher à l'utilisateur le résultat dans un format compréhensible.
Et c'est précisément une situation où « le contrat est réutilisé ». Donc, le DTO typé est votre style principal.
3. Arbre JSON : flexibilité et prix
Parlons maintenant de l'arbre JSON. JsonNode est une représentation universelle de JSON comme un ensemble de nœuds : objet, tableau, nombre, chaîne, null. Vous lisez tout le JSON dans un « arbre », puis vous le parcourez : root.get("docs").get(0).get("title")... Cela ressemble au travail avec le DOM dans un navigateur : vous ne décrivez pas les types à l'avance, vous « extrayez les éléments nécessaires en suivant le chemin ».
Cette approche est excellente lorsque vous avez besoin de :
- extraire rapidement un champ qui ne vaut pas la peine d'être transformé en DTO ;
- traiter des « données auxiliaires » qui ne participent pas au modèle normalisé du projet ;
- effectuer une petite « sonde » dans une réponse qui peut changer (mais vous ne voulez pas lier un modèle typé à celle-ci).
Mais l'arbre a un prix : vous commencez à écrire du code avec beaucoup de noms de champs sous forme de chaînes et de chaînes de .get(...). Et si vous n'êtes pas prudent, votre projet se transformera en « voyage dans la jungle de JsonNode », où toute faute de frappe dans "numFound" devient un bug, et l'IDE n'aide pas du tout.
Mini-exemple : lire « un champ auxiliaire »
Supposons que la réponse de recherche contienne un champ numFound (nombre d'enregistrements trouvés), et vous voulez simplement afficher « Total trouvé : N », mais vous ne voulez pas créer un DTO séparé juste pour un seul nombre.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
String json = """
{"docs":[{"key":"OL1M","title":"Clean Code"}],"numFound":1}
""";
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(json); // Lecture du JSON comme un arbre de nœuds
// path(...) est plus sûr que get(...) : si le champ est absent, on n'obtient pas de NPE
int found = root.path("numFound").asInt(0); // 0 — valeur par défaut si le champ est absent/n'est pas un nombre
System.out.println("found = " + found); // found = 1
Notez que j'utilise path(...) plutôt que get(...). C'est une petite astuce mais très importante : path(...) est plus sûr car il renvoie un « nœud manquant », et vous pouvez définir une valeur par défaut via asInt(0). Si vous faites get("numFound").asInt() et que le champ est absent, vous obtiendrez facilement un NullPointerException à un endroit inattendu. Et aujourd'hui, nous apprenons justement à écrire du code qui ne s'effondre pas comme un château de cartes à cause d'un seul champ manquant.
Quand l'arbre est une mauvaise idée
Dès que vous vous surprenez à penser : « Je dois extraire ce champ… et celui-ci… et encore celui-ci… », l'arbre commence presque toujours à perdre face au DTO typé. Parce que vous n'avez pas extrait « un seul morceau », mais vous construisez en fait manuellement un modèle de données, mais sans types et sans l'aide du compilateur.
Et voici le point le plus important : l'arbre JSON n'est pas un style de projet, mais un outil local. Dans ReadLater Starter, l'arbre peut être utile ponctuellement, mais il ne doit pas devenir « la principale façon de lire l'API externe ».
4. Comparaison sur l'exemple de catalog search
Lorsqu'on compare « DTO vs JsonNode », on se dispute souvent comme dans une guerre de religion : « DTO, c'est strict ! JsonNode, c'est flexible ! ». Soyons concrets : nous avons un cas d'utilisation spécifique, catalog search. Nous devons obtenir une liste de livres et afficher quelques champs à l'utilisateur.
Imaginons une réponse simplifiée :
{
"docs": [
{"key": "OL1M", "title": "Clean Code", "authors": ["Robert C. Martin"]}
],
"numFound": 123
}
Pour l'application, nous avons besoin de docs[*].key, docs[*].title, docs[*].authors[0]. C'est la partie stable du résultat. Quant à numFound, c'est utile mais pas nécessaire pour la normalisation ultérieure (c'est plutôt une « information pour l'utilisateur »).
Dans ce cas, l'hybride gagne le plus souvent : un DTO typé pour le « cœur » de la réponse et un arbre pour la « cerise sur le gâteau ». Mais il est important de garder l'hybride dans des limites, sinon vous obtiendrez deux façons de lire tout et n'importe quoi.
Petit tableau comparatif
| Critère | Typed DTO (readValue(...)) | Arbre (readTree(...), JsonNode) |
|---|---|---|
| Lisibilité du code principal | Généralement élevée : accès aux champs via des méthodes | Souvent faible avec les longs chemins root.path(...).path(...)... |
| Support IDE/compilateur | Excellent : types, autocomplétion, erreurs à la compilation | Presque nul : chaînes de champs, erreurs à l'exécution |
| Flexibilité face aux « champs supplémentaires » dans JSON | Nécessite configuration de ObjectMapper / annotations | Élevée : vous ne prenez que ce dont vous avez besoin |
| Quand c'est justifié | Quand les champs sont réellement utilisés et récurrents | Quand 1–2 champs auxiliaires sont nécessaires « sur place » |
Et maintenant, la conclusion clé pour notre projet : pour docs et les champs du livre — DTO typé, pour numFound (si nous l'utilisons uniquement pour un message) — l'arbre est acceptable.
5. Règle simple pour choisir dans ReadLater Starter
Voici une règle que l'on aurait envie d'imprimer et d'afficher près de l'écran. Non pas parce qu'elle est « sacrée », mais parce qu'elle permet d'économiser des heures lors des futurs refactorings.
Si un champ du JSON externe participe au DTO normalisé du projet ou à l'affichage utilisateur de manière régulière, c'est un candidat pour un DTO typé. Vous le décrivez une fois dans le DTO fournisseur et ensuite vous y accédez de manière typée.
Si un champ est nécessaire une seule fois, comme une « information auxiliaire », et qu'il ne devient pas une partie du modèle normalisé (par exemple, « total trouvé 123 »), alors il est acceptable de le lire prudemment depuis l'arbre JSON. Mais il est important que cette lecture soit courte, qu'elle se trouve près de l'endroit du mapping et qu'elle ne propage pas les noms de champs du fournisseur dans tout le projet.
On peut encore simplifier : si vous voyez que vous construisez un long chemin de 4–6 étapes avec JsonNode et que vous commencez à écrire path("docs").path(0).path("authors").path(0), arrêtez-vous et demandez-vous : « Est-ce que je ne suis pas en train de décrire un DTO manuellement, mais en moins bien ? ». Généralement, la réponse sera « oui ».
Mini-exemple : « signal » qu'il est temps de passer au DTO
Ce code ressemble à « je vais extraire l'auteur rapidement »… mais il est déjà désagréablement fragile :
// Long chemin avec indices et noms de champs — signe fréquent qu'il est temps de décrire un DTO.
String author = root.path("docs").path(0).path("authors").path(0).asText("Unknown");
// La valeur par défaut "Unknown" protège des tableaux vides/champs manquants, mais la lisibilité reste médiocre.
System.out.println(author); // Robert C. Martin
Tant qu'il est unique, c'est tolérable. Mais si de tels endroits deviennent dix, vous commencez à vivre dans un monde de chaînes magiques et de « est-on sûr que c'est authors et pas author_name ? ». Et c'est là que le DTO typé cesse d'être de la bureaucratie pour devenir une bouée de sauvetage.
6. Mini-patterns pour un JsonNode soigné
L'arbre JSON n'est utile que lorsque vous l'utilisez comme un scalpel, et non comme une tronçonneuse. Il existe plusieurs astuces simples qui aident à garder l'arbre « local et sûr », et elles correspondent bien à notre niveau actuel.
La première astuce : choisir path(...) plutôt que get(...) presque toujours, lorsque vous lisez des données externes. get(...) est bon quand vous êtes sûr que le champ existe et que vous voulez que l'erreur soit explicite, mais dans une API externe, la certitude est souvent « un optimisme ». path(...) permet de définir une valeur par défaut et d'obtenir un comportement prévisible.
// Si le champ est absent, asInt(0) renvoie 0 — et le code continue de fonctionner de manière prévisible.
int found = root.path("numFound").asInt(0);
System.out.println("numFound = " + found); // numFound = 1
La deuxième astuce : ne pas disperser les chaînes de noms de champs dans tout le projet. Si vous lisez numFound via l'arbre, faites-le à un seul endroit : par exemple, dans une méthode qui analyse la réponse de recherche. Ainsi, si l'API externe renomme le champ, vous n'aurez pas à chercher "numFound" dans tout le dépôt comme un trésor.
import com.fasterxml.jackson.databind.JsonNode;
// Il est préférable de déplacer l'accès au champ du fournisseur dans une méthode séparée et de la garder près de l'analyse de la réponse.
static int readNumFound(JsonNode root) {
return root.path("numFound").asInt(0); // Valeur par défaut en cas d'absence/de type inattendu
}
La troisième astuce : ne pas mélanger « extraire un champ » et « collecter tout le résultat ». Autrement dit, l'arbre est pour une petite information, et la liste docs devrait plutôt passer par un DTO typé. Sinon, vous glisserez facilement vers le style « et si on lisait tout via l'arbre, parce qu'on peut ». On peut, mais ensuite votre code commencera à implorer grâce.
Et encore un point important : si vous utilisez un DTO typé et que votre JSON contient des champs supplémentaires, Jackson peut râler si ObjectMapper est configuré strictement. Dans le cadre du cours, nous rendons généralement ObjectMapper plus tolérant afin que les « champs supplémentaires » externes ne vous gâchent pas la vie à chaque changement du fournisseur.
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
// Rend le mapper « tolérant » : l'API externe peut envoyer de nouveaux champs dont nous n'avons pas encore besoin.
ObjectMapper mapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
Cela ne signifie pas « le contrat ne nous intéresse pas ». Cela signifie « nous ne lisons que ce que nous utilisons réellement », et nous ne tombons pas si le fournisseur ajoute un nouveau champ qui ne nous intéresse pas pour le moment.
7. Erreurs typiques lors du choix entre Typed DTO et JsonNode
Erreur n°1 : faire de l'arbre JSON le style principal du projet parce que « c'est plus rapide pour commencer ».
Oui, au début, il semble que readTree(...) soit un code de triche : rien à décrire, on extrait simplement le champ et c'est prêt. Mais dans un projet réel, cela se transforme rapidement en un tas de chaînes magiques et de longues chaînes de .path(...), difficiles à lire et encore plus à modifier. Pour catalog search et catalog details dans notre cours, la voie principale doit rester orientée DTO.
Erreur n°2 : créer des DTO pour chaque champ rare, même s'il n'apparaît qu'une fois et n'est plus utilisé ailleurs.
C'est l'autre extrême : on peut transformer le projet en une collection de DTO « au cas où », où la moitié des classes ont été créées uniquement pour un seul nombre ou un seul drapeau. En conséquence, vous passez du temps à maintenir ce qui ne vous apporte aucune valeur. Si le champ est vraiment auxiliaire et à usage unique, l'arbre JSON peut être plus honnête et plus simple.
Erreur n°3 : disperser les noms de champs du fournisseur ("numFound", "docs", "authors") dans différentes parties de l'application.
Même si vous lisez un seul champ via JsonNode, gardez cette logique près de l'endroit où vous travaillez avec la réponse du fournisseur. Dès que les chaînes de noms de champs se retrouvent dans main(), dans le code d'affichage, dans différents services — le contrat externe commence à fuir dans tout le projet, et toute modification se transforme en chasse aux fautes de frappe.
Erreur n°4 : utiliser get(...) sans vérification et attraper un NullPointerException pour rien.
Le JSON externe peut ne pas contenir un champ, le tableau peut être vide, le type peut être inattendu. Si vous lisez tout via get(...) et appelez immédiatement asText(), vous construisez un château de cartes. Pour une API externe, il est plus sûr de commencer par path(...) et des valeurs par défaut, et si vous voulez un contrat strict, c'est un argument de plus en faveur du DTO typé.
Erreur n°5 : « l'arbre manuel » devient plus long et plus complexe qu'un DTO typé, mais vous continuez à l'écrire par principe.
Il existe un bon indicateur : si le chemin vers la valeur prend plus d'une ligne et que vous le répétez constamment à plusieurs endroits, alors vous décrivez déjà une structure de données — mais sans types et sans support IDE. À ce moment-là, il est plus simple de s'arrêter et d'extraire honnêtement la partie nécessaire dans un DTO fournisseur, et de laisser l'arbre pour les valeurs vraiment rares et auxiliaires.
GO TO FULL VERSION