Introduzione ai Decoratori TypeScript e all'AOP
Nel mondo dello sviluppo software, la complessità delle applicazioni cresce esponenzialmente. Man mano che i sistemi diventano più grandi, emergono preoccupazioni trasversali (cross-cutting concerns) come il logging, la cache, la validazione, l'autorizzazione e la gestione delle transazioni. Queste funzionalità tendono a essere sparse in numerosi moduli e classi, portando a codice duplicato, difficile da mantenere e da testare. Qui entra in gioco l'Aspect-Oriented Programming (AOP).
L'AOP è un paradigma di programmazione che mira a separare queste preoccupazioni trasversali dalla logica di business principale. Invece di mescolare il codice di logging con il codice che gestisce un utente, l'AOP permette di "iniettare" il logging solo dove è necessario, senza modificare direttamente la logica di business. Questo si traduce in moduli più puliti, più coesi e più facili da evolvere. In TypeScript, i Decoratori offrono un meccanismo elegante e potente per realizzare i principi dell'AOP.
I Decoratori sono una proposta di stage 3 per ECMAScript, ma sono già ampiamente utilizzati in TypeScript. Essi consentono di aggiungere annotazioni e una meta-programmazione a classi, metodi, proprietà, parametri e accessor. In sostanza, un decoratore è una funzione che viene eseguita in fase di dichiarazione (non in fase di runtime dell'istanza) e può osservare, modificare o sostituire la definizione della classe o del membro a cui è allegato. Questa capacità di intercettare e modificare la definizione del codice in modo dichiarativo li rende perfetti per l'implementazione di aspetti.
Questo articolo si propone di esplorare in profondità i TypeScript Decorators, mostrando come possono essere utilizzati per implementare pattern AOP, migliorando la modularità, la manutenibilità e la riusabilità del codice nelle vostre applicazioni web avanzate. Analizzeremo i diversi tipi di decoratori, le fabbriche di decoratori, l'ordine di esecuzione e forniremo esempi pratici per casi d'uso comuni.
Le Basi dei Decoratori TypeScript
Prima di immergerci nell'AOP, è fondamentale comprendere la sintassi e il comportamento base dei decoratori. Per abilitare i decoratori in TypeScript, è necessario configurare il file tsconfig.json aggiungendo le seguenti opzioni del compilatore:
{
"compilerOptions": {
"target": "ES2016",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "commonjs"
}
}
experimentalDecorators: trueabilita il supporto per i decoratori sperimentali.emitDecoratorMetadata: trueè necessario per abilitare l'emissione di metadati di tipo per i decoratori. Questo è cruciale per librerie comereflect-metadata(che dovrete installare connpm install reflect-metadatae importare conimport 'reflect-metadata';) e framework come Angular e NestJS, che si basano su questi metadati per l'iniezione delle dipendenze e altre funzionalità.
Un decoratore è semplicemente una funzione. La sua firma e ciò che può fare dipendono dal tipo di elemento che decora (classe, metodo, proprietà, parametro o accessor).
Tipi di Decoratori
-
Decoratori di Classe: Applicati alla dichiarazione di una classe. La funzione del decoratore riceve un solo argomento: il costruttore della classe.
function logClass(constructor: Function) { console.log(`Classe ${constructor.name} è stata definita.`); } @logClass class Utente { nome: string; constructor(nome: string) { this.nome = nome; } } // Output: Classe Utente è stata definita.Un decoratore di classe può anche sostituire la definizione della classe, restituendo un nuovo costruttore.
-
Decoratori di Metodo: Applicati alla dichiarazione di un metodo. La funzione del decoratore riceve tre argomenti:
target: Il prototipo della classe per i membri statici o il costruttore della classe per i membri di istanza.propertyKey: Il nome del metodo.descriptor: Il Property Descriptor del metodo, che include proprietà comevalue(la funzione del metodo),writable,enumerable,configurable.
function logMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = function (...args: any[]) { console.log(`Chiamata al metodo ${propertyKey} con argomenti: ${JSON.stringify(args)}`); const result = originalMethod.apply(this, args); console.log(`Metodo ${propertyKey} ha restituito: ${JSON.stringify(result)}`); return result; }; return descriptor; } class Calcolatrice { @logMethod somma(a: number, b: number): number { return a + b; } } const calc = new Calcolatrice(); calc.somma(5, 3); /* Output: Chiamata al metodo somma con argomenti: [5,3] Metodo somma ha restituito: 8 */Questo è il tipo di decoratore più potente per l'AOP, in quanto permette di intercettare e modificare il comportamento dei metodi.
-
Decoratori di Proprietà: Applicati alla dichiarazione di una proprietà di classe. La funzione del decoratore riceve due argomenti:
target: Il prototipo della classe.propertyKey: Il nome della proprietà.
function logProperty(target: any, propertyKey: string) { console.log(`Proprietà ${propertyKey} è stata definita sulla classe ${target.constructor.name}.`); } class Prodotto { @logProperty nome: string; constructor(nome: string) { this.nome = nome; } } // Output: Proprietà nome è stata definita sulla classe Prodotto.I decoratori di proprietà non possono modificare il Property Descriptor o il valore iniziale della proprietà direttamente in fase di dichiarazione, ma possono essere usati per registrare metadati o per interagire con accessor setter/getter.
-
Decoratori di Parametro: Applicati alla dichiarazione di un parametro di un metodo o di un costruttore. La funzione del decoratore riceve tre argomenti:
target: Il prototipo della classe.propertyKey: Il nome del metodo.parameterIndex: L'indice ordinale del parametro nella lista degli argomenti.
function required(target: any, propertyKey: string | symbol, parameterIndex: number) { console.log(`Parametro all'indice ${parameterIndex} del metodo ${String(propertyKey)} è marcato come richiesto.`); // In un'implementazione reale, si registrerebbero metadati per la validazione runtime } class ServizioUtente { registraUtente(@required nome: string, email: string) { // ... } } // Output: Parametro all'indice 0 del metodo registraUtente è marcato come richiesto.I decoratori di parametro sono spesso usati per registrare metadati sui parametri, utili per validazione o iniezione delle dipendenze (come in NestJS).
-
Decoratori di Accessor: Simili ai decoratori di metodo, ma applicati ai setter e getter. Ricevono gli stessi tre argomenti di un decoratore di metodo.
function readonly(target: any, propertyKey: string, descriptor: PropertyDescriptor) { descriptor.writable = false; return descriptor; } class Configurazione { private _versione: string; constructor(v: string) { this._versione = v; } @readonly get versione(): string { return this._versione; } } const config = new Configurazione("1.0.0"); console.log(config.versione); // 1.0.0 // config.versione = "2.0.0"; // Errore in strict mode, o non avrà effetto
Fabbriche di Decoratori
Spesso avrete bisogno di decoratori che accettano argomenti. Per fare ciò, si usa una "fabbrica di decoratori" (decorator factory), che è una funzione che restituisce la funzione del decoratore.
function logWithPrefix(prefix: string) {
return function (constructor: Function) {
console.log(`${prefix} - Classe ${constructor.name} è stata definita.`);
};
}
@logWithPrefix("DEBUG")
class AppService {
// ...
}
// Output: DEBUG - Classe AppService è stata definita.
Le fabbriche di decoratori sono fondamentali per creare decoratori configurabili e riutilizzabili, come vedremo negli esempi AOP.
Ordine di Esecuzione dei Decoratori
Quando più decoratori sono applicati allo stesso elemento, l'ordine di esecuzione è importante:
- Decoratori di Parametro vengono eseguiti per primi, nell'ordine in cui sono dichiarati (da sinistra a destra).
- Decoratori di Metodo, Accessor e Proprietà vengono eseguiti in seguito, nell'ordine in cui sono dichiarati (dall'alto verso il basso).
- Decoratori di Classe vengono eseguiti per ultimi.
Se più decoratori sono applicati allo stesso tipo (es. due decoratori di metodo), vengono eseguiti dall'ultimo al primo (bottom-up).
function D1(target: any, propertyKey: string, descriptor: PropertyDescriptor) { console.log('D1'); return descriptor; }
function D2(target: any, propertyKey: string, descriptor: PropertyDescriptor) { console.log('D2'); return descriptor; }
class MyClass {
@D1
@D2
myMethod() {}
}
// Output:
// D2
// D1
Questa esecuzione bottom-up è cruciale per comprendere come i decoratori modificano o wrappano il comportamento l'uno dell'altro.
Esempi Pratici di AOP con Decoratori
Ora applicheremo i concetti dei decoratori per implementare alcune preoccupazioni trasversali comuni, dimostrando il potere dell'AOP in TypeScript.
1. Decoratore per il Logging Dettagliato dei Metodi
Un requisito comune è registrare l'esecuzione dei metodi, inclusi gli argomenti e il valore di ritorno, per il debugging o il monitoraggio.
import 'reflect-metadata'; // Importante per i metadati di tipo
function LogMethodCall(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const className = target.constructor.name;
console.log(`[LOG] Chiamata a ${className}.${propertyKey} con args: ${JSON.stringify(args)}`);
try {
const result = originalMethod.apply(this, args);
console.log(`[LOG] ${className}.${propertyKey} restituito: ${JSON.stringify(result)}`);
return result;
} catch (error: any) {
console.error(`[ERROR] ${className}.${propertyKey} fallito con errore: ${error.message}`);
throw error; // Rilancia l'errore per non alterare il flusso originale
}
};
return descriptor;
}
class GestoreOrdini {
private ordini: { id: number, prodotto: string, quantita: number }[] = [];
private nextId = 1;
@LogMethodCall
creaOrdine(prodotto: string, quantita: number): { id: number, prodotto: string, quantita: number } {
if (quantita <= 0) {
throw new Error("La quantità deve essere positiva.");
}
const newOrder = { id: this.nextId++, prodotto, quantita };
this.ordini.push(newOrder);
return newOrder;
}
@LogMethodCall
ottieniOrdine(id: number): { id: number, prodotto: string, quantita: number } | undefined {
return this.ordini.find(o => o.id === id);
}
@LogMethodCall
aggiornaQuantitaOrdine(id: number, nuovaQuantita: number): boolean {
const ordine = this.ordini.find(o => o.id === id);
if (ordine) {
if (nuovaQuantita <= 0) {
throw new Error("La nuova quantità deve essere positiva.");
}
ordine.quantita = nuovaQuantita;
return true;
}
return false;
}
}
const gestore = new GestoreOrdini();
const ordine1 = gestore.creaOrdine("Laptop", 2);
console.log(gestore.ottieniOrdine(ordine1.id));
try {
gestore.aggiornaQuantitaOrdine(ordine1.id, 0);
} catch (e: any) {
console.log(`Catturato errore: ${e.message}`);
}
In questo esempio, LogMethodCall è un decoratore di metodo che avvolge la funzione originale del metodo. Prima e dopo l'esecuzione del metodo, vengono stampati messaggi di log. Questo decoupling significa che la logica di logging è centralizzata e può essere applicata a qualsiasi metodo con una singola annotazione @LogMethodCall, senza inquinare la logica di business.
2. Decoratore per la Cache dei Risultati di Metodi
Molti metodi eseguono calcoli costosi o accedono a dati che non cambiano frequentemente. Un decoratore di cache può memorizzare i risultati per evitare ricalcoli o chiamate API ripetute.
import 'reflect-metadata';
const methodCache = new Map<string, any>();
function Cacheable(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const cacheKey = `${target.constructor.name}:${propertyKey}:${JSON.stringify(args)}`;
if (methodCache.has(cacheKey)) {
console.log(`[CACHE] Recuperato da cache per ${propertyKey}`);
return methodCache.get(cacheKey);
}
console.log(`[CACHE] Esecuzione effettiva di ${propertyKey}`);
const result = originalMethod.apply(this, args);
methodCache.set(cacheKey, result);
return result;
};
return descriptor;
}
class ServizioDatiEsterni {
private callCount = 0;
@Cacheable
async fetchData(id: string): Promise<string> {
this.callCount++;
// Simulazione di una chiamata di rete costosa
return new Promise(resolve => {
setTimeout(() => {
resolve(`Dati per ID ${id} (chiamata ${this.callCount})`);
}, 1000);
});
}
@Cacheable
async calculateExpensiveValue(a: number, b: number): Promise<number> {
this.callCount++;
return new Promise(resolve => {
setTimeout(() => {
resolve(a * b * this.callCount);
}, 500);
});
}
}
async function testCache() {
const servizio = new ServizioDatiEsterni();
console.log(await servizio.fetchData("user-1")); // Esecuzione effettiva
console.log(await servizio.fetchData("user-1")); // Da cache
console.log(await servizio.fetchData("product-A")); // Esecuzione effettiva
console.log(await servizio.calculateExpensiveValue(2, 3)); // Esecuzione effettiva
console.log(await servizio.calculateExpensiveValue(2, 3)); // Da cache
console.log(await servizio.calculateExpensiveValue(4, 5)); // Esecuzione effettiva
}
testCache();
Questo decoratore Cacheable intercetta le chiamate al metodo. Se i parametri della chiamata sono già stati visti, restituisce il risultato memorizzato. Altrimenti, esegue il metodo originale, memorizza il risultato e lo restituisce. Questo è un esempio perfetto di come l'AOP possa migliorare le performance senza modificare la logica di recupero dati.
3. Decoratore per la Validazione dei Parametri
La validazione degli input è cruciale per la sicurezza e l'integrità dei dati. Con i decoratori di parametro e metodo, possiamo creare un sistema di validazione dichiarativo.
import 'reflect-metadata';
interface ValidatorMetadata {
[methodName: string]: { [paramIndex: number]: Function[] };
}
const validators: ValidatorMetadata = {};
function Required(target: any, propertyKey: string | symbol, parameterIndex: number) {
const methodName = String(propertyKey);
validators[methodName] = validators[methodName] || {};
validators[methodName][parameterIndex] = validators[methodName][parameterIndex] || [];
validators[methodName][parameterIndex].push((value: any) => {
if (value === null || value === undefined || (typeof value === 'string' && value.trim() === '')) {
return `Il parametro all'indice ${parameterIndex} è richiesto.`;
}
return null;
});
}
function MinLength(minLength: number) {
return function (target: any, propertyKey: string | symbol, parameterIndex: number) {
const methodName = String(propertyKey);
validators[methodName] = validators[methodName] || {};
validators[methodName][parameterIndex] = validators[methodName][parameterIndex] || [];
validators[methodName][parameterIndex].push((value: string) => {
if (typeof value === 'string' && value.length < minLength) {
return `Il parametro all'indice ${parameterIndex} deve avere almeno ${minLength} caratteri.`;
}
return null;
});
};
}
function Validate(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const methodName = String(propertyKey);
const methodValidators = validators[methodName];
const errors: string[] = [];
if (methodValidators) {
for (const paramIndex in methodValidators) {
const paramValidators = methodValidators[paramIndex];
const value = args[paramIndex];
for (const validatorFn of paramValidators) {
const error = validatorFn(value);
if (error) {
errors.push(error);
}
}
}
}
if (errors.length > 0) {
throw new Error(`Errori di validazione per ${methodName}:\
${errors.join('\
')}`);
}
return originalMethod.apply(this, args);
};
return descriptor;
}
class UtenteService {
@Validate
creaUtente(
@Required @MinLength(3) username: string,
@Required email: string,
eta?: number
): string {
return `Utente ${username} (${email}) creato.`;
}
}
const utenteServ = new UtenteService();
try {
console.log(utenteServ.creaUtente("alice", "alice@example.com"));
console.log(utenteServ.creaUtente("bo", "bob@example.com")); // Fallirà per MinLength
} catch (e: any) {
console.error(e.message);
}
try {
console.log(utenteServ.creaUtente("charlie", "")); // Fallirà per Required
} catch (e: any) {
console.error(e.message);
}
try {
console.log(utenteServ.creaUtente("dave", undefined as any)); // Fallirà per Required
} catch (e: any) {
console.error(e.message);
}
Questo esempio mostra una combinazione di decoratori di parametro (@Required, @MinLength) e un decoratore di metodo (@Validate). I decoratori di parametro registrano le regole di validazione in una struttura dati globale (validators). Il decoratore @Validate è quindi responsabile di leggere queste regole prima di eseguire il metodo e, se ci sono errori, di lanciare un'eccezione. Questo approccio rende la validazione dichiarativa e separata dalla logica di business.
4. Decoratore per la Misurazione delle Performance
Monitorare il tempo di esecuzione di metodi critici è fondamentale per l'ottimizzazione delle performance.
import 'reflect-metadata';
function MeasurePerformance(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const start = performance.now();
let result;
try {
result = originalMethod.apply(this, args);
} finally {
// Gestisce anche i metodi asincroni
if (result instanceof Promise) {
return result.finally(() => {
const end = performance.now();
console.log(`[PERF] ${target.constructor.name}.${propertyKey} eseguito in ${(end - start).toFixed(2)} ms.`);
});
} else {
const end = performance.now();
console.log(`[PERF] ${target.constructor.name}.${propertyKey} eseguito in ${(end - start).toFixed(2)} ms.`);
}
}
return result;
};
return descriptor;
}
class ElaboratoreDati {
@MeasurePerformance
processaDatiSincroni(data: number[]): number {
// Simulazione di un'operazione sincrona pesante
let sum = 0;
for (let i = 0; i < 1000000; i++) {
sum += Math.sqrt(i) * Math.random();
}
return sum + data.length;
}
@MeasurePerformance
async elaboraDatiAsincroni(input: string): Promise<string> {
// Simulazione di un'operazione asincrona pesante (es. I/O)
return new Promise(resolve => {
setTimeout(() => {
resolve(`Dati elaborati: ${input.toUpperCase()}`);
}, Math.random() * 500 + 200); // 200-700ms
});
}
}
async function testPerformance() {
const elaboratore = new ElaboratoreDati();
elaboratore.processaDatiSincroni([1, 2, 3]);
await elaboratore.elaboraDatiAsincroni("hello world");
await elaboratore.elaboraDatiAsincroni("typescript decorators");
}
testPerformance();
Il decoratore MeasurePerformance calcola il tempo impiegato per l'esecuzione di un metodo. È stato progettato per gestire sia metodi sincroni che asincroni, avvolgendo l'esecuzione e registrando il tempo al completamento. Questo permette di identificare facilmente i colli di bottiglia senza dover inserire manualmente console.time e console.timeEnd in ogni funzione.
Considerazioni Avanzate e Best Practices
Riflessione dei Metadati
Come accennato, emitDecoratorMetadata e la libreria reflect-metadata sono cruciali. Consentono di accedere a metadati di tipo in fase di runtime, come il tipo di ritorno di un metodo o i tipi dei suoi parametri. Questo è ampiamente utilizzato da framework come NestJS per l'iniezione di dipendenze automatica e la validazione.
import 'reflect-metadata';
function Injectable() {
return function (constructor: Function) {
// In un framework reale, qui si registrerebbero dipendenze
console.log(`Classe ${constructor.name} è Injectable.`);
};
}
function LogType(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const returnType = Reflect.getMetadata('design:returntype', target, propertyKey);
const paramTypes = Reflect.getMetadata('design:paramtypes', target, propertyKey);
console.log(`Metodo ${propertyKey}: Tipo di ritorno = ${returnType ? returnType.name : 'unknown'}, Tipi parametri = ${paramTypes ? paramTypes.map((t: any) => t.name).join(', ') : 'unknown'}`);
}
@Injectable()
class DatabaseService {
getConnection(): string { return "DB Connection"; }
}
class UserService {
constructor(private dbService: DatabaseService) {}
@LogType
getUserData(id: number): string {
return this.dbService.getConnection() + ` - User ${id}`;
}
}
// Output:
// Classe DatabaseService è Injectable.
// Metodo getUserData: Tipo di ritorno = String, Tipi parametri = Number
Reflect.getMetadata permette di leggere i tipi inferiti dal compilatore TypeScript, aprendo la porta a scenari di meta-programmazione molto avanzati.
Limiti e Alternative
- Standardizzazione: I decoratori sono ancora sperimentali in ECMAScript. Sebbene siano stabili in TypeScript e ampiamente usati, il loro futuro standard potrebbe differire leggermente. Questo è un rischio, seppur mitigato dalla loro adozione massiccia.
- Over-ingegneria: Non tutti i problemi richiedono i decoratori. A volte, una semplice funzione wrapper o un pattern di composizione può essere più leggibile e meno complesso. Usateli con giudizio.
- Bundle Size: L'uso di
reflect-metadatae decoratori può aggiungere un piccolo overhead al bundle finale, anche se solitamente trascurabile per applicazioni complesse. - Alternative a AOP: Oltre ai decoratori, si possono usare i Proxy JavaScript per intercettare operazioni su oggetti. I Proxy sono uno standard ES6 e offrono un controllo molto granulare, ma la loro sintassi può essere più verbosa per pattern AOP semplici rispetto ai decoratori.
Testing
Testare il codice con decoratori può richiedere un approccio specifico. Spesso è utile testare l'aspetto (il decoratore stesso) separatamente dalla logica di business. Quando si testa una classe decorata, assicuratevi che il vostro ambiente di test (es. Jest, Mocha) sia configurato per supportare i decoratori (tramite ts-node o Babel) e che reflect-metadata sia importato se necessario. Per decoratori che modificano il descrittore, potreste dover mockare il decoratore o testare il comportamento finale della classe.
Errori Comuni
- Dimenticare
experimentalDecoratorsoemitDecoratorMetadata: Senza queste opzioni neltsconfig.json, il compilatore TypeScript ignorerà i decoratori o non emetterà i metadati necessari, causando errori a runtime o semplicemente un comportamento non atteso. - Mancata importazione di
reflect-metadata: Se si usaemitDecoratorMetadata, è quasi sempre necessario importarereflect-metadataall'inizio del punto di ingresso dell'applicazione (import 'reflect-metadata';). Senza di esso,Reflect.getMetadatanon funzionerà. - Comprensione errata dell'ordine di esecuzione: Applicare più decoratori può portare a effetti inaspettati se non si comprende come vengono eseguiti (dal basso verso l'alto per lo stesso tipo, parametri prima di metodi, ecc.).
- Uso improprio del
thisall'interno del decoratore: Quando si avvolge un metodo, è cruciale usareoriginalMethod.apply(this, args)per garantire che il contesto (this) e gli argomenti siano passati correttamente al metodo originale. - Decoratori asincroni: Gestire i decoratori con metodi
async/awaitrichiede attenzione, come mostrato nell'esempioMeasurePerformanceconresult instanceof Promiseefinally(). Un decoratore che non gestisce correttamente le Promise potrebbe non aspettare il completamento del metodo originale prima di eseguire la sua logica post-esecuzione.
Prossimi Passi e Risorse
I TypeScript Decorators sono uno strumento potente che, se usato correttamente, può migliorare significativamente l'architettura e la manutenibilità delle vostre applicazioni. Per approfondire ulteriormente:
- Documentazione Ufficiale TypeScript: Consultate la sezione sui decoratori nella documentazione di TypeScript per gli ultimi aggiornamenti e dettagli tecnici.
- Framework AOP: Esplorate come framework moderni come Angular e NestJS utilizzano i decoratori per implementare l'iniezione delle dipendenze, i guard, gli interceptor e i pipeline di validazione, che sono tutti concetti AOP.
- Librerie ORM: Progetti come TypeORM fanno ampio uso dei decoratori per definire entità di database e relazioni in modo dichiarativo.
- Design Patterns AOP: Approfondite i design pattern specifici dell'AOP e come possono essere mappati sull'implementazione tramite decoratori.
- Prototipazione e Metaprogrammazione: Sperimentate con la meta-programmazione JavaScript e TypeScript, che va oltre i semplici decoratori, per comprendere come manipolare il codice a livello più profondo.
L'AOP e i decoratori vi offrono la possibilità di scrivere codice più pulito, più testabile e più scalabile, separando le preoccupazioni trasversali in modo efficace e elegante. Integrate questi concetti nelle vostre architetture per costruire applicazioni robuste e manutenibili.