Aller au contenu

Annotations et réflexion

Spring ressemble à de la magie : tu écris @Service, @GetMapping("/todos") ou @Transactional, et « ça marche ». Ce chapitre démonte le tour : les annotations sont de simples étiquettes, et c'est la réflexion (le code qui s'inspecte lui-même à l'exécution) qui leur donne un sens. À la fin, tu auras écrit un mini routeur, un mini conteneur d'injection et un mini @Transactional.

Dans ce chapitre

  • Les annotations : des métadonnées inertes, différentes des décorateurs Python
  • Déclarer ses annotations (@Retention, @Target, attributs)
  • La réflexion : Class<?>, Method.invoke, newInstance...
  • Trois mini-Spring : un routeur, un conteneur d'injection de dépendances, un proxy transactionnel
  • Pourquoi un auto-appel this.methode() contourne @Transactional
  • Le coût de la réflexion, et les annotations traitées à la compilation (Lombok, MapStruct)

Les annotations : des étiquettes

Une annotation (@Quelquechose) attache une donnée à une classe, une méthode, un champ ou un paramètre. Elle ne fait rien par elle-même : il faut un programme qui la lise. Trois lecteurs possibles :

  • le compilateur (@Override, @FunctionalInterface) ;
  • un processeur d'annotations, à la compilation (Lombok, MapStruct) : l'équivalent des macros procédurales de Rust ;
  • du code à l'exécution, via la réflexion : Spring, JUnit, Jackson, Hibernate. C'est le cas le plus courant.

Côté Rust, c'est proche des attributs #[...] (#[derive(Debug)], #[test]), mais ceux-ci sont traités à la compilation. Côté Python, attention au faux ami : un décorateur est une fonction exécutée à la définition, qui remplace la fonction décorée. Une annotation Java ne remplace rien :

def journaliser(f):
    def enveloppe(*args):
        print("appel de", f.__name__)
        return f(*args)
    return enveloppe

@journaliser            # exécuté tout de suite : bonjour devient enveloppe
def bonjour(nom):
    return "Bonjour " + nom

print(bonjour("Ada"))   # affiche : appel de bonjour, puis Bonjour Ada
@Retention(RetentionPolicy.RUNTIME)
@interface Journaliser {}

@Journaliser            // une simple étiquette posée sur la méthode
static String bonjour(String nom) { return "Bonjour " + nom; }

System.out.println(bonjour("Ada"));   // affiche : Bonjour Ada (et rien d'autre)

Les annotations standard

class Chat extends Animal {
    @Override String cri() { return "miaou"; }    // vérifie qu'on redéfinit bien
    @Override String cris() { return "miaou"; }
    // error: method does not override or implement a method from a supertype
}

class Api {
    @Deprecated(since = "2.0", forRemoval = true)
    static void vieux() {}
}
// à l'appel de Api.vieux() depuis une autre classe :
// warning: [removal] vieux() in Api has been deprecated and marked for removal

@SuppressWarnings("unchecked")      // fait taire un avertissement précis
List<String> l = (List<String>) objet;

@FunctionalInterface a été vue dans Lambdas et streams. Mets @Override systématiquement : une faute de frappe dans le nom devient une erreur au lieu d'une nouvelle méthode silencieuse.

Déclarer une annotation

Une annotation se déclare avec @interface, et ses « attributs » s'écrivent comme des méthodes sans paramètre :

@Retention(RetentionPolicy.RUNTIME)   // (1)!
@Target(ElementType.METHOD)           // (2)!
@interface Route {
    String value();                   // (3)!
    String methode() default "GET";   // attribut facultatif
}

@Route("/todos") void lister() {}                           // value = "/todos"
@Route(value = "/todos", methode = "POST") void creer() {}  // les deux attributs
  1. Jusqu'où l'annotation survit : SOURCE (le compilateur seulement, comme @Override), CLASS (dans le .class mais invisible à l'exécution, c'est la valeur par défaut), RUNTIME (lisible par réflexion : toutes les annotations de Spring).
  2. Où on a le droit de la poser : TYPE (classe, interface), METHOD, FIELD, PARAMETER, CONSTRUCTOR... Ailleurs, le compilateur refuse : annotation interface not applicable to this kind of declaration.
  3. Un attribut nommé value peut s'écrire sans son nom quand il est seul : @Route("/todos"). Les types autorisés : primitifs, String, Class, enums, autres annotations, et des tableaux de ces types.

Piège : oublier @Retention(RUNTIME)

Sans elle, la rétention vaut CLASS : ton annotation compile, apparaît dans le code, mais getAnnotation(...) renvoie null à l'exécution. Ton framework maison ignore alors tout, sans aucune erreur.

Les annotations peuvent en porter d'autres : c'est ainsi que Spring compose les siennes. @Service est elle-même annotée @Component, et @RestController est annotée @Controller et @ResponseBody.

La réflexion

Chaque classe chargée est représentée à l'exécution par un objet Class<?>, qui permet de lister et d'appeler ses membres. Rust n'a rien de tel (à part TypeId) ; Python a getattr, inspect et compagnie, et la réflexion Java en est la version typée.

Class<?> c1 = String.class;                     // depuis le type
Class<?> c2 = "abc".getClass();                 // depuis un objet
Class<?> c3 = Class.forName("java.lang.String"); // depuis un nom (texte)
// c1 == c2 && c2 == c3 : true, un seul objet Class par classe

Method maj = String.class.getMethod("toUpperCase");
maj.invoke("abc");                              // "ABC" : appel dynamique

Sur une classe à toi :

class Produit {
    private String nom = "clavier";
    private int prix;
    public Produit() {}
    private String secret() { return "chut"; }
    @Override public String toString() { return nom + " à " + prix + " €"; }
}

Class<?> cp = Produit.class;
cp.getDeclaredFields();      // [private String nom, private int prix]
cp.getDeclaredMethods();     // [toString, secret]  (ordre non garanti)

Object p = cp.getDeclaredConstructor().newInstance();   // ≈ new Produit()
Field prix = cp.getDeclaredField("prix");
prix.setAccessible(true);    // (1)!
prix.set(p, 42);
System.out.println(p);       // affiche : clavier à 42 € : un champ private modifié de l'extérieur
  1. Sans cette ligne : IllegalAccessException: class Reflexion cannot access a member of class Produit with modifiers "private". Avec, la réflexion ignore private : c'est ainsi qu'Hibernate peut remplir les champs privés de tes entités.

Les méthodes à connaître : getDeclaredXxx (tous les membres de la classe elle-même, y compris privés) contre getXxx (les membres publics, hérités compris) ; getAnnotation(A.class), isAnnotationPresent(A.class) ; Method.invoke(objet, args...) ; getDeclaredConstructors(), getParameterTypes().

Exemple 1 : un mini routeur

Le principe de @GetMapping : au démarrage, on parcourt les méthodes d'un contrôleur, on mémorise celles qui portent @Route, puis on les appelle selon le chemin demandé.

MiniRouteur.java
import java.lang.annotation.*;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.TreeMap;

public class MiniRouteur {
    public static void main(String[] args) throws Exception {
        Routeur routeur = new Routeur(new HelloController());
        System.out.println("Routes : " + routeur.routes().keySet());
        for (String chemin : new String[]{"/hello", "/heure", "/admin"}) {
            System.out.println(chemin + " -> " + routeur.traiter(chemin));
        }
    }
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Route {
    String value();
}

class HelloController {
    @Route("/hello")
    public String hello() { return "Bonjour !"; }

    @Route("/heure")
    public String heure() { return "Il est " + java.time.LocalTime.NOON; }

    public String utilitaire() { return "pas une route"; }
}

class Routeur {
    private final Object controleur;
    private final Map<String, Method> routes = new TreeMap<>();

    Routeur(Object controleur) {
        this.controleur = controleur;
        for (Method m : controleur.getClass().getDeclaredMethods()) {   // toutes les méthodes
            Route route = m.getAnnotation(Route.class);                  // annotée ?
            if (route != null) {
                routes.put(route.value(), m);                            // on mémorise
            }
        }
    }

    Map<String, Method> routes() { return routes; }

    String traiter(String chemin) throws Exception {
        Method m = routes.get(chemin);
        if (m == null) return "404 Not Found";
        return (String) m.invoke(controleur);                            // appel dynamique
    }
}
Routes : [/hello, /heure]
/hello -> Bonjour !
/heure -> Il est 12:00
/admin -> 404 Not Found

Spring MVC fait la même chose au démarrage, à grande échelle : il inspecte chaque bean annoté @RestController, enregistre ses méthodes @GetMapping, @PostMapping..., puis, pour chaque requête, convertit les paramètres (@PathVariable, @RequestBody) avant l'invoke et le résultat en JSON après (voir API REST).

Exemple 2 : un mini conteneur d'injection de dépendances

Le cœur de Spring : tu déclares des classes, chacune reçoit ses dépendances par son constructeur, et le conteneur fabrique tout le graphe d'objets à ta place. Voici le code « métier », sans aucune instanciation manuelle :

MiniSpring.java (1/2)
import java.lang.annotation.*;
import java.lang.reflect.Constructor;
import java.util.*;

public class MiniSpring {
    public static void main(String[] args) {
        Conteneur conteneur = new Conteneur(
            TodoController.class, TodoService.class, TodoRepository.class, Horloge.class);

        TodoController controleur = conteneur.get(TodoController.class);
        System.out.println(controleur.accueil());

        TodoService s1 = conteneur.get(TodoService.class);
        TodoService s2 = conteneur.get(TodoService.class);
        System.out.println("même instance : " + (s1 == s2));
    }
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@interface Composant {}

@Composant
class Horloge {
    String maintenant() { return "12:00"; }
}

@Composant
class TodoRepository {
    List<String> findAll() { return List.of("Apprendre Java", "Lire la doc Spring"); }
}

@Composant
class TodoService {
    private final TodoRepository repository;
    private final Horloge horloge;

    TodoService(TodoRepository repository, Horloge horloge) {   // les dépendances
        this.repository = repository;
        this.horloge = horloge;
    }

    String resume() { return horloge.maintenant() + " : " + repository.findAll().size() + " todos"; }
}

@Composant
class TodoController {
    private final TodoService service;

    TodoController(TodoService service) { this.service = service; }

    String accueil() { return "GET / -> " + service.resume(); }
}

Et le conteneur, dans le même fichier :

MiniSpring.java (2/2)
class Conteneur {
    private final Set<Class<?>> composants = new HashSet<>();
    private final Map<Class<?>, Object> singletons = new HashMap<>();
    private final Set<Class<?>> enCreation = new HashSet<>();

    Conteneur(Class<?>... classes) {
        for (Class<?> c : classes) {
            if (c.isAnnotationPresent(Composant.class)) composants.add(c);
        }
    }

    <T> T get(Class<T> type) {
        Object existant = singletons.get(type);
        if (existant != null) return type.cast(existant);              // déjà créé : cache

        if (!composants.contains(type)) {
            throw new IllegalStateException("Aucun composant de type " + type.getSimpleName());
        }
        if (!enCreation.add(type)) {
            throw new IllegalStateException("Dépendance circulaire sur " + type.getSimpleName());
        }
        try {
            Constructor<?> constructeur = type.getDeclaredConstructors()[0];
            Class<?>[] typesParams = constructeur.getParameterTypes();
            Object[] arguments = new Object[typesParams.length];
            for (int i = 0; i < typesParams.length; i++) {
                arguments[i] = get(typesParams[i]);                    // récursion
            }
            Object instance = constructeur.newInstance(arguments);
            System.out.println("  création de " + type.getSimpleName());
            singletons.put(type, instance);
            return type.cast(instance);
        } catch (ReflectiveOperationException e) {
            throw new IllegalStateException("Impossible de créer " + type.getSimpleName(), e);
        } finally {
            enCreation.remove(type);
        }
    }
}
  création de TodoRepository
  création de Horloge
  création de TodoService
  création de TodoController
GET / -> 12:00 : 2 todos
même instance : true

Piège : pas de forme compacte (void main()) ici

Dans un fichier compact, les classes déclarées deviennent des classes internes de la classe implicite : leur constructeur reçoit un paramètre caché, l'instance englobante. Le conteneur essaie alors de fabriquer un CompactSpring et échoue (Aucun composant de type CompactSpring). Les exemples de ce chapitre utilisent donc la forme classique.

Pour fabriquer le contrôleur, le conteneur lit les paramètres de son constructeur, fabrique d'abord un TodoService, qui a besoin d'un TodoRepository et d'une Horloge... Chaque objet est créé une seule fois puis mis en cache : ce sont des singletons. Deux classes qui dépendent l'une de l'autre déclenchent Dépendance circulaire sur A.

Voilà, en gros, ce que fait Spring. Le vrai conteneur (l'ApplicationContext) ajoute :

  • le scan du classpath : il trouve lui-même les classes annotées @Component, @Service, @Repository... dans les paquets de ton application, au lieu d'une liste ;
  • la création de tous les singletons au démarrage (une erreur de câblage fait échouer le lancement, pas la première requête) ;
  • la résolution par interface (exercice 3), @Qualifier quand il y a plusieurs candidats, la configuration (@Value), le cycle de vie (@PostConstruct)...
  • et les proxies, juste en dessous.

La suite est dans Injection de dépendances.

Exemple 3 : un proxy pour comprendre @Transactional

Comment Spring ajoute-t-il un BEGIN / COMMIT autour d'une méthode annotée @Transactional, sans toucher à ton code ? Il ne te donne pas ton objet, mais un proxy : un objet qui a la même interface, intercepte chaque appel, fait son travail avant et après, et délègue à ton objet.

Le JDK sait fabriquer un tel objet à l'exécution avec java.lang.reflect.Proxy : il génère une classe qui implémente l'interface demandée et redirige chaque appel vers un InvocationHandler.

MiniProxy.java
import java.lang.annotation.*;
import java.lang.reflect.*;

public class MiniProxy {
    public static void main(String[] args) {
        CompteService service = Transactions.envelopper(new CompteServiceImpl(), CompteService.class);
        System.out.println("classe réelle : " + service.getClass().getName());

        System.out.println("1) appel direct :");
        service.virer("Ada", "Bob", 100);

        System.out.println("2) appel qui échoue :");
        try {
            service.virer("Ada", "Bob", -5);
        } catch (IllegalArgumentException e) {
            System.out.println("  exception reçue : " + e.getMessage());
        }

        System.out.println("3) auto-appel :");
        service.virerLot();
    }
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Transactionnel {}

interface CompteService {
    void virer(String de, String vers, int montant);
    void virerLot();
}

class CompteServiceImpl implements CompteService {
    @Transactionnel
    public void virer(String de, String vers, int montant) {
        if (montant <= 0) throw new IllegalArgumentException("montant invalide : " + montant);
        System.out.println("  virement de " + montant + " € de " + de + " à " + vers);
    }

    public void virerLot() {                 // pas annotée
        this.virer("Ada", "Bob", 10);        // this = l'objet réel, pas le proxy !
        this.virer("Bob", "Carl", 20);
    }
}

class Transactions {
    @SuppressWarnings("unchecked")
    static <T> T envelopper(T cible, Class<T> interfaceType) {
        InvocationHandler handler = (proxy, methode, arguments) -> {
            Method reelle = cible.getClass().getMethod(methode.getName(), methode.getParameterTypes());
            if (!reelle.isAnnotationPresent(Transactionnel.class)) {
                return methode.invoke(cible, arguments);          // on délègue tel quel
            }
            System.out.println("  BEGIN");
            try {
                Object resultat = methode.invoke(cible, arguments);
                System.out.println("  COMMIT");
                return resultat;
            } catch (InvocationTargetException e) {               // (1)!
                System.out.println("  ROLLBACK");
                throw e.getCause();
            }
        };
        return (T) Proxy.newProxyInstance(
            interfaceType.getClassLoader(), new Class<?>[]{interfaceType}, handler);
    }
}
  1. invoke enveloppe toute exception levée par la méthode appelée dans une InvocationTargetException ; on relance l'originale.
classe réelle : $Proxy0
1) appel direct :
  BEGIN
  virement de 100 € de Ada à Bob
  COMMIT
2) appel qui échoue :
  BEGIN
  ROLLBACK
  exception reçue : montant invalide : -5
3) auto-appel :
  virement de 10 € de Ada à Bob
  virement de 20 € de Bob à Carl

Les cas 1 et 2 sont exactement le comportement de @Transactional. Le cas 3 est le piège le plus célèbre de Spring.

Piège : l'auto-appel contourne le proxy

virerLot() passe par le proxy, mais elle n'est pas annotée : le proxy délègue simplement. Ensuite, à l'intérieur de l'objet réel, this.virer(...) appelle directement la méthode, sans repasser par le proxy. Aucune transaction n'est ouverte, alors que virer porte l'annotation.

sequenceDiagram
    participant A as Appelant
    participant P as Proxy
    participant C as CompteServiceImpl
    A->>P: virer()
    P->>P: BEGIN
    P->>C: virer()
    P->>P: COMMIT
    A->>P: virerLot()
    P->>C: virerLot()
    C->>C: this.virer() : pas de proxy, pas de transaction

Dans Spring, c'est pareil : un appel à une méthode @Transactional (ou @Cacheable, @Async...) depuis la même classe ignore l'annotation. Les solutions : déplacer la méthode dans un autre bean (exercice 4), annoter la méthode appelée de l'extérieur, ou gérer la transaction par programme (TransactionTemplate).

Sous le capot : les proxies de Spring

Proxy du JDK ne sait implémenter que des interfaces : le proxy ci-dessus n'est pas un CompteServiceImpl (service instanceof CompteServiceImpl vaut false). Pour pouvoir injecter une classe sans interface, Spring Boot génère par défaut une sous-classe de ton bean à l'exécution, avec la bibliothèque CGLIB. Même principe, même piège de l'auto-appel, et une limite en plus : une méthode private ou final ne peut pas être redéfinie, donc pas interceptée.

Coût et limites de la réflexion

  • Plus lente qu'un appel direct : recherche par nom, contrôles d'accès, arguments mis dans un Object[] (avec boxing). Spring en fait l'essentiel au démarrage (scan, câblage), ce qui explique en partie les quelques secondes de lancement d'une appli ; par requête, le surcoût est négligeable devant un accès réseau ou base de données.
  • Aucune vérification à la compilation : une faute de frappe dans un nom de méthode passé en texte, un mauvais type d'argument... tout explose à l'exécution.
  • L'encapsulation des modules : depuis Java 9, les modules du JDK refusent la réflexion profonde. String.class.getDeclaredField("value").setAccessible(true) lève InaccessibleObjectException: ... module java.base does not "opens java.lang" to unnamed module.
  • Les images natives (GraalVM) doivent connaître à l'avance tout ce qui sera utilisé par réflexion. Spring Boot a un mode AOT (ahead of time) qui précalcule le câblage pour ce cas.
  • getDeclaredMethods() et getDeclaredFields() ne garantissent aucun ordre : trie si l'ordre compte.

Les annotations traitées à la compilation

Un processeur d'annotations est un plugin du compilateur javac : il lit les annotations pendant la compilation et génère du code source. C'est l'équivalent des macros procédurales de Rust (#[derive(Serialize)]), sans coût à l'exécution.

  • MapStruct : tu déclares une interface @Mapper avec TodoDto versDto(Todo todo), il génère la classe d'implémentation (visible dans target/generated-sources).
  • Lombok : @Getter, @Setter, @Builder, @RequiredArgsConstructor ajoutent des méthodes à ta classe. L'API officielle ne permet que de créer de nouveaux fichiers, pas de modifier une classe existante : Lombok y parvient en manipulant les structures internes du compilateur. C'est pratique, très répandu dans les projets Spring, mais fragile à chaque nouvelle version de Java. Avec les records, on en a beaucoup moins besoin.

Exercices

1. Écris un mini JUnit : une annotation @MonTest, et une méthode lancerTests(Class<?>) qui crée une instance de la classe pour chaque test, appelle chaque méthode annotée et affiche OK ou ÉCHEC avec le message de l'exception.

Solution
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface MonTest {}

static void lancerTests(Class<?> classe) throws Exception {
    Method[] methodes = classe.getDeclaredMethods();
    Arrays.sort(methodes, Comparator.comparing(Method::getName));  // ordre non garanti sinon
    int ok = 0, ko = 0;
    for (Method m : methodes) {
        if (!m.isAnnotationPresent(MonTest.class)) continue;
        Object instance = classe.getDeclaredConstructor().newInstance();
        try {
            m.invoke(instance);
            System.out.println("OK     " + m.getName());
            ok++;
        } catch (InvocationTargetException e) {
            System.out.println("ÉCHEC  " + m.getName() + " : " + e.getCause().getMessage());
            ko++;
        }
    }
    System.out.println(ok + " réussi(s), " + ko + " échec(s)");
}

class CalculTest {
    @MonTest void addition() { if (2 + 2 != 4) throw new AssertionError("2 + 2 != 4"); }
    @MonTest void division() { int zero = 0; System.out.println(1 / zero); }
    @MonTest void chaine() { if (!"java".toUpperCase().equals("JAVA")) throw new AssertionError(); }
    void utilitaire() { throw new IllegalStateException("ne doit pas être appelée"); }
}

lancerTests(CalculTest.class);
// affiche : OK     addition
//           OK     chaine
//           ÉCHEC  division : / by zero
//           2 réussi(s), 1 échec(s)

2. Écris String decrire(Object o) qui affiche le nom simple de la classe et tous ses champs (même privés) triés par nom, comme le ferait un sérialiseur JSON : Utilisateur{actif=true, age=36, nom=Ada}.

Solution
static String decrire(Object objet) throws IllegalAccessException {
    Class<?> classe = objet.getClass();
    Field[] champs = classe.getDeclaredFields();
    Arrays.sort(champs, Comparator.comparing(Field::getName));
    StringJoiner sj = new StringJoiner(", ", classe.getSimpleName() + "{", "}");
    for (Field f : champs) {
        f.setAccessible(true);                 // lire même les champs private
        sj.add(f.getName() + "=" + f.get(objet));
    }
    return sj.toString();
}

System.out.println(decrire(new Utilisateur("Ada", 36, true)));
// affiche : Utilisateur{actif=true, age=36, nom=Ada}

3. Transforme TodoRepository en interface implémentée par @Composant class TodoRepositoryMemoire. Modifie le conteneur pour qu'il trouve l'implémentation quand on lui demande une interface, et qu'il échoue s'il y en a zéro ou plusieurs.

Solution

Seuls les ajouts au Conteneur sont montrés :

<T> T get(Class<T> type) {
    if (type.isInterface()) {
        return type.cast(get(implementationDe(type)));
    }
    // ... le reste ne change pas
}

private Class<?> implementationDe(Class<?> interfaceType) {
    List<Class<?>> candidats = new ArrayList<>();
    for (Class<?> c : composants) {
        if (interfaceType.isAssignableFrom(c)) candidats.add(c);  // c implémente l'interface ?
    }
    if (candidats.size() != 1) {
        throw new IllegalStateException(candidats.size() + " implémentation(s) de "
            + interfaceType.getSimpleName() + " au lieu d'une seule");
    }
    return candidats.get(0);
}
// avec TodoRepositoryMemoire : GET / -> 12:00 : 2 todos
// avec une 2e implémentation : 2 implémentation(s) de TodoRepository au lieu d'une seule

Spring lève dans ce cas une NoUniqueBeanDefinitionException, qu'on résout avec @Primary ou @Qualifier.

4. Modifie l'exemple 3 pour que chaque virement d'un lot ait bien sa propre transaction, sans toucher à Transactions.

Solution

Sors virerLot dans une autre classe, qui reçoit le proxy par son constructeur (exactement ce que ferait l'injection de Spring) :

class LotService {
    private final CompteService comptes;
    LotService(CompteService comptes) { this.comptes = comptes; }

    void virerLot() {
        comptes.virer("Ada", "Bob", 10);    // passe par le proxy
        comptes.virer("Bob", "Carl", 20);
    }
}

CompteService service = Transactions.envelopper(new CompteServiceImpl(), CompteService.class);
new LotService(service).virerLot();
// affiche : BEGIN, virement de 10 € de Ada à Bob, COMMIT,
//           BEGIN, virement de 20 € de Bob à Carl, COMMIT

(et retire virerLot de l'interface CompteService).

À retenir

  • Une annotation est une métadonnée inerte : sans code qui la lit (compilateur, processeur, réflexion), elle ne fait rien. Rien à voir avec un décorateur Python.
  • @Retention(RUNTIME) est indispensable pour qu'une annotation soit visible par réflexion.
  • La réflexion (Class<?>, getDeclaredMethods, getAnnotation, invoke, newInstance, setAccessible) permet d'inspecter et d'appeler du code inconnu à la compilation.
  • Spring = scan des classes annotées + création des objets par leur constructeur en résolvant les dépendances + singletons en cache.
  • @Transactional et consorts reposent sur des proxies : un objet intermédiaire qui ajoute du comportement avant et après l'appel.
  • Un auto-appel this.methode() ne passe pas par le proxy : l'annotation est ignorée.
  • La réflexion est lente, non vérifiée à la compilation et bridée par les modules : Spring la concentre au démarrage.
  • Les processeurs d'annotations (Lombok, MapStruct) génèrent du code à la compilation, comme les macros procédurales de Rust.