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());
        }
    }
}

Aucun commentaire:

Enregistrer un commentaire