samedi 15 mars 2025

Simulation d'utilisation de proxy par Spring

Problématique

Imaginons que vous développez une application bancaire où vous devez valider les montants des transactions. Vous avez plusieurs méthodes qui traitent différents types de transactions (virements, retraits, dépôts), et chacune doit respecter certaines limites de montant.

L'une des solutions qui nous vient est la suivante : consiste à ajouter manuellement la vérification de ces limites dans chaque méthode :

import java.math.BigDecimal;

/**
 * Service bancaire avec validation des limites intégrée directement dans les méthodes
 */
public class BankService {
    
    // Constantes pour les limites de transactions
    private static final double VIREMENT_MIN = 10.0;
    private static final double VIREMENT_MAX = 10000.0;
    private static final double RETRAIT_MIN = 20.0;
    private static final double RETRAIT_MAX = 5000.0;
    private static final double DEPOT_MAX = 20000.0;
    
    /**
     * Effectue un virement avec validation des limites
     */
    public void effectuerVirement(String destinataire, BigDecimal montant) {
        // Validation des limites
        double valeurMontant = montant.doubleValue();
        if (valeurMontant < VIREMENT_MIN || valeurMontant > VIREMENT_MAX) {
            throw new TransactionLimitException(
                "Le montant du virement doit être entre " + VIREMENT_MIN + "€ et " + VIREMENT_MAX + "€" +
                " [actuel: " + valeurMontant + "€]"
            );
        }
        
        // Logique métier
        System.out.println("Virement de " + montant + "€ vers " + destinataire + " effectué avec succès");
    }
    
    /**
     * Effectue un retrait avec validation des limites
     */
    public void effectuerRetrait(String compte, BigDecimal montant) {
        // Validation des limites
        double valeurMontant = montant.doubleValue();
        if (valeurMontant < RETRAIT_MIN || valeurMontant > RETRAIT_MAX) {
            throw new TransactionLimitException(
                "Le montant du retrait doit être entre " + RETRAIT_MIN + "€ et " + RETRAIT_MAX + "€" +
                " [actuel: " + valeurMontant + "€]"
            );
        }
        
        // Logique métier
        System.out.println("Retrait de " + montant + "€ du compte " + compte + " effectué avec succès");
    }
    
    /**
     * Effectue un dépôt avec validation des limites
     */
    public void effectuerDepot(String compte, BigDecimal montant) {
        // Validation des limites
        double valeurMontant = montant.doubleValue();
        if (valeurMontant > DEPOT_MAX) {
            throw new TransactionLimitException(
                "Le dépôt ne peut pas dépasser " + DEPOT_MAX + "€" +
                " [actuel: " + valeurMontant + "€]"
            );
        }
        
        // Logique métier
        System.out.println("Dépôt de " + montant + "€ sur le compte " + compte + " effectué avec succès");
    }
}

/**
 * Exception levée lorsqu'une limite de transaction est dépassée
 */
class TransactionLimitException extends RuntimeException {
    public TransactionLimitException(String message) {
        super(message);
    }
}

Cette implémentation présente plusieurs inconvénients :
  1. Duplication du code : Le code de validation est répété dans chaque méthode, ce qui viole le principe DRY (Don't Repeat Yourself).
  2. Mélange des préoccupations : La logique de validation est mélangée avec la logique métier, ce qui rend le code moins lisible et plus difficile à maintenir.
  3. Manque de modularité : Si les règles de validation changent, vous devez modifier chaque méthode individuellement.
  4. Difficulté d'extension : L'ajout d'une nouvelle méthode nécessite de répéter le code de validation.
  5. Moins de déclarativité : Les contraintes ne sont pas clairement identifiables au premier coup d'œil comme avec les annotations.

Solution avec une annotation personnalisée

Au lieu d'ajouter manuellement la vérification de ces limites dans chaque méthode, vous pouvez créer une annotation personnalisée et un processeur qui automatisera cette validation.

L'idée est d'ajouter une annotation sur les méthodes avec comme paramètres : les montants min et max et un message d'erreur. Ensuite utiliser un proxy qui intercepte les appels des méthodes annotées du service et effectue les vérifications des limites. Le client utilisera le proxy service au lieu du service BankService.

Annotation :

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;
import java.math.BigDecimal;

/**
 * Annotation personnalisée pour valider les montants des transactions
 */
@Retention(RetentionPolicy.RUNTIME) // L'annotation sera disponible à l'exécution via la réflexion
@Target(ElementType.METHOD) // L'annotation peut être appliquée aux méthodes
public @interface TransactionLimit {
    double min() default 0.0; // Montant minimum autorisé
    double max() default Double.MAX_VALUE; // Montant maximum autorisé
    String message() default "Montant de transaction invalide"; // Message d'erreur personnalisé
}

Annotation personnalisée @TransactionLimit :
  • Définit les limites minimum et maximum pour un montant de transaction
  • Permet de personnaliser le message d'erreur
  • L'annotation est appliquée au niveau des méthodes avec @Target(ElementType.METHOD)
  • L'annotation est disponible à l'exécution avec @Retention(RetentionPolicy.RUNTIME)
Définissons la logique de validation des limites dans une classe de traitement d'annotation. Cette classe contient une méthode validateTransaction qui prend en paramètre une méthode effectuant une opération bancaire avec les arguments. Si la méthode admet une annotation, on vérifie les limites :


/**
 * Exception levée lorsqu'une limite de transaction est dépassée
 */
class TransactionLimitException extends RuntimeException {
    public TransactionLimitException(String message) {
        super(message);
    }
}

/**
 * Aspect qui intercepte les appels aux méthodes annotées avec @TransactionLimit
 * Note: Cet exemple utilise une implémentation simplifiée sans framework AOP.
 * Dans un cas réel, vous utiliseriez probablement AspectJ ou Spring AOP.
 */
class TransactionLimitAspect {
    
    /**
     * Valide le montant d'une transaction avant l'exécution d'une méthode
     */
    public static void validateTransaction(Method method, Object[] args) {
        if (method.isAnnotationPresent(TransactionLimit.class)) {
            TransactionLimit limit = method.getAnnotation(TransactionLimit.class);
            
            // Recherche du montant dans les arguments
            for (Object arg : args) {
                if (arg instanceof BigDecimal) {
                    BigDecimal amount = (BigDecimal) arg;
                    double amountValue = amount.doubleValue();
                    
                    // Validation des limites
                    if (amountValue < limit.min() || amountValue > limit.max()) {
                        throw new TransactionLimitException(
                            limit.message() + " [min: " + limit.min() + ", max: " + limit.max() + 
                            ", actuel: " + amountValue + "]"
                        );
                    }
                }
            }
        }
    }
}

Processeur d'annotation :
  • La classe TransactionLimitAspect contient la logique de validation
  • Elle utilise la réflexion Java pour examiner les méthodes annotées et leurs arguments
  • Elle vérifie si les montants de transaction respectent les limites définies
Voici la nouvelle version de la classe  BankService  avec l'annotation TransactionLimit :

  • Les méthodes du BankService sont annotées avec des limites différentes selon le type de transaction
  • Chaque méthode définit ses propres règles métier via l'annotation

  • /**
     * Service bancaire avec des méthodes qui utilisent l'annotation TransactionLimit
     */
    class BankService {
        
        @TransactionLimit(min = 10.0, max = 10000.0, message = "Le montant du virement doit être entre 10€ et 10 000€")
        public void effectuerVirement(String destinataire, BigDecimal montant) {
            System.out.println("Virement de " + montant + "€ vers " + destinataire + " effectué avec succès");
        }
        
        @TransactionLimit(min = 20.0, max = 5000.0, message = "Le montant du retrait doit être entre 20€ et 5 000€")
        public void effectuerRetrait(String compte, BigDecimal montant) {
            System.out.println("Retrait de " + montant + "€ du compte " + compte + " effectué avec succès");
        }
        
        @TransactionLimit(max = 20000.0, message = "Le dépôt ne peut pas dépasser 20 000€")
        public void effectuerDepot(String compte, BigDecimal montant) {
            System.out.println("Dépôt de " + montant + "€ sur le compte " + compte + " effectué avec succès");
        }
    }

    Maintenant définissons le proxy qui va intercepter les appels des méthodes du service BankService :
  • TransactionProxy intercepte les appels de méthode pour effectuer la validation avant l'exécution
  • Cette implémentation est simplifiée; dans un cas réel, vous utiliseriez AspectJ ou Spring AOP

  • /**
     * Proxy pour intercepter les appels de méthode (implémentation simplifiée)
     */
    class TransactionProxy {
        private final Object target;
        
        public TransactionProxy(Object target) {
            this.target = target;
        }
        
        /**
         * Méthode générique pour intercepter un appel de méthode
         */
        public Object invoke(String methodName, Class<?>[] paramTypes, Object[] args) throws Exception {
            Method method = target.getClass().getMethod(methodName, paramTypes);
            
            // Validation avant l'exécution
            TransactionLimitAspect.validateTransaction(method, args);
            
            // Exécution de la méthode
            return method.invoke(target, args);
        }
    }
    

    Un exemple d'utilisation du proxy :

    /**
     * Démonstration de l'utilisation
     */
    public class TransactionDemo {
        public static void main(String[] args) {
            BankService bankService = new BankService();
            TransactionProxy proxy = new TransactionProxy(bankService);
            
            try {
                // Transaction valide
                proxy.invoke("effectuerVirement", 
                    new Class<?>[] {String.class, BigDecimal.class}, 
                    new Object[] {"John Doe", new BigDecimal("1000.00")}
                );
                
                // Transaction invalide - montant trop élevé
                proxy.invoke("effectuerRetrait", 
                    new Class<?>[] {String.class, BigDecimal.class}, 
                    new Object[] {"123456789", new BigDecimal("6000.00")}
                );
            } catch (Exception e) {
                System.err.println("Erreur: " + e.getMessage());
            }
        }
    }
    

    Avantages de cette approche

    1. Séparation des préoccupations : La logique de validation est séparée de la logique métier.
    2. Réutilisabilité : L'annotation peut être appliquée à n'importe quelle méthode qui traite des transactions.
    3. Maintenabilité : Les règles de validation sont centralisées et faciles à modifier.
    4. Lisibilité : Le code métier reste propre et focalisé sur sa fonction principale.
    5. Extensibilité : Le système peut être facilement étendu pour ajouter d'autres types de validations.

    Cette solution applique le principe AOP (Programmation Orientée Aspect) pour séparer la validation (aspect transversal) de la logique métier, ce qui rend le code plus modulaire et plus facile à maintenir.

    Un article intéressant à lire ... : Java Dynamic proxy mechanism and how Spring is using it

    vendredi 14 mars 2025

    Architecture proxy de Spring

     

    L'architecture proxy de Spring : un écosystème de beans dédoublés

    Contexte

    Spring est un framework Java qui facilite le développement d'applications d'entreprise en fournissant une infrastructure complète. Au cœur de Spring se trouve le conteneur d'inversion de contrôle (IoC) qui gère le cycle de vie des objets et leurs dépendances. Pour implémenter certaines de ses fonctionnalités les plus puissantes comme l'AOP (Aspect-Oriented Programming) ou les transactions, Spring utilise un mécanisme appelé "proxy".

    Quelques mots sur le proxy

    Un proxy, dans le contexte de la programmation, est un objet qui agit comme un intermédiaire entre un client et un objet cible. Le proxy possède la même interface que l'objet cible, ce qui le rend transparent pour le client. Cependant, il peut intercepter les appels aux méthodes, les modifier, les enrichir ou même les bloquer avant de les transmettre à l'objet cible.

    Il existe deux types principaux de proxys dans Spring :

    • Les proxys JDK Dynamic : utilisés par défaut lorsque la classe cible implémente une interface
    • Les proxys CGLIB : utilisés lorsque la classe cible n'implémente pas d'interface ou lorsqu'on le configure explicitement

    La mécanique cachée des beans Spring

    Lorsqu'on travaille avec Spring, il est important de comprendre que le framework crée effectivement deux fois plus d'objets que ce qu'on pourrait imaginer pour certains types de beans. Pour chaque bean qui nécessite des fonctionnalités transversales comme les transactions, l'AOP ou la validation, Spring crée en réalité:

    1. Le bean cible original : l'instance réelle de la classe que vous avez définie
    2. Le bean proxy : une enveloppe autour du bean original qui intercepte les appels

    Quels beans sont concernés ?

    Ce dédoublement concerne principalement les beans qui utilisent:

    • @Transactional : tous les beans avec des méthodes transactionnelles
    • AOP : les beans ciblés par des aspects (logging, sécurité, etc.)
    • @Validated : les beans qui nécessitent une validation
    • @Async : les beans avec des méthodes asynchrones
    • @Cacheable et autres annotations de cache

    Pourquoi cette architecture dédoublée ?

    Cette architecture en deux couches permet à Spring d'implémenter le principe de séparation des préoccupations:

    1. Séparation du code métier et des services techniques : votre code métier reste propre et centré sur sa responsabilité
    2. Application transparente des fonctionnalités transversales : le proxy ajoute les fonctionnalités sans que le code métier n'ait à s'en préoccuper
    3. Respect du principe "Open/Closed" : on étend les fonctionnalités des beans sans modifier leur code source

    Implications pratiques

    Ce mécanisme de dédoublement a plusieurs conséquences:

    • Consommation mémoire : Spring crée effectivement plus d'objets que le nombre de classes que vous définissez
    • Performance d'initialisation : la création de proxies dynamiques ajoute un temps à l'initialisation du contexte
    • Comportement des appels internes : les appels de méthode au sein d'un même bean contournent le proxy, ce qui peut désactiver certaines fonctionnalités (notamment les transactions)
    • Débogage : lors du débogage, vous pouvez remarquer que l'instance réelle est enveloppée dans un proxy

    Comprendre cette architecture dédoublée est essentiel pour diagnostiquer certains problèmes courants dans les applications Spring et pour concevoir correctement vos services, particulièrement lorsqu'il s'agit de transactions ou d'autres fonctionnalités qui dépendent de l'interception des appels de méthodes.

    Cycle de création du proxy

    1. Initialisation du contexte Spring
      • Lorsque l'application démarre, Spring scanne les classes annotées (comme @Component, @Service, etc.)
      • Spring identifie les beans qui nécessitent un proxy (ceux ayant des annotations comme @Transactional ou ciblés par des aspects)
    2. Création du bean et de son proxy
      • Spring crée d'abord l'instance du bean réel
      • Ensuite, si nécessaire, Spring génère un proxy autour de ce bean
      • Le proxy est créé en utilisant soit JDK Dynamic Proxy (si le bean implémente une interface) soit CGLIB (si le bean n'implémente pas d'interface)
    3. Enregistrement dans le conteneur
      • Le proxy, et non le bean original, est enregistré dans le conteneur Spring
      • Toutes les injections de dépendances et les références pointent vers le proxy
      • Les clients interagissent uniquement avec le proxy, sans savoir qu'il s'agit d'un proxy

    Fréquence de création

    • Une seule fois par bean : Le proxy est créé une seule fois lors de l'initialisation du contexte Spring, et non à chaque appel de méthode
    • Réutilisation : Le même proxy est réutilisé pour tous les appels ultérieurs à toutes les méthodes du bean
    • Singleton : Dans la portée par défaut (singleton), il n'y a qu'une seule instance du proxy par contexte Spring

    À chaque appel de méthode, le proxy existant intercepte l'appel, applique la logique nécessaire (comme démarrer une transaction ou exécuter un aspect), puis délègue l'appel au bean réel.

    jeudi 13 mars 2025

    Java newProxyInstance et Proxy

     Le Proxy.newProxyInstance du JDK est une fonctionnalité puissante qui permet de créer des implémentations dynamiques d'interfaces Java à l'exécution. Il fait partie de l'API Reflection et est utilisé pour l'interception de méthodes.

    Voici à quoi sert Proxy.newProxyInstance :

    1. Création dynamique d'implémentations d'interfaces
    2. Interception d'appels de méthodes
    3. Délégation d'appels de méthodes
    4. Ajout de comportements avant/après l'exécution d'une méthode
    5. Mise en œuvre de design patterns comme le proxy, le décorateur, ou l'adaptateur
    Nous allons partir d'un besoin simple, comment ajouter une fonctionnalité transverse à une classe sans la modifier. Par exemple la fonctionnalité de cache. Nous disposons d'un ensemble de services qui pourront mettre de données provenant de la base de données en cache pour de besoin de performance car :
    1. Accès coûteux à des ressources distantes : Les appels aux bases de données, services web ou APIs tierces sont lents et consomment des ressources.
    2. Besoin de performances optimales : L'application doit rester réactive même lorsqu'elle dépend de ces services lents.
    3. Duplication de code : Implémenter manuellement un mécanisme de cache pour chaque service entraîne une duplication de code et augmente la complexité.
    4. Modifications intrusives : Modifier directement les services existants pour ajouter du cache rompt le principe de responsabilité unique et peut créer des dépendances non désirées.
    5. Manque de transparence : Les clients du service ne devraient pas avoir à se préoccuper de l'existence d'un cache ou de son fonctionnement.

    L'implémentation d'un système de mise en cache pour des services coûteux sans modifier leur code source représente un défi architectural classique.

    Le code de la classe de service (Interface et une implémentation) :

    package com.larbotech.cache.service;
    
    public interface DataService {
        String fetchData(String key);
        void updateData(String key, String value);
    }
    
    
    --------------------------------------------
    package com.larbotech.cache.service;
    
    import java.util.concurrent.TimeUnit;
    
    public class DataServiceImpl implements DataService {
        @Override
        public String fetchData(String key) {
            try {
                System.out.println("Accès à la base de données distante pour la clé: " + key);
                // Simulation d'un appel réseau lent (2 secondes)
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return "Données pour " + key;
        }
    
        @Override
        public void updateData(String key, String value) {
            try {
                System.out.println("Mise à jour de la base de données distante pour la clé: " + key);
                // Simulation d'un appel réseau lent (1 seconde)
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }

    Comment ajouter une fonctionnalité de cache sans modifier le service (pas lib externe, ni Aspect ... juste du java !) 

    Solution avec Proxy dynamique

    Les proxys dynamiques (Proxy.newProxyInstance) offrent une solution élégante à ce problème en permettant d'intercepter les appels aux méthodes de manière transparente, sans modifier le code des services existants.

    La première étape consiste à définir et implémenter l'interface : InvocationHandler. Cette interface est définie dans le package java.lang.reflect et contient une seule méthode :

    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
    

    Cette interface est utilisée lors de la création d'un proxy dynamique avec la méthode Proxy.newProxyInstance(). Chaque fois qu'une méthode est appelée sur l'objet proxy, c'est la méthode invoke() de l'InvocationHandler associé qui est exécutée.

    L'implémentation de cette interface (CacheInvocationHandler) va contenir la logique de la fonctionnalité du cache et :

    • Intercepte tous les appels au proxy
    • Vérifie si les données demandées sont déjà en cache
    • Appelle l'implémentation réelle seulement si nécessaire
    • Stocke les résultats dans le cache pour les futures requêtes

    Ainsi, le client utilise l'interface DataService normalement, sans savoir qu'un cache est présent, et la mise en cache est ajoutée de manière transparente sans modifier le code existant.

    Voici le code CacheInvocationHandler :

    package com.larbotech.cache.proxy;
    
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Method;
    import java.util.HashMap;
    import java.util.Map;
    
    // Gestionnaire qui implémente la logique de cache
    public class CachingInvocationHandler implements InvocationHandler {
        private final Object target;
        private final Map<String, Object> cache = new HashMap<>();
    
        public CachingInvocationHandler(Object target) {
            this.target = target;
        }
    
        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            // Traitement des méthodes de lecture (fetchData)
            if (method.getName().equals("fetchData") && args != null && args.length > 0) {
                String key = (String) args[0];
                String cacheKey = method.getName() + "-" + key;
    
                // Vérification du cache
                if (cache.containsKey(cacheKey)) {
                    System.out.println("Cache hit pour la clé: " + key);
                    return cache.get(cacheKey);
                }
    
                // Cache miss: appel au service réel
                Object result = method.invoke(target, args);
    
                // Mise en cache du résultat
                cache.put(cacheKey, result);
                return result;
            }
            // Traitement des méthodes de modification (updateData)
            else if (method.getName().equals("updateData") && args != null && args.length > 1) {
                String key = (String) args[0];
                String cacheKey = "fetchData-" + key;
    
                // Invalidation du cache
                if (cache.containsKey(cacheKey)) {
                    System.out.println("Invalidation du cache pour la clé: " + key);
                    cache.remove(cacheKey);
                }
    
                // Appel au service réel
                return method.invoke(target, args);
            }
    
            // Délégation par défaut
            return method.invoke(target, args);
        }
    }
    

    Le CachingInvocationHandler est une classe qui intercepte les appels aux méthodes d'un service et ajoute automatiquement une fonctionnalité de mise en cache. Voici comment il fonctionne en termes simples :
    1. Interception : Il se positionne entre l'utilisateur et le service réel pour écouter tous les appels de méthodes.
    2. Mémoire cache : Il maintient une "mémoire" (un dictionnaire/map) qui stocke les résultats précédents.
    3. Vérification intelligente : Pour chaque appel à la méthode fetchData, il vérifie :
      • "Ai-je déjà le résultat pour cette clé dans ma mémoire ?"
      • Si oui → il renvoie directement ce résultat stocké (rapide)
      • Si non → il appelle le service réel et stocke le résultat pour la prochaine fois
    4. Invalidation automatique : Quand quelqu'un appelle updateData pour modifier des données, il comprend que les données ont changé et supprime l'entrée correspondante de sa mémoire.

    Création du proxy

    La proxy est créée via Proxy.newProxyInstance(...) :


    public static Object newProxyInstance(
        ClassLoader loader,
        Class<?>[] interfaces,
        InvocationHandler handler
    ) throws IllegalArgumentException
    

    Le premier paramètre spécifie le ClassLoader à utiliser pour charger la classe proxy générée dynamiquement.
    • Le ClassLoader est responsable de charger la définition de classe dans la JVM ;
    • Il doit avoir accès aux interfaces que le proxy va implémenter ;
    • Généralement, on utilise le ClassLoader qui a chargé les interfaces ; (SomeInterface.class.getClassLoader())
    • Si le ClassLoader spécifié ne peut pas accéder aux interfaces, une IllegalArgumentException sera levée.
    Le deuxième paramètre est un tableau d'interfaces que le proxy doit implémenter :
    • Chaque élément du tableau doit être une interface (pas une classe concrète) ;
    • Le proxy implémentera toutes les méthodes déclarées dans toutes ces interfaces ;
    • Le tableau ne peut pas être vide, sinon une IllegalArgumentException sera levée ;
    • Si une classe du tableau n'est pas une interface, une IllegalArgumentException sera levée.
    Le dernier paramètre est une implémentation de l'interface InvocationHandler qui traitera tous les appels de méthode sur le proxy généré :
    • Chaque appel de méthode sur le proxy sera délégué à la méthode invoke() du handler
    • L'InvocationHandler est le cœur du mécanisme de proxy, c'est lui qui contrôle le comportement
    • Il doit implémenter l'interface java.lang.reflect.InvocationHandler
    • La méthode invoke() reçoit l'objet proxy, la méthode appelée et les arguments
    Voice le code d'une classe Factory pour créer le proxy :


    package com.larbotech.cache.proxy;
    
    import java.lang.reflect.Proxy;
    
    public class CachedServiceFactory {
        @SuppressWarnings("unchecked")
        public static <T> T createCachedService(T service) {
            return (T) Proxy.newProxyInstance(
                    service.getClass().getClassLoader(),
                    service.getClass().getInterfaces(),
                    new CachingInvocationHandler(service)
            );
        }
    }
    

    La méthode du Factory prend le service d'origine qu'on passe à l'implémentation CachingInvocationHandler afin qu'une fois la gestion de cache effectuée, on invoque la méthode du service.

    Voici l'exemple de code d'un client qui utilise la service DataService :




    package com.larbotech.cache;
    
    import com.larbotech.cache.service.DataService;
    import com.larbotech.cache.proxy.CachedServiceFactory;
    import com.larbotech.cache.service.DataServiceImpl;
    
    public class CachingProxyDemo {
    
        public static void main(String[] args) {
            // Service réel
            DataService realService = new DataServiceImpl();
    
            // Service amélioré avec cache via proxy
            DataService cachedService = CachedServiceFactory.createCachedService(realService);
    
            long start, end;
    
            // Premier appel (sans cache)
            start = System.currentTimeMillis();
            String result1 = cachedService.fetchData("user123");
            end = System.currentTimeMillis();
            System.out.println("Premier appel: " + result1);
            System.out.println("Temps écoulé: " + (end - start) + "ms");
    
            // Deuxième appel (avec cache)
            start = System.currentTimeMillis();
            String result2 = cachedService.fetchData("user123");
            end = System.currentTimeMillis();
            System.out.println("Deuxième appel: " + result2);
            System.out.println("Temps écoulé: " + (end - start) + "ms");
    
            // Mise à jour des données
            cachedService.updateData("user123", "Nouvelle valeur");
    
            // Troisième appel après mise à jour (sans cache)
            start = System.currentTimeMillis();
            String result3 = cachedService.fetchData("user123");
            end = System.currentTimeMillis();
            System.out.println("Troisième appel après mise à jour: " + result3);
            System.out.println("Temps écoulé: " + (end - start) + "ms");
        }
    }

    Nous pouvons remarquer ici que le client utilise plutôt le proxy .....
    La création du proxy engendre la création dynamique d'une implémentation du service DataService. Cette implémentation utilise CachingInvocationHandler et intercepte les appels vers la méthode invoke du Handler.
    L'ajout de cette option permet -Djdk.proxy.ProxyGenerator.saveGeneratedFiles=true permet de sauver la classe d'implémentation du proxy.... voici un exemple généré :

     
    //
    // Source code recreated from a .class file by IntelliJ IDEA
    // (powered by FernFlower decompiler)
    //
    
    package jdk.proxy1;
    
    import com.larbotech.cache.service.DataService;
    import java.lang.invoke.MethodHandles;
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Method;
    import java.lang.reflect.Proxy;
    import java.lang.reflect.UndeclaredThrowableException;
    
    public final class $Proxy0 extends Proxy implements DataService {
        private static final Method m0;
        private static final Method m1;
        private static final Method m2;
        private static final Method m3;
        private static final Method m4;
    
        public $Proxy0(InvocationHandler var1) {
            super(var1);
        }
    
        public final int hashCode() {
            try {
                return (Integer)super.h.invoke(this, m0, (Object[])null);
            } catch (RuntimeException | Error var2) {
                throw var2;
            } catch (Throwable var3) {
                throw new UndeclaredThrowableException(var3);
            }
        }
    
        public final boolean equals(Object var1) {
            try {
                return (Boolean)super.h.invoke(this, m1, new Object[]{var1});
            } catch (RuntimeException | Error var2) {
                throw var2;
            } catch (Throwable var3) {
                throw new UndeclaredThrowableException(var3);
            }
        }
    
        public final String toString() {
            try {
                return (String)super.h.invoke(this, m2, (Object[])null);
            } catch (RuntimeException | Error var2) {
                throw var2;
            } catch (Throwable var3) {
                throw new UndeclaredThrowableException(var3);
            }
        }
    
        public final String fetchData(String var1) {
            try {
                return (String)super.h.invoke(this, m3, new Object[]{var1});
            } catch (RuntimeException | Error var2) {
                throw var2;
            } catch (Throwable var3) {
                throw new UndeclaredThrowableException(var3);
            }
        }
    
        public final void updateData(String var1, String var2) {
            try {
                super.h.invoke(this, m4, new Object[]{var1, var2});
            } catch (RuntimeException | Error var3) {
                throw var3;
            } catch (Throwable var4) {
                throw new UndeclaredThrowableException(var4);
            }
        }
    
        static {
            ClassLoader var0 = $Proxy0.class.getClassLoader();
    
            try {
                m0 = Class.forName("java.lang.Object", false, var0).getMethod("hashCode");
                m1 = Class.forName("java.lang.Object", false, var0).getMethod("equals", Class.forName("java.lang.Object", false, var0));
                m2 = Class.forName("java.lang.Object", false, var0).getMethod("toString");
                m3 = Class.forName("com.larbotech.cache.service.DataService", false, var0).getMethod("fetchData", Class.forName("java.lang.String", false, var0));
                m4 = Class.forName("com.larbotech.cache.service.DataService", false, var0).getMethod("updateData", Class.forName("java.lang.String", false, var0), Class.forName("java.lang.String", false, var0));
            } catch (NoSuchMethodException var2) {
                throw new NoSuchMethodError(var2.getMessage());
            } catch (ClassNotFoundException var3) {
                throw new NoClassDefFoundError(var3.getMessage());
            }
        }
    
        private static MethodHandles.Lookup proxyClassLookup(MethodHandles.Lookup var0) throws IllegalAccessException {
            if (var0.lookupClass() == Proxy.class && var0.hasFullPrivilegeAccess()) {
                return MethodHandles.lookup();
            } else {
                throw new IllegalAccessException(var0.toString());
            }
        }
    }