I Dynamic Proxy in Java permettono di intercettare le chiamate effettuate verso un oggetto e di aggiungere comportamenti prima o dopo l’esecuzione del metodo originale. Possono essere utilizzati per implementare logging, misurazione delle prestazioni, controlli di sicurezza, validazione, cache, caricamento differito e molte altre funzionalità trasversali.
La piattaforma Java mette a disposizione un’implementazione nativa dei proxy dinamici attraverso le classi del package java.lang.reflect. La classe principale è Proxy, mentre ogni chiamata intercettata viene gestita da un oggetto che implementa l’interfaccia InvocationHandler.
- che cos’è il pattern Proxy;
- come funzionano i Dynamic Proxy di Java;
- come utilizzare
Proxy.newProxyInstance(); - come implementare un
InvocationHandler; - come intercettare parametri, risultati ed eccezioni;
- come aggiungere logging e misurazione dei tempi;
- come creare proxy con cache e lazy loading;
- come gestire i metodi
equals(),hashCode()etoString(); - come invocare i metodi di default delle interfacce;
- quali sono i limiti dei proxy dinamici JDK.
🧱 Che cos’è il pattern Proxy
Il Proxy è un design pattern strutturale nel quale un oggetto intermedio controlla l’accesso a un altro oggetto. Il chiamante non utilizza direttamente l’implementazione reale, ma comunica con un oggetto proxy che espone la stessa interfaccia.
Possiamo rappresentare il flusso in questo modo:
Client
│
▼
Proxy
│
├── Esegue controlli aggiuntivi
├── Registra la chiamata
├── Modifica gli argomenti
├── Utilizza una cache
└── Invoca l'oggetto reale
│
▼
Implementazione
Il proxy può limitarsi a inoltrare la chiamata oppure può aggiungere una quantità significativa di logica applicativa.
Tipologie comuni di proxy
| Tipologia | Scopo | Esempio |
|---|---|---|
| Virtual proxy | Ritardare la creazione di un oggetto costoso | Caricamento di un file solo al primo utilizzo |
| Protection proxy | Controllare permessi e autorizzazioni | Verifica del ruolo prima di eseguire un metodo |
| Logging proxy | Registrare chiamate, argomenti e risultati | Audit delle operazioni di pagamento |
| Caching proxy | Riutilizzare risultati già calcolati | Cache delle ricerche sul database |
| Remote proxy | Rappresentare localmente una risorsa remota | Client di un servizio remoto |
| Monitoring proxy | Misurare tempi ed errori | Raccolta di metriche applicative |
⚙️ Che cos’è un Dynamic Proxy in Java
Un proxy tradizionale può essere scritto manualmente creando una classe che implementa la stessa interfaccia dell’oggetto originale. Questo approccio è semplice, ma richiede di implementare esplicitamente tutti i metodi.
Supponiamo di avere questa interfaccia:
public interface NotificationService {
void send(String recipient, String message);
boolean isAvailable();
}
Un proxy statico dovrebbe dichiarare manualmente entrambi i metodi:
public final class StaticNotificationProxy
implements NotificationService {
private final NotificationService target;
public StaticNotificationProxy(
NotificationService target
) {
this.target = target;
}
@Override
public void send(
String recipient,
String message
) {
System.out.println("Invio della notifica");
target.send(recipient, message);
}
@Override
public boolean isAvailable() {
return target.isAvailable();
}
}
Con un Dynamic Proxy, invece, la JVM genera a runtime una classe che implementa una o più interfacce. Tutte le chiamate vengono inoltrate a un unico oggetto InvocationHandler.
I Dynamic Proxy forniti da java.lang.reflect.Proxy non estendono la classe originale. La classe generata estende internamente java.lang.reflect.Proxy e implementa le interfacce specificate durante la creazione.
Proxy statico e proxy dinamico a confronto
| Caratteristica | Proxy statico | Dynamic Proxy JDK |
|---|---|---|
| Creazione | Classe scritta manualmente | Classe generata a runtime |
| Metodi | Implementati uno per uno | Intercettati da un handler comune |
| Interfaccia richiesta | Non necessariamente | Sì |
| Flessibilità | Limitata alla classe scritta | Lo stesso handler può gestire più servizi |
| Debug | Immediato | Richiede familiarità con reflection e handler |
🧩 Proxy, interfaccia e InvocationHandler
Per creare un Dynamic Proxy servono normalmente tre componenti:
- una o più interfacce;
- un oggetto
InvocationHandler; - un class loader capace di vedere le interfacce.
La creazione avviene attraverso il metodo:
Proxy.newProxyInstance(
classLoader,
interfaces,
invocationHandler
);
I parametri di newProxyInstance()
| Parametro | Descrizione |
|---|---|
ClassLoader loader |
Il class loader utilizzato per definire la nuova classe proxy |
Class<?>[] interfaces |
Le interfacce che il proxy dovrà implementare |
InvocationHandler h |
L’oggetto che riceverà tutte le invocazioni |
Il metodo restituisce un Object, che deve essere convertito nel tipo dell’interfaccia implementata:
MyService proxy = (MyService) Proxy.newProxyInstance(
MyService.class.getClassLoader(),
new Class<?>[]{MyService.class},
handler
);
È possibile evitare un cast esplicito utilizzando Class.cast() all’interno di una factory generica.
Nelle applicazioni moderne è preferibile creare direttamente l’istanza con Proxy.newProxyInstance(). Il vecchio metodo Proxy.getProxyClass() è deprecato.
🚀 Primo esempio completo di Java Dynamic Proxy
Creiamo un servizio per la gestione dei pagamenti. Il proxy registrerà automaticamente l’inizio, la fine e l’eventuale errore di ogni operazione.
1. Definire l’interfaccia
package it.esempio.proxy;
import java.math.BigDecimal;
public interface PaymentService {
PaymentReceipt pay(
String orderId,
BigDecimal amount
) throws PaymentException;
void refund(
String paymentId
) throws PaymentException;
default String providerName() {
return "Provider generico";
}
}
2. Creare il tipo restituito
package it.esempio.proxy;
import java.math.BigDecimal;
import java.time.Instant;
public record PaymentReceipt(
String paymentId,
String orderId,
BigDecimal amount,
Instant createdAt
) {
}
3. Definire l’eccezione applicativa
package it.esempio.proxy;
public class PaymentException extends Exception {
public PaymentException(String message) {
super(message);
}
public PaymentException(
String message,
Throwable cause
) {
super(message, cause);
}
}
4. Implementare il servizio reale
package it.esempio.proxy;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public final class PaymentServiceImpl
implements PaymentService {
@Override
public PaymentReceipt pay(
String orderId,
BigDecimal amount
) throws PaymentException {
if (orderId == null || orderId.isBlank()) {
throw new PaymentException(
"L'ordine non può essere vuoto"
);
}
if (
amount == null
|| amount.signum() <= 0
) {
throw new PaymentException(
"L'importo deve essere maggiore di zero"
);
}
String paymentId =
UUID.randomUUID().toString();
return new PaymentReceipt(
paymentId,
orderId,
amount,
Instant.now()
);
}
@Override
public void refund(
String paymentId
) throws PaymentException {
if (
paymentId == null
|| paymentId.isBlank()
) {
throw new PaymentException(
"Identificativo di pagamento non valido"
);
}
System.out.println(
"Rimborso completato: " + paymentId
);
}
}
5. Creare l’InvocationHandler
package it.esempio.proxy;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Objects;
public final class LoggingInvocationHandler
implements InvocationHandler {
private final Object target;
public LoggingInvocationHandler(Object target) {
this.target = Objects.requireNonNull(
target,
"Il target non può essere null"
);
}
@Override
public Object invoke(
Object proxy,
Method method,
Object[] args
) throws Throwable {
/*
* I metodi dichiarati da Object richiedono
* una gestione specifica.
*/
if (method.getDeclaringClass() == Object.class) {
return handleObjectMethod(
proxy,
method,
args
);
}
String arguments = Arrays.toString(
args == null
? new Object[0]
: args
);
long start = System.nanoTime();
System.out.printf(
"[START] metodo=%s, argomenti=%s%n",
method.getName(),
arguments
);
try {
/*
* Invochiamo il metodo sull'oggetto reale.
*/
Object result = method.invoke(target, args);
long duration =
System.nanoTime() - start;
System.out.printf(
"[SUCCESS] metodo=%s, durata=%d µs, risultato=%s%n",
method.getName(),
duration / 1_000,
result
);
return result;
} catch (InvocationTargetException exception) {
/*
* Method.invoke() racchiude l'eccezione
* originale in InvocationTargetException.
* La estraiamo prima di rilanciarla.
*/
Throwable cause = exception.getCause();
long duration =
System.nanoTime() - start;
System.err.printf(
"[ERROR] metodo=%s, durata=%d µs, errore=%s%n",
method.getName(),
duration / 1_000,
cause.getMessage()
);
throw cause;
}
}
private Object handleObjectMethod(
Object proxy,
Method method,
Object[] args
) {
return switch (method.getName()) {
case "toString" ->
"DynamicProxy[target=" + target + "]";
case "hashCode" ->
System.identityHashCode(proxy);
case "equals" ->
proxy == args[0];
default ->
throw new UnsupportedOperationException(
"Metodo Object non supportato: "
+ method.getName()
);
};
}
}
6. Creare una factory generica
package it.esempio.proxy;
import java.lang.reflect.Proxy;
import java.util.Objects;
public final class DynamicProxyFactory {
private DynamicProxyFactory() {
/*
* Classe di utilità:
* il costruttore non deve essere utilizzato.
*/
}
public static <T> T createLoggingProxy(
Class<T> interfaceType,
T target
) {
Objects.requireNonNull(
interfaceType,
"Il tipo non può essere null"
);
Objects.requireNonNull(
target,
"Il target non può essere null"
);
if (!interfaceType.isInterface()) {
throw new IllegalArgumentException(
interfaceType.getName()
+ " non è un'interfaccia"
);
}
if (!interfaceType.isInstance(target)) {
throw new IllegalArgumentException(
"Il target non implementa "
+ interfaceType.getName()
);
}
Object proxy = Proxy.newProxyInstance(
interfaceType.getClassLoader(),
new Class<?>[]{interfaceType},
new LoggingInvocationHandler(target)
);
return interfaceType.cast(proxy);
}
}
7. Utilizzare il proxy
package it.esempio.proxy;
import java.math.BigDecimal;
public class Application {
public static void main(String[] args)
throws PaymentException {
PaymentService realService =
new PaymentServiceImpl();
PaymentService paymentService =
DynamicProxyFactory.createLoggingProxy(
PaymentService.class,
realService
);
PaymentReceipt receipt =
paymentService.pay(
"ORD-2026-001",
new BigDecimal("89.90")
);
System.out.println(
"Pagamento creato: "
+ receipt.paymentId()
);
paymentService.refund(
receipt.paymentId()
);
System.out.println(
paymentService.providerName()
);
}
}
Il client utilizza il proxy come se fosse una normale implementazione di PaymentService. Non è necessario conoscere il funzionamento interno dell’handler.
🔍 Come funziona il metodo invoke()
L’interfaccia InvocationHandler dichiara un solo metodo:
Object invoke(
Object proxy,
Method method,
Object[] args
) throws Throwable;
Ogni volta che viene invocato un metodo su un’istanza proxy, la JVM richiama questo metodo.
| Parametro | Contenuto |
|---|---|
proxy |
L’istanza proxy sulla quale è stato chiamato il metodo |
method |
L’oggetto reflection che descrive il metodo invocato |
args |
Gli argomenti della chiamata oppure null se il metodo non ha parametri |
È importante non confondere proxy con l’oggetto reale. Il primo parametro è il proxy generato dalla JVM, non il target sul quale vogliamo delegare la chiamata.
Questo codice è quasi sempre errato:
return method.invoke(proxy, args);
Invocando nuovamente il metodo sul proxy, la chiamata torna nell’handler e continua fino a provocare uno StackOverflowError. Per delegare l’operazione bisogna normalmente utilizzare l’oggetto target.
Ispezionare il metodo intercettato
L’oggetto Method permette di conoscere numerose informazioni:
String name = method.getName();
Class<?> returnType =
method.getReturnType();
Class<?>[] parameterTypes =
method.getParameterTypes();
Class<?> declaringClass =
method.getDeclaringClass();
boolean defaultMethod =
method.isDefault();
boolean varArgs =
method.isVarArgs();
È inoltre possibile cercare annotazioni dichiarate sul metodo:
Audit audit =
method.getAnnotation(Audit.class);
if (audit != null) {
System.out.println(
"Operazione sottoposta ad audit: "
+ audit.value()
);
}
🚨 Gestire correttamente le eccezioni
Quando si utilizza Method.invoke(), un’eccezione generata dal metodo originale viene racchiusa in una InvocationTargetException.
Un handler robusto dovrebbe estrarre e rilanciare la causa originale:
private Object invokeTarget(
Object target,
Method method,
Object[] args
) throws Throwable {
try {
return method.invoke(target, args);
} catch (InvocationTargetException exception) {
Throwable cause = exception.getCause();
if (cause == null) {
throw exception;
}
throw cause;
}
}
Senza questa operazione, il chiamante potrebbe ricevere un’eccezione reflection al posto dell’eccezione applicativa dichiarata nell’interfaccia.
UndeclaredThrowableException
Il metodo invoke() può tecnicamente lanciare qualsiasi Throwable. Tuttavia, il proxy deve rispettare il contratto dell’interfaccia.
Se l’handler lancia un’eccezione checked che non è dichiarata dal metodo dell’interfaccia, la chiamata sul proxy genera una UndeclaredThrowableException.
Consideriamo questo metodo:
public interface ReportService {
String generateReport();
}
Il metodo non dichiara eccezioni checked. Questo handler non dovrebbe quindi lanciare direttamente una IOException:
InvocationHandler handler =
(proxy, method, args) -> {
/*
* IOException è checked, ma generateReport()
* non la dichiara nel proprio contratto.
*/
throw new java.io.IOException(
"File non disponibile"
);
};
Le possibili soluzioni sono:
- dichiarare l’eccezione nell’interfaccia;
- convertirla in un’eccezione unchecked appropriata;
- gestirla internamente all’handler;
- restituire un risultato applicativo che rappresenti l’errore.
⏱️ Logging e misurazione dei tempi
Uno degli utilizzi più frequenti dei proxy dinamici è l’aggiunta di logging tecnico e metriche senza modificare le classi applicative.
Possiamo creare un’annotazione per indicare quali metodi devono essere misurati:
package it.esempio.proxy;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Timed {
String value() default "";
}
L’annotazione viene inserita sul metodo dell’interfaccia:
public interface ProductService {
@Timed("product.search")
Product findById(long id);
}
Il relativo handler può registrare la durata:
package it.esempio.proxy;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Objects;
public final class TimingInvocationHandler
implements InvocationHandler {
private final Object target;
public TimingInvocationHandler(Object target) {
this.target = Objects.requireNonNull(target);
}
@Override
public Object invoke(
Object proxy,
Method method,
Object[] args
) throws Throwable {
Timed timed =
method.getAnnotation(Timed.class);
/*
* I metodi senza annotazione vengono
* semplicemente inoltrati al target.
*/
if (timed == null) {
return invokeTarget(method, args);
}
long start = System.nanoTime();
boolean success = false;
try {
Object result =
invokeTarget(method, args);
success = true;
return result;
} finally {
long duration =
System.nanoTime() - start;
String metricName =
timed.value().isBlank()
? method.getName()
: timed.value();
System.out.printf(
"metric=%s, success=%s, duration=%d µs%n",
metricName,
success,
duration / 1_000
);
}
}
private Object invokeTarget(
Method method,
Object[] args
) throws Throwable {
try {
return method.invoke(target, args);
} catch (InvocationTargetException exception) {
throw exception.getCause();
}
}
}
L’utilizzo del blocco finally garantisce che il tempo venga registrato anche quando il metodo termina con un errore.
🔗 Creare una catena di interceptor
Un singolo InvocationHandler può diventare difficile da mantenere se deve occuparsi contemporaneamente di logging, autorizzazione, validazione, metriche e cache.
Una soluzione consiste nel creare una catena di piccoli interceptor, ciascuno responsabile di una sola funzionalità.
Definire l’interfaccia dell’interceptor
package it.esempio.proxy.chain;
@FunctionalInterface
public interface MethodInterceptor {
Object intercept(
InvocationContext context
) throws Throwable;
}
Creare il contesto dell’invocazione
package it.esempio.proxy.chain;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Objects;
public final class InvocationContext {
private final Object proxy;
private final Object target;
private final Method method;
private final Object[] arguments;
private final List<MethodInterceptor> interceptors;
private int currentIndex;
public InvocationContext(
Object proxy,
Object target,
Method method,
Object[] arguments,
List<MethodInterceptor> interceptors
) {
this.proxy = Objects.requireNonNull(proxy);
this.target = Objects.requireNonNull(target);
this.method = Objects.requireNonNull(method);
this.arguments = arguments == null
? new Object[0]
: arguments.clone();
this.interceptors = List.copyOf(
interceptors
);
}
public Object proceed() throws Throwable {
/*
* Finché esistono interceptor,
* eseguiamo il successivo.
*/
if (currentIndex < interceptors.size()) {
MethodInterceptor interceptor =
interceptors.get(currentIndex++);
return interceptor.intercept(this);
}
/*
* Terminata la catena, invochiamo
* il metodo sull'oggetto reale.
*/
try {
return method.invoke(
target,
arguments
);
} catch (InvocationTargetException exception) {
throw exception.getCause();
}
}
public Object proxy() {
return proxy;
}
public Object target() {
return target;
}
public Method method() {
return method;
}
public Object[] arguments() {
return arguments.clone();
}
}
Creare l’handler della catena
package it.esempio.proxy.chain;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Objects;
public final class InterceptorInvocationHandler
implements InvocationHandler {
private final Object target;
private final List<MethodInterceptor> interceptors;
public InterceptorInvocationHandler(
Object target,
List<MethodInterceptor> interceptors
) {
this.target =
Objects.requireNonNull(target);
this.interceptors =
List.copyOf(interceptors);
}
@Override
public Object invoke(
Object proxy,
Method method,
Object[] args
) throws Throwable {
InvocationContext context =
new InvocationContext(
proxy,
target,
method,
args,
interceptors
);
return context.proceed();
}
}
Interceptor di logging
package it.esempio.proxy.chain;
import java.util.Arrays;
public final class LoggingInterceptor
implements MethodInterceptor {
@Override
public Object intercept(
InvocationContext context
) throws Throwable {
System.out.printf(
"Chiamata %s con %s%n",
context.method().getName(),
Arrays.toString(
context.arguments()
)
);
Object result = context.proceed();
System.out.printf(
"Risultato di %s: %s%n",
context.method().getName(),
result
);
return result;
}
}
Interceptor per le prestazioni
package it.esempio.proxy.chain;
public final class PerformanceInterceptor
implements MethodInterceptor {
@Override
public Object intercept(
InvocationContext context
) throws Throwable {
long start = System.nanoTime();
try {
return context.proceed();
} finally {
long duration =
System.nanoTime() - start;
System.out.printf(
"Metodo %s completato in %d µs%n",
context.method().getName(),
duration / 1_000
);
}
}
}
Creare il proxy con più interceptor
List<MethodInterceptor> interceptors =
List.of(
new LoggingInterceptor(),
new PerformanceInterceptor()
);
PaymentService proxy =
(PaymentService) Proxy.newProxyInstance(
PaymentService.class.getClassLoader(),
new Class<?>[]{
PaymentService.class
},
new InterceptorInvocationHandler(
new PaymentServiceImpl(),
interceptors
)
);
L’ordine degli interceptor è importante. Nel nostro esempio il logging contiene l’intera esecuzione del performance interceptor e del metodo reale.
😴 Implementare un proxy con lazy loading
Un virtual proxy può rimandare la creazione dell’oggetto reale fino alla prima chiamata. Questo approccio è utile quando l’inizializzazione richiede tempo o molte risorse.
package it.esempio.proxy;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Objects;
import java.util.function.Supplier;
public final class LazyInvocationHandler<T>
implements InvocationHandler {
private final Supplier<? extends T> supplier;
/*
* volatile rende visibile il valore
* inizializzato anche agli altri thread.
*/
private volatile T target;
public LazyInvocationHandler(
Supplier<? extends T> supplier
) {
this.supplier =
Objects.requireNonNull(supplier);
}
@Override
public Object invoke(
Object proxy,
Method method,
Object[] args
) throws Throwable {
T realTarget = getOrCreateTarget();
try {
return method.invoke(
realTarget,
args
);
} catch (InvocationTargetException exception) {
throw exception.getCause();
}
}
private T getOrCreateTarget() {
T current = target;
if (current == null) {
synchronized (this) {
current = target;
if (current == null) {
System.out.println(
"Creazione dell'oggetto reale"
);
current = Objects.requireNonNull(
supplier.get(),
"Il supplier ha restituito null"
);
target = current;
}
}
}
return current;
}
}
Creiamo una factory per il proxy lazy:
public static <T> T createLazyProxy(
Class<T> interfaceType,
Supplier<? extends T> supplier
) {
Object proxy = Proxy.newProxyInstance(
interfaceType.getClassLoader(),
new Class<?>[]{interfaceType},
new LazyInvocationHandler<>(supplier)
);
return interfaceType.cast(proxy);
}
Utilizzo:
DocumentService documentService =
createLazyProxy(
DocumentService.class,
() -> new ExpensiveDocumentService()
);
/*
* ExpensiveDocumentService non è ancora stato creato.
*/
documentService.loadDocument("manuale.pdf");
/*
* L'implementazione reale viene creata
* durante la prima invocazione.
*/
🗄️ Creare un Dynamic Proxy con cache
Un caching proxy può memorizzare il risultato di determinati metodi ed evitare esecuzioni ripetute.
Creare l’annotazione Cacheable
package it.esempio.proxy.cache;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Cacheable {
}
Creare la chiave della cache
package it.esempio.proxy.cache;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public record CacheKey(
Method method,
List<Object> arguments
) {
public static CacheKey of(
Method method,
Object[] args
) {
Object[] copy = args == null
? new Object[0]
: args.clone();
/*
* Collections.unmodifiableList accetta anche
* eventuali valori null negli argomenti.
*/
List<Object> arguments =
Collections.unmodifiableList(
Arrays.asList(copy)
);
return new CacheKey(
method,
arguments
);
}
}
Implementare il caching handler
package it.esempio.proxy.cache;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public final class CachingInvocationHandler
implements InvocationHandler {
/*
* ConcurrentHashMap non consente valori null.
* Utilizziamo quindi un oggetto speciale.
*/
private static final Object NULL_VALUE =
new Object();
private final Object target;
private final ConcurrentMap<CacheKey, Object> cache =
new ConcurrentHashMap<>();
public CachingInvocationHandler(Object target) {
this.target =
Objects.requireNonNull(target);
}
@Override
public Object invoke(
Object proxy,
Method method,
Object[] args
) throws Throwable {
/*
* I metodi senza @Cacheable vengono
* eseguiti normalmente.
*/
if (!method.isAnnotationPresent(
Cacheable.class
)) {
return invokeTarget(method, args);
}
CacheKey key =
CacheKey.of(method, args);
Object cached = cache.get(key);
if (cached != null) {
System.out.println(
"Cache HIT: "
+ method.getName()
);
return cached == NULL_VALUE
? null
: cached;
}
System.out.println(
"Cache MISS: "
+ method.getName()
);
Object result =
invokeTarget(method, args);
cache.put(
key,
result == null
? NULL_VALUE
: result
);
return result;
}
private Object invokeTarget(
Method method,
Object[] args
) throws Throwable {
try {
return method.invoke(target, args);
} catch (InvocationTargetException exception) {
throw exception.getCause();
}
}
public void clearCache() {
cache.clear();
}
}
La relativa interfaccia può essere definita così:
public interface CustomerService {
@Cacheable
Customer findById(long customerId);
void update(Customer customer);
}
- durata dei valori;
- numero massimo di elementi;
- invalidazione dopo un aggiornamento;
- gestione di array e oggetti mutabili negli argomenti;
- esecuzioni concorrenti della stessa operazione;
- gestione di risultati molto grandi;
- eventuale cache distribuita.
🧬 Gestire i metodi default delle interfacce
Un’interfaccia Java può contenere metodi default con un’implementazione concreta. Anche queste chiamate vengono inoltrate all’InvocationHandler.
Quando il proxy non dispone di un normale oggetto target, l’handler può invocare l’implementazione di default con InvocationHandler.invokeDefault().
package it.esempio.proxy;
public interface WelcomeService {
default String welcome(String name) {
return "Benvenuto, " + name + "!";
}
String version();
}
Creiamo un proxy senza implementazione reale:
WelcomeService service =
(WelcomeService) Proxy.newProxyInstance(
WelcomeService.class.getClassLoader(),
new Class<?>[]{
WelcomeService.class
},
(proxy, method, args) -> {
if (
method.getDeclaringClass()
== Object.class
) {
return switch (
method.getName()
) {
case "toString" ->
"WelcomeServiceProxy";
case "hashCode" ->
System.identityHashCode(
proxy
);
case "equals" ->
proxy == args[0];
default ->
throw new UnsupportedOperationException(
method.getName()
);
};
}
/*
* Eseguiamo l'implementazione default
* dichiarata dall'interfaccia.
*/
if (method.isDefault()) {
return InvocationHandler.invokeDefault(
proxy,
method,
args
);
}
if (
method.getName()
.equals("version")
) {
return "1.0.0";
}
throw new UnsupportedOperationException(
"Metodo non gestito: "
+ method.getName()
);
}
);
System.out.println(
service.welcome("Luca")
);
System.out.println(
service.version()
);
Questa funzionalità è utile per creare implementazioni dinamiche di interfacce senza un oggetto concreto al quale delegare tutti i metodi.
🪞 Gestire equals(), hashCode() e toString()
Le chiamate ai metodi pubblici equals(), hashCode() e toString() vengono intercettate dall’handler come le chiamate ai metodi delle interfacce.
Ignorare questi metodi può generare comportamenti inattesi quando il proxy viene:
- stampato nei log;
- inserito in un
HashSet; - utilizzato come chiave di una
HashMap; - confrontato con un altro oggetto;
- analizzato da un framework.
Una possibile strategia basata sull’identità del proxy è:
private Object handleObjectMethod(
Object proxy,
Method method,
Object[] args
) {
return switch (method.getName()) {
case "equals" ->
proxy == args[0];
case "hashCode" ->
System.identityHashCode(proxy);
case "toString" ->
"Proxy["
+ target.getClass().getName()
+ "]";
default ->
throw new UnsupportedOperationException(
method.getName()
);
};
}
Un’altra possibilità consiste nel delegare questi metodi al target. La scelta dipende dal significato che si vuole attribuire al proxy.
È però importante mantenere coerenti equals() e hashCode(): se due proxy sono considerati uguali, devono restituire lo stesso hash code.
🧰 Implementare più interfacce con lo stesso proxy
Un Dynamic Proxy può implementare più interfacce contemporaneamente.
public interface HealthCheck {
boolean isHealthy();
}
Creiamo un proxy che implementa sia PaymentService sia HealthCheck:
PaymentService target =
new PaymentServiceImpl();
Object multiProxy =
Proxy.newProxyInstance(
PaymentService.class.getClassLoader(),
new Class<?>[]{
PaymentService.class,
HealthCheck.class
},
(proxy, method, args) -> {
if (
method.getDeclaringClass()
== Object.class
) {
return switch (
method.getName()
) {
case "toString" ->
"PaymentHealthProxy";
case "hashCode" ->
System.identityHashCode(
proxy
);
case "equals" ->
proxy == args[0];
default ->
throw new UnsupportedOperationException(
method.getName()
);
};
}
if (
method.getDeclaringClass()
== HealthCheck.class
) {
return true;
}
try {
return method.invoke(
target,
args
);
} catch (
InvocationTargetException exception
) {
throw exception.getCause();
}
}
);
PaymentService payments =
(PaymentService) multiProxy;
HealthCheck healthCheck =
(HealthCheck) multiProxy;
System.out.println(
healthCheck.isHealthy()
);
Entrambi i cast sono validi perché la classe generata implementa entrambe le interfacce.
Se più interfacce dichiarano metodi con lo stesso nome e gli stessi parametri, il Method ricevuto dall’handler dipende dall’ordine con cui le interfacce sono state passate a newProxyInstance().
🔬 Ispezionare un proxy a runtime
La classe Proxy offre alcuni metodi utili per analizzare le istanze generate.
Verificare se una classe è un proxy
boolean proxyClass =
Proxy.isProxyClass(
paymentService.getClass()
);
System.out.println(proxyClass);
Recuperare l’InvocationHandler
InvocationHandler handler =
Proxy.getInvocationHandler(
paymentService
);
System.out.println(
handler.getClass().getName()
);
Visualizzare la classe generata
Class<?> generatedClass =
paymentService.getClass();
System.out.println(
generatedClass.getName()
);
System.out.println(
generatedClass.getSuperclass()
);
for (
Class<?> implementedInterface
: generatedClass.getInterfaces()
) {
System.out.println(
implementedInterface.getName()
);
}
Il nome concreto della classe proxy è un dettaglio di implementazione e non dovrebbe essere utilizzato nella logica applicativa.
Metodi principali della classe Proxy
| Metodo | Funzione |
|---|---|
newProxyInstance() |
Crea una nuova istanza proxy |
isProxyClass() |
Verifica se una classe è stata generata come proxy JDK |
getInvocationHandler() |
Recupera l’handler associato a un proxy |
getProxyClass() |
Restituisce la classe proxy, ma è deprecato |
🚧 Limiti dei Dynamic Proxy JDK
I proxy dinamici della libreria standard sono molto utili, ma presentano alcuni limiti importanti.
1. Possono implementare soltanto interfacce
Non è possibile passare una classe concreta nell’array delle interfacce:
// Errato: PaymentServiceImpl è una classe.
Proxy.newProxyInstance(
PaymentServiceImpl.class.getClassLoader(),
new Class<?>[]{
PaymentServiceImpl.class
},
handler
);
La chiamata genera una IllegalArgumentException. Bisogna passare PaymentService.class.
2. Le chiamate interne non attraversano automaticamente il proxy
Consideriamo questa implementazione:
public final class OrderServiceImpl
implements OrderService {
@Override
public void createOrder() {
/*
* Chiamata interna sulla stessa istanza.
*/
validateOrder();
}
@Override
public void validateOrder() {
System.out.println("Validazione");
}
}
Se il client chiama proxy.createOrder(), l’handler intercetta createOrder(). Tuttavia, la successiva chiamata interna a validateOrder() viene eseguita direttamente sull’oggetto reale e non torna automaticamente attraverso il proxy.
3. Reflection aggiunge complessità
L’utilizzo della reflection comporta:
- stack trace più articolati;
- possibili errori individuati soltanto a runtime;
- gestione specifica delle eccezioni;
- maggiore difficoltà durante il debug;
- un costo aggiuntivo per ogni invocazione.
Nella maggior parte delle applicazioni il costo è accettabile, ma non bisogna utilizzare un proxy dinamico senza una reale necessità.
4. Class loader e moduli devono essere compatibili
Tutte le interfacce devono essere visibili attraverso il class loader utilizzato per creare il proxy. In applicazioni modulari, server applicativi o sistemi di plugin, la scelta del class loader può essere particolarmente importante.
Come regola generale, è spesso appropriato utilizzare il class loader dell’interfaccia principale:
ClassLoader loader =
ServiceInterface.class.getClassLoader();
5. Le interfacce sealed non possono essere utilizzate
L’array passato a newProxyInstance() deve contenere interfacce compatibili con la generazione dinamica. Le interfacce hidden o sealed non possono essere utilizzate come normali interfacce proxy JDK.
🧯 Errori comuni e soluzioni
| Errore | Causa probabile | Soluzione |
|---|---|---|
IllegalArgumentException: ... is not an interface |
È stata passata una classe concreta | Passare esclusivamente interfacce |
ClassCastException |
Il proxy non implementa l’interfaccia del cast | Aggiungere l’interfaccia a newProxyInstance() |
StackOverflowError |
L’handler invoca nuovamente il metodo sul proxy | Invocare il target o un metodo default appropriato |
InvocationTargetException |
Il metodo originale ha generato un errore | Estrarre e rilanciare getCause() |
UndeclaredThrowableException |
L’handler ha lanciato un’eccezione checked non dichiarata | Rispettare il contratto dell’interfaccia |
NullPointerException sul valore restituito |
L’handler ha restituito null per un metodo con tipo primitivo |
Restituire il wrapper corretto, per esempio Integer o Boolean |
| Il metodo non viene intercettato | La chiamata avviene direttamente sul target o internamente alla stessa classe | Assicurarsi che il client utilizzi l’istanza proxy |
🎯 Quando utilizzare un Dynamic Proxy
Un proxy dinamico è particolarmente utile quando la stessa logica deve essere applicata a molti servizi o metodi.
Utilizzi adatti
- logging uniforme delle chiamate;
- misurazione dei tempi di risposta;
- raccolta di metriche;
- controlli di autorizzazione;
- audit delle operazioni sensibili;
- validazione degli argomenti;
- cache dei risultati;
- gestione centralizzata delle eccezioni;
- retry di operazioni temporaneamente fallite;
- lazy loading di servizi costosi;
- creazione di mock e test double;
- client dinamici per servizi remoti;
- implementazione di funzionalità simili alla programmazione orientata agli aspetti.
Quando è preferibile una soluzione più semplice
Un proxy dinamico potrebbe non essere necessario quando:
- deve essere decorata una sola classe con pochi metodi;
- non è disponibile un’interfaccia;
- il comportamento aggiuntivo è specifico di un unico metodo;
- la massima velocità di invocazione è fondamentale;
- il proxy renderebbe il flusso applicativo difficile da comprendere;
- un normale decorator esplicito sarebbe più leggibile.
✅ Best practice per i Java Dynamic Proxy
1. Utilizzare interfacce chiare
Il contratto dell’interfaccia deve descrivere correttamente:
- tipi restituiti;
- eccezioni checked;
- valori eventualmente null;
- effetti collaterali;
- responsabilità dei singoli metodi.
2. Mantenere l’handler piccolo
Un handler con molte condizioni basate sul nome del metodo diventa rapidamente fragile:
if (method.getName().equals("save")) {
// ...
} else if (method.getName().equals("delete")) {
// ...
} else if (method.getName().equals("find")) {
// ...
}
È preferibile suddividere le responsabilità in interceptor, strategie o componenti dedicati.
3. Evitare confronti basati soltanto sul nome
Due metodi sovraccaricati possono avere lo stesso nome. Quando necessario, bisogna controllare anche i tipi dei parametri:
if (
method.getName().equals("find")
&& Arrays.equals(
method.getParameterTypes(),
new Class<?>[]{long.class}
)
) {
// Metodo find(long)
}
4. Non registrare dati sensibili
Un logging proxy non dovrebbe stampare indiscriminatamente:
- password;
- token di autenticazione;
- numeri completi di carte;
- dati sanitari;
- documenti personali;
- informazioni riservate.
È opportuno mascherare o escludere gli argomenti sensibili prima di registrarli.
5. Rilanciare la causa originale
Quando si delega attraverso Method.invoke(), bisogna gestire InvocationTargetException ed estrarre la causa.
6. Gestire esplicitamente i metodi Object
Definire una politica chiara per equals(), hashCode() e toString() evita problemi difficili da diagnosticare.
7. Validare la factory
Una factory generica dovrebbe verificare:
- che il tipo ricevuto sia un’interfaccia;
- che il target implementi l’interfaccia;
- che nessun parametro obbligatorio sia
null; - che il class loader sia appropriato.
8. Documentare che l’oggetto è un proxy
Gli sviluppatori che utilizzano il servizio devono sapere che alcune chiamate possono essere intercettate, memorizzate, ripetute o sottoposte a controlli.
9. Scrivere test specifici
Non bisogna testare soltanto il target reale. È necessario verificare anche il comportamento del proxy:
import static org.junit.jupiter.api.Assertions.*;
@Test
void shouldCreateProxyAndDelegatePayment()
throws PaymentException {
PaymentService target =
new PaymentServiceImpl();
PaymentService proxy =
DynamicProxyFactory.createLoggingProxy(
PaymentService.class,
target
);
assertTrue(
Proxy.isProxyClass(
proxy.getClass()
)
);
PaymentReceipt result =
proxy.pay(
"ORD-001",
new BigDecimal("25.00")
);
assertNotNull(result);
assertEquals(
"ORD-001",
result.orderId()
);
}
🏁 Conclusioni
I Java Dynamic Proxy consentono di creare a runtime oggetti che implementano una o più interfacce e inoltrano tutte le chiamate a un InvocationHandler. Questo meccanismo permette di aggiungere funzionalità trasversali senza modificare direttamente le implementazioni applicative.
La creazione del proxy avviene normalmente attraverso Proxy.newProxyInstance(). L’handler riceve il proxy, il metodo invocato e i relativi argomenti, potendo decidere se delegare la chiamata, modificarla, bloccarla o restituire un risultato alternativo.
Per utilizzare correttamente questa tecnologia è però necessario prestare attenzione alla gestione delle eccezioni, ai metodi ereditati da Object, alla visibilità delle interfacce, ai class loader e alle chiamate interne che non attraversano automaticamente il proxy.
I proxy dinamici risultano particolarmente efficaci per logging, monitoraggio, sicurezza, cache, lazy loading e altre funzionalità che devono essere applicate in modo uniforme a più servizi. Per casi semplici, invece, un decorator esplicito può essere più leggibile e immediato.
- creare un’interfaccia semplice;
- implementare un handler che registra le chiamate;
- delegare i metodi a un oggetto reale;
- gestire correttamente le eccezioni;
- aggiungere una factory generica;
- separare le funzionalità in una catena di interceptor;
- aggiungere cache, autorizzazione o lazy loading soltanto quando necessario.