:: Enseignements :: Master :: M1 :: 2024-2025 :: Java Avancé ::
[LOGO]

JSON, réflexion et annotations


Ce TD a pour but d'écrire un code transformant des records en texte JSON un utilisant la réflexion et des annotations.

Exercice 1 - Maven

Comme pour les TPs précédents, nous allons utiliser Maven comme outil de build. Dans ce TP, lors des tests, nous avons besoin de vérifier que le JSON produit est correct. Pour cela, nous allons utiliser la bibliothèque jackson.
        <dependency>
          <groupId>com.fasterxml.jackson.core</groupId>
          <artifactId>jackson-databind</artifactId>
          <version>2.18.1</version>
          <scope>test</scope>
        </dependency>
      
Comme cette dépendance est uniquement nécessaire pour les tests, elle est déclarée avec le scope test.
Comme précédemment, créer un projet Maven en cochant create simple project au niveau du premier écran, puis passer à l'écran suivant en indiquant Next.
Pour ce TP, le groupId est fr.uge.json , l'artefactId est json et la version est 0.0.1-SNAPSHOT. Pour finir, cliquer sur Finish.

Exercice 2 - JSON Encoder

On souhaite écrire un code qui permet d'afficher un objet au format JSON.
On suppose qu'il existe un record fr.uge.json.Person avec un prénom firstName et un nom lastName :
package fr.uge.json;

import static java.util.Objects.requireNonNull;
    
public record Person(String firstName, String lastName) {
  public Person {
    requireNonNull(firstName);
    requireNonNull(lastName);
  }
}
    
Dans une classe JSONPrinter, on peut alors écrire la méthode toJSON qui prend en paramètre une Person et renvoie une chaîne de caractères au format JSON :
package fr.uge.json;
   
public class JSONPrinter {
  public static String toJSON(Person person) {
    return """
      {
        "firstName": "%s",
        "lastName": "%s"
      }
      """
        .formatted(person.firstName(), person.lastName());
  }
  
  public static void main(String[] args) {
    var person = new Person("John", "Doe");
    System.out.println(toJSON(person));
  }
}
    
Supposons maintenant qu'il existe un record fr.uge.json.Alien avec un age age et une planète planet :
package fr.uge.json;

import static java.util.Objects.requireNonNull;
     
public record Alien(int age, String planet) {
  public Alien {
    if (age < 0) {
      throw new IllegalArgumentException("negative age");
    }
    requireNonNull(planet);
  }
}
    
si l'on veut aussi pouvoir afficher un Alien au format JSON, on va écrire une autre méthode toJSON dans la classe JSONPrinter :
public class JSONPrinter {
  ...
  public static String toJSON(Alien alien) {
    return """
      {
        "age": %s,
        "planet": %s
      }
      """
        .formatted(alien.age(), alien.planet());
  }
  
  public static void main(String[] args) {
    ...
    var alien = new Alien(100, "Saturn");
    System.out.println(toJSON(alien));
  }
}
    

Si l'on doit dupliquer le code de toJSON à chaque fois que l'on veut transformer en JSON un nouveau record, c'est embêtant...
A kitten dies each time you duplicate a bug !
Pour éviter l'hécatombe, on se propose de modifier la classe JSONPrinter, de commenter le code des deux méthodes toJSON et de les remplacer par une seule méthode toJSON prenant un Record en paramètre et utilisant la réflexion (reflection en anglais) pour trouver les composants du record à écrire au format JSON.

Les tests unitaires sont dans la classe JSONPrinterTest.java.

  1. Avant de se lancer dans l'écriture de la méthode toJSON, on va commencer par écrire une méthode d'aide (helper method) invoke(method, object) qui appelle la méthode method sur l'objet object en utilisant la réflexion. On extrait le code de cette méthode du reste du code, car on veut gérer les exceptions correctement.
    Quelle est la méthode qui permet d'appeler une méthode (de type java.lang.reflect.Method) sur un objet ?
    Quelle est l'exception qui peut être levée...
    • ... parce que les arguments de la méthode de java.lang.reflect.Method ne sont pas bons ?
      Comment doit-on la traiter ?
    • ... parce que la méthode à appeler n'est pas visible ?
      Ici on veut lever une Error si c'est le cas, quelle Error doit-on lever, et comment faire ?
    • ... parce que la méthode appelée lève elle-même une exception checked, une exception non checked ou une erreur ?
      Pour chacun de ses 3 cas, que doit-on faire ?

    Écrire la helper method invoke(method, object) et vérifier que les tests marqués "Q1" passent.

  2. On souhaite maintenant écrire la méthode toJSON qui prend en paramètre un java.lang.Record, utilise la réflexion pour accéder à l'ensemble des composants d'un record (java.lang.Class.getRecordComponent), sélectionne les accesseurs, puis affiche les couples nom du composant, valeur associée au format JSON.
    Puis vérifier que les tests marqués "Q2" passent.

    Note : il est recommandé d'écrire la méthode en utilisant un Stream.
    Note 2 : il y a une petite subtilité avec les guillemets. Dans le format JSON, les chaînes de caractères apparaissent entre "". Nous vous offrons la méthode suivante pour gérer cela :
        private static String escape(Object o) {
          return o instanceof String s ? "\"" + s + "\"": "" + o;
        }
        

  3. En fait, on peut avoir des noms de clé d'objet JSON qui ne sont pas des noms valides en Java, par exemple "book-title", pour cela, on se propose d'utiliser une annotation pour indiquer quel doit être le nom de clé utilisée pour générer le JSON.
    Déclarez l'annotation JSONProperty visible à l'exécution et permettant d'annoter des composants de record, puis modifiez le code de toJSON pour récupérer le nom de la propriété si le composant est annoté par l'annotation JSONProperty.
    Puis vérifier que les tests marqués "Q3" passent.

  4. On souhaite maintenant pouvoir gérer des listes de records, pour cela, nous allons ajouter une surcharge à la méthode toJSON qui prend en paramètre une liste de records.
    Écrire la méthode toJSON(list).
    Puis vérifier que les tests marqués "Q4" passent.

  5. On peut remarquer que lorsque l'on appelle toJSON(list), avec une liste d'instances d'un même record, on va appeler à l'exécution la méthode getRecordComponents plusieurs fois sur la même classe. On souhaite rendre le code un peu plus rapide en évitant d'appeler plusieurs fois cette méthode sur la même classe en utilisant un cache.
    L'appel à getRecordComponents est lent ; regardez la signature de cette méthode et expliquez pourquoi...

  6. Nous allons donc limiter les appels à getRecordComponents en stockant le résultat de getRecordComponents dans un cache pour éviter de faire l'appel à chaque fois qu'on utilise toJSON(record).
    Utilisez la classe ClassValue pour mettre en cache le résultat d'un appel à getRecordComponents pour une classe donnée.
      private static final ClassValue<RecordComponent[]> CACHE = ...;
        
    Modifier la méthode toJSON(record) pour qu'elle utilise le champ CACHE.
    Puis vérifier que les tests marqués "Q4" passent.

  7. En fait, on peut mettre en cache plus d'informations que ce que nous faisons actuellement : la résolution de l'annotation et la recherche de l'accesseur d'un composant du record peuvent être faites une seule fois et non pas à chaque appel.
    On se propose de créer une record ComponentInfo, contenant les informations pré-calculées, pour que, désormais, le CACHE stocke une liste de ComponentInfo :
      private record ComponentInfo(String prefix, Method accessor) {}
        
    Modifier le code d'initialisation du ClassValue ainsi que la méthode toJSON(record) pour que le code soit plus efficace.
    Vérifier que les tests unitaires passent tous.

  8. En fait, utiliser un record ComponentInfo n'est pas la meilleure structure de données ici. Dans notre cas, on utilise le préfixe et l'accesseur pour obtenir une seule valeur, donc on devrait modéliser notre problème non-pas avec un record, mais avec une lambda qui capture le préfixe et l'accesseur.
    Quelle doit être le type des paramètres et le type de retour de la lambda ?
    Quelle est le nom de l'interface fonctionnelle correspondante ?
    Modifier le CACHE pour renvoyer une liste de cette interface fonctionnelle et modifier la méthode toJSON en conséquence.
    Puis vérifier que les tests marqués "Q4" passent encore.