Aller au contenu
JavaannotationASTLombokBack

Lombok Internal

Lombok n'est pas un processeur d'annotations standard. Il détourne l'AST interne du compilateur avant qu'un seul octet de bytecode ne soit émis et ça change tout. J'ai recréé @Getter et @Logger from scratch pour comprendre exactement comment ça fonctionne.

Lombok Internal : définition

Introduction

Si vous n'avez jamais utilisé @Data, @Getter ou @Slf4j, vous avez probablement passé des années à écrire des getters et des setters à la main. La plupart des projets Java modernes utilisent Lombok, mais la plupart des développeurs ne cherchent jamais à savoir comment ça marche vraiment.

Dans cet article, je plonge dans les mécanismes internes de Lombok : comment il génère du code à la compilation, en me concentrant sur @Getter et @Logger. Pour comprendre vraiment comment ça marche, j'ai remonté le dépôt jusqu'aux premiers commits, construit un clone minimaliste et noté tout ce qui a cassé en route.

Comment Lombok fonctionne en interne

Le dépôt Lombok aujourd'hui, c'est un labyrinthe : gestionnaires d'annotations, transformateurs d'AST, chemins séparés pour Eclipse et javac, quinze ans de patches empilés. La branche master n'est pas un bon endroit pour commencer.

J'ai donc préféré remonter aux tout premiers commits. À cette époque, la structure était bien plus simple : une poignée de processeurs d'annotations, une séparation encore naissante entre Eclipse et javac, et les premières expérimentations sur l'AST, bien avant que la liste des annotations supportées n'explose.

Pour retrouver ces commits sans passer une heure à faire git log --reverse, j'ai développé GitGenesis, un petit outil qui isole les premiers commits de n'importe quel dépôt GitHub. Pour Lombok, les résultats pointent directement vers le commit initial de Reinier Zwitserloot, à l'époque où @Getter passait du concept au code fonctionnel.

L'analyse de ces premiers diffs m'a beaucoup appris : chaque annotation Lombok se résume, au fond, à un processeur d'annotations qui réécrit l'AST (Abstract Syntax Tree) avant même que javac ait fini son travail.

Le pipeline de compilation

Lombok s'accroche au processus de compilation de javac via l'API de traitement des annotations :

Code Source (.java) → Compilateur → Processeur d'Annotations → Code Généré → Bytecode (.class)

Avant d'aller plus loin, voici les termes techniques qui reviennent constamment quand on lit le code source :

  • Processeurs d'annotations (Annotation processors) : des classes implémentant javax.annotation.processing.Processor.
  • Tours de traitement (Processing rounds) : le compilateur exécute les processeurs en boucle jusqu'à ce qu'aucune nouvelle annotation n'apparaisse.
  • Manipulation de l'AST : au lieu d'émettre de nouveaux fichiers .java, Lombok greffe directement des nœuds supplémentaires (méthodes, champs) dans le JCTree.
  • Bytecode : une fois l'AST modifié, javac le compile comme n'importe quelle autre source.

Ce point est central : Lombok ne génère aucun fichier PersonGetters.java sur le disque. Il insère les méthodes directement dans l'AST de la classe. Elles atterrissent dans Person.class avec des numéros de ligne corrects et une vérification complète des types.

Construire un mini-Lombok

Lire le code source, c'est bien. Voir ce qui casse quand on essaie de reproduire le même mécanisme, c'est beaucoup plus instructif.

J'ai donc décidé de recréer deux annotations :

  • @Getter : génère un getter public pour un champ annoté.
  • @Logger : injecte la déclaration private static final Logger log sur la classe.
  • @Benchmark: petit bonus pour ceux qui vont regarder le code source.

Pour structurer cela, j'ai opté pour un projet Maven à deux modules :

  • mini-lombok-processor : la logique des annotations et des processeurs.
  • mini-lombok-demo : une classe Person qui les utilise, sans aucun getter écrit à la main.
lombok-internal/
├── mini-lombok-processor/
│   └── src/main/java/me/fayolabs/minilombok/
│       ├── Getter.java
│       ├── GetterProcessor.java
│       ├── Logger.java
│       └── LoggerProcessor.java
└── mini-lombok-demo/
    └── src/main/java/me/fayolabs/demo/
        ├── Person.java   (@Logger + @Getter, sans getters manuels)
        └── Main.java

Le code complet est disponible sur mon GitHub : lombok-internal.

Sur le papier, l'approche semble simple. C'est pourtant là que les murs ont commencé à apparaître.

Les obstacles rencontrés

Obstacle #1 : L'API standard ne permet pas de modifier des classes existantes

Ma première idée était simple : étendre AbstractProcessor, repérer les champs annotés, puis générer les getters. L'API standard semblait faire l'affaire :

@SupportedAnnotationTypes("me.fayolabs.minilombok.Getter")
public class GetterProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        for (Element element : roundEnv.getElementsAnnotatedWith(Getter.class)) {
            // C'est bien beau de générer le getter... mais où le placer ?
        }
        return true;
    }
}

Le problème : AbstractProcessor ne permet que la création de nouveaux fichiers. L'interface Filer propose createSourceFile() et createClassFile(), mais il n'existe pas de modifySourceFile(). Les sources existantes sont en lecture seule.

Un processeur naïf émettrait donc un fichier annexe PersonGetters.java ou Person_Generated.java. Mais Lombok, lui, injecte ses getters directement dans Person.class. Pour faire de même, il faut aller plus bas.

La solution : Manipuler l'AST directement

Les bibliothèques comme ByteBuddy, ASM ou Javassist interviennent sur les fichiers .class une fois la compilation terminée. Lombok, lui, corrige l'AST en pleine compilation, avant que le moindre bytecode soit généré. C'est cette différence qui donne l'impression que les méthodes générées étaient présentes dans le code source dès le départ.

J'ai donc suivi la même voie : la manipulation directe de l'AST de javac.

Lombok s'infiltre dans com.sun.tools.javac.* pour muter l'AST directement en mémoire, juste avant que javac n'émette le bytecode. Si vous ajoutez un nœud à cet arbre, javac le compile. C'est aussi simple que ça.

Classe Son rôle
JCTree.JCClassDecl Nœud de déclaration d'une classe.
JCTree.JCMethodDecl Nœud de méthode.
JCTree.JCVariableDecl Nœud de champ ou de paramètre.
TreeMaker Factory pour instancier de nouveaux nœuds AST.
Names Gère l'internement des identifiants (évite d'utiliser des String brutes).

La ligne clé de toute cette mécanique :

classNode.defs = classNode.defs.prepend(getter);

JCClassDecl.defs contient tous les membres d'une classe (champs, méthodes, constructeurs). Comme c'est une instance immuable de com.sun.tools.javac.util.List, il faut la remplacer par une nouvelle liste qui inclut la méthode générée. Une fois le traitement terminé, javac compile cet AST modifié, et le getter se retrouve dans le fichier .class comme s'il avait toujours été là.

Pour accéder à ces fonctionnalités de bas niveau, il faut d'abord déballer (unwrap) le ProcessingEnvironment standard pour récupérer son implémentation interne javac :

@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
    super.init(processingEnv);

    JavacProcessingEnvironment javacEnv =
        (JavacProcessingEnvironment) unwrap(processingEnv);

    Context context = javacEnv.getContext();
    this.treeMaker = TreeMaker.instance(context);
    this.names     = Names.instance(context);
    this.trees     = Trees.instance(processingEnv);
}

L'appel à unwrap() est nécessaire parce que des outils comme IntelliJ ou Gradle enveloppent souvent le ProcessingEnvironment dans un proxy dynamique. Lombok gère ça en remontant récursivement un champ delegate à travers la hiérarchie des classes via la réflexion Java.

Une fois le TreeMaker en main, construire un getter ressemble à assembler un arbre :

private JCTree.JCMethodDecl createGetter(JCTree.JCVariableDecl field) {
    // Équivalent à : public FieldType getFieldName() { return this.fieldName; }
    return treeMaker.MethodDef(
        treeMaker.Modifiers(Flags.PUBLIC),
        names.fromString(toGetterName(field.name.toString())),
        field.vartype,
        List.nil(), List.nil(), List.nil(),
        treeMaker.Block(0L, List.of(
            treeMaker.Return(
                treeMaker.Select(treeMaker.Ident(names._this), field.name)
            )
        )),
        null
    );
}

Un détail important : appelez treeMaker.at(classNode.pos) avant d'instancier vos nouveaux nœuds. C'est ce qui définit correctement les positions dans le fichier source. Sans cela, le compilateur pointe des erreurs à des endroits complètement aléatoires.

Toutes ces API internes résident dans le module jdk.compiler, verrouillé par défaut depuis Java 9. Il faut donc ajouter les directives --add-exports suivantes :

<!-- mini-lombok-processor/pom.xml -->
<compilerArgs>
    <arg>--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
    <arg>--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
    <arg>--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
    <arg>--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
    <arg>--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
    <arg>--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
</compilerArgs>

Ajoutez aussi -proc:none lors de la compilation du module processeur lui-même , sinon il tente de traiter ses propres sources de manière récursive, et vous obtiendriez une belle boucle infinie.

Obstacle #2 : IntelliJ affiche des erreurs en rouge sur le code généré.

mvn install passe sans problème. Mais à l'ouverture de la démo dans IntelliJ, surprise : la classe Person essaie d'utiliser la variable log injectée par @Logger, et l'IDE répond :

Cannot resolve symbol 'log'

Maven compile correctement. L'éditeur, lui, est perdu.

La raison : IntelliJ n'utilise pas javac pour son analyse en temps réel. Il s'appuie sur son propre moteur, le PSI (Program Structure Interface), qui lit strictement le texte brut des fichiers sources. Puisque log n'est pas écrit dans Person.java, le PSI le signale comme manquant.

Les processeurs classiques qui appellent Filer.createSourceFile() produisent de vrais fichiers .java sur le disque, que le PSI peut lire. Mais les processeurs qui modifient l'AST à la volée ne laissent aucune trace sur le disque, leurs modifications existent uniquement en mémoire, pendant la durée de la compilation.

C'est exactement pour cela que Lombok fournit un plugin IntelliJ aussi massif. Il n'exécute pas le processeur d'annotations en arrière-plan, il dit explicitement au moteur PSI ce que chaque annotation va produire. Il lui chuchote : @Getter présent ici → attends-toi à une méthode getXxx() à l'exécution. C'est une base de code entièrement séparée, à maintenir en synchronisation parfaite avec le processeur principal.

Face à ce problème avec mini-Lombok, trois options s'offrent à vous :

  • Vivre avec les lignes rouges : le projet compile et s'exécute correctement en ligne de commande, et cela illustre très concrètement le problème.
  • Déléguer les builds de l'IDE à Maven (voir l'obstacle #3) : le bouton Run fonctionne alors, même si l'éditeur continue à se plaindre.
  • Développer un vrai plugin IntelliJ : mais c'est un projet à part entière, bien au-delà du cadre de cette expérimentation.

Obstacle #3 : IntelliJ et --add-exports refusent de coopérer

En essayant de lancer Main depuis le bouton Run d'IntelliJ, nouvelle erreur :

java: exporting a package from system module jdk.compiler is not allowed with --release

IntelliJ utilise par défaut son propre compilateur interne, pas Maven. Lors de ce processus, il passe automatiquement --release 21, ce qui entre en conflit avec --add-exports sur les modules système. De plus, les arguments définis dans pom.xml sont ignorés.

La solution : déléguer à Maven

Il suffit d'aller dans Settings > Build, Execution, Deployment > Build Tools > Maven > Runner > cocher "Delegate IDE build/run actions to Maven".

L'action Run invoque alors mvn exec:java en arrière-plan, et tous les flags définis dans pom.xml sont appliqués.

Un détail subtil : dans mini-lombok-demo/pom.xml, l'argument --add-exports doit être précédé du préfixe -J :

<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>

Ce préfixe transmet le flag directement à la JVM qui exécute javac, plutôt qu'au compilateur lui-même. Notre processeur d'annotations s'exécute à l'intérieur de cette JVM hôte, il a besoin de ces droits d'accès lors de son exécution. Sans le -J, le flag n'affecterait que la compilation du code utilisateur, pas l'environnement d'exécution de javac.

C'est aussi pour ça que --release est absent du pom — il est incompatible avec --add-exports sur les modules internes. On utilise à la place :

<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>

Le résultat final

Une fois ces trois obstacles franchis, voilà ce que donne le clone en action :

@Logger
public class Person {

    @Getter private String name;
    @Getter private int age;
    @Getter private String email;

    public Person(String name, int age, String email) { ... }

    public void introduce() {
        log.info("Salut, je suis " + name);  // 'log' injecté par @Logger
    }
    // Aucun getter écrit à la main.
}

Aucun fichier source généré sur le disque, aucune classe utilitaire parasite. Les méthodes et champs injectés vivent dans Person.class , invisibles dans les sources, mais bien là à l'exécution.

Ce que cette expérience révèle sur Lombok

Lombok n'est pas un processeur d'annotations standard. JSR-269 a été conçue pour générer de nouveaux fichiers, un point c'est tout. Lombok choisit de modifier des nœuds AST existants à la volée, ce qui détourne complètement l'API de son objectif initial. C'est brillant, et un peu fou.

Le plugin IntelliJ représente une charge de travail entièrement distincte. Il oblige à réimplémenter toute la logique de génération, cette fois pour le moteur PSI. Chaque nouvelle annotation dans Lombok exige une double mise à jour : le processeur Java d'un côté, le plugin de l'éditeur de l'autre.

Les API internes de javac sont instables par nature. Les paquets sous com.sun.tools.javac.* n'offrent aucune garantie de compatibilité entre versions de Java. C'est pourquoi l'équipe Lombok maintient une collection d'adaptateurs (shims) pour chaque version majeure. Bâtir un outil sur ces fondations implique un fardeau de maintenance permanent.

À très bientôt et bon code !

Dernier