Antoine – Java & Moi https://javaetmoi.com Développeur Java, Spring & co, et fier de l'être Mon, 03 Nov 2025 17:50:01 +0000 fr-FR hourly 1 https://wordpress.org/?v=6.9.4 https://javaetmoi.com/wp-content/uploads/2022/05/cropped-java-icon-32x32.png Antoine – Java & Moi https://javaetmoi.com 32 32 Révolutionnez votre prise de notes : du Bullet Journal à Obsidian https://javaetmoi.com/2025/10/revolutionnez-votre-prise-de-notes-du-bullet-journal-a-obsidian/ https://javaetmoi.com/2025/10/revolutionnez-votre-prise-de-notes-du-bullet-journal-a-obsidian/#comments Thu, 30 Oct 2025 16:45:25 +0000 https://javaetmoi.com/?p=2612 Lors de la conférence Devfest Nantes 2025, j’ai assisté au talk d’Hoani Cross portant sur la prise de notes. Loin d’être nouveau, ce sujet m’a particulièrement interpellé. Figurez-vous en effet qu’une partie des articles publiés sur ce blog (dont celui que vous avez sous les yeux) vient des notes rédigées lors de conférences, de projets personnels ou bien encore de ma veille techno.

Dans son talk, Hoani nous présente le logiciel Obsidian, la manière dont il l’utilise au quotidien pour noter et gérer son activité, qu’elle soit professionnelle ou personnelle.
Je suis sorti de sa présentation quelque peu désarçonné. Hoani utilise Obsidian comme un Bullet Journal (qu’on appelle aussi « bujo ») numérique pour compiler notes, pense-bêtes, objectifs, rappels, tracking, plannings et coups de coeur. Son utilisation est vraiment avancée et très régulière. Je ne me voyais pas passer autant de temps que lui sur Obsidian.

L’autre domaine dans lequel Obsidian semble exceller consiste en la possibilité de se créer un un Second Cerveau. Les notes peuvent être reliées ensemble à l’aide de Map Of Content (MOC). Une alternative aux hashtags et l’organisation hiérarchisée en dossiers et sous-dossiers.
Une note de type Map of Content s’assimile à une thématique, un sujet principal, auquel on rattache bidirectionnellement des notes et qui va faire office de table de matières et de tableau de bord. La création de sous-MOCs spécialisés reste possible. On se rapproche du web, des liens hypertextes et du mind mapping.
Dans ce billet, j’aimerais vous restituer la prestation d’Hoani et vous laisser découvrir son utilisation Obsidian.

Du papier à Obsidian

Senior backend architect chez Sfeir, Hoani Cross nous explique avoir utilisé de nombreux carnets manuscrits pour apprendre et structurer sa pensée. Hoani pratique le Bullet Journal et son talk se veut être un partage de ses résultats après des années de pratiques.
Hoani commence par nous rappeler les bienfaits de prendre des notes :

  • Ralentir pour assimiler, poser son téléphone pour se focaliser
  • Graver dans la mémoire
  • Transformer l’écoute en savoir, résumer avec ses propres mots
  • Entraîner le cerveau : sport mental, reformulation, concentration
  • Connecter des idées parfois éloignées les unes des autres
  • Améliorer sa compréhension tout en construisant une bibliothèque personnelle (le fameux Second Cerveau)

Adepte du Bullet Journal (Bujo), Hoani a longtemps utilisé un carnet numéroté, un stylo et un index en début de carnet. Son carnet comportait une page par jour contenant des idées, des notes, le suivi des variables (énergie / humeur) et des sujets spéciaux. Pour s’y retrouver, il utilise des conventions de notation appelées Bullet Journal Key. Par exemple : un carré pour une tâche.
Première limitation : les Bujo ne permettent pas de faire de recherche textuelle.

Hoani a essayé de basculer sur des carnets numériques avec le reMarkable et a testé différents logiciels : OneNote, EverNote, Notion, Keep, Joplin. Nul n’est arrivé à la hauteur d’Obsidian qui est presque parfait pour les raisons suivantes :

  • Basé sur le langage de balisage léger Markdown, ce qui permet à l’auteur de rester propriétaire de ses données
  • Rendu basé le moteur web Electron (comme VS Code)
  • Logiciel gratuit, créé pendant le confinement de la Covid19, mais pas Open Source
  • Plus de 2 500 plugins OpenSource
  • Multiplateforme : Windows, Linux, MacOS, Android, iOS

Obsidian intègre les fonctionnalités suivantes  :

  • Coffre avec navigateur de fichiers : toutes les notes sont stockées localement dans un dossier (appelé « coffre »), consultable via un explorateur intégré.
  • Éditeur de Markdown intelligent : éditeur fluide combinant texte brut et mise en forme instantanée, avec gestion des liens internes, blocs de code, formules et tableaux.
  • Canvas pour composer ses notes : espace visuel libre pour organiser ses idées sous forme de cartes reliées, idéal pour le brainstorming et la modélisation de concepts.
  • Gestion des daily notes : fonction de journal quotidien permettant de consigner rapidement pensées, tâches ou réflexions, avec génération automatique de notes datées.
  • Diapositives : transformation instantanée d’une note en présentation interactive, pratique pour exposer un projet ou partager ses idées sans quitter Obsidian.
  • Enregistrement audio : capture vocale intégrée pour enregistrer des idées, réunions ou commentaires, directement stockés et liés dans le coffre.
  • Vue graphique de l’arborescence des notes : représentation visuelle du réseau de liens entre notes, offrant une cartographie claire et dynamique de son écosystème de connaissances.

L’une des forces du logiciel Obsidian réside dans le fait qu’il soit multiplafeforme : on peut commencer une note oralement sur son Smartphone puis la reprendre plus tard avec un clavier sur son laptop. Plusieurs techniques de synchronisation des coffres entre différents devices existent :

  1. Offre de service intégré payant : Sync (4$ par mois)
  2. Stockage Cloud avec iCloud, OneDrive, Google Drive
  3. Syncthing (OSS) : synchronisation Peer-to-Peer de fichiers. Vos données ne sont jamais stockées dans le Cloud
  4. Plugin Git (instable sur mobile) : personnellement, c’est ce dernier que j’utilise avec un repo GitHub privé.

À noter que l’utilisation d’un repo Git rend possible le partage en équipe d’un coffre Obsidian.

Utilisation d’Obsidian

Le coffre d’Hoani contient toutes ses données, tant personnelles que professionnelles. Cela peut poser un problème de confidentialité : faire sortir des données pros sur son ordi perso peut être contraire aux règles de sécurité de son entreprise.

L’organisation de son coffre comporte 8 grands dossiers :

  • Notes persos
  • Notes pros
  • Notes de lecture (livres ou vidéo youtube)
  • Notes non classées (temporaire)
  • Second Brain : wiki perso
  • Bullet Journal (Bujo)
  • Archives
  • Templates

Chaque note .md suit la structuration suivante :

  • En-tête en front matter pour ajouter des méta-données
  • Corps en Markdown
  • Tags hiérarchisés

Le tableau suivant présente la syntaxe Markdown supportée par Obsidian :

SyntaxDescription
[[Link]]Internal links
![[Link]]Embed files
![[Link#^id]]Block references
^idDefining a block
[^id]Footnotes
%%Text%%Comments
~~Text~~Strikethroughs
==Text==Highlights
```Code blocks
- [ ]Incomplete task
- [x]Completed task
> [!note]Callouts
(see link)Tables

Dans son dossier Bujo, Hoani s’appuie sur le plugin Periodic Notes pour gérer ses notes périodiques et les hiérarchise en année / trimestre / mois / jours.

Le plugin Tasks permet de gérer les tâches dans Obsidian. La syntaxe Markdown [x] et [] permet de reconnaitre une tâche terminée d’une tâche à faire. L’utilisation d’Emojis est possible pour les méta-données.
A noter la possibilité de créer des tableaux dynamiques à l’aide de requête, par exemple pour créer une liste de TODO à faire aujourd’hui.

Son daily est centré autour d’une simple bullet list renseignée tout au long de la journée.
Chaque sujet commence par un tag (ex : #perso, #tech). Hoani utilise les sous-listes pour détailler le sujet.
Chaque jour, Hoani ouvre son Daily du jour et

  • Regarde les tâches à réaliser
  • Note en bullet list
  • Crée une tâche quand nécessaire
  • Termine les tâches accomplies

Le bénéfice du numérique

Comparé au manuscrit, les bénéfices du numérique sont nombreux :

  • Personnaliser son daily en ajoutant toutes les infos qui lui passent par la tête
  • Suivre des variables quotidiennes (comme le temps passé à s’entrainer au Kendama)
  • Afficher les tâches à réaliser et être notifié de celles en retard
  • Automatiser certains traitements à l’aide de plugins additionnels : Templater, Tasks et Dataview

Le contenu du Daily peut être très riche et comporter :

  • Métadonnées avec tags
  • Cartouche de navigation
  • Widgets de progression mois/année
  • Définition de la tâche ultime de la journée
  • Rappel des tâches via un filtre de recherche et un report facilité des tâches
  • Le journal en lui-même
    – Entrainement au Kendama
  • Des métriques sur livres (page en cours) et jeux vidéo (temps, winrate)

Exemples de métadonnées d’un Daily permettant de suivre des variables quotidiennes :

energy_level_morning : 6
energy_level_evening : 7
mood_morning : insomniaque
mood_evening : détendu
walked : 400
working_place_morning : HOME

La barre de navigation du Daily permet de passer rapidement du daily précédent au suivant, à la semaine associée …
L’ajout de barres de progression est possible via le plugin Progressbar.

Le numérique permet de créer d’autres types de notes périodiques que le daily. On peut, par exemple, se fixer des objectifs annuels (ex: courir un marathon), suivre visuellement leurs avancements puis, une fois la période écoulée, faire un bilan de la période et définir de nouveaux objectifs. Hoani insiste sur le fait que l’ouverture et la fermeture d’une période doit être vécue comme un rituel.
Pour automatiser la création de ces différentes notes périodiques, on peut s’aider du plugin Templater qui ajoute à Obsidian un langage de templating permettant d’exécuter des fonctions JavaScript. Ce plugin permet de pré-sélectionner un template en fonction du nom du fichier créé.

Conclusion

Après la démonstration de son utilisation avancée d’Obsidian, Hoani reconnaît que le démarrage peut être long, qu’il peut être difficile de décider quoi noter et surtout comment organiser son coffre. Son conseil est de rester rigoureux, y noter ce que l’on souhaite et faire en sorte que cela reste amusant et donc pas une corvée. Ses slides sont en ligne : [Devfest Nantes 25] Révolutionnez votre prise de notes – du Bullet Journal à Obsidian. Son repo GitHub rend public ses templates Obsidian.

Pour ma part, j’expérimente Obsidian depuis seulement deux semaines, dans un cadre purement personnel — ce billet a d’ailleurs été rédigé avec l’application. J’ai choisi de débuter sans installer le moindre plugin, histoire de me faire une idée précise de ce que le logiciel propose “out of the box”.
Pour l’instant, l’expérience est très agréable : l’ergonomie est soignée, la prise en main rapide et la rédaction des notes en Markdown particulièrement efficace.
Au travail, pour ma prise de notes, je reste néanmoins sur OneNote, qui s’intègre parfaitement à l’écosystème Microsoft 365 et tire parti de Copilot pour la recherche et la synthèse de contenus.

]]>
https://javaetmoi.com/2025/10/revolutionnez-votre-prise-de-notes-du-bullet-journal-a-obsidian/feed/ 2
JSpecify + NullAway + ErrorProne : la configuration Maven ultime pour dire adieu aux NullPointerException https://javaetmoi.com/2025/07/jspecify-nullaway-errorprone-la-configuration-maven-ultime-pour-dire-adieu-aux-nullpointerexception/ https://javaetmoi.com/2025/07/jspecify-nullaway-errorprone-la-configuration-maven-ultime-pour-dire-adieu-aux-nullpointerexception/#respond Sat, 05 Jul 2025 17:06:00 +0000 https://javaetmoi.com/?p=2599 Continuer la lecture de JSpecify + NullAway + ErrorProne : la configuration Maven ultime pour dire adieu aux NullPointerException ]]>
La gestion de la nullabilité en Java a longtemps été source de bugs et de fragmentation. Contrairement à Kotlin par exemple, Java ne possède pas encore nativement de moyen d’exprimer la nullité d’un type. Qui n’aura donc jamais ragé contre une NullPointerException survenue en production ? En juin 2024, avec l’arrivée de la spécification JSpecify, soutenue par des acteurs majeurs comme Google, Microsoft, JetBrains, Oracle, Sonar ou bien encore Broadcom (Spring), l’écosystème Java dispose enfin d’une bibliothèque unifiée d’annotations de nullité. Pour bénéficier d’une détection efficace des NullPointerException dès la compilation, il est nécessaire de coupler JSpecify à des outils d’analyse statique comme NullAway (Uber) et ErrorProne (Google).
Ce court article explique comment mettre en place sur un projet d’entreprise la configuration Maven correspondante qui fera casser votre build et votre CI lorsque vous essayerez de passer une variable null en paramètre d’une méthode qui ne les accepte pas.

Dépendance Maven JSpecify

Ajoutez simplement la dépendance suivante au niveau de la balise <dependencies> de votre pom.xml :

<dependency>
    <groupId>org.jspecify</groupId>
    <artifactId>jspecify</artifactId>
    <version>1.0.0</version>
</dependency>

A ce stade, les IDE comme IntelliJ supportant JSpecify seront à même de détecter des erreurs. Exemple extrait de Sring Petclinic dont les packages Java sont annotés avec @NullMarked :



Dans le cas où ces warnings n’apparaissent pas dans IntelliJ, vérifier que les inspections Nullability problems sont bien activées :

Configuration du Maven Compiler Plugin avec NullAway et ErrorProne

Pour faire échouer le build Maven dans le cas où un développeur ne respecterait pas les annotations JSpecify, le compilateur Java doit être strictement configuré à l’aide des plugins Error Prone de Google et de son extension NullAway d’Uber :

<plugin>
 <groupId>org.apache.maven.plugins</groupId>
 <artifactId>maven-compiler-plugin</artifactId>
 <configuration>
   <parameters>true</parameters>
   <compilerArgs>
     <arg>-XDcompilePolicy=simple</arg>
     <arg>--should-stop=ifError=FLOW</arg>
     <arg>-XDaddTypeAnnotationsToSymbol=true</arg>
     <arg>-Xplugin:ErrorProne
       -XepOpt:NullAway:AnnotatedPackages=com.javaetmoi.myapp
       -XepOpt:NullAway:UnannotatedSubPackages=com.javaetMoi.myapp.controller.api,com.javaetMoi.myapp.controller.dto 
       -XepOpt:NullAway:JSpecifyMode=true
       -XepDisableAllChecks
       -Xep:NullAway:ERROR
       -XepExcludedPaths:.*/src/test/java/.*
       -XepDisableWarningsInGeneratedCode
     </arg>
   <annotationProcessorPaths>
     <path>
       <groupId>com.google.errorprone</groupId>
       <artifactId>error_prone_core</artifactId>
       <version>2.42.0</version>
     </path>
     <path>
       <groupId>com.uber.nullaway</groupId>
       <artifactId>nullaway</artifactId>
       <version>0.12.11</version>
     </path>
   </annotationProcessorPaths>
 </configuration>
</plugin>

Explications clés :

  • L’option -Xep:NullAway:ERROR fait échouer le build Maven lorsqu’un éventuel NullPointerException est détecté. Par défaut, de simples WARNING sont générés dans la console et risquent donc de passer inaperçus.
  • L’option –Xplugin:ErrorProne active le plugin ErrorProne.
  • L’option -XepDisableAllChecks désactive toutes les règles de vérification de code ErrorProne. On n’utilise ici ErrorProne que pour la nullsafety. Libre à vousd’utiliser pleinement ErrorProne ou pas.
  • L’option -XepOpt:NullAway:AnnotatedPackages=com.javaetmoi.myapp active NullAway sur le package Java racine de l’application métier. A noter que cette option peut être remplacer par -XepOpt:NullAway:OnlyNullMarked afin de ne scanner que les packages annotés avec @NullMarked.
  • A contrario, l’option -XepOpt:NullAway:UnannotatedSubPackages=com.javaetMoi.myapp.controller.api,com.javaetMoi.myapp.controller.dto désactive NullAway sur une liste de sous-packages. Cela permet d’exclure le code généré par des plugins comme cxf-codegen-plugin ou MapStruct qui ne supportent pas encore JSpecify.
  • Dans le cadre d’utilisation de JSpecify dans un projet legacy, il peut-être intéressant d’exclure de l’analsyse les classes de tests avec l’option -XepExcludedPaths:.*/src/test/java/.*
  • L’option -XepOpt:NullAway:JSpecifyMode=true active le support complet de JSpecify et exploite pleinement la sémantique de JSpecify, notamment au niveau des types génériques. 
  • L’argument javac -XDaddTypeAnnotationsToSymbol=true est requis par la version 0.12.11 de NullAway lors de l’utilisation d’une version de Java antérieure à Java 22.

Toutes les options de NullAway peuvent être retrouvées sur sa page de Configuration.

A partir de la version 16 du langage Java, la documentation d’installation d’error prone explique comment activer des flags à la JVM via le fichier .mvn/jvm.config :

--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED


Avec cette configuration Maven, toute tentative d’accès à une référence potentiellement nulle sera détectée… dès la compilation, que ce soit sur notre poste de dév ou notre CI Jenkins, GitHub ou GitLab ! Fini les NullPointerException surprises en production.

Exemple d’une commande mvn compile sur l’exemple précédent :

[ERROR] COMPILATION ERROR : 
[INFO] -------------------------------------------------------------
[ERROR] /Users/arey/Dev/GitHub/spring-petclinic/spring-petclinic/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:[106,82] [NullAway] passing @Nullable parameter 'lastName' where @NonNull is required
    (see http://t.uber.com/nullaway )

Conclusion

Cinq années auront été nécessaires par le groupe de travail JSpecify pour formaliser et se mettre d’accord sur quatre annotations Java. Depuis un an, nos outillages, nos IDE et nos frameworks convergent vers ce lot d’annotations. Quelques bugs de jeunesse existent encore, à l’image de ce bug Maven Build Cache Extension corrigé par mon collègue Marco et qui faisait échouer la synchronisation Maven IntelliJ avec le message « java: plug-in not found: ErrorProne ».

A nos projets métiers de se mettre à JSpecify et d’être prêts pour Spring Framework 7 et Spring Boot 4 qui sortiront fin 2025.
Si vos projets exploitent les annotations JSR-305 ou JetBrains, migrez vers JSpecify à l’aide de la recette OpenRewrite MigrateToJSpecify.

Sachez enfin que Dan Smith a proposé la JEP draft: Null-Restricted and Nullable Types (Preview) visant à ajouter des marqueurs syntaxiques directement au niveau du langage Java. Adopter JSpecify aujourd’hui facilitera l’adoption de cette JEP.

]]>
https://javaetmoi.com/2025/07/jspecify-nullaway-errorprone-la-configuration-maven-ultime-pour-dire-adieu-aux-nullpointerexception/feed/ 0
De Spring Data JPA à jOOQ https://javaetmoi.com/2025/06/de-spring-data-jpa-a-jooq/ https://javaetmoi.com/2025/06/de-spring-data-jpa-a-jooq/#respond Mon, 16 Jun 2025 06:57:01 +0000 https://javaetmoi.com/?p=2580 Continuer la lecture de De Spring Data JPA à jOOQ ]]>
jOOQ: The easiest way to write SQL in Java

Lors de la conférence Devoxx France 2025, j’ai participé à un hands-on lab de 2h intitulé Sortir des ORMs avec jOOQ. Acronyme de « Java Object Oriented Querying », jOOQ se présente comme une alternative à JPA permettant d’écrire des requêtes SQL en Java via une fluent API. Animé par Sylvain Decout et Samuel Lefebvre, cet atelier visait à migrer une application Spring Boot / JPA vers jOOQ à l’aide du starter Spring Boot pour jOOQ. Pour les curieux, le repo de l’atelier est disponible sur Github : jooq-handson.

Fort de cette découverte, je me suis à mon tour prêté à l’exercice de migrer vers jOOQ la couche de persistance Spring Data JPA de l’application démo Spring Petclinic. Un nouveau fork est né : spring-petclinic-jooq. Bienvenue à ce dernier dans la communauté Spring Petclinic.

L’usage de jOOQ se rapproche de l’utilisation de JdbcTemplate. Le développeur maitrise le nombre de requêtes envoyées à la base de données relationnelle. Ce qui les différencie, c’est la syntaxe : pas de SQL, mais une API Java fluide et type-safe spécifique à jOOQ qu’il va falloir appréhender. Rassurez-vous, cette API se rapproche du SQL : on y retrouve les mots clés select, update, insertInto, where, from, join, on, as … A ceux-ci, on ajoute des mots clés spécifiques à jOOQ : paginate, fetch, convertFrom … La documentation de jOOQ est très complète. On y apprend comment écrire des requêtes complexes à base de window function ou de Common Table Expressions (CTE) et comment utiliser des fonctionnalités avancées de SQL que peu de frameworks ORM supportent nativement : JSON functions, PIVOT, MERGE, UNION

Cet article a pour objectif d’expliquer les étapes adoptées pour migrer l’implémentation Spring Data JPA des repository vers jOOQ. Des exemples de code avant / après y sont proposés.

Configuration du build

Spring Boot supporte nativement l’usage des versions commerciales et Open Source de jOOQ. Dans le pom.xml ou le fichier build.gradle, commencer par déclarer le starter Spring Boot pour jOOQ spring-boot-starter-jooq :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jooq</artifactId>
</dependency>

L’étape suivante consiste à générer les classes Java à partir du schéma de la base de données. jOOQs propose différentes possibilités : à partir du script DDL de création du schéma comme sur Petclinic, de scripts Liquibase ou bien encore des méta-données d’une base existante.
Les plugins jooq-codegen-maven ou jooq-codegen-gradle sont à configurer.
Voici un exemple extrait de jOOQ Spring Petclinic :

<plugin>
  <groupId>org.jooq</groupId>
  <artifactId>jooq-codegen-maven</artifactId>
  <executions>
    <execution>
      <goals>
        <goal>generate</goal>
      </goals>
    </execution>
  </executions>
  <configuration>
    <generator>
      <database>
        <name>org.jooq.meta.extensions.ddl.DDLDatabase</name>
        <properties>
          <property>
            <key>scripts</key>
            <value>src/main/resources/db/h2/schema.sql</value>
          </property>
          <property>
            <key>sort</key>
            <value>semantic</value>
          </property>
          <property>
            <key>unqualifiedSchema</key>
            <value>none</value>
          </property>
          <property>
            <key>defaultNameCase</key>
            <value>as_is</value>
          </property>
        </properties>
      </database>
    </generator>
  </configuration>
  <dependencies>
    <dependency>
      <groupId>org.jooq</groupId>
      <artifactId>jooq-meta-extensions</artifactId>
      <version>${jooq-meta-extensions.version}</version>
    </dependency>
  </dependencies>
</plugin>

La classe org.jooq.meta.extensions.ddl.DDLDatabase provenant de l’extension jooq-meta-extensions permet au plugin jooq-codegen-maven d’exploiter le script DDL src/main/resources/db/h2/schema.sql utilisé par défaut lors du démarrage de l’application avec le profil Spring par défaut.

Dans le package org.jooq.generated.tables, l’exécution du plugin jOOQ génère une classe Vets héritant de la classe TableImpl<VetsRecord> et modélisant la table éponyme. La classe VetsRecord a également été générée dans le sous-package records. Elle représente une ligne de la table pets.
Nous verrons leurs usages lors de la migration de la classe PetRepository.

Migration des repositories

L’une des forces de jOOQ est qu’il sait cohabiter aux côtés de JPA. On peut donc migrer au fil de l’eau les respositories d’une application et même choisir de conserver les 2 solutions en fonction des besoins. Cette capacité a été pratique dans le travail de migration : chaque repository a été migré l’un après l’autre. L’application Petclinic est restée fonctionnelle tout du long.

Premier changement notable : la nature des repositories qui passent d’une interface héritant de l’interface Repository de Spring Data JPA à une classe concrète qu’on annote avec @Repository et dont le constructeur accepte une instance de DSLContext.

Avant :

public interface VetRepository extends Repository<Vet, Integer> {

Après:

@Repository
public class VetRepository {

    private final DSLContext dsl;

    public VetRepository(DSLContext dslContext) {
       this.dsl = dslContext;
    }

Continuons par changer l’implémentation d’une première requête SQL simple utilisant un SELECT.
Requête native SQL utilisée par Spring Data JPA :

@Query("SELECT ptype FROM PetType ptype ORDER BY ptype.name")
List<PetType> findPetTypes();

Implémentation équivalente avec jOOQ :

public List<PetType> findPetTypes() {
    return dsl
       .selectFrom(TYPES)
       .orderBy(TYPES.NAME)
       .fetchInto(PetType.class);
}

On retrouve ici tous les éléments de la requête SQL, mais avec une syntaxe Java jOOQ équivalente. Le mot clé SQL « SELECT » est remplacé par la méthode selectFrom(), le « ORDER BY » par la méthode orderBy(). A noter que nous n’utilisons pas de chaines de caractères pour nommer les tables et les colonnes, mais les constantes générées par le plugin jOOQ. Ainsi, en cas de changement de schéma (ex : nom de colonne renommée), le code Java ne compilera plus et il faudra l’adapter. Avec cette approche, les erreurs de syntaxe ne sont plus possibles. On perçoit ici toute la sécurité apportée par la type-safety de jOOQ.
Enfin, la méthode fetchInto() mappe les lignes retournées par la base dans une liste d’instance de PetType.

Là où JPA et Hibernate nous facilitaient la sauvegarde de nos entités JPA, jOOQ va demander un travail nettement plus important. En effet, la méthode save() de l’interface CrudRepository de Spring Data JPA ne demandait qu’à être appelée. La magie des ORM opérait grâce aux annotations JPA apposées sur les entités. jOOQ nécessite de prévoir les 2 requêtes SQL correspondantes et d’effectuer à la main le binding des propriétés du Owner vers les colonnes. Exemple de sauvegarde d’un Owner :

public Integer saveOrUpdateDetails(Owner owner) {
    if (owner.isNew()) {
       return requireNonNull(
          dsl.insertInto(OWNERS)
             .set(mapOwnerToRecord(owner))
             .returningResult(OWNERS.ID)
             .fetchOne())
          .getValue(OWNERS.ID);
    } else {
       dsl.update(OWNERS)
          .set(mapOwnerToRecord(owner))
          .where(OWNERS.ID.eq(owner.getId()))
          .execute();
       return owner.getId();
    }
}

private Map<Field<?>, Object> mapOwnerToRecord(Owner owner) {
    return Map.of(OWNERS.FIRST_NAME, owner.getFirstName(), OWNERS.LAST_NAME, owner.getLastName(), OWNERS.ADDRESS,
       owner.getAddress(), OWNERS.CITY, owner.getCity(), OWNERS.TELEPHONE, owner.getTelephone());
}

Rien de compliqué, mais un peu plus verbeux.
L’un des principaux avantages de jOOQ consiste à maitriser le nombre de requêtes SQL envoyées à la base. En l’occurrence, dans cet exemple, une seule et unique requête de type UPDATE est envoyée lors d’un mise à jour.
Avec l’implémentation Spring Data JPA, dans le cadre de la mise à jour d’un Owner, comme expliqué dans l’article Skip Select Before Insert in Spring Data JPA, Spring Data JPA appelle la méthode merge() de l’entity manager JPA qui, si l’entité n’est pas en cache, va charger le Owner en exécutant autant de requêtes SQL de type SELECT que nécessaires.

Autre différence notable : jOOQ laisse décider du ou des champs à mettre à jour. Dans notre exemple, les Pets associés à leur Owner ne seront par exemple pas mis à jour. Avec JPA, on utilisait le cascading et l’attribut cascade des annotations comme @OneToMany.

Dans la même idée, lors de requêtes de type SELECT, les jointures entre tables devront être systématiquement précisées. A titre d’exemple, charger un animal et son type nécessitera un appel à join() :

@Transactional(readOnly = true)
public Optional<Pet> findByIdWithoutVisits(Integer petId) {
    return dsl.select()
       .from(PETS)
       .join(PETS.types_())
       .where(PETS.ID.eq(petId))
       .fetchOptional(PetRepository::toPet);
}

Noter ici l’utilisation d’une jointure implicite basée sur la clé étrangère, évitant ainsi d’ajouter une clause ON entre la PK de PETS et la FK de TYPES.
Avec une association 1:1, le chargement et le mapping du type d’animal ne présente aucune difficulté :

private static Pet toPet(org.jooq.Record row) {
    return new Pet(row.get(PETS.ID), row.get(PETS.NAME), row.get(PETS.BIRTH_DATE),
          new PetType(row.get(PETS.TYPE_ID), row.get(TYPES.NAME)));
}

Le chargement des associations 1:N se complexifie. L’usage de l’opérateur mulstiset() du SQL qui est supporté par jOOQ permet de charger les Vets et leurs Specialities en une seule requête :

public List<Vet> findAll(){
    return dsl.select(VETS.ID, VETS.FIRST_NAME, VETS.LAST_NAME, MULTISET_SPECIALITIES)
       .from(VETS)
       .leftJoin(VETS.vetSpecialties())
       .orderBy(VETS.ID)
       .fetch(VetRepository::toVet);
}

private static final Field<List<Specialty>> MULTISET_SPECIALITIES = multiset(
    select(VET_SPECIALTIES.specialties().ID, VET_SPECIALTIES.specialties().NAME)
       .from(VET_SPECIALTIES)
       .where(VET_SPECIALTIES.VET_ID.eq(VETS.ID)))
    .as("specialties")
    .convertFrom(result -> result.map(it -> new Specialty(it.get(SPECIALTIES.ID), it.get(SPECIALTIES.NAME))));


private static Vet toVet(Record4<Integer, String, String, List<Specialty>> row) {
    return new Vet(row.get(VETS.ID), row.get(VETS.FIRST_NAME), row.get(VETS.LAST_NAME),
       new HashSet<>(row.get(MULTISET_SPECIALITIES)));
}

jOOQ permet d’imbriquer plusieurs multiset afin de charger les visites des animaux d’un propriétaire en une seule requête. Je vous renvoie à la classe OwnerRepository.

Pour finir, les écrans « Find Owners » et « Veterinarians » affichent les résultats de manière paginée. jOOQ supporte la pagination au travers de la Seek Method (aussi appelée Keyset paging) ou du calcul des méta-données de pagination en une seule requête SQL. C’est cette dernière approche qui a été utilisée sur jOOQ Petclinic afin de garder iso-fonctionnels les écrans paginés. Les plus curieux peuvent se référer à l’implémentation de la méthode findAll(Pageable pageable) de VetRepository et à la méthode paginate() du JooqHelper. Sur le même modèle que ce que propose Spring Data, des records Pageable et Page ont été introduits dans la base de code.

Au revoir JPA

Une fois l’ensemble des Repository migrés, la dernière étape a consisté à retirer la dépendance spring-boot-starter-data-jpa ainsi que toutes les annotations JPA apposées sur les entités (@Entity, @Table, @ ManyToMany …).

Débarrassé de JPA, nous pouvons revoir en partie le design de l’application qui avait été limité par ce dernier. En effet, les entités JPA ne peuvent pas être modélisées avec des record Java. Suite à la migration vers jOOQ, les entités du domaine métier de Spring Petclinic n’ont plus d’adhérence avec la couche de persistance. Les classes immutables ont pu être converties en record. Exemple du value object Speciality :

public record Specialty(Integer id, String name) {
}

Le refactoring de la modélisation aurait pu aller plus loin, mais ce n’était pas l’objectif de cette version de Petclinic dédiée à jOOQ et non à la Clean Architecture. Peut-être l’objet d’un prochain article ?

Conclusion

A travers l’exemple de migration de l’application démo Spring Petclinic, cet article donne un aperçu des possibilités offertes par jOOQ. Cette librairie est mature, a plus de 15 ans (la version 1.0.0 de jOOQ est sortie en 2010) et est utilisée par de grands comptes comme Apple, Allianz et Mastercard.
Notez néanmoins que jOOQ possède un système de double licence : commerciale et Open Source. Les distributions commerciales de jOOQ maintiennent un support versionné des SGBDR. A contrario, l’édition Open Source de jOOQ ne supporte que la dernière version des SGBDR Open Source. A ce titre, l’utilisation de jOOQ avec Oracle et SQL Server requière une licence commerciale.

En replongeant dans le SQL, je me suis aperçu que j’étais passé à côté de certaines fonctionnalités avancées comme les MULTISET. A retenir.

Enfin, je remercie Sylvain pour sa relecture du code de Spring Petclinic jOOQ et ses conseils avisés. J’invite tous les autres experts jOOQ à venir améliorer le repository spring-petclinic-jooq en soumettant des Issues ou en proposant des Pull Requests.

]]>
https://javaetmoi.com/2025/06/de-spring-data-jpa-a-jooq/feed/ 0
L’API Gatherers : l’outil qui manquait à vos Streams https://javaetmoi.com/2025/04/api-gatherers-outil-qui-manquait-a-vos-streams/ https://javaetmoi.com/2025/04/api-gatherers-outil-qui-manquait-a-vos-streams/#respond Fri, 25 Apr 2025 06:09:01 +0000 https://javaetmoi.com/?p=2551 Continuer la lecture de L’API Gatherers : l’outil qui manquait à vos Streams ]]> Date : 16 avril 2025
Conférence : Devoxx France 2025
Speaker : José Paumard (Oracle)
Format : Conférence 45 mn
Support : slides sur Speakerdeck / replay Youtube

Java Developer Advocate chez Oracle, José Paumard nous présente la nouvelle API Gatherers qui, depuis Java 24, vient se greffer sur l’API Stream Java sortie il y’a 11 ans avec Java 8.

Tout comme l’API Collector, José commence par rappeler que l’API Gatherers est indépendante de l’API Stream. Cette API a été introduite dans Java via la JEP 485 Stream Gatherers conduite par Viktor Klang. Les plus curieux pourront regarder la vidéo Youtube du Deep Dive qu’a animé Viktor lors de la conférence JavaOne qui s’est tenue en mars 2025.

L’article The Gatherer API permet également d’approfondir votre étude des Gatherers. Notez que le site dev.java permet désormais d’exécuter des snippets Java (pas directement dans le navigateur, mais sur un serveur Cloud).

Toutes les classes et interfaces de l’API Gatherers ont été ajoutées au package java.util.stream.

Opérations intermédiaires et terminales d’un Stream

Pour rappel, un Stream se connecte à une source de données (collections, fichier, générateur de nombres aléatoires, regex). Un stream est composé de :

  1. zéro, une ou plusieurs opérations intermédiaires qui retournent un Stream
  2. une seule et unique opération terminale qui retourne un résultat et clôture le Sream.

Viktor assimile l’API Stream à celle d’un Builder : on décrit un pipeline d’opérations puis on appelle l’opération terminale pour déclencher son traitement.

Exemples d’opération terminales proposées par l’API Stream :

  • reduce() : opération de réduction
  • findFirst() : renvoie un objet de type Optional qui encapsule le premier élément du Stream s’il existe, ne consomme pas tous les éléments du Streams.
  • collect() : prend en paramètre un Collector
  • toList() : méthode raccourcie disponible depuis Java 16

Les Collector permettent de créer ses propres opérations de réduction. Gatherer est le pendant des Collector pour les opérations intermédiaires. Une différence notable est qu’un Collector ne peut pas interrompre un Stream : il ne le connait pas.

Le JDK propose de nombreuses opérations intermédiaires comme map(), filter(), dropWhile(), limit() ou bien encore mapMulti() ajoutée plus récemment. L’API Gatherers va nous permettre de créer nos propres opérations intermédiaires. Ce n’était pas possible jusque-là. Parmi ces opérations intermédiaires, il existe des opérations stateless comme filter() et des opérations statefull come sorted() qui doivent consommer tous les éléments du stream avant de produire quelque chose vers le down stream.

Il n’y avait pas moyen de créer d’opérations intermédiaires jusqu’aux Gatherers.

Que propose l’API Gatherer ?

L’interface générique Gatherer s’appuie sur 3 paramètres :

interface Gatherer<T, A, R> { 
    Integrator<A, T, R> integrator(); 
}
  • T : type des éléments consommés
  • A : type mutable utilisé en interne par les Gatherers
  • R : type des éléments poussés dans le down stream

Avec sa méthode principale integrator(), José compare l’interface Gatherer à une interface fonctionnelle de type Supplier.

L’interface Gatherer met à disposition 3 interfaces fonctionnelles imbriquées dont nous étudierons le fonctionnement : Downstream, Greedy et Integrator.
Exemple de le l’interface Integrator :

@FunctionalInterface
interface Integrator<A, T, R> {
    boolean integrate(A state, T element, Downstream<? super R> downstream); 
}

Afin de pouvoir utiliser le Gatherer, l’interface Stream de l’API Stream propose désormais depuis Java 24 la méthode gather :

Stream<R> downStream = upstream.gather(gatherer);

Le JDK s’enrichit de la classe factory Gatherers (notez son pluriel) utilisées par les différentes implémentations des méthodes of() de l’interface Gatherer.

Publier dans le Downstream

Un Downstream reçoit des données traitées par une opération intermédiaire. C’est le flux de sortie d’un Gatherer.
Voici un exemple de Gatherer chargé de pousser un élément dans le Downstream :

Gatherer<T, ?, R> gatherer = Gatherer.of(
     (_, element, downStream) -> downStream.push(element) // returns a boolean
);

Le booléen renvoyé en retour est important. Son fonctionnement est subtil : renvoyer false permet l’arrêt du traitement des éléments suivants. Il ne se passe alors plus rien lorsqu’on pousse des éléments au downStream qui n’en accepte désormais plus. Aucune exception n’est levée. Cela peut surprendre.

Dans le jargon de l’API Gatherer, lorsqu’un Integrator retourne directement la valeur du downstream.push(element), on dit qu’il est Greedy. Il traitera nécessairement tous les éléments du Stream. Son exécution est optimisée. Exemple :

Gatherer<T, ?, R> gatherer = Gatherer.of(
    Integrator.of((_, element, downstream) -> downstream.push(element))
);

Lorsqu’un Integrator n’utilise pas de coupe-circuit et consomme donc l’intégralité des éléments reçus, il est recommandé d’utiliser la méthode factory Integrator.ofGreedy() pour instancier un Integrator :

Gatherer<T, ?, R> gatherer = Gatherer.of(
    Integrator.ofGreedy((_, element, downstream) -> downstream.push(element))
);

Un Downstream possède un état nommé rejecting. La méthode isRejecting() de l’interface Downstream propose d’y accéder. Cet état a 3 propriétés :

  1. Commence à false
  2. Ne peut commuter que de false vers true (ne peut pas se rouvrir)
  3. L’état de peut commuter que lors d’un push() => règle spécifique aux API du JDK

José nous met en garde : dans un Integrator, l’appel à la méthode isRejecting() ne sert à rien. Il s’agit d’une fausse optimisation qui s’apparente à du code mort.

(_, element, downstream) -> {
    if (downstream.isRejecting()) { //
        return false;               // Condition inutile
    }                               // 
    return downstream.push(mapper.apply(element));
}

José continue sa présentation en nous expliquant les bonnes pratiques à adopter lorsqu’on publie sur le Downstream :

  • Ne pas faire de test isRejecting() sur le Downstream
  • Privilégiez l’usage de la méthode allMath() plus efficace que takeWhile()
  • Fermer les ressources si nécessaire. Lorsque le Stream agit sur un fichier, il faut fermer le fichier et ne pas oublier le try with ressources

Exemple exempté de bugs :

(_, element, downstream) -> {
    try (Stream<R> elements = flatMapper.apply(element);) {
        return elements.allMatch(downstream::push); 
    }
}

Un Downstream n’est pas un objet thread-safe. Il est donc nécessaire de ne pas générer d’effet de bord sur les données externes. Attention aux race conditions et plus particulièrement dans les parallel streams.
A ce titre, la méthode Gatherer.oSequential() permet de créer un Gatherer séquentiel (non parallélisable).

L’élément state est un état mutable pouvant être utilisé par le Gatherer. En complément de l’Integrator, il est nécessaire de fournir à l’API de création d’un Gatherer un Supplier chargé d’initialiser l’état du state.

Exemple d’un Gatherer limitant le nombre d’éléments et initialisant un compteur :

class Counter { long count = 0L; }

var gatherer = Gatherer.ofSequential( 
    Counter::new, // the initializer
    (state, element, downstream) -> {
        if (state.count++ < limit) {
            return downstream.push(element);
        } else {
            return false;
        }
});

A noter que l’opérateur var retient le type des classes anonymes.

Pour agir sur l’ensemble des données du Gatherer, on peut stocker les éléments dans une collection tel qu’un HashSet dans l’exemple suivant « Distinct Gatherer »:

var gatherer = Gatherer.ofSequential(
    () -> new Object() { Set<T> set = new HashSet<>(); },
    (state, element, downstream) -> {
        if (state.set.add(element)) {
            return downstream.push(element);
        } else {
            return true;
        }
});

Pour publier l’état final d’un Gatherer, on peut ajouter après l’Initializer et l’Integrator une 3ième lambda de type BiConsumer agitant comme finisher et pouvant consommer tous les éléments du state :

var gatherer = Gatherer.ofSequential(
    () -> new Object() { Set<T> set = new TreeSet<>(); },
    (state, element, downstream) -> { ... },
    (state, downstream) -> { // finisher
        state.set.stream().allMatch(downstream::push);
});

Les Parallel Gatherers

Les développeurs Java peuvent choisir de construire un Gather supportant ou non le parallélisme et les parallel Streams. A cet effet, 2 méthodes de type fabrique sont à leur disposition :

  1. Gatherer.of()
  2. Gatherer.ofSequential()

Pour supporter le parallélisme, l’API Gatherer adopte le principe suivant : un objet state par thread. Cela permet de ne pas utiliser de collections synchronisées dégradant les performances.
Dans chaque Stream parallèle, on a donc autant de state que de threads. A la fin de l’opération intermédiaire, il est nécessaire d’utiliser un Combiner pour combiner tous les états.


Ce Combiner est un 4ième paramètre à passer à la méthode factory of() :

var gatherer = Gatherer.of(
    () -> new Object() { Set<T> set = new HashSet<>(); }, 
    (state, element, downstream) -> { // executed in
        state.set.add(element); // different threads
        return true;
    },
    (state1, state2) -> { // combiner
        state1.set.addAll(state2.set);
        return state1;
    },
    (state, downstream) -> { // finisher
        state.set.allMatch(downstream::push);
    }
);

Les Sequential Gatherers ne peuvent pas être appelés en même temps depuis différents thhreads. Ils ne possèdent pas de Combiner. Pour autant, José nous explique que l’API Stream est capable de séquencer les appels vers un Sequential Gatherer. Cette fonctionnalité est nouvelle et donc à utiliser avec précaution. Tester les perfs.

Pour aller plus loin, José nous invite à consulter le repo GitHub SvenWoltmann/stream-gatherers. Le JDK vient avec de nouveaux Gatherers comme scan(), fold() ou bien encore mapConcurrent().
Des librairies tierces comme gatherers4j proposent également leur propres gatherers : reverse(), repeat(n), groupBy(fn) …

Pour conclure, retenons qu’un Gatherer est construit sur 4 éléments. Tous ne sont pas obligatoires.

]]>
https://javaetmoi.com/2025/04/api-gatherers-outil-qui-manquait-a-vos-streams/feed/ 0
Les clés de l’architecture pour les dévs https://javaetmoi.com/2025/04/cles-de-l-architecture-pour-les-devs/ https://javaetmoi.com/2025/04/cles-de-l-architecture-pour-les-devs/#respond Tue, 22 Apr 2025 17:54:32 +0000 https://javaetmoi.com/?p=2508 Continuer la lecture de Les clés de l’architecture pour les dévs ]]> Conférence : Devoxx France 2025
Date : 17 avril 2025
Speakers : Cyrille Martraire (Arolla), Eric Le Merdy (QuickSign) remplaçant de Christian Sperandio (Arolla)
Format : Conférence (45mn) / Replay Youtube

Cette conférence a pour objectif d’ouvrir les portes en nous donnant les clés de l’architecture. Pour seconder Cyrille, Eric a du remplacer Christian au pied levé.
Un constat est posé. Sur les dix dernières années, les systèmes ont changé : ils sont devenus modulaires, de plus en plus distribués. La modularité permise par le Cloud permet de répartir la charge. Il y’a de plus en plus d’interconnexions entre briques applicatives.
L’architecture bouge tout le temps, évolue constamment.
Cyrille Martraire et Eric Le Merdy sur la scène de Devoxx France 2025

Que doit-on savoir ? Pour commencer, on ne saura jamais tout et il faudra vivre avec. Personne ne sait tout. Même le plus capé des architectes.

Comme fil conducteur, Cyrille et Eric prennent un exemple réel issu du monde des télécommunications.
Pour cahier des charges, le client précise que le système va recevoir des fichiers chaque minute et doit les intégrer tous les 15mn. Contexte : ces fichiers viennent d’équipements télécom.

Première question à se poser : « est-ce possible de synchroniser la temporalité ? ».
Réponse du client : « Non, ce n’est pas possible ».
La brique centrale est nommée Aggregator.
Diagramme de contexte C4 du système

Diagramme de contexte C4 correspondant :

Première clé donnée dans ce talk : commencer par identifier le problème.
Comment l’appliquer : quel est le but ? Ici c’est d’agréger les données reçues.
Cette première clé parait banal : penser problème avant de penser à la solution. Cet adage bien connu s’applique : « un problème bien posé est à moitié une solution ».

Après avoir cerner le problème, on continue en prenant en compte les nombreux Software Quality Attributes dont font partis le cout, la performance, la sécurité ou bien encore le sourcing des dévelopeurs. Liste complète sur arc42-templates et la FAQ C-1-2.

Examinons à présent les* contraintes du système.
1ière contrainte : disponibilité
Toujours Up pour recevoir les données. Calcule de données toutes les 15mn.

2nde contrainte : performance
Le besoin initial mentionnait la réception d’un fichier par minute. En questionnant le métier, on dénombre un fichier par équipement. Sachant qu’il y’a 50 équipements, cela ferait 50 fichiers. Pas tout à fait, puisqu’un équipement compte 40 000 capteurs. Au total, ce sont 6 milliards de données que le système devra traiter toutes les 15 minutes.
Quality Attributes Clusters
La formule de calcul de l’agrégation est compliquée ; ce n’est pas de simples additions.
Le métier souhaiterait que le calcul soit instantané. Jouant sur le cout financier d’une telle exigence, Eric a réussi à négocier avec le client un temps de traitement de 2 minutes max. Cette durée est acceptable au vu du besoin : anticiper les pannes et remonter des alertes.

Seconde clé donnée dans ce talk : négocier, étudier, éduquer les gens.
Pas nécessaire de mettre systématiquement de la cohérence transactionnelle partout.

Les contraintes techniques nous guident pour définir l’architecture technique. Cette approche n’est pas antinomique avec le DDD. Dans notre exemple, il existe une corrélation entre les contraintes techniques et le découpage en sous-domaine.
On peut identifier 2 sous-domaines : le parsing lors de l’ingest et le calcul de statistiques.

Une troisième clé nous est donnée : penser modulaire pour adresser le problème.

Voyons à présent comment implémenter ces 2 sous-domaines.
On pourrait partir sur 2 services. Mais dans un premier temps, Eric propose de commencer par seul service, plus simple, plus facile à implémenter et livrer. Par contre, afin de préparer un éventuel futur découplage, on utilise l’approche pragmatique de modular monolith. Bel exercice de frugalité : une solution distribuée est remplacée par un monolith.

Modular monolith

L’architecture se pense à différents niveaux, à plusieurs.
Architectural perspectives
Les architectes d’entreprise ont souvent une vue d’ensemble globale. Les développeurs vont quant à eux s’intéresser davantage aux technologies.
Un conseil, garder en tête cet objectif : bien s’entendre avec tout le monde

Les différents types d’architectures ont leurs avantages et inconvénients. Voici celles qui auraient pu être choisies :

  1. Microservices : modularité jusqu’au bout
  2. Modular monolith : facilite le découpage en microservices
  3. Function as a Service
  4. Big Ball of Mud : monolith avec archi spaghetti

Parmi les contraintes techniques, le vrai risque consiste à tenir le délai de traitement d’agrégation des données en dessous des 2 minutes. La première étape consiste à lever ce risque. Il faut lever ce risque et commencer les développements.

Integration options between modules 1
Integration options between modules 2

Réversible, l’architecture n°2 est retenue avec une approche hexagonale. On reste pragmatique : les deux sous-domaines s’appellent dans la même JVM par appel de fonction. Cyrille rappelle que l’architecture hexagonale demande de créer un peu plus de code, mais ce n’est pas les 30 secondes que met la création d’une interface qui va les ralentir. Cela permet de prévoir des options pas chères pour être réversible et changer son architecture en cours de route. Les décisions sont réversibles.

Une première version de l’application est déployée en production. Passent 1mn, puis 2, puis 5. On coupe tout. Trop long. Cela ne marche pas. Cyrille invite à célébrer ce constat : on sait que çà ne marche pas. Et on l’a découvert très vite.

La cause est rapidement identifiée : l’agrégateur du monolith est mono-thread. 3 solutions son envisagées :
1. Solution 1 : mono instance avec du multi-threading. Plus de vCPU, worker pools.

2. Solution 2 : multi instance avec du pub-sub. Rien à faire. On s’appuie sur un service du Cloud Provider. Clé : on reconnait les problèmes difficiles et on les délègue à du middleware en managé.

3. Solution 3 : combine multi-thread et multi-instance : trop compliqué et trop chère. Combine tous les inconvénients. A ne pas faire.

Approche choisie : solution 2. L’architecture est l’art du tradeoff (du compromis).



La solution retenue impose la fin du modular monolith. Nécessité de passer en micro-services : 2 services, 2 deployments et N services

Réfléchissons à présent sur ce qui pourrait mal se passer avec un tuyau asynchrone : messages en double ou triple, manque de ressources, messages perdus …

Le fournisseur de Cloud garantie une partie des problèmes évoqués.
Cyrille rappelle la nécessité d’un consumer à être idempotent pour gérer les messages en double.

Pubsub architecture tradeoffs

Avant de faire un choix sur l’implémentation de l’adaptateur et assurer la persistance des données (ex : PostgreSQL vs Redis), Eric propose de rester en mémoire pour tester rapidement en prod. Cela permet de gagner du temps et de vérifier les hypothèses.
On va livrer en prod un mock. Pas de honte. Vrai essaie sur de vraies machines avec les vraies données. On utilise la prod, le vrai environnement.


Le calcul dure moins de 2 minutes : l’hypothèse est validée. L’adaptateur peut désormais être implémenté avec Redis.

Message de fond : l’architecture est évolutive. Il ne faut pas la mettre en place dès le début. L’architecture est dynamique. Tout bouge.

L’application est composée de 2 systèmes qui doivent se parler. Un contrat JSON est définit entre dispatcher et aggregator. Le contrat est très explicite avec les unités.
Cyrille fait remarquer un problème de typo sur un champ : latencyy_ms avec 2 lettres y

Un renommage serait possible mais il est recommandé de positionner 2 champs pour respecter le contrat.


Autre clé : on ne change pas un contrat. A partir du moment où il est publié, on doit rester dans la même version majeure pour toujours. Contracts are forever. On ne doit pas casser les clients existants.

Cyrille rappelle les utiles à l’heure de l’IA :
Architectural Decision Records (ADR) : template
ArchUnit : try architecture tests



Autres clés proposées par Cyrille pour avoir des réunions constructives.
Commencer par time boxer les réunions. Utiliser un tableau blanc ou numérique.
Alterner raisonnement individuel et raisonnement en équipe :

1. Chacun s’isole pour réfléchir de son côté au même problème
2. Chacun vient ensuite expliquer son architecture. On essaie de dépersonnaliser sa solution. Cet exercice permet d’apprendre de ses collègues et de connaitre leurs points d’attention.

Remember :

  • The system = the software + the people
  • Baby steps : on apprend progressivement, par petits pas, rapidement => réduit le risque dans un monde avec beaucoup d’incertitudes
  • Rester simple
  • Books : toutes ces attitudes nécessaires à l’Architecture restent inchangées depuis 30 ans : couplage et cohésion, contrats, modularités, API … Cet apprentissage est pérenne et en vaut donc la peine. Les livres recommandés par Cyrille resteront intemporels.
3 livres recommandés par Cyrille Martraire
]]>
https://javaetmoi.com/2025/04/cles-de-l-architecture-pour-les-devs/feed/ 0
Optimisez vos applications Spring Boot avec CDS et le projet Leyden https://javaetmoi.com/2025/04/optimisez-vos-applications-spring-boot-avec-cds-et-le-projet-leyden/ https://javaetmoi.com/2025/04/optimisez-vos-applications-spring-boot-avec-cds-et-le-projet-leyden/#respond Mon, 21 Apr 2025 12:04:33 +0000 https://javaetmoi.com/?p=2482 Continuer la lecture de Optimisez vos applications Spring Boot avec CDS et le projet Leyden ]]> Conférence : Devoxx France 2025
Date : 17 avril 2025
Speaker : Sébastien Deleuze (Broadcom)
Format : Conférence (45 mn) / Replay Youtube

Sébastien est Core Commiter sur Spring Framework. Il intervient également sur des sujets transverses au portfolio Spring : support de Kotlin, null-safety (avec JSpecify) et les sujets d’optimisation. Dans ce talk, il a pour ambition de nous montrer comment améliorer l’efficacité de 80% des applications Spring, que ce soit de nouvelles applications ou des applications Legacy.

Sébastien Deleuze at Devoxx France 2025

Les raisons d’améliorer l’efficacité de nos applications sont multiples :

  • Baisser le cout de run des applications
  • Développement durable pour diminuer la consommation d’énergie, de mémoire et de CPU
  • Optimiser les applications pour les containers (sur le Cloud ou OnPremise)

Pour arriver à nos fins, Sébastien nous propose 3 technologies :

  1. CDS : techno relativement vieille mais qui s’est améliorée au fil des versions de Java
  2. AOT cache : Java 24 permet d’utiliser l’AOT cache qui est une version améliorée CDS. Sébastien prédit l’exploision de AOT Cache avec la LTS Java 25
  3. AOT cache with profiling : technologie expérimentale et prometeuse

Ces 3 technologies nécessitent un training run. Cette « exécution d’entrainement de l’application » consiste à lancer l’application pour charger les classes et créer un cache utilisé par la suite pour les déploiements en production. Le gain est triple :

  • Temps de démarrage réduit
  • Empreinte mémoire réduite
  • Warmup de la JVM plus rapide
Training run workflow

1. Class Data Sharing (CDS)

Disponible depuis Java 9 (2017), CDS a continué à évoluer au fil des versions de Java.

Par facilité (notamment pour la fonctionnalité d’extraction), un prérequis conseillé par Sébastien consiste à utiliser Java 17 et Spring Boot 3.3 et +.

Pour utiliser la fonctionnalité CDS, une archive CDS (format .jsa) doit être créée pour le classpath de l’application. Spring Framework fournit un mécanisme facilitant la création de cette archive. Une fois l’archive disponible, on peut l’utiliser via un flag de la JVM.

La création de l’archive CDS nécessite 2 paramètres de JVM :

  • -Dspring.context.exit=onRefresh : démarrage les beans singletons Spring non lazy puis arrête l’application.
  • -XX:ArchiveClassesAtExit=spring-petclinic.jsa : création de l’archive CDS lorsque la JVM s’arrête.
java -Dspring.context.exit=onRefresh

Les plugins Maven et Gradle de Spring Boot permettent de créer un JAR auto-exécutable. Disposer d’une seul JAR est bien pratique pour le déploiement et le téléchargement d’une application depuis le repository Maven d’entreprise, mais pas efficiente avec CDS qui ne supporte pas les JAR imbriqués. La version 3.3 de Spring Boot a facilité le support de CDS en ajoutant une fonctionnalité d’auto-extraction du JAR via le paramètre -Djarmode=tools. Son utilisation est illustrée par la commande suivante :

java -Djarmode=tools -jar spring-petclinic.jar extract
CDS file layout

Sébastien fait une démonstration à l’aide de l’application Spring Petclinic.

Commande permettant d’utiliser l’archive CDS au démarrage de l’application :

java -XX :SharedArchiveFile=spring-petclinic.jsa  jar spring-petclinic.jar

Le gain au démarrage est de 19%. A noter que l’utilisation de CDS peut engendrer des effets de bord si l’on ne suit pas les bonnes pratiques suivantes :

  • Utiliser idéalement la même JVM pour la capture du CDS et le run de l’application
  • Spécifier le classpath avec la liste complète des JAR et ne pas utiliser de wildcard *
  • Le timestamp des JAR doit être préservé
  • Les éventuels JAR additionnels doivent être ajoutés à la fin du classpath.

L’étape de démarrage nécessaire à l’enregistrement de l’archive CDS est appelée le training run. Cette étape peut être intégrée dans le pipeline CI/CD de build de l’application Spring. Spring utilise une base de données H2 en mémoire. Une application d’entreprise se connectera à un PosgreSQL ou un MongoDB. Le paramétrage Spring diffère en fonction des dépendances externes de l’application. Sébastien nous recommande de consulter le repository spring-lifecycle-smoke-tests donnant différents exemples de configuration pour Spring Data, Spring Batch, Spring Coud, Kafka, Spring Security …

Training run configuration for your database

Par exemple, pour éviter qu’une application Spring Data JPA ne fasse appel à la base de données, il est possible de désactiver la lecture des méta-données par Hibernate. Ne pouvant plus déterminer automatiquement le dialect, il est alors nécessaire de lui spécifier.

# Specify explicitly the dialect (here for PostgreSQL, adapt for your database)
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect

# Disable Hibernate usage of JDBC metadata
spring.jpa.properties.hibernate.boot.allow_jdbc_metadata_access=false

# Database initialization should typically be performed outside of Spring lifecycle
spring.jpa.hibernate.ddl-auto=none
spring.sql.init.mode=never

Le support de Buildpack par les plugins Maven et Gradle de Spring Boo transforme une application Spring Boot en une image OCI (Docker). Commandes :

mvn spring-boot:build-image
gradle bootBuildImlage
CDS support in Buildpacks

Le support de CDS est prévu dans Buildpacks via l’activation du flag BP_JVM_CDS_ENABLED. Exemple de configuration Maven :

<plugin>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-maven-plugin</artifactId>
	<configuration>
		<image>
			<env>
				<BP_JVM_CDS_ENABLED>true</BP_JVM_CDS_ENABLED>
			</env>
		</image>
	</configuration>
</plugin>

Buildpack effectue le training run et ajoute l’archive jsa dans le container.

Pour un effort mesuré, le temps de démarrage de Spring Petclinic est réduit de 3 secondes à 1,8 secondes.

2. AOT Cache

Successeur de CDS, AOT Cache (Ahead-Of-Time Cache) est une fonctionnalité de la JVM intégrée à Java 24 qui permet d’améliorer l’efficience de nos applications Java. Permettant de diminuer les temps de démarrage, Spring AOT est une fonctionnalité Spring obligatoire pour les images natives mais optionnelle sur la JVM. Sébastien perçoit une synergie entre AOT Cache et Spring AOT.

L’utilisation Spring AOT impose certaines contraintes comme la pré-configuration des profiles Spring. Le repo Github sdeuleuze/demo-profile-aot montre comment activer les profils Spring dans un build Maven et Gradle.

Pensée dans le cadre du projet Leyden, La JEP 483 Ahead-of-Time Class Loading & Linking est disponible dans Java 24 et des améliorations prévues dans les versions suivantes de Java. L’utiliser est un bon investissement.

AOT Cache and Spring AOT are different but they combine well

La création du cache AOT nécessite 2 étapes :

La 1ière étape consiste à générer le fichier .aotconf à l’aide de l’option -XX:AOTMode=record et la ligne de commande suivante :

java -XX:AOTMode=record -XX:AOTConfiguration=spring-petclinic.aotconf \
-jar spring-petclinic.jar

Au préalable, comme avec CDS, le JAR auto-exécutable aura été extrait à l’aide de -Djarmode=tools.

La 2ième étape consiste à générer un fichier .aot avec l’option -XX:AOTMode=create et la ligne de commande suivante :

java -XX:AOTMode=create -XX:AOTConfiguration=spring-petclinic.aotconf -XX:AOTCache=spring-petclinic.aot -jar spring-petclinic.jar

Le temps de démarrage de Spring Petclinic descend à 1,3 secondes :

Spring Petclinic startup time (seconds)

3. AOT Cache with code compilation and Spring AOT

Sébastien rappelle que ce n’est que le début de l’histoire et que ces temps de démarrage à l’aide d’AOT Cache ne pourront que s’améliorer dans le futur. En effet, le projet Leyden prévoit de nouvelles améliorations, donc les 3 JEPs en draft :

  1. Ahead-of-Time Method Profiling : amelioration du temps de chauffe
  2. Ahead-of-time Command Line Ergonomics : une seule étape au lieu de 2 étapes pour créer les fichiers .aotconf et .aot
  3. Ahead-of-time Code Compilation : récupération du code natif du JIT pour le réutiliser en prod

Sébastien nous fait une démo live à partir d’une version du JDK compilée en local avec les dernières fonctionnalités du projet Leyden. Cette fois-ci, le workflow va être un peu différent : on ne fait pas de stop après le démarrage. On laisse tourner l’application. Sébastien utilise l’outil oha pour chauffer la JVM (faire le warmup). Il mesure des améliorations très significatives alors même que techno en work-in-progress. Jugez par vous-même :

Spring Petclinic startup time (secondes)
Warmup with and without AOT cache
  • Courbe bleue : JVM classique qui prend son temps pour le warmup
  • Courbe rouge : AOT profiling. Warmup assez rapide.
  • Courbe jaune : warmup passé de 30 à 7 secondes. Fonctionne sur des applications Legacy

Récapitulatif

Le tableau récapitulatif ci-dessous compare 3 technologies d’optimisation d’une application Java :

Comparing GraalVM, Project CRaC and AOT cache
  1. GraalVM : la version gratuite de GraalVM vient avec le Garbage Collector serial adapté pour les applications ayant une faible empreinte mémoire et une petite taille du de Heap. Le GC G1 n’est disponible que dans la version commerciale Oracle de GraalVM. Avantage : conso mémoire réduite au max. Pas fait pour des applications qu’on déploie / construit plusieurs fois par jour. Dépend de la taille de l’appli : ok pour un microservice mais pas un monolith. Spring fait le travail pour préconfigurer GraalVM. Mais quid des autres librairies qui ne supporte pas GraalVM et pour lesquels les développeurs doivent ajouter des méta-données. Sébastien considère que GraalVM est une niche pour 5 à 10% des applications Spring. Cela dit, des travaux sont en cours pour améliorer le support dans Spring.
  2. JVM with Project CRaC : Sébastien est assez sévère sur cette technologie qui comporte 2 énorme défauts : Linux uniquement mais surtout à cause du cycle de vie nécessitant de restaurer l’application (handles filesystems, sockets réseaux …). Quid du support des autres librairies ? L’API de ces librairies peut bloquer. Plus encore : l’image snapshot de la JVM contient les credentials. Aussi, Sébastien de recommande pas CRaC pour la production. La techno a des limites malgré l’effort de l’équipe Spring.
  3. JVM with AOT cache : utilisable sur un grand nombre de projets legacy. Temps de démarrages est réduit de 2 à 4 fois. Les effets de bord sont mesurés. La CI doit être adaptée pour lancer le warmup.

Le projet Leyden et la JVM continue à évoluer. Preuve en est la Pull Request #44 datant de février 2025 du projet Leyden : 8350488: [leyden] Experimental AOT-only mode

D’autres améliorations concernant les applications Legacy seront annoncées à la conférence Spring IO qui aura lieu du 21 au 23 mai 2025 à Barcelone.

Enfin, pour ses benchmarks, Sébastien passe une annonce : il recherche de plus grosses applications Open Source basées sur Spring Boot et plus réalistes que l’application démo Petclinic.

Si vous voulez creuser le sujet, je vous recommande la lecture de l’article intitulé Spring Boot CDS support and Project Leyden anticipation qu’a publié Sébastien le 29 aout 2024.

]]>
https://javaetmoi.com/2025/04/optimisez-vos-applications-spring-boot-avec-cds-et-le-projet-leyden/feed/ 0
Spring Petclinic sous extensions Quarkus https://javaetmoi.com/2025/04/spring-petclinic-sous-extensions-quarkus/ https://javaetmoi.com/2025/04/spring-petclinic-sous-extensions-quarkus/#respond Sun, 13 Apr 2025 16:55:14 +0000 https://javaetmoi.com/?p=2443 Continuer la lecture de Spring Petclinic sous extensions Quarkus ]]> Spring et Quarkus dans le même repository Git, ou presque. Cela vous intrigue ?
Figurez-vous qu’il y’a quelques mois, la lecture du très bon livre Understanding Quarkus 2.x d’Antonio Gongalves m’a donné envie de pratiquer ce framework alternatif à Spring Boot. Et pour apprendre une nouvelle technologie, quoi de plus stimulant que de se fixer un objectif. Je me suis donc donné comme challenge de migrer vers Quarkus l’application démo Spring Boot que je connais bien. Une fois migrée, l’application devait rester iso-fonctionnelle.
A travers leur repo quarkus-petclinic, RedHat avait fait l’exercice avant moi. Malheureusement, l’historique Git a été écrasé, ne laissant aucune trace du chemin de migration parcouru. Pendant 3 mois, j’ai donc travaillé sur un nouveau fork que je suis fier de vous présenter : quarkus-spring-petclinic. Ajouté à la communauté Spring Petclinic, ce fork a un double objectif :

  1. Montrer comment migrer une application Spring Boot 3.4 vers Quarkus 3.21, avec le minium d’effort et en modifiant le moins de code possible
  2. Utiliser les extensions Spring proposées par Quarkus pour garder un lien avec le monde Spring tout en soulignant l’effort de l’équipe Quarkus pour supporter Spring, un framework incontournable de l’écosystème Java

Les extensions Spring pour Quarkus utilisées sont au nombre de quatre : Spring DI, Spring Web, Spring Data JPA et Spring Cache.
Le changement majeur aura été de porter le templating des pages HTML de Thymeleaf vers Qute.

Débutant en Quarkus, le code proposé ne respecte peut-être pas toutes les règles de l’art prônées par l’équipe de dév Quarkus. Je m’en excuse par avance. Si vous voulez contribuer et corriger le tir : issue et Pull Request sont les bienvenues.

Le différenciel complet entre la version Spring Boot et la version Quarkus de Petclinic peut-être visualisé sur Github.

Configuration du build Maven et Gradle

Spring Petclinic supporte les 2 principales plateformes de build de l’ecosystème Java, à savoir Maven et Gradle. Pour chaque dépendance Spring Boot, le tableau ci-dessous dresse l’équivalent utilisé sur la version Quarkus :

Dépendances Spring Boot


Dépendances Quarkus correspondantes


Commentaire

spring-boot-starter-actuator

quarkus-smallrye-health

SmallRye Health est une implementation de la MicroProfile Health.

spring-boot-starter-cache

cache-api
caffeine

quarkus-spring-cache

Extension Spring Cache pour Quarkus
Quarkus utilise par défaut Caffeine.

spring-boot-starter-data-jpa

quarkus-spring-data-jpa
quarkus-narayana-jta

Extension Spring Data JPA pour Quarkus
Quarkus s’appuie sur Hibernate ORM et Panache. Le gestionnaire de transactions JTA est à ajouter manuellement.

spring-boot-starter-web

quarkus-spring-web
quarkus-rest-jackson

L’extension Spring Web pour Quarkus requière quarkus-rest-jackson ou quarkus-resteasy-jackson.

spring-boot-starter-validation

quarkus-hibernate-validator

Les versions Spring Boot et Quarkus de Petclinic s’appuient toutes 2 sur Hibernate Validator.

spring-boot-starter-thymeleaf

quarkus-qute

Pas de correspondance directe car Quarkus utilise Qute pour le templating.

spring-boot-starter-test

quarkus-junit5
quarkus-junit5-mockito
quarkus-test-h2
rest-assured

Rest Assured remplace MockMvc pour tester les contrôleurs REST.

h2

quarkus-jdbc-h2

 

mysql-connector-j

quarkus-jdbc-mysql

En plus des drivers JDBC, tire le pool de connexions Agroal qui remplace HikariCP.

postgresql

quarkus-jdbc-postgresql

« 

webjars-locator-lite

quarkus-web-dependency-locator

Utiles pour les webjars.

spring-boot-devtools

 

Pas de correspondance directe. Quarkus inclue le mode dev par défaut.

spring-boot-docker-compose

Utilisé par les tests d’intégration reposant sur Testcontainers.
Pas d’équivalent côté Quarkus qui sait nativement démarrer des conteneurs Docker lorsqu’aucune configuration n’est précisée.

(spring-core et spring-beans)

quarkus-spring-di

Support des annotations Spring d’injection de dépendance, mais en tirant ArC, une implémentation light de CDI spécifique à Quarkus.

 

quarkus-container-image-docker

Création d’images Docker multi-plateformes.


Les dépendances vers les 2 webjars bootstrap et font-awesome sont restés inchangées.
La migration a été faite avec une approche top-down : on part de la couche persistance pour remonter vers la couche de présentation.

Adaptation de la couche Spring Data JPA

L’extension Spring Data JPA pour Quarkus présente l’avantage de pouvoir conserver les conventions de nommage des interfaces des repository Spring Data JPA. Sous le capot, l’implémentation est générée à l’aide de Panache. Les repository migrés peuvent continuer à implémenter les interfaces JpaRepository et ListCrudRepository, à utiliser les interfaces Spring Data Page et Pageable pour la pagination.

Ce portage a permis de conserver 90% du code existant de la couche de persistance de Spring Petclinic. Je l’ai personnellement trouvé plus strict que l’original. Preuve en est ce premier exemple possible avec Spring Data JPA, mais qui ne fonctionne pas sous Quakus : déclarer sur l’interface OwnerRepository la méthode findPetTypes manipulant des entités JPA de type PetType et non de type Owner.
L’erreur suivante était générée pendant le build :

Query annotations may only use interfaces to map results to non-entity types. Offending query string is "SELECT ptype FROM PetType ptype ORDER BY ptype.name" on method findPetTypes of Repository org.springframework.samples.petclinic.owner.OwnerRepository

Les messages d’erreur ne sont pas explicites. Aussi, pour debugger et trouver la cause, j’ai eu besoin d’ajouter temporairement la dépendance suivante :

<dependency> 
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-spring-data-jpa-deployment</artifactId>
</dependency>

Le moyen de contournement a consisté tout simplement à découper en deux l’interface OwnerRepository. L’interface PetTypeRepository a été ajoutée et a pour responsabilité l’accès aux PetType. On a ainsi un meilleur découplage.

Second cas dysfonctionnant sous Quarkus :

public interface VetRepository extends Repository<Vet, Integer> {
	Collection<Vet> findAll();
}

Quarkus génère l’exception suivante :

Caused by: io.quarkus.spring.data.deployment.UnableToParseMethodException: Method 'findAll' of repository 'org.springframework.samples.petclinic.vet.VetRepository' cannot be parsed as there is no proper 'By' clause in the name.

La classe MethodNameParser ne supporte pas le type de retour Collection. Triviale, la correction a consisté à le changer en List.

Dernier changement mineur apporté à la couche de persistance : l’exception non checkée DataAccessException n’est pas supportée par Quarkus. Elle a donc été retirée de l’interface des méthodes des Repository.

Une fois migrée, l’interface OwnerRepository n’a aucune adhérence à Quarkus ou Panache. Elle conserve ses imports sur les classes de Spring Data Commons et Spring Data JPA :

package org.springframework.samples.petclinic.owner;

import java.util.Optional;

import jakarta.annotation.Nonnull;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface OwnerRepository extends JpaRepository<Owner, Integer> {

	Page<Owner> findByLastNameStartingWith(String lastName, Pageable pageable);

	Optional<Owner> findById(@Nonnull Integer id);

	Page<Owner> findAll(Pageable pageable);

Adaptation des scripts SQL

Migrer les Repository Spring Data JPA, c’est bien. Les tester, c’est mieux. Les tests unitaires de Quarkus Spring Petclinic utilisent la base de données embarquées H2.
L’exécution du script data.sql échouait avec l’erreur suivante :

Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Intégrité référentielle violation de contrainte: "FK35UIBOYRPFN1BNDRR5JORCJ0M: PUBLIC.VET_SPECIALTIES FOREIGN KEY(SPECIALTY_ID) REFERENCES PUBLIC.SPECIALTIES(ID) (4)"
Referential integrity constraint violation: "FK35UIBOYRPFN1BNDRR5JORCJ0M: PUBLIC.VET_SPECIALTIES FOREIGN KEY(SPECIALTY_ID) REFERENCES PUBLIC.SPECIALTIES(ID) (4)"; SQL statement:
INSERT INTO vet_specialties VALUES (4, 2) [23506-230]

Cette différence de comportement s’explique par le fait que Quarkus utilise Hibernate pour générer le script DDL de création du schéma et non pas directement le script DDL schema.sql. L’ordre des colonnes diffère entre le script DDL généré par Hibernate et le script SQL existant. Je n’ai pas trouvé la possibilité d’utiliser le script schema.sql. Je ne suis apparemment pas le seul. Si vous avez une idée, vous pouvez contribuer à l’issue #8.

En attendant de trouver une solution, j’ai modifié le script SQL en précisant le nom des colonnes dans l’instruction INSERT, ce qui est une bonne pratique :

Portage des tests AssertJ vers Hamcrest

Pour les tests unitaires, Quarkus recommande l’utilisation de JUnit 5 déjà utilisé sur Spring Petclinic. Les assertions de JUnit sont limitées. Là où Spring Petclinic utilise la librairie AssertJ, Quarkus préconise l’utilisation d’Hamcrest. D’après l’issue #38689 “Include AssertJ with Quarkus releases”, le support d’AssertJ dans Quarlus ne semble pas planifié.

Migrer des assertions AssertJ vers les matchers Hamcrest peut être facilitée par la recette Open Rewrite MigrateHamcrestToAssertJ. L’inverse n’est pas vrai. C’est là où Github Copilot ou Codeium facilite la tâche. On migre un premier test, et l’IA vous assiste pour la suite.

Exemple avec la méthode shouldFindSingleOwnerWithPet() extrait de la classe ClinicServiceTests :

Avant migration sous AssertJ :

@Test
void shouldFindSingleOwnerWithPet() {
	Optional<Owner> optionalOwner = this.owners.findById(1);
	assertThat(optionalOwner).isPresent();
	Owner owner = optionalOwner.get();
	assertThat(owner.getLastName()).startsWith("Franklin");
	assertThat(owner.getPets()).hasSize(1);
	assertThat(owner.getPets().get(0).getType()).isNotNull();
	assertThat(owner.getPets().get(0).getType().getName()).isEqualTo("cat");
}

Après migration sous Hamcrest :

@Test
void shouldFindSingleOwnerWithPet() {
	Optional<Owner> optionalOwner = this.owners.findById(1);
	assertThat(optionalOwner.isPresent(), is(true));
	Owner owner = optionalOwner.get();
	assertThat(owner.getLastName(), startsWith("Franklin"));
	assertThat(owner.getPets(), hasSize(1));
	assertThat(owner.getPets().get(0).getType(), notNullValue());
	assertThat(owner.getPets().get(0).getType().getName(), is(equalTo("cat")));
}

Passer à l’annotation @TestTransaction

Dans les classes de tests faisant appels à des Repository, l’annotation org.springframework.transaction.annotation.Transactional du module spring-tx a été remplacée par io.quarkus.test.TestTransaction du module quarkus-test-commons. Ces annotations permettent de rollbacker la transaction à la fin de l’exécution d’une méthode de test, laissant ainsi la base de données inchangée pour le prochain test.

Exemple avec la méthode shouldInsertOwner() extrait de la classe ClinicServiceTests :

@Test
@TestTransaction
void shouldInsertOwner() {
	Page<Owner> owners = this.owners.findByLastNameStartingWith("Schultz", pageable);
	int found = (int) owners.getTotalElements();

	Owner owner = new Owner();
	owner.setFirstName("Sam");
	owner.setLastName("Schultz");
	owner.setAddress("4, Evans Street");
	owner.setCity("Wollongong");
	owner.setTelephone("4444444444");
	this.owners.save(owner);
	assertThat(owner.getId(), is(not(0)));

	owners = this.owners.findByLastNameStartingWith("Schultz", pageable);
	assertThat(owners.getTotalElements(), is(equalTo(found + 1L)));
}

De DataJpaTest à QuarkusTest

Pour tester les Repository JPA, Spring Boot met à disposition l’annotation @DataJpaTest automatisant la configuration des classes de test. Elle s’occupe notamment de démarrer en mémoire une base de données embarquée H2, de créer son schéma et de charger un jeu de données de test.

Pour arriver à un résultat similaire avec Quarkus, l’annotation @DataJpaTest a été remplacée par 2 annotations :

@QuarkusTest
@QuarkusTestResource(H2DatabaseTestResource.class)
class ClinicServiceTests {

L’annotation @QuarkusTestResource permet de référencer la classe H2DatabaseTestResource (fournie par l’artefact io.quarkus:quarkus-test-h2) chargée de démarrer / arrêter un serveur H2.

Par défaut, l’application Spring Petclinic démarre une base de données H2, la même que celle utilisée pour les tests. La propriété quarkus.hibernate-orm.sql-load-script du fichier application.properties a été positionnée sur h2 :

quarkus.datasource.db-kind=h2
quarkus.hibernate-orm.log.sql=true
quarkus.hibernate-orm.sql-load-script=db/${quarkus.datasource.db-kind}/data.sql

La propriété quarkus.hibernate-orm.sql-load-script a quant à elle permis de réutiliser le script DML existant data.sql insérant quelques données de test.

A ce stade de la migration vers Quarkus, les tests unitaires de la couche de persistance et de la couche service sont passants.

Internationalisation

Le support de l’internationalisation (i18n pour les intimes) est incomplet dans Spring Petclinic (cf. issue #1854). Le ressource bundle messages contient différent fichiers properties de traduction. Les clés sont utilisées dans certains templates Thymeleaf (ex : welcome) et pour les messages d’erreur (ex : required, typeMismatch.birthDate). Ce ressource bundle a pu être réutilisé dans la version Quarkus.

Qute propose un mécanisme typesafe de ressource bundle basé sur l’annotation @ResourceBundle. La classe AppMessages a été ajoutée à Petclinic. En voici un extrait contenant 3 clés :

import io.quarkus.qute.i18n.Message;
import io.quarkus.qute.i18n.MessageBundle;

@MessageBundle(value = "messages", locale = "en")
public interface AppMessages {

  @Message
  String welcome();

	@Message
	String required();

	@Message
	String typeMismatch_birthDate();

Le nom des clés des properties ne semble pas accepter le caractère point (ex : @Message(value = « typeMismatch.birthDate »). Certaines clés ont donc dû être renommées (ex : typeMismatch.birthDate vers typeMismatch_birthDate).

Au runtime, l’usage du ressource bundle Quarkus peut-être utilisé dans un template Qute via le namespace du message bundle. Exemple :

{#for err in errors}
  {#if err == 'notFound'}
    <p>{messages:notFound}</p>
  {#else}
    <p>{err}</p>
  {/if}
{/for}

Ce même ressource bundle peut également être exploité depuis un contrôleur REST. La création de classe I18nHelper permet d’exploiter dynamiquement l’en-tête HTTP Accept-Language :

@GetMapping("/")
public TemplateInstance processFindForm(@RequestParam(defaultValue = "1") int page, @RequestParam String lastName,
		@HeaderParam("Accept-Language") String language) {
	Page<Owner> ownersResults = findPaginatedForOwnersLastName(page, lastName);

if (ownersResults.isEmpty()) {
	// no owners found
	String notFound = I18nHelper.lookupAppMessages(language).notFound();
	return OwnerTemplates.findOwners(List.of(notFound));
}

Possible que Quarkus propose nativement un mécanisme similaire. Quarkus Renarde utilise quant à lui le header Accept-Language et un cookie.

En passant, l’exemple précédent montre l’usage des annotations Spring @GetMapping et @RequestParam. L’annotation Spring @RequestHeader n’est pas supportée par Quarkus et a dû être substituée par l’annotation @HeaderParam de JAX-RS.

Le debuggage de la méthode MessageBundleProcessor::parseKeyToTemplateFromLocalizedFile aura nécessité d’ajouter temporairement au classpath la dépendance io.quarkus:quarkus-qute-deployment.

Ressources statiques

Afin de se conforter aux conventions de Quarkus, les ressources statiques (fonts, css et images) ont été déplacées du répertoire static/resources vers le répertoire META-INF/resources.

Migration templates Thymeleaf vers Qute

Les templates Thymeleaf de Spring Petclinic utilisent le mécanisme de fragments Thymeleaf à la fois pour le gabarit des pages (layout.html) et pour les tags HTML réutilisables (inputField.html et selectField.html).

Une première étape a donc consisté à migrer ces fragments Thymeleaf vers une équivalence Qute. La syntaxe de ces 2 moteurs de templating Java diffère beaucoup. A l’aide du guide de référence de Qute, le gabarit des pages layout.html a été migré sans difficulté majeure. La gestion dynamique du menu est désormais gérée en JavaScript. Ce gabarit est référencé dans les autres templates Qute via la section {#include fragments/layout}.

Template Thymeleaf de la page welcome originale :

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org" th:replace="~{fragments/layout :: layout (~{::body},'home')}">
  <body>
    <h2 th:text="#{welcome}">Welcome</h2>
    <div class="row">
        <div class="col-md-12">
          <img class="img-responsive" src="../static/resources/images/pets.png" th:src="@{/resources/images/pets.png}"/>
        </div>
    </div>
  </body>
</html>

Template Qute équivalent de la page welcome :

{#include fragments/layout}
  <body>
    <h2>{messages:welcome}</h2>
    <div class="row">
        <div class="col-md-12">
          <img class="img-responsive" src="/images/pets.png" />
        </div>
    </div>
  </body>
{/include}

Afin d’être enregistrés automatiquement par Quarkus, les user-defined tags input et select ont été déplacés dans le répertoire src/main/resources/templates/tags. Les 2 exemples de tags suivants permettent de comparer les syntaxes Thymeleaf et Qute.

Exemple du tag Thymeleaf inputField.html :

<html>
<body>
  <form>
    <th:block th:fragment="input (label, name, type)">
      <div th:with="valid=${!#fields.hasErrors(name)}"
        th:class="${'form-group' + (valid ? '' : ' has-error')}"
        class="form-group">
        <label class="col-sm-2 control-label" th:text="${label}">Label</label>
        <div class="col-sm-10">
            <div th:switch="${type}">
                <input th:case="'text'" class="form-control" type="text" th:field="*{__${name}__}" />
                <input th:case="'date'" class="form-control" type="date" th:field="*{__${name}__}"/>
            </div>
          <span th:if="${valid}"
            class="fa fa-ok form-control-feedback"
            aria-hidden="true"></span>
          <th:block th:if="${!valid}">
            <span
              class="fa fa-remove form-control-feedback"
              aria-hidden="true"></span>
            <span class="help-inline" th:errors="*{__${name}__}">Error</span>
          </th:block>
        </div>
      </div>
    </th:block>
  </form>
</body>
</html>

Exemple équivalent du tag Qute inputField.html :

 {#let invalid=result.hasErrors(name)}
      <div class="form-group {#if invalid} has-error {/if}">
        <label for="{name}" class="col-sm-2 control-label">{it}
        </label>
        <div class="col-sm-10">
          <input class="form-control" id="{name}" name="{name}" type="{type}" value="{field}" />
          <span class="fa {#if invalid}fa-remove{#else}fa-ok{/if} form-control-feedback" aria-hidden="true"></span>
          {#if invalid}
            <span class="help-inline">{result.getErrorMessage(name)}</span>
          {/if}
        </div>
      </div>
{/let}

Binding du modèle

Une fois les templates Thymeleaf converties en Qute, des ajustements ont été nécessaire du côté des contrôleurs web, notamment au niveau du binding des champs du formulaire. Le binding est le processus par lequel les données envoyées par l’utilisateur, généralement via un formulaire, sont automatiquement associées à un objet du modèle. Spring Web MVC gère le binding en utilisant des DataBinder qui convertissent automatiquement les paramètres de requête HTTP en propriétés d’un objet Java, en s’appuyant sur les noms des champs du formulaire et les conventions de nommage. Dans l’exemple suivant, la méthode processCreationForm accepte en paramètre un objet de type Owner bindé avec les champs du formulaire createOrUpdateOwnerForm.html :

@PostMapping("/owners/new")
public String processCreationForm(@Valid Owner owner, BindingResult result, RedirectAttributes redirectAttributes) {

Positionnée sur le paramtètre owner, l’annotation @Valid permet d’exécuter la validation Bean Validation / Hibernate Validator. Je n’ai pas trouvé dans Quarkus l’équivalent des classes BindingResult et RedirectAttibutes. Ainsi, la signature de cette méthode s’allège en Quarkus :

@PostMapping("/owners/new")
public TemplateInstance processCreationForm(Owner owner) {

On retrouve l’annotation Spring @PostMapping supportée par l’extension Quarkus. Le type de retour n’est plus une String correspondant à la vue MVC à afficher, mais une TemplateInstance.

Pour binder la classe Owner, un changement a dû être opéré au niveau de la classe Owner et de ses classes parentes Person et NamedEntity : ajouter l’annotation JAX-RS @FormParam sur les attributs bindés comme address. Extrait de la classe Owner :

public class Owner extends Person {

	@Column(name = "address")
	@NotBlank
	@FormParam("address")
	private String address;

Sans ce changement, voici le message d’erreur obtenu lors de la création d’un nouveau propriétaire d’animal de compagnie :

2025-04-12 17:36:04,095 ERROR [org.spr.sam.pet.sys.ExceptionMappers] (executor-thread-1) Internal server error: jakarta.ws.rs.NotSupportedException: HTTP 415 Unsupported Media Type
at org.jboss.resteasy.reactive.server.handlers.RequestDeserializeHandler.handle(RequestDeserializeHandler.java:75)

Ce ciblage explicite des champs bindés depuis un formulaire HTML pourrait être justifié par des mesures de sécurité.

Pour terminer sur le binding du modèle, l’interface org.springframework.ui.Model est conservée dans quarkus-spring-context-api mais ne semble pas être exploitée par Quarkus.

Validation des données

Dans le paragraphe précédent, nous avons vu comment récupérer de manière typée les données saisies par l’utilisateur dans l’interface web de Petclinic. Nous allons voir à présent comment il est possible de valider les données avant de les insérer en base de données.

Le guide Validation with Hibernate Validator explique comment mettre en place Bean Validation sur une API REST. L’annotation @jakarta.validation.Valid est supportée par Quakus. Pour autant, son usage n’a pas pu être conservé dans Petclinic. En effet, si on la laisse, Quarkus valide les données du Owner et, en cas d’erreur, ne rentre pas dans la méthode processCreationForm. Il renvoie directement un flux texte contenant le rapport d’erreur complet. Exemple de la soumission d’un formulaire vide :

ViolationReport{title='Constraint Violation', status=400, violations=[Violation{field='processCreationForm.owner.address', message='ne doit pas être vide'}, Violation{field='processCreationForm.owner.telephone', message='ne doit pas être vide'}, Violation{field='processCreationForm.owner.telephone', message='Telephone must be a 10-digit number'}, Violation{field='processCreationForm.owner.city', message='ne doit pas être vide'}, Violation{field='processCreationForm.owner.lastName', message='ne doit pas être vide'}, Violation{field='processCreationForm.owner.firstName', message='ne doit pas être vide'}]}

Dans Petclinic, on souhaite renvoyer le formulaire HTML en erreur avec le message d’erreur à côté de chaque champ erroné.

Dans la documentation Quakus Qute, je n’ai pas trouvé l’équivalent de ce que propose Spring Web MVC, grâce notamment à la classe BindingResult. Pour contourner cette limitation, j’ai introduit le record Result. L’appel au Validator Bean Validation est fait de manière impérative. Son résultat (un ensemble de ConstraintViolation) permet de construire une instance de Result.

Exemple en Spring :

@PostMapping("/owners/new")
public String processCreationForm(@Valid Owner owner, BindingResult result, RedirectAttributes redirectAttributes) {
	if (result.hasErrors()) {
		redirectAttributes.addFlashAttribute("error", "There was an error in creating the owner.");
		return VIEWS_OWNER_CREATE_OR_UPDATE_FORM;
	}

	this.owners.save(owner);
	redirectAttributes.addFlashAttribute("message", "New Owner Created");
	return "redirect:/owners/" + owner.getId();
}

Exemple équivalent en Quarkus :

@PostMapping("/new")
public TemplateInstance processCreationForm(Owner owner) {
	Result result = Result.from(validator.validate(owner));
	if (result.hasErrors()) {
		return OwnerTemplates.createOrUpdateOwnerForm(owner, result);
	}

	this.owners.save(owner);
	return OwnerTemplates.ownerDetails(owner, Result.success("New Owner Created"));
}

Noter l’appel à la méthode OwnerTemplates::ownerDetails() dont nous allons étudier le fonctionnement dans le paragraphe suivant.

A noter également un écart de fonctionnement entre les versions Spring Boot et Quarkus de Petclinic : lors de la soumission d’un formulaire (POST), la version Spring utilise une redirection http pour rediriger l’utilisateur sur l’URL de consultation (GET). Nativement, Quarkus et Qute ne supportent pas ce fonctionnement. Pour être iso-fonctionnel, il aurait fallu utiliser Quarkus Renarde qui supporte les redirections et le scope flash.

Enfin, dans la version Spring, la classe PetValidator assure la validation des champs obligatoires name, type et birthDate. Dans la version Quarkus, cette classe a été supprimée au profit de l’utilisation de l’annotations @NotNull ajoutée sur classe Pet et du support de Bean Validation.

Templates Qute type-safe

Dans la version Quarkus de Petclinic, on note l’introduction de 3 nouvelles classes annotées chacune avec @CheckedTemplate : OwnerTemplates, PetTemplates et VetTemplates. Appelées depuis les contrôleurs REST, leurs méthodes natives permettent de sélectionner le template à rendre, ceci de manière type-safe. Exemple de la classe OwnerTemplates :

@CheckedTemplate(basePath = "owners")
public class OwnerTemplates {

	public static native TemplateInstance findOwners(List<String> errors);

	public static native TemplateInstance ownersList(List<Owner> owners, int currentPage, Page<Owner> page);

	public static native TemplateInstance ownerDetails(Owner owner, Result result);

	public static native TemplateInstance createOrUpdateOwnerForm(Owner owner, Result result);

}

Les paramètres des méthodes correspondent au modèle de données requis lors du rendu des templates Qute. Lors du build, la classe QuteProcessor vérifie leur concordance. C’est la magie de Quarkus. Voici un exemple explicite d’erreur remontée si l’on omet le paramètre owners à la méthode ownersList:

io.quarkus.qute.TemplateException: owners/ownersList.html:20:36 - {owner.firstName}: Only type-safe expressions are allowed in the checked template defined via: org.springframework.samples.petclinic.owner.OwnerTemplates.ownersList(); an expression must be based on a checked template parameter [page, currentPage], or bound via a param declaration, or the requirement must be relaxed via @CheckedTemplate(requireTypeSafeExpressions = false)

Au niveau de l’annotation @CheckedTemplate, l’attribut basePath permet de pointer sur le répertoire templates/owners et ne pas toucher à la localisation des fichiers te template html. Quarkus utilise le nom de la méthode pour retrouver le fichier html du même nom dans le répertoire templates/owner.

Test des contrôleurs

Le test unitaire Spring Boot de la classe OwnerController utilise l’annotation @WebMvcTest pour configurer un contexte d’application limité, ciblant uniquement les composants liés à la couche web, ceci afin de tester les endpoints HTTP sans charger l’intégralité du contexte Spring de l’application.
La classe utilitaire MockMvc permet à Spring de simuler des requêtes HTTP et de tester les contrôleurs Spring MVC sans démarrer un serveur web.

La migration des tests des contrôleurs REST vers Quarkus demande un peu de travail. En effet, Quarkus préconise l’utilisation de la bibilothèque REST-assured. Cette dernière permet de tester les API REST en facilitant l’envoi de requêtes HTTP et la vérification des réponses de manière fluide et intuitive à l’aide d’une fluent API.

Combinée à l’annotation @QuarkusTest, l’annotation @TestHTTPEndpoint permet de tester spécifiquement un contrôleur REST. Le support par Quarkus des annotations Spring demande quelques ajustements. En effet, la classe QuarkusTestExtension fait appel à la classe SpringWebEndpointProvider qui s’attend à ce qu’une annotation @RequestMapping annote le contrôleur REST testé. Pour être testable, le code de prod a dû être refactoré : il a été nécessaire de déclarer une annotation @RequestMapping au top niveau de chaque contrôleur REST.

Avant la mise en place du test OwnerControllerTests :

@RestController
class OwnerController {

	@GetMapping("/owners/new")
	public TemplateInstance initCreationForm() {

Après la mise en place du test OwnerControllerTests :

@RestController
@RequestMapping("/owners")
class OwnerController {

	@GetMapping("/new")
	public TemplateInstance initCreationForm() {

En prenant comme exemple la méthode testProcessCreationFormSuccess, vous pouvez comparer le code d’un test migré de Spring MockMvc vers REST-assured.
Test avec Spring MockMvc :

@Test
void testProcessCreationFormSuccess() throws Exception {
	mockMvc
		.perform(post("/owners/new").param("firstName", "Joe")
			.param("lastName", "Bloggs")
			.param("address", "123 Caramel Street")
			.param("city", "London")
			.param("telephone", "1316761638"))
		.andExpect(status().is3xxRedirection());
}

Test équivalent avec REST-assured :

@Test
void testProcessCreationFormSuccess() {
	RestAssured
    .given()
		    .param("firstName", "Joe")
	      .param("lastName", "Bloggs")
  	    .param("address", "123 Caramel Street")
		    .param("city", "London")
		    .param("telephone", "1316761638")
		.when()
		    .post("/new")
		.then()
		    .statusCode(200)
		    .body("html.body.div.span", is("New Owner Created"));
}

Du formatter Spring au ParamConverter JAX-RS

Dans la version Spring, la classe PetTypeFormatter est chargée de parser et d’afficher une instance de PetType. Elle s’appuie sur l’interface Formatter de Spring Framework supportée par Spring MVC.

La migration de cette classe vers Quarkus a consisté à utiliser l’interface ParamConverter de JAX-RS. Le paragraphe Parameter mapping du guide Writing REST Services with Quarkus REST explique comment implémenter une telle classe et la mettre à disposition via un provider implémentant l’interface ParamConverterProvider, ce qui a été fait à travers la classe PetclinicParamConverterProvider.

Exemple de la classe PetTypeFormatter:

@Component
public class PetTypeFormatter implements ParamConverter<PetType> {

	private final PetTypeRepository petTypes;

	public PetTypeFormatter(PetTypeRepository petTypes) {
		this.petTypes = petTypes;
	}

	@Override
	public String toString(PetType petType) {
		return petType.getName();
	}

	@Override
	public PetType fromString(String text) {
		Collection<PetType> findPetTypes = this.petTypes.findAllByOrderByName();
		for (PetType type : findPetTypes) {
			if (type.getName().equals(text)) {
				return type;
			}
		}
		throw new IllegalArgumentException("type not found: " + text);
	}

}

Bien que le nom des méthodes ait changé, le code fonctionnel consistant à chercher un type d’animal dans les données de référence est resté inchangé.

Conversion des dates

Les formulaires de l’application Petclinic permettent de saisir la date de naissance d’un animal ainsi que sa date de visite à la clinique vétérinaire. Ces champs dates peuvent être laissées vides. La validation des données saisies est faite côté serveur.

Or, la classe org.jboss.resteasy.reactive.server.core.parameters.converters.LocalDateParamConverter ne supporte pas les chaines vides :

Caused by: java.time.format.DateTimeParseException: Text '' could not be parsed at index 0 at java.base/java.time.format.DateTimeFormatter.parseResolved0(DateTimeFormatter.java:2108) at java.base/java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:2010) at java.base/java.time.LocalDate.parse(LocalDate.java:435) at org.jboss.resteasy.reactive.server.core.parameters.converters.LocalDateParamConverter.convert(LocalDateParamConverter.java:24) at org.jboss.resteasy.reactive.server.core.parameters.converters.LocalDateParamConverter.convert(LocalDateParamConverter.java:6) at org.jboss.resteasy.reactive.server.core.parameters.converters.TemporalParamConverter.convert(TemporalParamConverter.java:29) ... 14 more

Sur le même modèle que le PetTypeFormatter vu précédemment, la classe LocalDateParamConverter implémentant l’interface ParamConverter a été introduite puis déclarée dans le provider PetclinicParamConverterProvider.

Cache applicatif

Spring Petclinic utilise Spring Cache et Caffeine pour mettre en cache la liste des vétérinaires. La version Quarkus s’appuie sur l’Extension Quarkus for Spring Cache API qui permet de conserver l’usage de l’annotation @Cacheable de Spring Cache.

Une différence de comportement entre Quarkus et Spring Boot a été identifiée lors des tests. En effet, apposée initialement sur les méthodes du repository VetRepository, les annotations @Cacheable n’étaient prises en compte par Quarkus. Une correction a consisté à déplacer l’annotation @Cacheable au niveau du contrôleur VetController :

@GetMapping
@Cacheable("vets")
public TemplateInstance showVetList(@RequestParam(defaultValue = "1") int page) {
	Vets vets = new Vets();
	Page<Vet> paginated = findPaginated(page);
	vets.getVetList().addAll(paginated.toList());
	return VetTemplates.vetList(paginated.getContent(), page, paginated);
}

Devenue inutile avec Quarkus, la classe CacheConfiguration a été supprimée.

Gestion transactionnelle

L’extension Narayana JTA apporte à Quarkus un gestionnaire de transaction JTA utilisable par Hibernate ORM.

L’annotation Spring org.springframework.transaction.annotation.Transactional a été remplacée par son équivalant JTA jakarta.transaction.Transactional.

Comme pour l’annotation @Cacheable, l’annotation @Transactional n’est pas prise en compte par Quarkus lorsqu’elle est utilisée au niveau du VetRepository. Spring Petclinic n’ayant plus de couche service, l’annotation @Transactional a été déplacée au niveau du contrôleur VetController :

@GetMapping
@Cacheable("vets")
@Transactional
public TemplateInstance showVetList(@RequestParam(defaultValue = "1") int page) {
	Vets vets = new Vets();
	Page<Vet> paginated = findPaginated(page);
	vets.getVetList().addAll(paginated.toList());
	return VetTemplates.vetList(paginated.getContent(), page, paginated);
}

Propriétés Spring Boot

Déclarée le temps de la migration puis supprimée une fois celle-ci terminée, l’extension Quarkus for Spring Boot properties a permis d’identifier les clés Quarkus à convertir dans le fichier application.properties. C’est le cas par exemple de la durée du cache des ressources statiques, configurées par défaut à 24h dans Quarkus, ramenée à 12h dans Petclinic.

// Avant
spring.web.resources.cache.cachecontrol.max-age=12h 
// Après
quarkus.http.static-resources.max-age=12h

Tests d’intégration avec Testcontainers

En complément des tests unitaires, Spring Petclinic utilise Testcontainers pour ses tests d’intégration avec les bases MySQL et PostgreSQL. C’est par exemple le cas du test @SpringBootTest PostgresIntegrationTests qui démarre une base PostgreSQL configurée dans le fichier docker-compose.yml, utilisant à ce titre la dépendance spring-boot-docker-compose.

Le support par Quarkus de la bibliothèque Testcontainers est particulièrement bien aboutie et presque transparent. La version @QuarkusTest de PostgresIntegrationTests ressemble à un test sans Docker :

@QuarkusTest
@TestProfile(Profiles.Postgres.class)
class PostgresIntegrationTests {

	@Autowired
	private VetRepository vets;

	@Test
	void testFindAll() {
		vets.findAll();
	}

	@Test
	void testOwnerDetails() {
		RestAssured.when()
			.get("/owners/1")
			.then()
			.statusCode(200)
			.contentType(ContentType.HTML)
			.body(containsString("Owner Information"))
			.body(containsString("George Franklin"))
			.body(containsString("110 W. Liberty St."))
			.body(containsString("Madison"))
			.body(containsString("6085551023"))
			.body(containsString("Leo"))
			.body(containsString("cat"));
	}

}

L’annotation Quarkus @TestProfile permet de référencer l’inner-class Postgres implémentant l’interface QuarkusTestProfile.

import io.quarkus.test.junit.QuarkusTestProfile;

public class Profiles {

	public static class Postgres implements QuarkusTestProfile {
		@Override
		public String getConfigProfile() {
			return "postgres-it";
		}
	}

	public static class MySQL implements QuarkusTestProfile {
		@Override
		public String getConfigProfile() {
			return "mysql-it";
		}
	}
}

Notez la présence de 2 profils Quarkus posgres-it et mysql-it dédiés aux tests d’intégrations
Dans le fichier application.properties, une ligne a été ajoutée pour chacun de ces profils :

%postgres-it.quarkus.datasource.db-kind=postgresql
%mysql-it.quarkus.datasource.db-kind=mysql

Ces 2 profils ont été ajoutés afin que l’URL JDBC de la base de données ne soit pas valorisée et que Quarkus utilise Dev Services pour démarrer l’image Docker PostgreSQL.

Binaire natif GraalVM

Grâce aux plugins native-maven-plugin et spring-boot-maven-plugin, la version Spring Boot de Petclinic permet de générer un binaire natif en s’appuyant sur GraalVM.

Le guide Building a Native Executable a permis de mettre en place facilement la génération d’un exécutable natif de Quakus Spring Petclinic. Dans le pom.xml, la configuration d’un profile maven native permet d’activer la propriété quarkus.native.enabled.

Contrairement à la version Spring Boot qui s’appuyait sur une base H2, la version Quarkus requière le démarrage d’une base PosgreSQL ou MySQL.

L’installation de GraalVM (ex : sdk install java 21-graal ) et la déclaration de la variable d’environnement GRAALVM_HOME est nécessaire.

./mvnw package -Dnative -Dquarkus.profile=postgres
docker compose up postgres 
./target/quarkus-spring-petclinic-*-runner

Quarkus Spring Petclinic démarre en 126 millisecondes :

2025-04-13 15:54:29,755 INFO [io.quarkus] (main) quarkus-spring-petclinic 3.21.0 native (powered by Quarkus 3.21.0) started in 0.126s. Listening on: http://0.0.0.0:8080
2025-04-13 15:54:29,755 INFO [io.quarkus] (main) Profile postgres activated.
2025-04-13 15:54:29,755 INFO [io.quarkus] (main) Installed features: [agroal, cache, cdi, hibernate-orm, hibernate-orm-panache, hibernate-validator, jdbc-h2, jdbc-mysql, jdbc-postgresql, narayana-jta, qute, rest, rest-jackson, rest-qute, smallrye-context-propagation, smallrye-health, spring-cache, spring-data-jpa, spring-di, spring-web, vertx, web-dependency-locator]

Conclusion

A travers ce billet, vous aurez entre-aperçu les différentes étapes nécessaires pour migrer vers Quarkus et Qute une application Spring Web MVC avec Thymeleaf comme moteur de templating et Spring Data JPA pour la persistance. L’usage des extensions Quarkus pour Spring facilite grandement cette migration. Les ingénieurs de chez Quarkus ont fait du très bon travail. Malgré les quelques écarts de fonctionnement soulignés dans cet article, j’en ai été assez bluffé. Bravo à eux !

J’ai profité de cette migration pour soumettre une dizaine de Pull Request dans la version originale de Spring Petclinic (ex : PR #1775).

Débutant en Quarkus, je ne serais pas surpris d’apprendre par mes lecteurs des axes d’améliorations. Utilisateur et amateur de Spring depuis 20 ans, j’ai essayé de rester neutre. A vous de comparer les 2 versions de Petclinic et de vous faire votre avis. Mon ressenti personnelle est que l’éco-système Java se porte bien et que la concurrence est saine et stimulante !

Ressources

]]>
https://javaetmoi.com/2025/04/spring-petclinic-sous-extensions-quarkus/feed/ 0
Intégrer un Chatbot dans une webapp Java avec LangChain4j https://javaetmoi.com/2024/11/integrer-un-chatbot-dans-une-webapp-java-avec-langchain4j/ https://javaetmoi.com/2024/11/integrer-un-chatbot-dans-une-webapp-java-avec-langchain4j/#respond Mon, 11 Nov 2024 18:34:24 +0000 https://javaetmoi.com/?p=2391 Continuer la lecture de Intégrer un Chatbot dans une webapp Java avec LangChain4j ]]>
Logo du framework LangChain4j

Cet article explique comment intégrer un chatbot utilisant l’IA générative dans une application de gestion codée en Java.

Nous nous appuierons sur le framework Open Source LangChain4j, une adaptation Java de la célèbre librairie python LangChain, visant à simplifier l’intégration de grands modèles de langage (LLM). LangChain4j permet de créer des agents conversationnels, des assistants virtuels (comme notre chatbot), ou des applications capables d’effectuer des analyses de texte et de répondre en fonction de données contextuelles, le tout sans devoir écrire de code complexe et avec un haut niveau d’abstraction. Elle facilite notamment l’utilisation des API des Large Langage Model comme OpenAI et Hugging Face, et propose différents connecteurs pour des bases de données vectorielles, incluant Elasticsearch et Qdrant. Pour accélérer son intégration, LangChain4j propose des extensions pour Quarkus et des starters pour Spring Boot.

Pour illustrer cet article, nous utiliserons l’illustre application démo Spring Petclinic et son récent fork dédié à LangChain4j : spring-petclinic-langchain4j
Propulsé par Spring Boot, Spring Petclinic s’appuie sur Spring Data JPA pour l’accès aux données et Thymeleaf pour la couche présentation HTML / CSS / JavaScript.
En septembre 2024, Oded Shopen, contributeur en 2020 du fork Spring Petclinic Cloud, a proposé une intégration de Spring AI dans Spring Petclinic. De son travail, est né le projet spring-petclinic-ai. Le repository spring-petclinic-langchain4j est un portage du framework Spring AI vers LangChain4j. Y a été ajouté notamment une fonctionnalité de streaming.
Extraits du sample, les exemples de code s’appuient sur les versions 3.3 de Spring Boot et 0.35.0 de LangChaing4j.

Démo

Avant de se plonger dans le code Java, je vous propose de voir le résultat final en visionnant ce screencast durant moins de 2 minutes et dans lequel je pose 4 questions à l’assistant :

Impressionnant, non ? Lorsqu’on pose les mêmes questions en français, le chatbot répond en français.

Compte développeur OpenAI

A ce jour, l’application Spring Petclinic LangChain4j supporte OpenAI et son service hébergé sur Azure : Azure OpenAI. Dans cet article, nous nous focaliserons sur l’intégration OpenAI. Pour faire fonctionner ce sample, moyennant quelques euros de crédits, vous aurez besoin d’un compte développeur OpenAI et d’une clé d’API personnelle exportée en tant que variable d’environnement OPENAI_API_KEY.

Si vous ne disposez pas de votre propre clé API OpenAI ou ne souhaitez pas dépenser le moindre centime, vous pouvez utiliser temporairement la clé de démonstration demo que OpenAI fournit gratuitement. Seul le modèle gpt-4o-mini sera alors disponible avec cette clé et le nombre de tokens sera limité à 5000.

export OPENAI_API_KEY=demo

Déclarer les starters Spring Boot

La documentation Spring Boot Integration de LangChain4j explique comment les starters Spring Boot aident à configurer l’usage des larges modèles de langages, des embedding models et des embedding stores par le biais de propriétés à déclarer dans le fichier application.properties (ou application.yaml).

Dans le pom.xml de Spring Petclinic, commençons par déclarer les deux dépendances langchain4j-spring-boot-starter et langchain4j-open-ai-spring-boot-starter :

<properties>
  <langchain4j.version>0.35.0</langchain4j.version>
</properties>

<dependency>
  <groupId>dev.langchain4j</groupId>
  <artifactId>langchain4j-spring-boot-starter</artifactId>
  <version>${langchain4j.version}</version>
</dependency>
<dependency>
  <groupId>dev.langchain4j</groupId>
  <artifactId>langchain4j-open-ai-spring-boot-starter</artifactId>
  <version>${langchain4j.version}</version>
</dependency>

Le premier starter langchain4j-spring-boot-starter expose la classe d’auto-configuration pour Spring Boot LangChain4jAutoConfig et donne, entre autre, accès à l’annotation @AiService que nous utiliserons dans une prochaine étape.

Le second starter langchain4j-open-ai-spring-boot-starter permet quant à lui de parser et binder les propriétés spécifiques à OpenAI du fichier de configuration application.properties (ex : langchain4j.azure-open-ai.chat-model.api-key). Par transitivité, il tire les artefacts langchain4j-open-ai et dev.ai4j:openai4j. En interne, LangChain4j s’appuie sur le client Java non officiel openai4j permettant de connecter des applications Java à l’API OpenAI.

Configuration OpenAI

Dans une première version du chatbot ne faisant pas encore l’usage du streaming, ajouter au fichier application.properties les 4 propriétés suivantes :

langchain4j.open-ai.chat-model.api-key=${OPENAI_API_KEY}
langchain4j.open-ai.chat-model.model-name=gpt-4o
langchain4j.open-ai.chat-model.log-requests=true
langchain4j.open-ai.chat-model.log-responses=true

Plus compact et moins cher que le modèle gpt-4o préconisé pour la démo, le modèle gpt-4o-mini peut également être utilisé et sait répondre aux exemples de questions suggérées dans le readme.md.  

Spring Boot détermine les beans à instancier en fonction des propriétés déclarées. A titre d’exemple, la classe AutoConfig du starter LangChain4j OpenAI pour Spring Boot, déclare conditionnellement un bean de type OpenAiChatModelimplémentant l’interface agnostique ChatLanguageModellorsque la propriété langchain4j.open-ai.chat-model.api-key est déclarée. Dans la suite de cet article, nous aurons besoin d’un bean de type StreamingChatLanguageModel permettant de streamer la réponse du LLM token par token. 
Sur le même principe, la propriété langchain4j.open-ai.streaming-chat-model.api-key déclenchera l’instanciation d’un bean de type OpenAiStreamingChatModel implémentant l’interface StreamingChatLanguageModel.

Déclarer un AI Service

Dans la suite de cet article, le code Java dédié au chatbot est localisé dans un package dédié : org.springframework.samples.petclinic.chat.

Dans le code métier, l’interaction avec le LLM se fait au travers d’une simple interface Java nommée Assistantet annotée avec l’annotation @AiService. LangChain4j propose un mécanisme similaire à Spring Data et Square Retrofit : on définit de manière déclarative une interface respectant des conventions de nommage et, au runtime, LangChain4j fournit une implémentation de cette interface. Se référer à la documentation AI Services pour davantage d’explications.
L’interface Assistant propose une seule et unique méthode chat. Celle-ci accepte une question de l’utilisateur et renvoie la réponse du LLM sous forme de String.

import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.spring.AiService;

@AiService
interface Assistant {

    @SystemMessage(fromResource = "/prompts/system.st")
    String chat(String userMessage);

}

Le bean implémentant cette interface est mise à disposition par Spring et pourra être injecté, par exemple, dans le contrôleur REST.

Prompter un Message Système

Pour répondre à l’utilisateur, nous guidons le comportement du LLM en définissant un « system message » via l’annotation @SystemMessage.  Les directives sont externalisées dans le fichier texte system.st :

You are a friendly AI assistant designed to help with the management of a veterinarian pet clinic called Spring Petclinic.
Your job is to answer questions about and to perform actions on the user's behalf, mainly around
veterinarians, owners, owners' pets and owners' visits.
If you need access to pet owners or pet types, list and locate them without asking the user.
You are required to answer in a professional manner. If you don't know the answer, politely inform the user,
and then ask a follow-up question to help clarify what they are asking.
If you do know the answer, provide the answer but do not provide any additional followup questions.
When dealing with vets, if the user is unsure about the returned results, explain that there may be additional data that was not returned.
Only if the user is asking about the total number of all vets, answer that there are a lot and ask for some additional criteria.
For owners, pets or visits - provide the correct data.

Comme expliqué par Oded dans son article de blog, le contexte système doit être régulièrement enrichi et optimisé afin que les réponses soient les plus précises et les plus fiables possibles.
Par exemple, afin que le LLM prenne des initiatives sans demander l’aval de l’utilisateur, le message système a été récemment complété avec la directive suivante :

If you need access to pet owners or pet types, list and locate them without asking the user.

Sans cette directive, le LLM demande l’autorisation de rechercher l’ID de Betty :

Déclarer un contrôleur REST

Le chabot est appelé depuis le navigateur via une API REST. Déclarer un contrôleur Rest AssistantController exposant le endpoint /chat :

@RestController
class AssistantController {

    private final Assistant assistant;

    AssistantController(Assistant assistant) {
       this.assistant = assistant;
    }

    @PostMapping("/chat")
    public String chat(@RequestBody String query) {
       return assistant.chat(query);
    }

}

Démarrer l’application Spring Boot et vérifier le fonctionnement du chatbot via un simple appel curl :

Paramétrer la mémoire conversationnelle de l’assistant

A ce stade, le chatbot n’a pas encore de mémoire. Il ne peut donc pas s’aider des précédents échanges pour générer une réponse. Voici un des exemples des plus connus :

Pour remédier à ce problème, nous déclarons un bean Spring de type ChatMemory qui conserve l’historique des 10 derniers messages.

@Configuration
class AssistantConfiguration {

    @Bean
    ChatMemory chatMemory() {
       return MessageWindowChatMemory.withMaxMessages(10);
    }

}

Le prénom donné lors du premier appel est désormais réutilisé par le LLM lors du deuxième appel :

Par défaut, les messages sont sauvegardés en mémoire dans un InMemoryChatMemoryStore. En cas de redémarrage de l’application, les messages volatiles sont perdus. Avec plusieurs instances de la même application sans affinité de sessions, l’historique des messages est réparti sur différentes JVM. Cela pose également problème. Une solution consiste à implémenter l’interface ChatMemoryStore afin de persister les messages en base ou dans un cache distribué. Se référer à l’exemple ServiceWithPersistentMemoryForEachUserExample.java.

Supporter plusieurs utilisateurs

A ce stade, la même instance de ChatMemory est utilisée pour toutes les invocations du service d’IA. Cette approche a des limites et ne fonctionnera pas avec plusieurs utilisateurs. Chaque utilisateur a besoin de sa propre instance de ChatMemory pour maintenir sa conversation individuelle.
Une solution proposée par LangChain4j consiste à utiliser un ChatMemoryProvider :

@Configuration
class AssistantConfiguration {

	@Bean
	ChatMemoryProvider chatMemoryProvider() {
		return memoryId -> MessageWindowChatMemory.withMaxMessages(10);
	}
}

Chaque utilisateur est associé à un memoryId qui lui est dédié et dispose donc de sa propre ChatMemory.

La signature de la méthode chat de l’interface Assistant prend désormais un second paramètre nommé memoryId, annoté avec l’annotation @MemoryIdet de type UUID v4. Le paramètre userMessage est quant à lui annoté avec @UserMessage :

import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.spring.AiService;

@AiService
interface Assistant {

    @SystemMessage(fromResource = "/prompts/system.st")
    String chat(@MemoryId UUID memoryId, @UserMessage String userMessage);

}

Le contrôleur REST est adapté en fonction :

@PostMapping(value = "/chat/{user}")
public String chat(@PathVariable UUID user, @RequestBody String query) {

    return assistant.chat(user, query);
}

La Pull Request #8 Support multiple users with @MemoryId montre un exemple d’illustration côté frontend.

Ajouter un widget de chat

L’interface web du chat a été designée par Oded. Les codes HTML, JavaScript et CSS sont respectivement localisés dans les fichiers layout.html et chat.js et chat.css

Certaines réponses d’OpenAI sont formattés en Markdown.
Côté front, la librairie MarkedJS permet de convertir le markdown en HTML. Elle est ajoutée dans la configuration maven en tant que webjar :

<dependency>
  <groupId>org.webjars.npm</groupId>
  <artifactId>marked</artifactId>
  <version>${webjars-marked.version}</version>
</dependency>

Ajouter une première fonction

Afin d’interagir avec le code métier de l’application, les développeurs peuvent proposer aux LLM d’appeler des fonctions, en l’occurrence du code Java. L’appel de fonctions personnalisées renforce la capacité des LLM à fournir des réponses plus pertinentes et contextuelles. Le LLM peut, par exemple, accéder aux données de l’application.
Le LLM n’appelle pas directement les fonctions : le modèle produit une sortie de données structurées qui spécifie le nom de la fonction à appeler ainsi que les arguments suggérés. Les fonctions sont appelées par l’application Java ayant appelée le LLM.
A noter que tous les LLM ne supportent pas encore l’appel de fonctions.

LangChain4j facilite et standardise l’appel de fonctions via les Tools. Deux niveaux d’abstraction sont proposés :

  1. Low-level, en utilisant la classe ToolSpecification pour décrire les fonctions au LLM : nom, description, paramètres d’entrée / sortie.
  2. High-level, à l’aide des services d’IA et des méthodes Java annotées @Tool

Nous mettrons en œuvre celui de haut niveau permettant d’annoter n’importe quelle méthode Java avec l’annotation @Tool. LangChain4j génère automatiquement les ToolSpecifications à partir de la signature des méthodes annotées.  Lors de l’appel du LLM, la description des fonctions qui sont mises à sa disposition lui sont transmises. Lorsque le LLM décide d’appeler une fonction, LangChain4j exécute automatiquement la méthode Java appropriée et sa valeur de retour est renvoyée au LLM. Sous la forme d’un simple bean Spring, la classe AssistantTool expose les fonctions que le LLM pourra invoquer pour récupérer des données de référence, lister les propriétaires ou bien encore ajouter en base un animal de compagnie. Commençons par déclarer une function nommée getAllOwners :

@Component
public class AssistantTool {

    private final OwnerRepository ownerRepository;

    public AssistantTool(OwnerRepository ownerRepository) {
       this.ownerRepository = ownerRepository;
    }


    @Tool("List the owners that the pet clinic has: ownerId, name, address, phone number, pets")
    public OwnersResponse getAllOwners() {
       Pageable pageable = PageRequest.of(0, 100);
       Page<Owner> ownerPage = ownerRepository.findAll(pageable);
       return new OwnersResponse(ownerPage.getContent());
    }

}

record OwnersResponse(List<Owner> owners) {
}

En interne, la classe AssistantTool utilise le repository Spring Data JPA OwnerRepository utilisé par l’application.
Apposée au niveau de l’annotation @Tool, la description aide le LLM à comprendre quand appeler la fonction.
La fonction getAllOwners() ne prend pas de paramètre. Elle retourne le record OwnersResponse qui contient une liste de Owner. La classe Owner est une entité JPA existante et utilisée pour l’IHM. Cet exemple démontre donc les capacités de LangChain4j à réutiliser le code existant.
Une fois la fonction appelée, LangChain4j convertit le record OwnersResponse au format JSON pour que le LLM puisse le traiter.

A noter que la méthode getAllOwners n’aurait pas sa place dans une application d’entreprise. L’application démo Spring Petclinic compte seulement 10 propriétaires. Renvoyer toutes les données de la base ne pose donc pas de problème de performance. Néanmoins, dans une vraie application de gestion, proposer une méthode de recherche multi-critères serait préférable. C’est ce que propose l’issue #9.

Interrogeons à présent le chatbot avec la question « Please list the owners that come to the clinic. » et regardons le flux d’échange entre l’application Petclinic et OpenAI.

Au préalable, dans le fichier application.properties, nous avons activé les logs des requêtes et réponses envoyées à OpenAI :

langchain4j.open-ai.chat-model.log-requests=true
langchain4j.open-ai.chat-model.log-responses=true

Lors du 1er appel à OpenAI, à côté de la question saisie par l’utilisateur dans le fenêtre de chat, la fonction getAllOwners est proposée dans une liste de tools.

Log partiel de la requête #1 :

- method: POST
- url: https://api.openai.com/v1/chat/completions
- headers: [Authorization: Bearer xxxx], [User-Agent: langchain4j-openai]
- body: {
"model" : "gpt-4o",
"messages" : [ {
"role" : "system",
"content" : "You are a friendly AI assistant designed to help with the management of a veterinarian pet clinic called Spring Petclinic…"
}, {
"role" : "user",
"content" : "\"Please list the owners that come to the clinic.\"
} ],
"temperature" : 0.7,
"tools" : [{
"type" : "function",
"function" : {
"name" : "getAllOwners",
"description" : "List the owners that the pet clinic has: ownerId, name, address, phone number, pets",
"parameters" : {
"type" : "object",
"properties" : { },
"required" : [ ]
}
}
}, …

Comme attendu, OpenAI demande à l’application d’appeler la function getAllOwners.
Log partiel de la réponse #1 :

status code: 200
- headers: xxxx
- body: {
"id": "chatcmpl-AOqizmPVZnGZ9jAB2of6NhayYi2mY",
"object": "chat.completion",
"created": 1730485909,
"model": "gpt-4o-2024-08-06",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_6fe84CTFo3zwOvo10ZBgBqjl",
"type": "function",
"function": {
"name": "getAllOwners",
"arguments": "{}"
}
}
],
…. }

LangChain4j fait aussitôt appel à la méhtode getAllOwners du bean AssistantTool. Le résultat est sérialisé en JSON et placé dans l’attribut content lors du second appel au LLM.

Log partiel de la requête #2 : 

- method: POST
- url: https://api.openai.com/v1/chat/completions
- headers: [Authorization: Bearer sk-Qw...MA], [User-Agent: langchain4j-openai]
- body: {
"model" : "gpt-4o",
"messages" : [ {
"role" : "system",
"content" : "You are a friendly AI …"
}, {
"role" : "user",
"content" : "\"Please list the owners that come to the clinic"
}, {
"role" : "assistant",
"tool_calls" : [ {
"id" : "call_6fe84CTFo3zwOvo10ZBgBqjl",
"type" : "function",
"function" : {
"name" : "getAllOwners",
"arguments" : "{}"
}
} ]
}, {
"role" : "tool",
"tool_call_id" : "call_6fe84CTFo3zwOvo10ZBgBqjl",
"content" : "{\n \"owners\": [\n {\n \"address\": \"110 W. Liberty St.\",\n \"city\": \"Madison\",\n \"telephone\": \"6085551023\",\n \"pets\": [\n {\n \"birthDate\": \"2010-09-07\",\n \"type\": {\n \"name\": \"cat\",\n \"id\": 1\n },\n \"visits\": [],\n \"name\": \"Leo\",\n \"id\": 1\n }\n ],\n \"firstName\": \"George\",\n \"lastName\": \"Franklin\",\n \"id\": 1\n },\n {\n \"address\": \"638 Cardinal Ave.\",\n \"city\": \"Sun Prairie\",\n \"telephone\": \"6085551749\",\n \"pets\": [\n {\n \"birthDate\": \"2012-08-06\",\n \"type\": {\n \"name\": \"hamster\",\n \"id\": 6\n },\n \"visits\": [],\n \"name\": \"Basil\",\n \"id\": 2\n }\n ],\n \"firstName\": \"Betty\",\n \"lastName\": \"Davis\",\n \"id\": 2\n, …
} ],
"temperature" : 0.7,
"tools" : [ { …}]

OpenAI utilise le résultat de l’appel à la fonction getAllOwners pour générer une réponse présentant une liste de propriétaires d’animaux formatée en markdown :

Log partiel de la réponse #2 :

- status code: 200
- headers: …
- body: {
"id": "chatcmpl-AOqj0Y9yhJjzYtzV7QMXiBU4URkJ7",
"object": "chat.completion",
"created": 1730485910,
"model": "gpt-4o-2024-08-06",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Here is a list of the owners at the Spring Petclinic:\n\n1. **George Franklin**\n - Address: 110 W. Liberty St., Madison\n - Telephone: 6085551023\n - Pets: \n - Leo (Cat, born on 2010-09-07)\n\n2. **Betty Davis**\n - Address: 638 Cardinal Ave., Sun Prairie\n - Telephone: 6085551749\n - Pets: \n - Basil (Hamster, born on 2012-08-06)\n\n3. **Eduardo Rodriquez**\n - Address: 2693 Commerce St., McFarland\n - Telephone: 6085558763\n - Pets: \n - Jewel (Dog, born on 2010-03-07)\n - Rosy (Dog, born on 2011-04-17)\n\n4. **Harold Davis**\...",

}
],
"usage": { … }

Cette première fonction a montré comment le LLM peut récupérer des données depuis la base de données pour générer sa réponse.

Agent conversationnel

Ajoutons à présent les fonctions permettant à un vétérinaire de déclarer un nouvel animal de compagnie pour l’un de ses clients, en formulant dans le chat la requête suivante :

 Add a dog for Betty Davis. His name is Moopsie. His birthday is on 2 October 2024.

Dans la classe AssistantTool, ajoutons une seconde fonction addPetToOwner permettant à un vétérinaire de déclarer un nouvel animal de compagnie à l’un de ses clients :

@Tool("Add a pet with the specified petTypeId, to an owner identified by the ownerId")
public AddedPetResponse addPetToOwner(AddPetRequest request) {
    Owner owner = ownerRepository.findById(request.ownerId());
    owner.addPet(request.pet());
    this.ownerRepository.save(owner);
    return new AddedPetResponse(owner);
}

Cette fois-ci, la méthode accepte un paramètre de type AddPetRequest :

record AddPetRequest(Pet pet, Integer ownerId) {
}

Pour ajouter un animal de compagnie, le LLM doit connaitre l’identifiant du propriétaire (le ownerId) et les données caractérisant son compagnon. Cet identifiant peut être récupéré par le LLM via l’appel de la fonction getAllOwners.
Le LLM doit également savoir comment valoriser les attributs de la classe Pet : name, birthDate, visits et type. Les identifiants du type PetType (ex : 1=cat, 2=dog …) peuvent être listés par le LLM via l’appel de la nouvelle fonction populatePetTypes :

@Tool("List all pairs of petTypeId and pet type name")
public List<PetType> populatePetTypes() {
    return this.ownerRepository.findPetTypes();
}

Lorsque OpenAI est interrogé, dans sa première réponse, il demande à LangChain4j d’appeler 2 fonctions / tools. Optimisé, cela évitera les allers-retours :

Log partiel de la réponse #1 :

2024-11-02T18:14:50.532+01:00 DEBUG 10650 --- [.openai.com/...] d.a.openai4j.StreamingRequestExecutor    : onEvent() {"id":"chatcmpl-APC01s26BWq4QFXC1tpIgHuSml798","object":"chat.completion.chunk","created":1730567689,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_159d8341cc","usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_T0QYuwvX9NGD6kX9KxFLKrDm","type":"function","function":{"name":"getAllOwners","arguments":""}}]},"logprobs":null,"finish_reason":null}]}
2024-11-02T18:14:50.534+01:00 DEBUG 10650 --- [.openai.com/...] d.a.openai4j.StreamingRequestExecutor : onEvent() {"id":"chatcmpl-APC01s26BWq4QFXC1tpIgHuSml798","object":"chat.completion.chunk","created":1730567689,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_159d8341cc","usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_hRf3HX1yLDIU0DtAr5Sjmov5","type":"function","function":{"name":"populatePetTypes","arguments":""}}]},"logprobs":null,"finish_reason":null}]}
2024-11-02T18:14:50.534+01:00 DEBUG 10650 --- [.openai.com/...] d.a.openai4j.StreamingRequestExecutor : onEvent() {"id":"chatcmpl-APC01s26BWq4QFXC1tpIgHuSml798","object":"chat.completion.chunk","created":1730567689,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_159d8341cc","usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{}"}}]},"logprobs":null,"finish_reason":null}]}

LangChain4j appelle séquentiellement ces 2 fonctions (paralléliser ces appels serait un axe d’optimisation de notre application : issue #13) puis renvoie les résultats à OpenAI.

Log partiel de la requête #2 :

  "tool_calls" : [ {
"id" : "call_T0QYuwvX9NGD6kX9KxFLKrDm",
"type" : "function",
"function" : {
"name" : "getAllOwners",
"arguments" : "{}"
}
}, {
"id" : "call_hRf3HX1yLDIU0DtAr5Sjmov5",
"type" : "function",
"function" : {
"name" : "populatePetTypes",
"arguments" : "{}"
}
} ]
}, {
"role" : "tool",
"tool_call_id" : "call_T0QYuwvX9NGD6kX9KxFLKrDm",
"content" : "{\n \"owners\": [\n {\n \"address\": \"638 Cardinal Ave.\",\n \"city\": \"Sun Prairie\",\n \"telephone\": \"6085551749\",\n \"pets\": [\n {\n \"birthDate\": \"2012-08-06\",\n \"type\": {\n \"name\": \"hamster\",\n \"id\": 6\n },\n \"visits\": [],\n \"name\": \"Basil\",\n \"id\": 2\n }\n ],\n \"firstName\": \"Betty\",\n \"lastName\": \"Davis\",\n \"id\": 2\n }, …\]\n}"
}, {
"role" : "tool",
"tool_call_id" : "call_hRf3HX1yLDIU0DtAr5Sjmov5",
"content" : "[\n {\n \"name\": \"bird\",\n \"id\": 5\n },\n {\n \"name\": \"cat\",\n \"id\": 1\n },\n {\n \"name\": \"dog\",\n \"id\": 2\n },\n {\n \"name\": \"hamster\",\n \"id\": 6\n },\n {\n \"name\": \"lizard\",\n \"id\": 3\n },\n {\n \"name\": \"snake\",\n \"id\": 4\n }\n]"
} ],

De ces 2 appels de fonctions, OpenAI déduit l’identifiant de Betty Davis égal à 2 ainsi que l’identifiant d’un chien lui aussi égal à 2. En réponse, il demande à LangChain4j d’appeler la fonction addPetToOwner en lui passant ces deux identifiants, ainsi que le nom et la date de naissance donné par l’utilisateur.

2024-11-02T18:14:51.734+01:00 DEBUG 10650 --- [.openai.com/...] d.l.service.tool.DefaultToolExecutor     : About to execute ToolExecutionRequest { id = "call_7TdLNNZPsMD4ujev8wRytdyf", name = "addPetToOwner", arguments = "{"request":{"ownerId":2,"pet":{"name":"Moopsie","birthDate":{"year":2024,"month":10,"day":2},"type":{"id":2}}}}" } for memoryId 510e5396-3c19-46c2-991c-3200a653f90f
2024-11-02T18:14:51.798+01:00 DEBUG 10650 --- [.openai.com/...] d.l.service.tool.DefaultToolExecutor : Tool execution result: {
"owner": {
"address": "638 Cardinal Ave.",
"city": "Sun Prairie",
"telephone": "6085551749",
"pets": [
{
"birthDate": "2012-08-06",
"type": {
"name": "hamster",
"id": 6
},
"visits": [],
"name": "Basil",
"id": 2
},
{
"birthDate": "2024-10-02",
"type": {
"id": 2
},
"visits": [],
"name": "Moopsie"
}
],
"firstName": "Betty",
"lastName": "Davis",
"id": 2
}
}

Cette fois-ci, LangChain4j doit passer un paramètre de type AddPetRequest lors de l’appel à la fonction addPetToOwner. La structure de donnée a préalablement été communiquée au LLM lors de la description de la fonction mise à sa disposition :

{
  "type": "function",
  "function": {
    "name": "addPetToOwner",
    "description": "Add a pet with the specified petTypeId, to an owner identified by the ownerId",
    "parameters": {
      "type": "object",
      "properties": {
        "request": {
          "type": "object",
          "properties": {
            "ownerId": {
              "type": "integer"
            },
            "pet": {
              "type": "object",
              "properties": {
                "visits": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "date": {
                        "type": "object",
                        "properties": {
                          "month": {
                            "type": "integer"
                          },
                          "year": {
                            "type": "integer"
                          },
                          "day": {
                            "type": "integer"
                          }
                        },
                        "required": []
                      },
                      "description": {
                        "type": "string"
                      }
                    },
                    "required": []
                  }
                },
                "type": {
                  "type": "object",
                  "properties": {},
                  "required": []
                },
                "birthDate": {
                  "type": "object",
                  "properties": {
                    "month": {
                      "type": "integer"
                    },
                    "year": {
                      "type": "integer"
                    },
                    "day": {
                      "type": "integer"
                    }
                  },
                  "required": []
                }
              },
              "required": []
            }
          },
          "required": []
        }
      },
      "required": [
        "request"
      ]
    }
  }
}


Le LLM a structuré en JSON les paramètres d’appel de fonction. La classe DefaultToolExecutor de LangChain4j se charge d’unmarshaller les données JSON. En interne, elle s’appuie sur une librairie JSON (à termes, Jackson doit remplacer Google GSON).

Les résultats des 3 appels de fonction sont renvoyés à OpenAI dans une 3ième et dernière requête. Ce dernier conclue que l’ajout s’est bien passé et récapitule les informations enregistrées.

Voici un diagramme de séquences illustrant les appels que nous venons de décrire :


Response Streaming

La méthode chat() déclarée dans le @AiService renvoie une simple String. L’utilisateur doit attendre que le LLM ait généré l’intégralité de sa réponse avant de recevoir le résultat. Ceci est regrettable lorsqu’on sait qu’un LLM génère du texte un jeton à la fois.
La plupart des LLM propose un moyen de diffuser la réponse jeton par jeton au lieu d’attendre que l’ensemble du texte soit généré. Cette possibilité améliore l’expérience de l’utilisateur qui n’a alors pas besoin d’attendre une durée inconnue et peut commencer à lire la réponse presque immédiatement. LangChain4j supporte nativement cette fonctionnalité de Response Streaming. Il sait streamer token par token en utilisant l’interface TokenStream comme type de réponse. Le client peut s’abonner aux flux de jetons renvoyé par le LLM et ainsi être notifié lorsqu’un nouveau jeton est disponible. Modifions la signature de notre méthode :

interface Assistant {

    @SystemMessage(fromResource = "/prompts/system.st")
    TokenStream chat(@MemoryId UUID memoryId, @UserMessage String userMessage);

}

Remarque : cette version de l’application Spring Petclinic est développée sur une stack non réactive avec Spring MVC. Si elle l’avait été avec Spring Webflux, nous aurions pu utiliser le type Flux<String> à la place de TokenStream.

Le contrôleur REST AssistantController doit à son tour être adapté. De la même manière que sur l’application web ChatGPT, nous utilisons la technologie Server Sent Events (SSE) pour que le serveur envoie au navigateur au fil de l’eau les réponses du LLM. Spring Framework supporte nativement SSE depuis 2015 via la classe SseEmitter, se référer à sa documentation.

Chaque token est envoyé dans un message structuré en JSON. L’onglet EventStream de Google Chrome donne un aperçu du résultat :

Dans le contrôleur, l’appel à la méthode chat() est fait en asynchrone par un ExecutorService. L’appelant n’est pas bloqué. L’envoie des tokens au client (dans notre cas au navigateur) est assuré par l’appel à la classe SseEmitter.

@RestController
class AssistantController {

    private static final Logger LOGGER = LoggerFactory.getLogger(AssistantController.class);

    private final Assistant assistant;

    private final ExecutorService nonBlockingService = Executors.newCachedThreadPool();

    AssistantController(Assistant assistant) {
       this.assistant = assistant;
    }

    // Using the POST method due to chat memory capabilities
    @PostMapping(value = "/chat/{user}")
    public SseEmitter chat(@PathVariable UUID user, @RequestBody String query) {
       SseEmitter emitter = new SseEmitter();
       nonBlockingService.execute(() -> assistant.chat(user, query).onNext(message -> {
          try {
             sendMessage(emitter, message);
          }
          catch (IOException e) {
             LOGGER.error("Error while writing next token", e);
             emitter.completeWithError(e);
          }
       }).onComplete(token -> emitter.complete()).onError(error -> {
          LOGGER.error("Unexpected chat error", error);
          try {
             sendMessage(emitter, error.getMessage());
          }
          catch (IOException e) {
             LOGGER.error("Error while writing next token", e);
          }
          emitter.completeWithError(error);
       }).start());
       return emitter;
    }

    private static void sendMessage(SseEmitter emitter, String message) throws IOException {
       String token = message
          // Hack line break problem when using Server Sent Events (SSE)
          .replace("\n", "<br>")
          // Escape JSON quotes
          .replace("\"", "\\\"");
       emitter.send("{\"t\": \"" + token + "\"}");
    }

}

A noter un hack (issue #12) remplaçant les sauts de ligne du LLM pour pallier au problème connu des sauts de lignes avec SSE.

En interne, pour streamer la réponse du LLM, LangChain4j utilise l’interface StreamingChatLanguageModel (à la place de ChatLanguageModel). Dans le fichier de configuration application.properties, les propriétés langchain4j.open-ai.chat-model.xxx sont renommées en langchain4j.open-ai.streaming-chat-model.xxx :

langchain4j.open-ai.streaming-chat-model.api-key=${OPENAI_API_KEY}
langchain4j.open-ai.streaming-chat-model.model-name=gpt-4o
langchain4j.open-ai.streaming-chat-model.log-requests=true
langchain4j.open-ai.streaming-chat-model.log-responses=true

Côté front, le code JavaScript du fichier chat.js a été adapté pour accepter le type MIME text/event-stream et parser les messages JSON.

La Pull Request #3 Response Streaming and SSE décrit tous les changements appliqués côté back et front pour passer au mode streaming.

Retrieval Augmented Generation (RAG)

L’ensemble des tools mis à disposition du LLM par Petclinic lui permettent d’accéder aux données des propriétaires, de leurs animaux et de leurs visites. Rien sur les vétérinaires officiant dans la clinique. Afin de permettre aux utilisateurs de poser des questions sur les vétérinaires, nous allons exploiter une autre fonctionnalité majeure des LLM et de LangChain4j : la génération augmentée par récupération, connue en anglais sous l’acronyme RAG pour Retrieval Augmented Generation. Un RAG permet de fournir à un LLM des informations complémentaires dont il pourrait avoir besoin pour répondre aux requêtes des utilisateurs, en particulier lorsqu’il s’agit de données plus récentes ou de contenus privés non accessibles lors de son entraînement.
Un RAG permet d’utiliser la recherche sémantique. Par exemple, dans la question suivante, l’utilisateur utilise des synonymes des spécialités déclarées en base de données dans le référentiel : radiography (radiographie) pour radiology (radiologue) et odontology (odontologie) pour dentistry (dentiste).

Question : « I’m looking for a veterinarian who specializes in both radiography and odontology for my pet »

A l’aide du RAG, l’application Petclinic retrouve 2 vétérinaires ayant la spécialité de radiology et de dentistry. L’utilisation d’un index inversé Lucene n’aurait pas permis d’arriver à ce résultat.

Pour intégrer le RAG à Petclinic, nous devons procéder en 2 étapes : la phase d’ingestion (indexation) des vétérinaires et la phase de requêtage (retrieval en anglais). La documentation de LangChain4j sur le support des RAG propose deux diagrammes illustrant les étapes d’indexation et de retrieval.

Ingestion d’embeddings

Afin de pouvoir être utilisées par le LLM, les données des 3 tables vets, specialties et vet_specialties doivent préalablement être ingérées et stockées dans une base de données vectorielle. PostgreSQL avec l’extension pgVector est probablement le choix le plus populaire. Greenplum et Qdrant sont 2 autres bases de données vectorielles. LangChain4j supporte plus de 25 bases vectorielles avec des niveaux plus ou moins avancés.

Lors de la phase d’ingestion, les données textuelles des vétérinaires (nom, prénom et spécialités) sont converties en vecteurs multidimensionnels appelés embedding puis stockés dans la base vectorielle. La documentation de LangChain4j parle d’Embedding Stores. Pour notre application d’exemple, par simplicité, nous allons utiliser la base vectorielle en mémoire proposée par LangChain4j. Dans la classe de configuration Spring AssistantConfiguration, commençons par déclarer le bean de type InMemoryEmbeddingStore :

@Bean
InMemoryEmbeddingStore<TextSegment> embeddingStore() {
    return new InMemoryEmbeddingStore<>();
}


Nous devons ensuite choisir un modèle de embedding. LangChain4j en supporte plus de 19. J’ai opté pour un modèle de type in-process basé sur le runtime ONNX. Ce type de modèle présente l’avantage de pouvoir s’exécuter dans la même JVM que celle de Petclinic.
Le repo git langchain4j-embeddings propose une douzaine d’artefact (JAR) embarquant chacun un modèle au format .onnx. Parmis eux, on retrouve l’artefact langchain4j-embeddings-all-minilm-l6-v2.

Le modèle all-MiniLM-L6-v2 est un modèle de langage basé sur la famille MiniLM conçue par Microsoft. Entrainé pour la similarité sémantique et les recherches de phrases, ce modèle de 86 Mo est compact et optimisé pour offrir des performances élevées en termes de qualité d’encodage de phrases, tout en restant léger et rapide. Il semble parfait pour notre chatbot et la recherche de similarité.

Une fois le choix du modèle arrêté, ajoutons sa dépendance dans le pom.xml :

<dependency>
  <groupId>dev.langchain4j</groupId>
  <artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId>
  <version>${langchain4j.version}</version>
</dependency>


Dans la classe de configuration Spring AssistantConfiguration, déclarons un bean de type EmbeddingModel :

@Bean
EmbeddingModel embeddingModel() {
    return new AllMiniLmL6V2EmbeddingModel();
}


L’ingestion des données vétérinaires est réalisée en moins d’une seconde au démarrage de l’application Petclinic via la classe EmbeddingStoreInit :

@Component
public class EmbeddingStoreInit {

    private final Logger logger = LoggerFactory.getLogger(EmbeddingStoreInit.class);

    private final InMemoryEmbeddingStore<TextSegment> embeddingStore;

    private final EmbeddingModel embeddingModel;

    private final VetRepository vetRepository;

    public EmbeddingStoreInit(InMemoryEmbeddingStore<TextSegment> embeddingStore, EmbeddingModel embeddingModel,
          VetRepository vetRepository) {
       this.embeddingStore = embeddingStore;
       this.embeddingModel = embeddingModel;
       this.vetRepository = vetRepository;
    }

    @EventListener
    public void loadVetDataToEmbeddingStoreOnStartup(ApplicationStartedEvent event) {
       Pageable pageable = PageRequest.of(0, Integer.MAX_VALUE);
       Page<Vet> vetsPage = vetRepository.findAll(pageable);

       String vetsAsJson = convertListToJson(vetsPage.getContent());

       EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
          .documentSplitter(new DocumentByLineSplitter(1000, 200))
          .embeddingModel(embeddingModel)
          .embeddingStore(embeddingStore)
          .build();

       ingestor.ingest(new Document(vetsAsJson));
    }

    public String convertListToJson(List<Vet> vets) {
       ObjectMapper objectMapper = new ObjectMapper();
       try {
          // Convert List<Vet> to JSON string
          StringBuilder jsonArray = new StringBuilder();
          for (Vet vet : vets) {
             String jsonElement = objectMapper.writeValueAsString(vet);
             jsonArray.append(jsonElement).append("\n"); // For use of the
                                              // DocumentByLineSplitter
          }
          return jsonArray.toString();
       }
       catch (JsonProcessingException e) {
          logger.error("Problems encountered when generating JSON from the vets list", e);
          return null;
       }
    }

}


La classe EmbeddingStoreInit fait appel au VetRepository pour charger tous vétérinaires de la base, les marshalle en un gros Document JSON puis fait appel à la classe EmbeddingStoreIngestor de LangChain4j. Ce EmbeddingStoreIngestor est configuré avec le modèle d’embedding, la base vectorielle où les embeddings seront stockés et un DocumentByLineSplitter chargé de découper le volumineux document JSON en TextSegment censé améliorer la qualité des recherches de similarité et de réduire la taille et le coût d’une invite envoyée au LLM.

Une fois le EmbeddingStoreIngestor construit, la méthode ingest() est appelée pour ingérer le document. Comme le montre les logs ci-dessous, ce dernier est découpé en 33 segments de texte. Les embeddings sont calculés sur les 33 segments puis stockés dans la base vectorielle :

EmbeddingStoreIngestor  : Starting to ingest 1 documents
EmbeddingStoreIngestor : Documents were split into 33 text segments
EmbeddingStoreIngestor : Starting to embed 33 text segments
EmbeddingStoreIngestor : Finished embedding 33 text segments
EmbeddingStoreIngestor : Starting to store 33 text segments into the embedding store
EmbeddingStoreIngestor : Finished storing 33 text segments into the embedding store

Requêtage des embeddings

A présent que l’ensemble des données vétérinaires sont stockées en base vectorielle sous forme d’embeddings, configurons l’application pour que le chatbot utilise ces données lors de son dialogue avec le LLM.

Pour utiliser les fonctionnalités RAG, la classe @AiService Assistant passe par l’interface RetrievalAugmentor et son implémentation par défaut mise à disposition par LangChain4j. Cette interface est chargée d’enrichir le ChatMessage avec des contenus pertinents extraits d’une ou plusieurs sources de données, comme par exemple notre base vectorielle en mémoire. Pour avoir un aperçu des composants manipulés par le RetrievalAugmentor, je vous invite à consulter le schéma du paragraphe Advanced RAG de la documentation de LangChain4j. On y voit l’utilisation d’un ContentRetriever pour interroger une base vectorielle, un moteur de recherche, une base SQL ou bien encore un moteur de recherche.

Dans Petclinic, nous déclarons un bean ContentRetriever de type EmbeddingStoreContentRetriever chargé de récupérer des données vétérinaires dans notre base vectorielle :

@Bean
EmbeddingStoreContentRetriever contentRetriever(InMemoryEmbeddingStore<TextSegment> embeddingStore,
       EmbeddingModel embeddingModel) {
    return new EmbeddingStoreContentRetriever(embeddingStore, embeddingModel);
}


En redémarrant l’application Petclinic puis en posant une question au chatbot, on s’aperçoit que LangChain4j complète le prompt de l’utilisateur en concaténant à la suite de sa question la liste des vétérinaires issus de la base vectorielle et qui se rapprochent sémantiquement de sa question :

- method: POST
- url: https://api.openai.com/v1/chat/completions
- headers: [Accept: text/event-stream], [Authorization: Bearer xxx], [User-Agent: langchain4j-openai]
- body: {
"model" : "gpt-4o",
"messages" : [ {
"role" : "system",
"content" : "You are a friendly AI assistant …"
}, {
"role" : "user",
"content" : "\"I'm looking for a veterinarian who specializes in both radiography and odontology for my pet \"\n\ content Answer using the following information:\n{\"id\":158,\"firstName\":\"Lauren\",\"lastName\":\"Wood\",\"new\":false,\"specialties\":[{\"id\":2,\"name\":\"surgery\",\"new\":false}]}\n{\"id\":159,\"firstName\":\"Gary\",\"lastName\":\"Coleman\",\"new\":false,\"specialties\":[{\"id\":1,\"name\":\"radiology\",\"new\":false},{\"id\":2,\"name\":\"surgery\",\"new\":false}]}\ …"
} ],
"temperature" : 0.7, … }

Routage de questions

Le dernier point présenté dans cet article consiste à utiliser la fonctionnalité Query Router de LangChain4j. Interroger la base vectorielle pour chaque question n’a pas nécessairement d’intérêt. Par exemple pour un simple « Hello » ou une question portant uniquement sur les propriétaires.
Comme son nom le laisse supposer, un Query Router est responsable de router une requête utilisateur vers le ou les ContentRetriever appropriés si nécessaire.

L’implémentation de l’interface QueryRouter est à la charge du développeur. Pour déterminer si la question d’un utilisateur porte sur les vétérinaires, on aurait pu utiliser une simple recherche de la chaine de caractère « vet ». D’une part, on n’aurait pas supporter le multilingue et d’autre part on aurait interrogé la base vectorielle si l’utilisateur nous avait posé une question hors contexte sur, par exemples, les vétérans. Qui mieux qu’un LLM peut déterminer la sémantique de la question ?
La classe VetQueryRouter fait un premier appel au LLM pour répondre à la question  « Is the following query related to one or more veterinarians of the pet clinic? ». On demande au LLM de répondre par oui ou par non. Sé réponse détermine si l’usage du Embedding Store est nécessaire. Nul besoin ici d’utiliser de streaming.

class VetQueryRouter implements QueryRouter {

    private static final Logger LOGGER = LoggerFactory.getLogger(VetQueryRouter.class);

    private static final PromptTemplate PROMPT_TEMPLATE = PromptTemplate.from("""
          Is the following query related to one or more veterinarians of the pet clinic?
          Answer only 'yes' or 'no'.
          Query: {{it}}
          """);

    private final ContentRetriever vetContentRetriever;

    private final ChatLanguageModel chatLanguageModel;

    public VetQueryRouter(ChatLanguageModel chatLanguageModel, ContentRetriever vetContentRetriever) {
       this.chatLanguageModel = chatLanguageModel;
       this.vetContentRetriever = vetContentRetriever;
    }

    @Override
    public Collection<ContentRetriever> route(Query query) {
       Prompt prompt = PROMPT_TEMPLATE.apply(query.text());

       AiMessage aiMessage = chatLanguageModel.generate(prompt.toUserMessage()).content();
       LOGGER.debug("LLM decided: {}", aiMessage.text());

       if (aiMessage.text().toLowerCase().contains("yes")) {
          return singletonList(vetContentRetriever);
       }
       return emptyList();
    }
}


La déclaration du VetQueryRouter au niveau de AssistantConfiguration passe par l’utilisation de la méthode builder de la classe DefaultRetrievalAugmentor :

@Bean
RetrievalAugmentor retrievalAugmentor(ChatLanguageModel chatLanguageModel, ContentRetriever vetContentRetriever) {
    return DefaultRetrievalAugmentor.builder()
       .queryRouter(new VetQueryRouter(chatLanguageModel, vetContentRetriever))
       .build();
}


Petclinic utilisant désormais le ChatLanguageModel et le StreamingChatLanguageModel, le fichier de configuration application.properties doit être complété :

langchain4j.open-ai.streaming-chat-model.api-key=${OPENAI_API_KEY}
langchain4j.open-ai.streaming-chat-model.model-name=gpt-4o
langchain4j.open-ai.streaming-chat-model.log-requests=true
langchain4j.open-ai.streaming-chat-model.log-responses=true
langchain4j.open-ai.chat-model.api-key=${OPENAI_API_KEY}
langchain4j.open-ai.chat-model.model-name=gpt-4o-mini
langchain4j.open-ai.chat-model.log-requests=true
langchain4j.open-ai.chat-model.log-responses=true

Dans les logs applicatifs, un premier appel est désormais envoyé au LLM avant toute autre appel :

- method: POST
- url: https://api.openai.com/v1/chat/completions
- headers: [Authorization: Bearer sk-Qw...MA], [User-Agent: langchain4j-openai]
- body: {
"model" : "gpt-4o-mini",
"messages" : [ {
"role" : "user",
"content" : "Is the following query related to one or more veterinarians of the pet clinic?\nAnswer only 'yes' or 'no'.\nQuery: \"I'm looking for a veterinarian who specializes in both radiography and odontology for my pet \"\n"
} ],
"temperature" : 0.7
}

Conclusion

Cet article aura montré comment intégrer LangChain4j dans une application de gestion basée sur Spring Boot.

Récapitulons les principales fonctionnalités de LangChain4j qui ont été mises en œuvre :

  1. AI Service : définit de manière déclarative l’interface entre notre application Java et un LLM.
  2. Memory : permet d’historiser les conversations entre l’utilisateur et le LLM, supporte le multi-utilisateurs et la persistance.
  3. System prompt : joue un rôle essentiel dans les LLM car il détermine la manière dont les modèles interprètent les requêtes des utilisateurs et y répondent.
  4. Tooling (ou appel de fonction) : permet au LLM d’appeler, si nécessaire, une ou plusieurs méthodes Java de l’application.
  5. Streaming : réponse au fil de l’eau, token par token, en utilisant côté client le Server-Sent Events.
  6. RAG : utilisation d’un embedding store en mémoire pour ingérer les données vétérinaires, faire des recherches de similarité et enrichir le prompt utilisateur en fonction d’une règle de routage.

Personnellement, le développement de la version LangChain4j de Spring Petclinic m’aura permis de contribuer modestement au projet Open Source LangChain4j (PR #49, #50, #51 et #2000).

Je tiens à remercier mon fils Evan pour son montage de ma video Youtube. Merci également à Antonio Goncalves, Julien Dubois, Guillaume Laforge et Valentin Deleplace pour leurs workshops sur LangChain4j avec Azure OpenAI et Gemini.

Si vous souhaitez contribuez à votre tour à Spring Petclinic LangChain4j, des issues vous attendent. L’issue #10 vise notamment à intégrer d’autres LLM que OpenAI et Azure OpenAI. Parmi les candidats potentiels figurent Google Vertex AI Gemini, Ollama ou bien encore Mistral AI. Avis aux amatrices et aux amateurs.

Ressources :

]]>
https://javaetmoi.com/2024/11/integrer-un-chatbot-dans-une-webapp-java-avec-langchain4j/feed/ 0
Compatibilité Jakarta EE 9 de vieux frameworks https://javaetmoi.com/2024/08/compatibilite-jakarta-ee-9-de-vieux-frameworks/ https://javaetmoi.com/2024/08/compatibilite-jakarta-ee-9-de-vieux-frameworks/#respond Sun, 25 Aug 2024 15:54:14 +0000 https://javaetmoi.com/?p=2374 Continuer la lecture de Compatibilité Jakarta EE 9 de vieux frameworks ]]> De Java EE à Jakarta EE

En 2017, Oracle a fait don de la spécification Java EE (précédemment connu sous le nom de J2EE) à la fondation Eclipse. Java EE regroupe différentes API utilisées aussi bien par des serveurs d’applications, des containers de servlets et des frameworks comme Quarkus ou Spring : Servlet, JSP, JSF, JPA, JTA, JAX-WS, JAX-RS, JAXB, WebSocket, Bean Validation, CDI, EL …

Sous l’égide d’Eclipse, Java EE a été rebaptisé Jakarta EE. La fondation a récupéré la base de code Java et les TCK. En 2019 est sortie une version Jakarta EE 8 pleinement compatible avec Java EE 8. Comme seul changement notable pour les dév, le groupId des artefacts Maven a été renommé de javax à jakarta. Le patch du numéro de version a été incrémenté. A titre d’exemple, l’artefact jakarta.faces:jakarta.faces-api:2.3.1 est identique à javax.faces:javax.faces-api:2.3. Pas si anodin, ce changement de GAV Maven fait que notre outil de build peut être amené, via le mécanisme de dépendances transitives, à placer dans le classpath deux mêmes artefacts ayant des groupId différents. Les exclusions maven permettent de corriger le tir.

En décembre 2020, la communauté Java est secouée par la sortie de Java EE 9. 20 ans de rétrocompatibilité s’écroulent. Oracle a souhaité conserver la marque Java. Les packages javax.* de la spécification Java EE ont été renommés en jakarta.*. Certains sous-packages ont également été renommés. 
Pour exemple, la classe Marshaller de l’API JAXB change de package : de javax.xml.bind.Marshaller vers jakarta.xml.bind.Marshaller

A cette occasion, le numéro de version majeur a été incrémenté.
Les coordonnées Maven Jakarta EE 8 de l’API JSF jakarta.faces:jakarta.faces-api:2.3.1 changent en jakarta.faces:jakarta.faces-api:3.0.0 sous Jakarta EE 9.

A noter que les packages javax du JDK et qui n’appartiennent donc pas à Java EE ne sont pas renommés. On peut citer : javax.sql, javax.swing, javax.naming, javax.transaction.xa et javax.naming.

Ce changement de package Java est on ne peut plus impactant :

  1. Le code Java non migré ne fonctionne pas avec un container/runtime plus récent
  2. Un ancien container/runtime ne fonctionne pas avec du code Java récent migré

Ce changement a impacté tout l’écosystème Java : les projets Open Source, le code propriétaire / métier, les IDE, les outils de build …

Quatre ans plus tard, la grande majorité des projets Open Source actifs proposent une version de leurs artefacts compatibles jakarta. Les frameworks les plus utilisés comme Quarkus ou Spring étaient attendus par leur communauté et l’ont fait relativement rapidement. Par exemple, Spring Framework 6.0 et Spring Boot 3.0 sont tous les deux sortis en novembre 2022.
Pour migrer vers Jakarta EE 9 et le package jakarta, un projet reposant lui-même sur d’autres librairies tierces doit attendre que ses dépendances soient migrées. Cela a créé une certaine inertie dans l’écosystème Java. Par exemple, le framework Apache CXF, qui offre un support pour Spring, a dû attendre la sortie de Spring Framework 6 pour sortir à son tour en décembre 2022 la version CXF 4.

Migrer des applications legacy

Prenons l’exemple d’un SI composé de dizaines d’applications Java qui, pour des questions de sécurité et d’obsolescence, doivent migrer sur un Tomcat 10.

Les applications les plus modernes, basées sur Spring Boot, Quarkus ou Micronaut, s’appuient en général sur des stacks techniques récentes et actives. Migrer de Spring Boot 2.7 à Spring Boot 3 ne pose pas de difficultés majeures. Armé du guide de migration Spring Boot 3 et d’outils comme la recette OpenRewrite Migrate to Spring Boot 3.0, le projet Spring Boot Migrator (SBM) ou bien encore l’IntelliJ IDEA’s migration tool, les développeurs sont assistés dans leur travail et trouvent les ressources nécessaires sur le Net.

A contrario, les applications Java les plus anciennes du SI, pouvant avoir jusqu’à 25 ans, peuvent continuer pour certaines à s’appuyer sur des frameworks et des librairies non maintenus, abandonnés depuis des années par leurs créateurs. Lorsque cela est possible, identifier puis migrer vers une alternative est recommandé. Par exemple, l’équipe projet Dozer invite à migrer vers MapStruct ou ModelMapper et propose même un plugin IntelliJ pour faciliter la tâche.
Qu’en est-il de frameworks plus structurants ? Je pense notamment à de vieux frameworks frontends sur lesquels sont conçus des centaines d’écrans d’applications de gestion.

Par exemple, Struts 1 n’est pas compatible Jakarta EE 9 et les nouveaux packages en jakarta.* Il s’appuie sur l’API javax.servlet.http.HttpServlet du package javax.servlet. Le conteneur web Tomcat 10 manipule quant à lui la classe jakarta.servlet.http.HttpServlet. Même chose pour Richfaces abandonné par JBoss depuis 2016.

Migrer les écrans d’une application de Struts 1 vers Struts 6, React ou Angular est envisageable. Le cout en sera nettement plus élevé. Les délais aussi. L’automatisation aura ses limites. Autre solution : utiliser Struts 1 Reloaded dont la version 1.5.0 est compatible Jakarta EE 9. Maintenu par un seul et unique développeur, la base de code a divergé de l’original. Il pourrait y avoir des régressions.

Faute de budget conséquent, ces applications seraient-elles vouées à rester ad vitam æternam sur du Spring Boot 2 ? Non, la suite de cet article explique comment automatiser la compatibilité jakarta de vieux frameworks et de vielles librairies.

Solution technique

Les développeurs du conteneur Tomcat ont adressé cette problématique lors de la sortie de Tomcat 10. En effet, Tomcat 10 sait convertir une application web existante de Java EE 8 à Jakarta EE 9 au moment du déploiement en utilisant l’outil de migration Apache Tomcat pour Jakarta EE. Pratique, cet outil peut être utilisé en dehors de Tomcat, sous forme d’un jar auto-exécutable ou d’une tâche Ant. Contrairement à ce que son nom pourrait laisser penser, il n’est pas lié au conteneur Tomcat et pourrait être utilisé pour cibler des versions récentes de Jetty et de Wildfly.

Le projet tomcat-jakartaee-migration effectue tous les changements nécessaires pour migrer une application de Java EE 8 vers Jakarta EE 9 en renommant chaque package Java EE 8 vers son remplaçant Jakarta EE 9. Cela inclut les références aux package dans les classes, les constantes de type String, les fichiers de configuration, les JSP, les TLD …
Tous les packages javax.* ne font pas partie de Java EE. Seuls ceux définis par Java EE sont déplacés vers l’espace de noms jakarta.*.
Il n’est pas nécessaire de migrer les références aux schémas XML. Les schémas ne font pas directement référence aux packages javax et Jakarta EE 9 continuera à supporter l’utilisation des schémas de Java EE 8 et antérieurs.

Cet outil propose 2 profils : le profil partiel TOMCAT ciblant les conteneurs web comme Tomcat et Jetty et le profil EE ciblant toutes les dépendances Java EE 8.
L’outil sait parcourir différents types d’archives : jar, zip, war … Via ses converters (TextConverter, ClassConverter, ManifestConvert), il sait également manipuler plusieurs formats de fichiers : les classes compilées contenues dans les JAR comme le code source Java, les fichiers XML, JSON et properties, les pages JSP (jsp, jspxf, jspx), les tags JSP (tag, tld, tagx) …

L’outil tomcat-jakartaee-migration peut donc aussi bien travailler sur des JAR de librairies tierces que sur du code source métier qu’on souhaite migrer vers Jakarta EE 9 et même Jakarta EE 10.

Guide d’utilisation

Rendre compatible Jakarta EE 9 des librairies tierces puis les utiliser dans le code métier se fait en 2 étapes :

Etape 1 : migrer les librairies tierces

1. Récupérer le binaire depuis la page https://tomcat.apache.org/download-migration.cgi

2. Executer la ligne de commande suivante (exemple avec jsf-api-1.2_14.jar) :

set MIGRATION_TOOL=C:\dev\jakartaee-migration\lib\jakartaee-migration-1.0.8.jar
set M2_REPO=C:\dev\maven\repository
java -jar %MIGRATION_TOOL% -profile=EE %M2_REPO%\javax\faces\jsf-api\1.2_14\jsf-api-1.2_14.jar %M2_REPO%\javax\faces\jsf-api\1.2_14-jakarta\jsf-api-1.2_14-jakarta.jar

Le fichier JAR jsf-api-1.2_14-jakarta.jar généré est désormais compatible Jakarta EE 9.
Extrait de la classe FacesServlet :

jsf-api-1.2_14.jar compatible Java EE 8

jsf-api-1.2_14-jakarta.jar migré à Jakarta EE 9

package javax.faces.webapp;
 
import javax.faces.FacesException;
import javax.faces.FactoryFinder;
import javax.faces.context.FacesContext;
import javax.faces.context.FacesContextFactory;
import javax.faces.lifecycle.Lifecycle;
import javax.faces.lifecycle.LifecycleFactory;
import javax.servlet.Servlet;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.UnavailableException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
 
import java.io.IOException;
import java.util.ResourceBundle;
import java.util.logging.Level;
import java.util.logging.Logger;
 
public final class FacesServlet implements Servlet {

package jakarta.faces.webapp;
 
import jakarta.faces.FacesException;
import jakarta.faces.FactoryFinder;
import jakarta.faces.context.FacesContext;
import jakarta.faces.context.FacesContextFactory;
import jakarta.faces.lifecycle.Lifecycle;
import jakarta.faces.lifecycle.LifecycleFactory;
import jakarta.servlet.Servlet;
import jakarta.servlet.ServletConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.UnavailableException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
 
import java.io.IOException;
import java.util.ResourceBundle;
import java.util.logging.Level;
import java.util.logging.Logger;
 
public final class FacesServlet implements Servlet {

3. Renouveler l’opération pour le JAR du code source.
Exemple sur jsf-api-1.2_14-sources.jar :

java -jar $MIGRATION_TOOL -profile=EE $M2_REPO/javax/faces/jsf-api/1.2_14/jsf-api-1.2_14-sources.jar $M2_REPO/javax/faces/jsf-api/1.2_14-jakarta/jsf-api-1.2_14-jakarta-sources.jar 

4. Uploader le JAR et ses sources dans le repository binaire d’entreprise (ex : Artifactory ou Nexus Sonatype). Privilégiez l’ajout du suffixe -jakarta au numéro de version Maven à l’utilisation d’un classifier Maven.

Cette étape de migration peut être complètement automatisée par un pipeline CI Jenkins ou GitLab.

Etape 2 : utiliser les librairies tierces migrées

1. Comme pré-requis, le code source de l’application doit avoir commencé sa migration à Jakarta EE 9 (ou supérieur).

2. Une fois les différentes librairies et frameworks migrés et uploadés dans le repository d’entreprise, il est possible de les référencer dans les pom.xml de l’application

3. Il est ensuite nécessaire d’adapter le code métier utilisant les classes de ces librairies qui ont changé de package, au niveau des imports du code source java, mais également dans le fichiers XML. 
Exemple du web.xml référençant jakarta.faces.webapp.FacesServlet :

<servlet>
<servlet-name>Faces Servlet</servlet-name>
<servlet-class>jakarta.faces.webapp.FacesServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>

Pour y arriver, 4 possibilités s’offrent à nous :

java -jar %MIGRATION_TOOL% -logLevel=FINEST -profile=EE C:\dev\project\my-webapp C:\dev\project\my-webapp-jakarta

Cette dernière option est à privilégier. En attente de prise en compte de la PR #60 de mon collègue Marco pour exclure le sous-répertoire .git et utiliser le répertoire source comme cible.

5. Vérifier que tout compile

mvn clean install

Conclusion

Cette solution présente plusieurs avantages :

  • Simplicité 
  • Cout défiant toute concurrence
  • Réutilisation d’un outil Open Source maintenu par l’équipe Tomcat et massivement éprouvé
  • Automatisation possible

Son principal inconvénient réside dans le fait que l’application continue à utiliser une librairie non maintenue. A moyen termes, trouver un financement pour refondre ou migrer l’application vers une technologie cible reste donc préconisé.

Enfin, d’autres outils que celui d’Apache existe, par exemple Eclipse Transformer. Avant de vous lancer, comparez-les.

Ressources :

]]>
https://javaetmoi.com/2024/08/compatibilite-jakarta-ee-9-de-vieux-frameworks/feed/ 0
Revue de code, on n’est pas venu-e-s ici pour souffrir ! https://javaetmoi.com/2024/05/revue-de-code-on-nest-pas-venu-ici-pour-souffrir/ https://javaetmoi.com/2024/05/revue-de-code-on-nest-pas-venu-ici-pour-souffrir/#respond Fri, 03 May 2024 13:15:02 +0000 https://javaetmoi.com/?p=2356 Continuer la lecture de Revue de code, on n’est pas venu-e-s ici pour souffrir ! ]]>
Conférence : Devoxx France 2024
Vidéo Youtube : https://www.youtube.com/watch?v=KeM1cjKiMr4
Date : 18 avril 2024
Speakerines : Pauline Rambaud et Anne-Laure de Boissieu (Bedrock Streaming)
Format : Conférence (45mn)

Déjà donnée à plusieurs reprises dans différents meetups et conférences, Pauline et Anne-Laure ont repensé spécialement cette présentation pour Devoxx France. Quel honneur !

Afin de démontrer à l’assistance qu’un commentaire laissé dans une revue de code peut amener de la confusion, nos deux speakerines commencent leur show en nous montrant une Pull Request sur le repo git de leurs slides reveal : une simple émoticône. Mal interprétée, elle entraine un biais de communication.

C’est quoi la revue de code ?

Développeuses GO, Anne-Laure et Pauline rappellent que la revue de code fait partie intégrante du métier de développeur. Elle consiste à examiner le code écrit par un autre développeur afin d’en améliorer la qualité, détecter les bugs et s’assurer du respect des normes de codage. Il existe différents types de revue. Au cours de cette présentation, elles se focaliseront sur les revues centrées sur le delta du code écrit pour corriger bug ou implémenter une feature.

Intérêts d’une revue de code

  • Obtenir une meilleure qualité de code
  • Rechercher et corriger des bugs au plus tôt
  • Partager de la connaissance
  • Partager la responsabilité : si bug, faute de l’équipe et pas du dév
  • Se former en continue : on apprend des autres et on fait grandir l’équipe

Il existe d’autres formats que les revues asynchrones via Pull Request : le MOB programming, le Pair Programming ou même pas de revue de code. A chaque équipe de choisir.

Pas de raison apparente de souffrir ?

Contrastant avec tous les bénéfices évoqués, les revues peuvent néanmoins faire souffrir auteurs et relecteurs à cause d’incompréhensions et de maladresses. Dans certaines situations, les commentaires et les retours s’accumulent, on se retrouve submerger et on n’arrive pas à les prendre en compte.

Bonnes pratiques plus efficaces

Apportant peu de valeur ajoutée, certaines catégories de commentaires pourraient être détectés et écrits par des robots. Les erreurs de formatage en sont un exemple flagrant. Utiliser un outil comme un linter et un formateur fait gagner du temps aux relecteurs humains. De la même manière, les anomalies évidentes de code et les anti-patterns communs peuvent être détectés automatiquement par des outils d’analyse statique de code comme SonarLint. Ces outils sont intégrables à la CI.

Autre bonne pratique : mettre à disposition de la documentation pour les nouveaux arrivants. Utile pour l’onboarding, elle doit expliquer les pratiques mises en œuvre sur le projet ou dans l’entreprise.

Template de Pull Request

Créer un modèle de template pour les Pull Request (PR) permet de faciliter la création d’une demande de changement et donner du contexte aux reviewers. GitHub et GitLab proposent cette fonctionnalité. Elle permet au développeur de se poser les questions essentielles à la bonne délimitation de sa PR. Dans le template, on peut retrouver les questions des Five W’s mais également la référence à la User Story implémentée (ex : lien Jira).


Definition of Done de la PR

Avant de soumettre une Pull Request, une bonne pratique consiste à vérifier la checklist de Definition of Done des PR. Voici quelques exemples de points à contrôler :

  • Tests unitaires écrits
  • Code testé fonctionnement à la main via la UI
  • Code propre exempt de TODO et de code commenté

Vérifier tous ces points fera gagner du temps aux relecteurs et permet d’anticiper d’éventuels retours.

Definition of Reviewed

Sur le même principe, un relecteur de code peut suivre une checklist :

  • Je commente uniquement si besoin (et pas pour le fun)
  • J’envoie tous mes retours en une seule fois (fonctionnalité de « batch comment »)
  • Je vérifie que le code est testé
  • Je teste une fois sur un tenant

Pour approfondir le sujet, je vous renvoie à mon billet Check-list revue de code Java.

Conventional comments

Certains commentaires sont sujets à interprétation : « Ce n’est pas clair », « Pourquoi tu as fait çà comme çà ? », « Oh my Gosh », « Poubelle », « Je n’aurais pas fait çà comme çà » …
L’utilisation d’emoji n’aide pas toujours à en faciliter l’interprétation. Par exemple, d’une génération à l’autre, certaines emojis n’ont pas le même sens.

Les commentaires interrogatifs sont multi-interprétables. Dans l’exemple « Pourquoi tu as fait çà comme çà ? », on ne sait pas s’il s’agit d’une vraie question visant à progresser ou s’il s’agit d’une moquerie.

Pour se prémunir de ces incompréhensions, il parait nécessaire de standardiser les commentaires de revue de code en utilisant, par exemple, l’approche des Conventional Comments fortement inspiré des Conventional Commits.
Les conventional comments proposent de formatter ses commentaires de commit en suivant ce formalisme :

<label> [decorations]: <subject>

[discussion]

Décorations et discussions sont optionnels.

L’utilisation de libellés (label) permet de donner son intention :

praise:

Les éloges soulignent un élément positif. Essayez de laisser au moins un de ces commentaires par revue de code. Ne laissez pas de fausses louanges (qui peuvent en fait être préjudiciables). Cherchez à être sincère. Second degré interdit.

nitpick:

Les « nitpicks » sont des demandes de changements triviaux. Elles ne devraient pas être bloquantes.

suggestion:

Les suggestions proposent des améliorations. Il est important d’être explicite et clair sur ce qui est suggéré et pourquoi il s’agit d’une amélioration. Envisagez d’utiliser les décorations bloquantes ou non bloquantes pour mieux communiquer votre intention.

issue:

Les problèmes mettent en évidence des dysfonctionnements du code examiné. Bloquants par nature, ces problèmes peuvent affecter directement la fonctionnalité implémentée ou introduire une régression ailleurs. Il est fortement recommandé d’associer un problème à une suggestion de correction. Si vous n’êtes pas certain de l’existence d’un problème, posez une question pour clarifier la situation.

question:

Les questions sont pertinentes si vous avez un doute potentiel sur le code, même s’il n’est pas clair s’il s’agit d’un vrai problème. Demander à l’auteur de clarifier certains points ou de mener une investigation plus poussée peut permettre une résolution rapide.

thought:

Des réflexions représentent des idées qui ont émergé lors de l’examen du code. Ces commentaires ne bloquent pas l’avancement de la Pull Request. Cependant, ils peuvent être très précieux car ils peuvent mener à des initiatives plus ciblées ou à des opportunités de mentorat.

chore:

Les corvées représentent des tâches simples qui doivent être effectuées avant que la Pull Request puisse être « officiellement » acceptée. En général, ces commentaires font référence à un processus standard. Essayez de fournir un lien vers la description du processus pour que l’auteur puisse savoir comment résoudre la corvée.

Pauline et Anne-Laure nous invitent à sélectionner un sous-ensemble de ces libellés et même à les personnaliser. A titre d’exemple, elles-mêmes utilisent applause à la place de praise. Elles n’utilisent pas thought et chore mais ont introduit 2 autres notations : todo et typo.
Pour standardiser les revues et faciliter la vue des nouveaux arrivants, elles mettent à disposition de leurs équipes un markdown avec les libellés et les emojis associés :

Les commentaires conventionnels de commit permettent de moins souffrir grâce à :

  • Une compréhension facilitée
  • Une meilleure lisibilité grâce aux labels
  • Moins de mauvaises impressions sur le ton employé
  • Moins de perte de temps

Ils permettent d’éviter ce type de commentaire inutile : « On pourrait être plus efficace »

Afin d’éviter de spammer la Pull Request, lorsqu’une même erreur de typographie est détectée plusieurs fois, Anne-Laure et Pauline recommandent de créer un seul commentaire référençant toutes les lignes où l’erreur apparait.

Pour terminer sur les commentaires de PR, voici quelques bonnes pratiques :

  • Le mentoring paie : cela fait progresser les dévs et l’équipe récoltera le fruit de ce travail
  • Laisser des commentaires exploitables fait gagner en efficacité
  • Combiner les commentaires similaires
  • Utiliser le « Nous » plutôt que le « Tu »
  • Remplacer le « Nous devons » par « Nous pourrions »

Toute grande organisation dispose de ses propres règles de revue de code. C’est par exemple le cas de Google qui les publie sur GitHub : Google Engineering Practices Documentation.

L’attitude avant tout

Dans la catégorie du « commentaire difficile à exploiter », on retrouve :

  • « Je t’ai déjà dit 2 fois qu’il ne fallait pas faire ça comme çà »
  • « Ce n’est pas très élégant »
  • « Beuh »

Non aidant, ces commentaires questionnent sur l’attitude qu’un relecteur doit adopter.

Une attitude bienveillante et constructive est un pré-requis à la communication asynchrone.
Théorisée en 1971 par Gerald Weinbert, la Programmation sans ego (ergoless programming) reste d’actualités. Elle vise à ce que notre égo ait le moins d’impact. On minimise les facteurs personnels et on émet des critiques respectueuses.

Quelques principes clés de Gerald Weinbert :

  • Comprenez et acceptez que vous faites des erreurs. C’est formateur. Traitez les autres comme vous aimeriez qu’on vous traite.
  • Vous n’êtes pas votre code. Se détacher de son code permet d’accepter des critiques sur le code.
  • Ne réécrivez pas le code de l’autre sans consultation : vous n’aiderez pas le dév qui l’a écrit, et cela est très frustrant de voir son code réécrit.
  • Traiter les personnes avec respect, considération et patience. Le risque est que les personnes ne fassent pas de retour et n’osent pas s’exprimer.
  • Battez-vous pour ce en quoi vous croyez, mais acceptez la défaite avec grâce. Lorsque votre solution / proposition est rejetée par l’équipe, ne pas prendre la mouche. Se dire que sa solution était bonne, mais que l’équipe a décidé d’en prendre une autre. Egoless ne veut pas dire sans égo.
  • Critiquez le code, pas les personnes

Que retenir ?

  • Tenter de moins laisser notre ego nous contrôler
  • Faisons preuve d’empathie
  • C’est en groupe qu’on produit les meilleures choses

Dans certaines équipes, personne ne fait de revue de code. La revue de code est un muscle. Il faut s’entrainer et le faire travailler. Alors lancez-vous et entrainez-vous !

Les débutants ne savent peut-être pas toujours comment s’y prendre, d’autant plus lorsqu’ils sont amenés à revoir du code de développeurs plus senior. Pour les aider, il faut absolument éviter de réécrire le code à leur place, laisser des commentaires plus constructifs et faire part d’empathie (on a tous été junior).

Pratiques à essayer :

  • Organiser un vis ma vie : permet de voir comment le tech lead fait ses retours de PR
  • Animer un MOB programming autour des revues
  • Proposer une méthode et découper le travail ensemble
  • Expliquer et discuter ensemble
  • Accepter le temps passé à relire

Après toutes ces recommandations, arriverez-vous à la conclusion qu’on ne peut plus rien dire dans une revue ? Et bien non ! Les feedbacks négatifs sont nécessaires pour :

  • Améliorer la qualité du code
  • Garantir sa maintenabilité
  • Prévenir les risques de bug

Lorsqu’aucun label n’est associé à un commentaire, on peut se demander si ce commentaire va être exploitable ? Peut-être que ce commentaire n’a pas lieu d’être, du moins à l’écrit.
En cas de conflits, parlons-en ailleurs que dans une PR. La PR ne doit pas être le lieu de gestion des conflits.

Ne restons pas en souffrance. Lorsqu’un dév souffre, il n’est souvent pas le seul. Il est nécessaire d’en discuter avec d’autres dévs.
Améliorer la revue de code, c’est avant tout se poser des questions et améliorer l’équipe

]]>
https://javaetmoi.com/2024/05/revue-de-code-on-nest-pas-venu-ici-pour-souffrir/feed/ 0