Verbesserung des Anwendungsverhaltens mit AOP-Dekoratoren (Decorators) in NestJS und tsyringe
Emily Parker
Product Engineer · Leapcell

Einleitung
In der modernen Softwareentwicklung teilen Anwendungen häufig gemeinsame, sich wiederholende Aufgaben, die sich über verschiedene Module oder Komponenten erstrecken. Diese als "querschnittliche Belange" bezeichneten Aufgaben können Protokollierung, Caching, Authentifizierung, Fehlerbehandlung oder Transaktionsverwaltung umfassen. Obwohl sie für robuste Anwendungen unerlässlich sind, kann die Verteilung ihrer Logik im gesamten Code zu Code-Duplizierung, erhöhter Komplexität und verringerter Wartbarkeit führen – ein Phänomen, das im großen Maßstab oft als "Callback Hell" oder "Spaghetti Code" bezeichnet wird. Hier kommt die aspektorientierte Programmierung (AOP) ins Spiel. AOP bietet ein leistungsfähiges Paradigma zur Modularisierung dieser querschnittlichen Belange und trennt sie von der Kerngeschäftslogik.
Im Ökosystem von JavaScript und TypeScript, insbesondere innerhalb von Frameworks wie NestJS und Bibliotheken für die Abhängigkeitsinjektion wie tsyringe, bieten Dekoratoren einen eleganten und idiomatischen Weg zur Implementierung von AOP-Prinzipien. Sie ermöglichen es uns, explizit Verhaltensweisen in Klassen und Methoden einzufügen, ohne ihre ursprüngliche Implementierung zu ändern, was die Lesbarkeit und Wartbarkeit des Codes erheblich verbessert. Dieser Artikel befasst sich damit, wie wir Dekoratoren in NestJS und tsyringe nutzen können, um praktische AOP-Implementierungen für gängige Szenarien wie Protokollierung und Caching zu erreichen, was letztendlich zu saubereren, effizienteren und einfacher zu verwaltenden Anwendungen führt.
Die Bausteine der AOP verstehen
Bevor wir uns praktischen Beispielen zuwenden, lassen Sie uns einige wichtige AOP-Terminologien klären, die für unsere Diskussion zentral sein werden:
- Aspekt (Aspect): Eine modulare Einheit querschnittlicher Belange. Ein Aspekt kapselt das Verhalten, das sich über mehrere Teile der Anwendung erstreckt, z. B. "Protokollierung" oder "Caching".
- Join Point: Ein bestimmter Punkt in der Ausführung eines Programms, an dem ein Aspekt angewendet werden kann. In der objektorientierten Programmierung umfasst dies typischerweise Methodenaufrufe, Methodenausführung, Konstruktoraufrufe und Feldzugriffe.
- Advice: Die Aktion, die ein Aspekt an einem bestimmten Join Point ausführt. Advice definiert, was der Aspekt tut. Gängige Arten von Advice sind:
- Before advice: Ausführung vor einem Join Point.
- After advice: Ausführung nach einem Join Point (unabhängig vom Ergebnis).
- After returning advice: Ausführung nur, wenn ein Join Point erfolgreich abgeschlossen wird.
- After throwing advice: Ausführung nur, wenn ein Join Point eine Ausnahme auslöst.
- Around advice: Umschließt einen Join Point und ermöglicht die Ausführung benutzerdefinierter Logik davor und danach, sogar die Änderung der Argumente oder des Rückgabewerts. Dies ist die leistungsfähigste Art von Advice.
- Pointcut: Eine Menge von Join Points, an denen ein Advice angewendet werden soll. Pointcuts geben an, wo das Verhalten des Aspekts injiziert werden soll. Bei unserem dekoratorbasierten Ansatz definiert die Anwesenheit des Dekorators selbst oft den Pointcut.
- Weaving: Der Prozess der Kombination von Aspekten mit dem Kernanwendungscode zur Erstellung des endgültigen ausführbaren Systems. Mit Dekoratoren erfolgt dieses Weaving zur Kompilierzeit oder zur Laufzeit, abhängig von der TypeScript-Kompilierung und JavaScript-Ausführung.
Dekoratoren in TypeScript bieten eine syntaktische Zuckerung, die es uns ermöglicht, Klassen, Methoden, Accessoren, Eigenschaften oder Parameter zu annotieren. Wenn sie für AOP verwendet werden, fungieren sie effektiv als Pointcuts, und die Logik innerhalb der Dekoratorfunktion implementiert das Advice.
AOP-Implementierung mit Dekoratoren: Protokollierung und Caching
Lassen Sie uns untersuchen, wie AOP für Protokollierung und Caching mithilfe von Dekoratoren in einem NestJS-Kontext implementiert werden kann, der auch Prinzipien demonstriert, die auf tsyringe anwendbar sind.
Protokollierungsaspekt (Logging Aspect)
Eine häufige Anforderung ist die Protokollierung der Ausführung von Methoden, einschließlich ihrer Argumente und Rückgabewerte sowie aller Fehler, die auftreten können. Dies hilft bei der Fehlersuche und Überwachung.
// log.decorator.ts import { Logger } from '@nestjs/common'; export function LogMethod( logLevel: 'log' | 'error' | 'warn' | 'debug' | 'verbose' = 'log', logArgs: boolean = true, logResult: boolean = true, logError: boolean = true, ) { const logger = new Logger('LogMethod'); return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor, ) { const originalMethod = descriptor.value; descriptor.value = async function (...args: any[]) { const methodName = `${target.constructor.name}.${propertyKey}`; if (logArgs) { logger[logLevel](`Aufruf von ${methodName} mit Args: ${JSON.stringify(args)}`); } else { logger[logLevel](`Aufruf von ${methodName}`); } try { const result = await originalMethod.apply(this, args); if (logResult) { logger[logLevel]( `Methode ${methodName} gab zurück: ${JSON.stringify(result)}`, ); } return result; } catch (error) { if (logError) { logger.error( `Methode ${methodName} löste einen Fehler aus: ${error.message}`, error.stack, ); } throw error; // Fehler neu auslösen, damit die ursprüngliche Logik ihn verarbeiten kann } }; return descriptor; }; }
Lassen Sie uns diesen Dekorator nun auf einen NestJS-Dienst anwenden:
// hero.service.ts import { Injectable } from '@nestjs/common'; import { LogMethod } from './log.decorator'; interface Hero { id: number; name: string; } @Injectable() export class HeroService { private heroes: Hero[] = [ { id: 1, name: 'Superman' }, { id: 2, name: 'Batman' }, ]; @LogMethod('verbose', true, true, true) async findAllHeroes(): Promise<Hero[]> { // Async-Operation simulieren await new Promise((resolve) => setTimeout(resolve, 100)); return this.heroes; } @LogMethod('error', true, false, true) async findHeroById(id: number): Promise<Hero> { await new Promise((resolve) => setTimeout(resolve, 50)); const hero = this.heroes.find((h) => h.id === id); if (!hero) { throw new Error(`Held mit ID ${id} nicht gefunden.`); } return hero; } }
In diesem Beispiel fungiert @LogMethod als Around Advice. Es fängt die Methoden findAllHeroes und findHeroById ab, protokolliert ihre Ausführung, Argumente und Ergebnisse (oder Fehler) und fährt dann mit der Ausführung der ursprünglichen Methode fort. Beachten Sie, wie die Geschäftslogik sauber bleibt und nicht durch Protokollierungsanweisungen belastet wird.
Caching-Aspekt
Caching ist ein weiterer entscheidender querschnittlicher Belang, insbesondere zur Leistungsoptimierung. Wir können einen Dekorator erstellen, um Methodenergebnisse zu cachen.
// cache.decorator.ts import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject } from '@nestjs/common'; import { Cache } from 'cache-manager'; export function CacheResult(ttlSeconds: number = 60) { return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor, ) { const originalMethod = descriptor.value; descriptor.value = async function (...args: any[]) { // NestJS bietet eine Möglichkeit, Abhängigkeiten zur Laufzeit in Dekoratoren einzufügen // Für eine robustere Lösung, insbesondere mit tsyringe, benötigen Sie möglicherweise eine benutzerdefinierte Dekoratorfabrik oder ein Service Locator Muster, // um auf den Cache-Manager zugreifen zu können. Hier gehen wir davon aus, dass CacheManager verfügbar ist. // Dies ist ein vereinfachter Zugriff; in einer echten NestJS-App würden Sie normalerweise den CacheManager über den Konstruktor injizieren. // Für Dekoratoren benötigen Sie möglicherweise einen benutzerdefinierten Provider oder eine Modulkonfiguration, // um ihn in Closures verfügbar zu machen oder den Inject-Dekorator direkter zu verwenden. // Zur Demonstration simulieren wir ihn oder gehen von der Verfügbarkeit aus. // Ein besserer Ansatz für NestJS wäre die Verwendung eines Interceptors für das Caching. // Für reine Dekorator-AOP simulieren wir ihn jedoch. // Wenn Sie tsyringe verwenden, könnten Sie es direkt injizieren, wenn der Dekorator auf eine Klasseninstanz angewendet wird, die tsyringe verwaltet. // Ein wirklich Framework-unabhängiger Dekorator für das Caching würde eine Möglichkeit benötigen, die Cache-Instanz zu erhalten. // Vorerst mocken wir ihn oder gehen von der Verfügbarkeit aus. // Hier demonstrieren wir die Verwendung eines einfachen In-Memory-Caches zur Vereinfachung // oder gehen von der Verfügbarkeit aus. cacheManager = (this as any).cacheManager; // Annahme, dass cacheManager in die Klasse injiziert wurde if (!cacheManager) { throw new Error('CacheManager im Kontext des CacheResult-Dekorators nicht verfügbar.'); } } catch (e) { console.warn('CacheManager für Dekorator nicht gefunden, Fortfahren ohne Cache. Fehler:', e.message); // Fallback zur ursprünglichen Methode, wenn der Cache-Manager nicht verfügbar ist return originalMethod.apply(this, args); } const cacheKey = `${target.constructor.name}:${propertyKey}:${JSON.stringify(args)}`; const cachedResult = await cacheManager.get(cacheKey); if (cachedResult) { console.log(`Cache-Treffer für ${cacheKey}`); return cachedResult; } console.log(`Cache-Fehlversuch für ${cacheKey}, Ausführung der ursprünglichen Methode.`); const result = await originalMethod.apply(this, args); await cacheManager.set(cacheKey, result, ttlSeconds * 1000); // ttl in Millisekunden return result; }; return descriptor; }; }
Hinweis zur CacheManager-Injektion in Dekoratoren: Die direkte Injektion von CACHE_MANAGER (oder einem anderen Dienst) in einen Methodendekorator ist in NestJS/TypeScript nicht einfach, da Dekoratoren zur Modul-Ladezeit ausgeführt werden, bevor Instanzen erstellt oder Abhängigkeiten injiziert werden. Das obige Beispiel trifft die vereinfachende Annahme, dass cacheManager auf this verfügbar ist. In einer praktischen NestJS-Anwendung wäre ein robusterer Weg, AOP für das Caching zu implementieren, die Verwendung von Interceptoren oder die Erstellung eines benutzerdefinierten Providers, der die Dekoratorfabrik mit Möglichkeiten zur Abhängigkeitsinjektion umschließt.
Für Bibliotheken wie tsyringe könnten Sie deren Container nutzen, um Abhängigkeiten innerhalb einer Dekoratorfabrik aufzulösen, wenn Sie Ihre Klasse so umschließen, dass dies möglich ist. Hier ist eine konzeptionelle Möglichkeit, wie Sie dies mit tsyringe erreichen könnten, um auf einen CacheService zuzugreifen:
// cache.service.ts import { injectable, container } from 'tsyringe'; @injectable() export class CacheService { private cache = new Map<string, any>(); async get<T>(key: string): Promise<T | undefined> { return this.cache.get(key); } async set<T>(key: string, value: T, ttlMs: number): Promise<void> { this.cache.set(key, value); if (ttlMs > 0) { setTimeout(() => this.cache.delete(key), ttlMs); } } // CacheService mit tsyringe registrieren static register() { container.registerSingleton(CacheService); } } // In Ihrer Hauptanwendungs-Einrichtung: // CacheService.register(); // cache.decorator.ts (tsyringe-fähig) import { container } from 'tsyringe'; import { CacheService } from './cache.service'; export function CacheResultTsyringe(ttlSeconds: number = 60) { return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor, ) { const originalMethod = descriptor.value; descriptor.value = async function (...args: any[]) { const cacheService = container.resolve(CacheService); // Vom tsyringe-Container auflösen const cacheKey = `${target.constructor.name}:${propertyKey}:${JSON.stringify(args)}`; const cachedResult = await cacheService.get(cacheKey); if (cachedResult) { console.log(`Cache-Treffer für ${cacheKey}`); return cachedResult; } console.log(`Cache-Fehlversuch für ${cacheKey}, Ausführung der ursprünglichen Methode.`); const result = await originalMethod.apply(this, args); await cacheService.set(cacheKey, result, ttlSeconds * 1000); return result; }; return descriptor; }; }
Dann würden Sie @CacheResultTsyringe auf Ihren Methoden in Klassen anwenden, die selbst von tsyringe verwaltet werden.
// hero.service.ts (von tsyringe verwaltet) import { injectable } from 'tsyringe'; import { CacheResultTsyringe } from './cache.decorator'; interface Hero { id: number; name: string; } @injectable() export class HeroServiceTsyringe { private heroes: Hero[] = [ { id: 1, name: 'Wonder Woman' }, { id: 2, name: 'Aquaman' }, ]; @CacheResultTsyringe(30) // 30 Sekunden cachen async findAllHeroes(): Promise<Hero[]> { console.log('Abrufen aller Helden aus der Datenquelle...'); await new Promise((resolve) => setTimeout(resolve, 500)); // Verzögerung simulieren return this.heroes; } } // Verwendung: // const heroService = container.resolve(HeroServiceTsyringe); // await heroService.findAllHeroes(); // Erster Aufruf - Cache-Fehlversuch // await heroService.findAllHeroes(); // Zweiter Aufruf - Cache-Treffer
Anwendungsfälle
- Protokollierung: Über die grundlegende Protokollierung beim Ein- und Austritt hinaus kann die AOP-Protokollierung zur Überwachung spezifischer kritischer Operationen, zur Verfolgung von Leistungsmetriken (Methodenausführungszeit) oder zur Protokollierung des Zugriffs auf sensible Daten verwendet werden.
- Caching: Unerlässlich für leistungsstarke Anwendungen, insbesondere für Lese-intensive Operationen, die auf Datenbanken oder externe APIs zugreifen. Sie können Datenbankabfragen, API-Antworten oder rechenintensive Funktionsergebnisse cachen.
- Authentifizierung/Autorisierung: Ein Dekorator kann die Benutzerberechtigungen überprüfen, bevor die Ausführung einer Methode zugelassen wird.
- Transaktionsverwaltung: Sicherstellen, dass eine Reihe von Datenbankoperationen entweder alle erfolgreich sind oder alle fehlschlagen. Ein Dekorator kann eine Methode in eine Datenbanktransaktion einschließen.
- Eingabevalidierung: Dekoratoren können die Methodenargumente automatisch validieren, bevor die Kernlogik ausgeführt wird.
- Fehlerbehandlung: Ein Dekorator kann bestimmte Ausnahmen abfangen, diese mit zusätzlichen Kontexten versehen oder benutzerdefinierte Fallback-Verhalten auslösen.
Fazit
Dekoratoren in NestJS und mit tsyringe bieten eine äußerst effektive Möglichkeit zur Implementierung der aspektorientierten Programmierung, die es Entwicklern ermöglicht, querschnittliche Belange wie Protokollierung und Caching sauber und effizient zu verwalten. Durch die Zentralisierung dieser gemeinsamen Verhaltensweisen in wiederverwendbaren Dekoratoren reduzieren wir erheblich den Boilerplate-Code, erhöhen die Modularität und verbessern die allgemeine Wartbarkeit und Lesbarkeit unserer Anwendungen. AOP, angetrieben durch Dekoratoren, befähigt uns, robuste und skalierbare Systeme aufzubauen, bei denen die Geschäftslogik fokussiert und klar bleibt, während wesentliche Infrastrukturbelange nahtlos im Hintergrund gehandhabt werden. Dieser Ansatz führt zu agilerer Entwicklung und einfacherer Anpassung an sich ändernde Anforderungen.