Entdecken Sie die Leistungsfähigkeit von TypeScript Decorators für Metadatenprogrammierung, aspektorientierte Programmierung und die Verbesserung von Code durch deklarative Muster. Ein umfassender Leitfaden für Entwickler weltweit.
TypeScript Decorators: Metadaten-Programmiermuster für robuste Anwendungen meistern
In der riesigen Landschaft der modernen Softwareentwicklung ist die Pflege von sauberen, skalierbaren und wartbaren Codebasen von größter Bedeutung. TypeScript, mit seinem leistungsstarken Typsystem und fortgeschrittenen Funktionen, bietet Entwicklern die Werkzeuge, um dies zu erreichen. Zu seinen faszinierendsten und transformativsten Funktionen gehören die Dekoratoren. Obwohl sie zum Zeitpunkt des Schreibens noch ein experimentelles Feature sind (Stage 3 Proposal für ECMAScript), werden Dekoratoren in Frameworks wie Angular und TypeORM weit verbreitet eingesetzt und verändern grundlegend, wie wir an Designmuster, Metadatenprogrammierung und aspektorientierte Programmierung (AOP) herangehen.
Dieser umfassende Leitfaden wird tief in TypeScript-Dekoratoren eintauchen und ihre Mechanik, verschiedene Typen, praktische Anwendungen und Best Practices untersuchen. Egal, ob Sie große Unternehmensanwendungen, Microservices oder clientseitige Web-Interfaces erstellen, das Verständnis von Dekoratoren wird Sie befähigen, deklarativeren, wartbareren und leistungsfähigeren TypeScript-Code zu schreiben.
Das Kernkonzept verstehen: Was ist ein Dekorator?
Im Kern ist ein Dekorator eine spezielle Art von Deklaration, die an eine Klassendeklaration, Methode, einen Accessor, eine Eigenschaft oder einen Parameter angehängt werden kann. Dekoratoren sind Funktionen, die einen neuen Wert zurückgeben (oder einen bestehenden modifizieren) für das Ziel, das sie dekorieren. Ihr Hauptzweck ist es, Metadaten hinzuzufügen oder das Verhalten der Deklaration, an die sie angehängt sind, zu ändern, ohne die zugrunde liegende Codestruktur direkt zu modifizieren. Diese externe, deklarative Art der Code-Erweiterung ist unglaublich leistungsstark.
Stellen Sie sich Dekoratoren als Annotationen oder Labels vor, die Sie auf Teile Ihres Codes anwenden. Diese Labels können dann von anderen Teilen Ihrer Anwendung oder von Frameworks gelesen oder verarbeitet werden, oft zur Laufzeit, um zusätzliche Funktionalität oder Konfiguration bereitzustellen.
Die Syntax eines Dekorators
Dekoratoren wird ein @
-Symbol vorangestellt, gefolgt vom Namen der Dekorator-Funktion. Sie werden unmittelbar vor der Deklaration platziert, die sie dekorieren.
@MyDecorator
class MyClass {
@AnotherDecorator
myMethod() {
// ...
}
}
Aktivieren von Dekoratoren in TypeScript
Bevor Sie Dekoratoren verwenden können, müssen Sie die Compiler-Option experimentalDecorators
in Ihrer tsconfig.json
-Datei aktivieren. Zusätzlich benötigen Sie für fortgeschrittene Metadaten-Reflektionsfähigkeiten (die oft von Frameworks genutzt werden) auch emitDecoratorMetadata
und das reflect-metadata
-Polyfill.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
Sie müssen auch reflect-metadata
installieren:
npm install reflect-metadata --save
# oder
yarn add reflect-metadata
Und importieren Sie es ganz am Anfang des Einstiegspunkts Ihrer Anwendung (z. B. main.ts
oder app.ts
):
import "reflect-metadata";
// Ihr Anwendungscode folgt
Dekorator-Fabriken: Anpassung auf Knopfdruck
Während ein einfacher Dekorator eine Funktion ist, müssen Sie oft Argumente an einen Dekorator übergeben, um sein Verhalten zu konfigurieren. Dies wird durch die Verwendung einer Dekorator-Fabrik erreicht. Eine Dekorator-Fabrik ist eine Funktion, die die eigentliche Dekorator-Funktion zurückgibt. Wenn Sie eine Dekorator-Fabrik anwenden, rufen Sie sie mit ihren Argumenten auf, und sie gibt dann die Dekorator-Funktion zurück, die TypeScript auf Ihren Code anwendet.
Erstellen eines einfachen Dekorator-Fabrik-Beispiels
Lassen Sie uns eine Fabrik für einen Logger
-Dekorator erstellen, der Nachrichten mit unterschiedlichen Präfixen protokollieren kann.
function Logger(prefix: string) {
return function (target: Function) {
console.log(`[${prefix}] Klasse ${target.name} wurde definiert.`);
};
}
@Logger("APP_INIT")
class ApplicationBootstrap {
constructor() {
console.log("Anwendung startet...");
}
}
const app = new ApplicationBootstrap();
// Ausgabe:
// [APP_INIT] Klasse ApplicationBootstrap wurde definiert.
// Anwendung startet...
In diesem Beispiel ist Logger("APP_INIT")
der Aufruf der Dekorator-Fabrik. Er gibt die eigentliche Dekorator-Funktion zurück, die target: Function
(den Klassenkonstruktor) als Argument entgegennimmt. Dies ermöglicht eine dynamische Konfiguration des Verhaltens des Dekorators.
Typen von Dekoratoren in TypeScript
TypeScript unterstützt fünf verschiedene Arten von Dekoratoren, die jeweils auf eine bestimmte Art von Deklaration anwendbar sind. Die Signatur der Dekorator-Funktion variiert je nach dem Kontext, in dem sie angewendet wird.
1. Klassendekoratoren
Klassendekoratoren werden auf Klassendeklarationen angewendet. Die Dekorator-Funktion erhält den Konstruktor der Klasse als einziges Argument. Ein Klassendekorator kann eine Klassendefinition beobachten, modifizieren oder sogar ersetzen.
Signatur:
function ClassDecorator(target: Function) { ... }
Rückgabewert:
Wenn der Klassendekorator einen Wert zurückgibt, ersetzt dieser die Klassendeklaration durch die bereitgestellte Konstruktorfunktion. Dies ist eine mächtige Funktion, die oft für Mixins oder Klassenerweiterungen verwendet wird. Wenn kein Wert zurückgegeben wird, wird die ursprüngliche Klasse verwendet.
Anwendungsfälle:
- Registrierung von Klassen in einem Dependency-Injection-Container.
- Anwenden von Mixins oder zusätzlichen Funktionalitäten auf eine Klasse.
- Framework-spezifische Konfigurationen (z. B. Routing in einem Web-Framework).
- Hinzufügen von Lifecycle-Hooks zu Klassen.
Beispiel für einen Klassendekorator: Injizieren eines Dienstes
Stellen Sie sich ein einfaches Dependency-Injection-Szenario vor, in dem Sie eine Klasse als "injectable" markieren und ihr optional einen Namen in einem Container geben möchten.
const InjectableServiceRegistry = new Map<string, Function>();
function Injectable(name?: string) {
return function<T extends { new(...args: any[]): {} }>(constructor: T) {
const serviceName = name || constructor.name;
InjectableServiceRegistry.set(serviceName, constructor);
console.log(`Dienst registriert: ${serviceName}`);
// Optional könnten Sie hier eine neue Klasse zurückgeben, um das Verhalten zu erweitern
return class extends constructor {
createdAt = new Date();
// Zusätzliche Eigenschaften oder Methoden für alle injizierten Dienste
};
};
}
@Injectable("UserService")
class UserDataService {
getUsers() {
return [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];
}
}
@Injectable()
class ProductDataService {
getProducts() {
return [{ id: 101, name: "Laptop" }, { id: 102, name: "Mouse" }];
}
}
console.log("--- Dienste registriert ---");
console.log(Array.from(InjectableServiceRegistry.keys()));
const userServiceConstructor = InjectableServiceRegistry.get("UserService");
if (userServiceConstructor) {
const userServiceInstance = new userServiceConstructor();
console.log("Benutzer:", userServiceInstance.getUsers());
// console.log("User Service erstellt am:", userServiceInstance.createdAt); // Wenn die zurückgegebene Klasse verwendet wird
}
Dieses Beispiel zeigt, wie ein Klassendekorator eine Klasse registrieren und sogar ihren Konstruktor modifizieren kann. Der Injectable
-Dekorator macht die Klasse für ein theoretisches Dependency-Injection-System auffindbar.
2. Methodendekoratoren
Methodendekoratoren werden auf Methodendeklarationen angewendet. Sie erhalten drei Argumente: das Zielobjekt (für statische Member die Konstruktorfunktion; für Instanzmember den Prototyp der Klasse), den Namen der Methode und den Property-Deskriptor der Methode.
Signatur:
function MethodDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }
Rückgabewert:
Ein Methodendekorator kann einen neuen PropertyDescriptor
zurückgeben. Wenn er dies tut, wird dieser Deskriptor verwendet, um die Methode zu definieren. Dies ermöglicht es Ihnen, die Implementierung der ursprünglichen Methode zu modifizieren oder zu ersetzen, was es für AOP unglaublich mächtig macht.
Anwendungsfälle:
- Protokollierung von Methodenaufrufen und deren Argumenten/Ergebnissen.
- Zwischenspeichern von Methodenergebnissen zur Leistungsverbesserung.
- Anwenden von Autorisierungsprüfungen vor der Methodenausführung.
- Messen der Ausführungszeit von Methoden.
- Debouncing oder Throttling von Methodenaufrufen.
Beispiel für einen Methodendekorator: Leistungsüberwachung
Erstellen wir einen MeasurePerformance
-Dekorator, um die Ausführungszeit einer Methode zu protokollieren.
function MeasurePerformance(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
const start = process.hrtime.bigint();
const result = originalMethod.apply(this, args);
const end = process.hrtime.bigint();
const duration = Number(end - start) / 1_000_000;
console.log(`Methode "${propertyKey}" wurde in ${duration.toFixed(2)} ms ausgeführt`);
return result;
};
return descriptor;
}
class DataProcessor {
@MeasurePerformance
processData(data: number[]): number[] {
// Simuliert eine komplexe, zeitaufwändige Operation
for (let i = 0; i < 1_000_000; i++) {
Math.sin(i);
}
return data.map(n => n * 2);
}
@MeasurePerformance
fetchRemoteData(id: string): Promise<string> {
return new Promise(resolve => {
setTimeout(() => {
resolve(`Daten für ID: ${id}`);
}, 500);
});
}
}
const processor = new DataProcessor();
processor.processData([1, 2, 3]);
processor.fetchRemoteData("abc").then(result => console.log(result));
Der MeasurePerformance
-Dekorator umschließt die ursprüngliche Methode mit Zeitmessungslogik und gibt die Ausführungsdauer aus, ohne die Geschäftslogik innerhalb der Methode selbst zu überladen. Dies ist ein klassisches Beispiel für aspektorientierte Programmierung (AOP).
3. Accessor-Dekoratoren
Accessor-Dekoratoren werden auf Accessor-Deklarationen (get
und set
) angewendet. Ähnlich wie Methodendekoratoren erhalten sie das Zielobjekt, den Namen des Accessors und dessen Property-Deskriptor.
Signatur:
function AccessorDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }
Rückgabewert:
Ein Accessor-Dekorator kann einen neuen PropertyDescriptor
zurückgeben, der zur Definition des Accessors verwendet wird.
Anwendungsfälle:
- Validierung beim Setzen einer Eigenschaft.
- Transformation eines Wertes, bevor er gesetzt oder nachdem er abgerufen wird.
- Kontrolle der Zugriffsberechtigungen für Eigenschaften.
Beispiel für einen Accessor-Dekorator: Caching von Gettern
Erstellen wir einen Dekorator, der das Ergebnis einer teuren Getter-Berechnung zwischenspeichert.
function CachedGetter(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalGetter = descriptor.get;
const cacheKey = `_cached_${String(propertyKey)}`;
if (originalGetter) {
descriptor.get = function() {
if (this[cacheKey] === undefined) {
console.log(`[Cache Miss] Wert für ${String(propertyKey)} wird berechnet`);
this[cacheKey] = originalGetter.apply(this);
} else {
console.log(`[Cache Hit] Zwischengespeicherter Wert für ${String(propertyKey)} wird verwendet`);
}
return this[cacheKey];
};
}
return descriptor;
}
class ReportGenerator {
private data: number[];
constructor(data: number[]) {
this.data = data;
}
// Simuliert eine teure Berechnung
@CachedGetter
get expensiveSummary(): number {
console.log("Führe teure Zusammenfassungsberechnung durch...");
return this.data.reduce((sum, current) => sum + current, 0) / this.data.length;
}
}
const generator = new ReportGenerator([10, 20, 30, 40, 50]);
console.log("Erster Zugriff:", generator.expensiveSummary);
console.log("Zweiter Zugriff:", generator.expensiveSummary);
console.log("Dritter Zugriff:", generator.expensiveSummary);
Dieser Dekorator stellt sicher, dass die Berechnung des expensiveSummary
-Getters nur einmal ausgeführt wird; nachfolgende Aufrufe geben den zwischengespeicherten Wert zurück. Dieses Muster ist sehr nützlich zur Leistungsoptimierung, wenn der Zugriff auf Eigenschaften aufwändige Berechnungen oder externe Aufrufe beinhaltet.
4. Eigenschaftsdekoratoren
Eigenschaftsdekoratoren werden auf Eigenschaftsdeklarationen angewendet. Sie erhalten zwei Argumente: das Zielobjekt (für statische Member die Konstruktorfunktion; für Instanzmember den Prototyp der Klasse) und den Namen der Eigenschaft.
Signatur:
function PropertyDecorator(target: Object, propertyKey: string | symbol) { ... }
Rückgabewert:
Eigenschaftsdekoratoren können keinen Wert zurückgeben. Ihre Hauptaufgabe besteht darin, Metadaten über die Eigenschaft zu registrieren. Sie können den Wert der Eigenschaft oder ihren Deskriptor zum Zeitpunkt der Dekoration nicht direkt ändern, da der Deskriptor für eine Eigenschaft noch nicht vollständig definiert ist, wenn Eigenschaftsdekoratoren ausgeführt werden.
Anwendungsfälle:
- Registrierung von Eigenschaften für die Serialisierung/Deserialisierung.
- Anwenden von Validierungsregeln auf Eigenschaften.
- Setzen von Standardwerten oder Konfigurationen für Eigenschaften.
- ORM (Object-Relational Mapping) Spaltenzuordnung (z. B.
@Column()
in TypeORM).
Beispiel für einen Eigenschaftsdekorator: Validierung erforderlicher Felder
Erstellen wir einen Dekorator, um eine Eigenschaft als "erforderlich" zu markieren und sie dann zur Laufzeit zu validieren.
interface ValidationRule {
property: string | symbol;
validate: (value: any) => boolean;
message: string;
}
const validationRules: Map<Function, ValidationRule[]> = new Map();
function Required(target: Object, propertyKey: string | symbol) {
const rules = validationRules.get(target.constructor) || [];
rules.push({
property: propertyKey,
validate: (value: any) => value !== null && value !== undefined && value !== "",
message: `${String(propertyKey)} ist erforderlich.`
});
validationRules.set(target.constructor, rules);
}
function validate(instance: any): string[] {
const classRules = validationRules.get(instance.constructor) || [];
const errors: string[] = [];
for (const rule of classRules) {
if (!rule.validate(instance[rule.property])) {
errors.push(rule.message);
}
}
return errors;
}
class UserProfile {
@Required
firstName: string;
@Required
lastName: string;
age?: number;
constructor(firstName: string, lastName: string, age?: number) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
}
const user1 = new UserProfile("John", "Doe", 30);
console.log("Validierungsfehler Benutzer 1:", validate(user1)); // []
const user2 = new UserProfile("", "Smith");
console.log("Validierungsfehler Benutzer 2:", validate(user2)); // ["firstName ist erforderlich."]
const user3 = new UserProfile("Alice", "");
console.log("Validierungsfehler Benutzer 3:", validate(user3)); // ["lastName ist erforderlich."]
Der Required
-Dekorator registriert einfach die Validierungsregel in einer zentralen validationRules
-Map. Eine separate validate
-Funktion verwendet dann diese Metadaten, um die Instanz zur Laufzeit zu überprüfen. Dieses Muster trennt die Validierungslogik von der Datendefinition, was sie wiederverwendbar und sauber macht.
5. Parameterdekoratoren
Parameterdekoratoren werden auf Parameter innerhalb eines Klassenkonstruktors oder einer Methode angewendet. Sie erhalten drei Argumente: das Zielobjekt (für statische Member die Konstruktorfunktion; für Instanzmember den Prototyp der Klasse), den Namen der Methode (oder undefined
für Konstruktorparameter) und den ordinalen Index des Parameters in der Parameterliste der Funktion.
Signatur:
function ParameterDecorator(target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) { ... }
Rückgabewert:
Parameterdekoratoren können keinen Wert zurückgeben. Wie Eigenschaftsdekoratoren besteht ihre Hauptaufgabe darin, Metadaten über den Parameter hinzuzufügen.
Anwendungsfälle:
- Registrierung von Parametertypen für die Dependency Injection (z. B.
@Inject()
in Angular). - Anwenden von Validierung oder Transformation auf bestimmte Parameter.
- Extrahieren von Metadaten über API-Anforderungsparameter in Web-Frameworks.
Beispiel für einen Parameterdekorator: Injizieren von Anforderungsdaten
Simulieren wir, wie ein Web-Framework Parameterdekoratoren verwenden könnte, um spezifische Daten in einen Methodenparameter zu injizieren, wie z. B. eine Benutzer-ID aus einer Anforderung.
interface ParameterMetadata {
index: number;
key: string | symbol;
resolver: (request: any) => any;
}
const parameterResolvers: Map<Function, Map<string | symbol, ParameterMetadata[]>> = new Map();
function RequestParam(paramName: string) {
return function (target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) {
const targetKey = propertyKey || "constructor";
let methodResolvers = parameterResolvers.get(target.constructor);
if (!methodResolvers) {
methodResolvers = new Map();
parameterResolvers.set(target.constructor, methodResolvers);
}
const paramMetadata = methodResolvers.get(targetKey) || [];
paramMetadata.push({
index: parameterIndex,
key: targetKey,
resolver: (request: any) => request[paramName]
});
methodResolvers.set(targetKey, paramMetadata);
};
}
// Eine hypothetische Framework-Funktion, um eine Methode mit aufgelösten Parametern aufzurufen
function executeWithParams(instance: any, methodName: string, request: any) {
const classResolvers = parameterResolvers.get(instance.constructor);
if (!classResolvers) {
return (instance[methodName] as Function).apply(instance, []);
}
const methodParamMetadata = classResolvers.get(methodName);
if (!methodParamMetadata) {
return (instance[methodName] as Function).apply(instance, []);
}
const args: any[] = Array(methodParamMetadata.length);
for (const meta of methodParamMetadata) {
args[meta.index] = meta.resolver(request);
}
return (instance[methodName] as Function).apply(instance, args);
}
class UserController {
getUser(@RequestParam("id") userId: string, @RequestParam("token") authToken?: string) {
console.log(`Rufe Benutzer mit ID ab: ${userId}, Token: ${authToken || "N/A"}`);
return { id: userId, name: "Jane Doe" };
}
deleteUser(@RequestParam("id") userId: string) {
console.log(`Lösche Benutzer mit ID: ${userId}`);
return { status: "deleted", id: userId };
}
}
const userController = new UserController();
// Simuliere eine eingehende Anfrage
const mockRequest = {
id: "user123",
token: "abc-123",
someOtherProp: "xyz"
};
console.log("\n--- Führe getUser aus ---");
executeWithParams(userController, "getUser", mockRequest);
console.log("\n--- Führe deleteUser aus ---");
executeWithParams(userController, "deleteUser", { id: "user456" });
Dieses Beispiel zeigt, wie Parameterdekoratoren Informationen über erforderliche Methodenparameter sammeln können. Ein Framework kann dann diese gesammelten Metadaten verwenden, um automatisch geeignete Werte aufzulösen und zu injizieren, wenn die Methode aufgerufen wird, was die Controller- oder Service-Logik erheblich vereinfacht.
Komposition und Ausführungsreihenfolge von Dekoratoren
Dekoratoren können in verschiedenen Kombinationen angewendet werden, und das Verständnis ihrer Ausführungsreihenfolge ist entscheidend, um das Verhalten vorherzusagen und unerwartete Probleme zu vermeiden.
Mehrere Dekoratoren auf einem einzigen Ziel
Wenn mehrere Dekoratoren auf eine einzelne Deklaration (z. B. eine Klasse, Methode oder Eigenschaft) angewendet werden, werden sie in einer bestimmten Reihenfolge ausgeführt: von unten nach oben oder von rechts nach links für ihre Auswertung. Ihre Ergebnisse werden jedoch in umgekehrter Reihenfolge angewendet.
@DecoratorA
@DecoratorB
class MyClass {
// ...
}
Hier wird zuerst DecoratorB
ausgewertet, dann DecoratorA
. Wenn sie die Klasse modifizieren (z. B. durch Rückgabe eines neuen Konstruktors), wird die Modifikation von DecoratorA
die Modifikation von DecoratorB
umschließen oder darüber angewendet.
Beispiel: Verkettung von Methodendekoratoren
Betrachten Sie zwei Methodendekoratoren: LogCall
und Authorization
.
function LogCall(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`[LOG] Rufe ${String(propertyKey)} auf mit Argumenten:`, args);
const result = originalMethod.apply(this, args);
console.log(`[LOG] Methode ${String(propertyKey)} gab zurück:`, result);
return result;
};
return descriptor;
}
function Authorization(roles: string[]) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const currentUserRoles = ["admin"]; // Simuliere das Abrufen der aktuellen Benutzerrollen
const authorized = roles.some(role => currentUserRoles.includes(role));
if (!authorized) {
console.warn(`[AUTH] Zugriff verweigert für ${String(propertyKey)}. Erforderliche Rollen: ${roles.join(", ")}`);
throw new Error("Unbefugter Zugriff");
}
console.log(`[AUTH] Zugriff gewährt für ${String(propertyKey)}`);
return originalMethod.apply(this, args);
};
return descriptor;
};
}
class SecureService {
@LogCall
@Authorization(["admin"])
deleteSensitiveData(id: string) {
console.log(`Lösche sensible Daten für ID: ${id}`);
return `Daten-ID ${id} gelöscht.`;
}
@Authorization(["user"])
@LogCall // Reihenfolge hier geändert
fetchPublicData(query: string) {
console.log(`Rufe öffentliche Daten mit Abfrage ab: ${query}`);
return `Öffentliche Daten für Abfrage: ${query}`;
}
}
const service = new SecureService();
try {
console.log("\n--- Rufe deleteSensitiveData auf (Admin-Benutzer) ---");
service.deleteSensitiveData("record123");
} catch (error: any) {
console.error(error.message);
}
try {
console.log("\n--- Rufe fetchPublicData auf (Nicht-Admin-Benutzer) ---");
// Simuliere einen Nicht-Admin-Benutzer, der versucht, auf fetchPublicData zuzugreifen, was die 'user'-Rolle erfordert
const mockUserRoles = ["guest"]; // Dies wird die Authentifizierung fehlschlagen lassen
// Um dies dynamisch zu machen, bräuchte man ein DI-System oder einen statischen Kontext für die aktuellen Benutzerrollen.
// Der Einfachheit halber nehmen wir an, dass der Authorization-Dekorator Zugriff auf den aktuellen Benutzerkontext hat.
// Lassen Sie uns den Authorization-Dekorator anpassen, um zu Demozwecken immer 'admin' anzunehmen,
// damit der erste Aufruf erfolgreich ist und der zweite fehlschlägt, um verschiedene Pfade zu zeigen.
// Erneuter Durchlauf mit Benutzerrolle für fetchPublicData, damit es erfolgreich ist.
// Stellen Sie sich vor, currentUserRoles in Authorization wird zu: ['user']
// Für dieses Beispiel halten wir es einfach und zeigen den Effekt der Reihenfolge.
service.fetchPublicData("Suchbegriff"); // Dies führt Auth -> Log aus
} catch (error: any) {
console.error(error.message);
}
/* Erwartete Ausgabe für deleteSensitiveData:
[AUTH] Zugriff gewährt für deleteSensitiveData
[LOG] Rufe deleteSensitiveData auf mit Argumenten: [ 'record123' ]
Lösche sensible Daten für ID: record123
[LOG] Methode deleteSensitiveData gab zurück: Daten-ID record123 gelöscht.
*/
/* Erwartete Ausgabe für fetchPublicData (wenn der Benutzer die 'user'-Rolle hat):
[LOG] Rufe fetchPublicData auf mit Argumenten: [ 'Suchbegriff' ]
[AUTH] Zugriff gewährt für fetchPublicData
Rufe öffentliche Daten mit Abfrage ab: Suchbegriff
[LOG] Methode fetchPublicData gab zurück: Öffentliche Daten für Abfrage: Suchbegriff
*/
Beachten Sie die Reihenfolge: Bei deleteSensitiveData
wird zuerst Authorization
(unten) ausgeführt, dann umschließt LogCall
(oben) es. Die innere Logik von Authorization
wird zuerst ausgeführt. Bei fetchPublicData
wird zuerst LogCall
(unten) ausgeführt, dann umschließt Authorization
(oben) es. Das bedeutet, dass der LogCall
-Aspekt außerhalb des Authorization
-Aspekts liegt. Dieser Unterschied ist entscheidend für querschnittliche Belange wie Protokollierung oder Fehlerbehandlung, bei denen die Ausführungsreihenfolge das Verhalten erheblich beeinflussen kann.
Ausführungsreihenfolge für verschiedene Ziele
Wenn eine Klasse, ihre Member und Parameter alle Dekoratoren haben, ist die Ausführungsreihenfolge klar definiert:
- Parameterdekoratoren werden zuerst angewendet, für jeden Parameter, beginnend vom letzten bis zum ersten Parameter.
- Dann werden Methoden-, Accessor- oder Eigenschaftsdekoratoren für jeden Member angewendet.
- Zuletzt werden Klassendekoratoren auf die Klasse selbst angewendet.
Innerhalb jeder Kategorie werden mehrere Dekoratoren auf demselben Ziel von unten nach oben (oder von rechts nach links) angewendet.
Beispiel: Vollständige Ausführungsreihenfolge
function log(message: string) {
return function (target: any, propertyKey: string | symbol | undefined, descriptorOrIndex?: PropertyDescriptor | number) {
if (typeof descriptorOrIndex === 'number') {
console.log(`Parameter-Dekorator: ${message} an Parameter #${descriptorOrIndex} von ${String(propertyKey || "constructor")}`);
} else if (typeof propertyKey === 'string' || typeof propertyKey === 'symbol') {
if (descriptorOrIndex && 'value' in descriptorOrIndex && typeof descriptorOrIndex.value === 'function') {
console.log(`Methoden-/Accessor-Dekorator: ${message} an ${String(propertyKey)}`);
} else {
console.log(`Eigenschafts-Dekorator: ${message} an ${String(propertyKey)}`);
}
} else {
console.log(`Klassen-Dekorator: ${message} an ${target.name}`);
}
return descriptorOrIndex; // Deskriptor für Methode/Accessor zurückgeben, undefined für andere
};
}
@log("Klassenebene D")
@log("Klassenebene C")
class MyDecoratedClass {
@log("Statische Eigenschaft A")
static staticProp: string = "";
@log("Instanzeigenschaft B")
instanceProp: number = 0;
@log("Methode D")
@log("Methode C")
myMethod(
@log("Parameter Z") paramZ: string,
@log("Parameter Y") paramY: number
) {
console.log("Methode myMethod ausgeführt.");
}
@log("Getter/Setter F")
get myAccessor() {
return "";
}
set myAccessor(value: string) {
//...
}
constructor() {
console.log("Konstruktor ausgeführt.");
}
}
new MyDecoratedClass();
// Methode aufrufen, um Methodendekorator auszulösen
new MyDecoratedClass().myMethod("hello", 123);
/* Vorhergesagte Ausgabereihenfolge (ungefähr, abhängig von der spezifischen TypeScript-Version und Kompilierung):
Parameter-Dekorator: Parameter Y an Parameter #1 von myMethod
Parameter-Dekorator: Parameter Z an Parameter #0 von myMethod
Eigenschafts-Dekorator: Statische Eigenschaft A an staticProp
Eigenschafts-Dekorator: Instanzeigenschaft B an instanceProp
Methoden-/Accessor-Dekorator: Getter/Setter F an myAccessor
Methoden-/Accessor-Dekorator: Methode C an myMethod
Methoden-/Accessor-Dekorator: Methode D an myMethod
Klassen-Dekorator: Klassenebene C an MyDecoratedClass
Klassen-Dekorator: Klassenebene D an MyDecoratedClass
Konstruktor ausgeführt.
Methode myMethod ausgeführt.
*/
Das genaue Timing der Konsolenausgaben kann je nach Aufrufzeitpunkt eines Konstruktors oder einer Methode leicht variieren, aber die Reihenfolge, in der die Dekorator-Funktionen selbst ausgeführt werden (und somit ihre Nebeneffekte oder zurückgegebenen Werte angewendet werden), folgt den oben genannten Regeln.
Praktische Anwendungen und Designmuster mit Dekoratoren
Dekoratoren, insbesondere in Verbindung mit dem reflect-metadata
-Polyfill, eröffnen ein neues Reich der metadatengesteuerten Programmierung. Dies ermöglicht leistungsstarke Designmuster, die Boilerplate-Code und querschnittliche Belange abstrahieren.
1. Dependency Injection (DI)
Eine der prominentesten Anwendungen von Dekoratoren findet sich in Dependency-Injection-Frameworks (wie Angulars @Injectable()
, @Component()
usw. oder NestJS' umfangreiche Nutzung von DI). Dekoratoren ermöglichen es Ihnen, Abhängigkeiten direkt in Konstruktoren oder an Eigenschaften zu deklarieren, wodurch das Framework die richtigen Dienste automatisch instanziieren und bereitstellen kann.
Beispiel: Vereinfachte Service-Injektion
import "reflect-metadata"; // Unverzichtbar für emitDecoratorMetadata
const INJECTABLE_METADATA_KEY = Symbol("injectable");
const INJECT_METADATA_KEY = Symbol("inject");
function Injectable() {
return function (target: Function) {
Reflect.defineMetadata(INJECTABLE_METADATA_KEY, true, target);
};
}
function Inject(token: any) {
return function (target: Object, propertyKey: string | symbol, parameterIndex: number) {
const existingInjections: any[] = Reflect.getOwnMetadata(INJECT_METADATA_KEY, target, propertyKey) || [];
existingInjections[parameterIndex] = token;
Reflect.defineMetadata(INJECT_METADATA_KEY, existingInjections, target, propertyKey);
};
}
class Container {
private static instances = new Map<any, any>();
static resolve<T>(target: { new (...args: any[]): T }): T {
if (Container.instances.has(target)) {
return Container.instances.get(target);
}
const isInjectable = Reflect.getMetadata(INJECTABLE_METADATA_KEY, target);
if (!isInjectable) {
throw new Error(`Klasse ${target.name} ist nicht als @Injectable markiert.`);
}
// Typen der Konstruktorparameter abrufen (erfordert emitDecoratorMetadata)
const paramTypes: any[] = Reflect.getMetadata("design:paramtypes", target) || [];
const explicitInjections: any[] = Reflect.getMetadata(INJECT_METADATA_KEY, target) || [];
const dependencies = paramTypes.map((paramType, index) => {
// Expliziten @Inject-Token verwenden, falls vorhanden, andernfalls Typ ableiten
const token = explicitInjections[index] || paramType;
if (token === undefined) {
throw new Error(`Parameter am Index ${index} für ${target.name} kann nicht aufgelöst werden. Es könnte eine zirkuläre Abhängigkeit oder ein primitiver Typ ohne explizites @Inject sein.`);
}
return Container.resolve(token);
});
const instance = new target(...dependencies);
Container.instances.set(target, instance);
return instance;
}
}
// Dienste definieren
@Injectable()
class DatabaseService {
connect() {
console.log("Verbindung zur Datenbank wird hergestellt...");
return "DB-Verbindung";
}
}
@Injectable()
class AuthService {
private db: DatabaseService;
constructor(db: DatabaseService) {
this.db = db;
}
login() {
console.log(`AuthService: Authentifizierung über ${this.db.connect()}`);
return "Benutzer angemeldet";
}
}
@Injectable()
class UserService {
private authService: AuthService;
private dbService: DatabaseService; // Beispiel für die Injektion über eine Eigenschaft mit einem benutzerdefinierten Dekorator oder einer Framework-Funktion
constructor(@Inject(AuthService) authService: AuthService,
@Inject(DatabaseService) dbService: DatabaseService) {
this.authService = authService;
this.dbService = dbService;
}
getUserProfile() {
this.authService.login();
this.dbService.connect();
console.log("UserService: Benutzerprofil wird abgerufen...");
return { id: 1, name: "Globaler Benutzer" };
}
}
// Hauptdienst auflösen
console.log("--- UserService wird aufgelöst ---");
const userService = Container.resolve(UserService);
console.log(userService.getUserProfile());
console.log("\n--- AuthService wird aufgelöst (sollte zwischengespeichert sein) ---");
const authService = Container.resolve(AuthService);
authService.login();
Dieses ausführliche Beispiel zeigt, wie die Dekoratoren @Injectable
und @Inject
in Kombination mit reflect-metadata
einem benutzerdefinierten Container
ermöglichen, Abhängigkeiten automatisch aufzulösen und bereitzustellen. Die von TypeScript automatisch emittierten design:paramtypes
-Metadaten (wenn emitDecoratorMetadata
auf true gesetzt ist) sind hier entscheidend.
2. Aspektorientierte Programmierung (AOP)
AOP konzentriert sich auf die Modularisierung von querschnittlichen Belangen (z. B. Protokollierung, Sicherheit, Transaktionen), die sich über mehrere Klassen und Module erstrecken. Dekoratoren sind eine ausgezeichnete Wahl für die Implementierung von AOP-Konzepten in TypeScript.
Beispiel: Protokollierung mit Methodendekorator
Der bereits erwähnte LogCall
-Dekorator ist ein perfektes Beispiel für AOP. Er fügt jeder Methode Protokollierungsverhalten hinzu, ohne den ursprünglichen Code der Methode zu ändern. Dies trennt das "Was zu tun ist" (Geschäftslogik) vom "Wie es zu tun ist" (Protokollierung, Leistungsüberwachung usw.).
function LogMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`[LOG AOP] Betrete Methode: ${String(propertyKey)} mit Argumenten:`, args);
try {
const result = originalMethod.apply(this, args);
console.log(`[LOG AOP] Verlasse Methode: ${String(propertyKey)} mit Ergebnis:`, result);
return result;
} catch (error: any) {
console.error(`[LOG AOP] Fehler in Methode ${String(propertyKey)}:`, error.message);
throw error;
}
};
return descriptor;
}
class PaymentProcessor {
@LogMethod
processPayment(amount: number, currency: string) {
if (amount <= 0) {
throw new Error("Zahlungsbetrag muss positiv sein.");
}
console.log(`Verarbeite Zahlung von ${amount} ${currency}...`);
return `Zahlung von ${amount} ${currency} erfolgreich verarbeitet.`;
}
@LogMethod
refundPayment(transactionId: string) {
console.log(`Erstatte Zahlung für Transaktions-ID: ${transactionId}...`);
return `Rückerstattung für ${transactionId} eingeleitet.`;
}
}
const processor = new PaymentProcessor();
processor.processPayment(100, "USD");
try {
processor.processPayment(-50, "EUR");
} catch (error: any) {
console.error("Fehler abgefangen:", error.message);
}
Dieser Ansatz hält die PaymentProcessor
-Klasse rein auf die Zahlungslogik fokussiert, während der LogMethod
-Dekorator den querschnittlichen Belang der Protokollierung behandelt.
3. Validierung und Transformation
Dekoratoren sind unglaublich nützlich, um Validierungsregeln direkt an Eigenschaften zu definieren oder Daten während der Serialisierung/Deserialisierung zu transformieren.
Beispiel: Datenvalidierung mit Eigenschaftsdekoratoren
Das @Required
-Beispiel hat dies bereits demonstriert. Hier ist ein weiteres Beispiel mit einer Validierung für einen numerischen Bereich.
interface FieldValidationRule {
property: string | symbol;
validator: (value: any) => boolean;
message: string;
}
const fieldValidationRules = new Map<Function, FieldValidationRule[]>();
function addValidationRule(target: Object, propertyKey: string | symbol, validator: (value: any) => boolean, message: string) {
const rules = fieldValidationRules.get(target.constructor) || [];
rules.push({ property: propertyKey, validator, message });
fieldValidationRules.set(target.constructor, rules);
}
function IsPositive(target: Object, propertyKey: string | symbol) {
addValidationRule(target, propertyKey, (value: number) => value > 0, `${String(propertyKey)} muss eine positive Zahl sein.`);
}
function MaxLength(maxLength: number) {
return function (target: Object, propertyKey: string | symbol) {
addValidationRule(target, propertyKey, (value: string) => value.length <= maxLength, `${String(propertyKey)} darf höchstens ${maxLength} Zeichen lang sein.`);
};
}
class Product {
@MaxLength(50)
name: string;
@IsPositive
price: number;
constructor(name: string, price: number) {
this.name = name;
this.price = price;
}
static validate(instance: any): string[] {
const errors: string[] = [];
const rules = fieldValidationRules.get(instance.constructor) || [];
for (const rule of rules) {
if (!rule.validator(instance[rule.property])) {
errors.push(rule.message);
}
}
return errors;
}
}
const product1 = new Product("Laptop", 1200);
console.log("Produkt 1 Fehler:", Product.validate(product1)); // []
const product2 = new Product("Sehr langer Produktname, der die Fünfzig-Zeichen-Grenze zu Testzwecken überschreitet", 50);
console.log("Produkt 2 Fehler:", Product.validate(product2)); // ["name darf höchstens 50 Zeichen lang sein."]
const product3 = new Product("Buch", -10);
console.log("Produkt 3 Fehler:", Product.validate(product3)); // ["price muss eine positive Zahl sein."]
Dieses Setup ermöglicht es Ihnen, Validierungsregeln deklarativ auf Ihren Modelleigenschaften zu definieren, wodurch Ihre Datenmodelle in Bezug auf ihre Einschränkungen selbstbeschreibend werden.
Best Practices und Überlegungen
Obwohl Dekoratoren mächtig sind, sollten sie mit Bedacht eingesetzt werden. Missbrauch kann zu Code führen, der schwerer zu debuggen oder zu verstehen ist.
Wann man Dekoratoren verwenden sollte (und wann nicht)
- Verwenden Sie sie für:
- Querschnittliche Belange: Protokollierung, Caching, Autorisierung, Transaktionsmanagement.
- Metadatendeklaration: Definition von Schemata für ORMs, Validierungsregeln, DI-Konfiguration.
- Framework-Integration: Beim Erstellen oder Verwenden von Frameworks, die Metadaten nutzen.
- Reduzierung von Boilerplate-Code: Abstraktion wiederkehrender Codemuster.
- Vermeiden Sie sie für:
- Einfache Funktionsaufrufe: Wenn ein einfacher Funktionsaufruf das gleiche Ergebnis klar erzielen kann, bevorzugen Sie diesen.
- Geschäftslogik: Dekoratoren sollten die Kern-Geschäftslogik erweitern, nicht definieren.
- Überkomplizierung: Wenn die Verwendung eines Dekorators den Code weniger lesbar oder schwerer zu testen macht, überdenken Sie die Entscheidung.
Leistungsauswirkungen
Dekoratoren werden zur Kompilierzeit (oder Definitionszeit in der JavaScript-Laufzeitumgebung, wenn transpiliert) ausgeführt. Die Transformation oder Metadatensammlung erfolgt, wenn die Klasse/Methode definiert wird, nicht bei jedem Aufruf. Daher ist der Laufzeit-Leistungseinfluss des *Anwendens* von Dekoratoren minimal. Die *Logik innerhalb* Ihrer Dekoratoren kann jedoch einen Leistungseinfluss haben, insbesondere wenn sie bei jedem Methodenaufruf aufwändige Operationen durchführen (z. B. komplexe Berechnungen innerhalb eines Methodendekorators).
Wartbarkeit und Lesbarkeit
Dekoratoren können bei richtiger Anwendung die Lesbarkeit erheblich verbessern, indem sie Boilerplate-Code aus der Hauptlogik entfernen. Wenn sie jedoch komplexe, versteckte Transformationen durchführen, kann das Debugging zu einer Herausforderung werden. Stellen Sie sicher, dass Ihre Dekoratoren gut dokumentiert sind und ihr Verhalten vorhersagbar ist.
Experimenteller Status und Zukunft der Dekoratoren
Es ist wichtig zu wiederholen, dass TypeScript-Dekoratoren auf einem Stage 3 TC39-Vorschlag basieren. Das bedeutet, dass die Spezifikation weitgehend stabil ist, aber noch geringfügige Änderungen erfahren könnte, bevor sie Teil des offiziellen ECMAScript-Standards wird. Frameworks wie Angular haben sie angenommen und setzen auf ihre eventuelle Standardisierung. Dies birgt ein gewisses Risiko, obwohl angesichts ihrer weiten Verbreitung signifikante Breaking Changes unwahrscheinlich sind.
Der TC39-Vorschlag hat sich weiterentwickelt. Die aktuelle Implementierung von TypeScript basiert auf einer älteren Version des Vorschlags. Es gibt eine Unterscheidung zwischen "Legacy Decorators" und "Standard Decorators". Wenn der offizielle Standard landet, wird TypeScript wahrscheinlich seine Implementierung aktualisieren. Für die meisten Entwickler, die Frameworks verwenden, wird dieser Übergang vom Framework selbst verwaltet. Für Bibliotheksautoren könnte es notwendig werden, die subtilen Unterschiede zwischen Legacy- und zukünftigen Standard-Dekoratoren zu verstehen.
Die emitDecoratorMetadata
-Compiler-Option
Diese Option, wenn sie in tsconfig.json
auf true
gesetzt ist, weist den TypeScript-Compiler an, bestimmte Design-Time-Typ-Metadaten in das kompilierte JavaScript zu emittieren. Diese Metadaten umfassen den Typ der Konstruktorparameter (design:paramtypes
), den Rückgabetyp von Methoden (design:returntype
) und den Typ von Eigenschaften (design:type
).
Diese emittierten Metadaten sind nicht Teil der Standard-JavaScript-Laufzeitumgebung. Sie werden typischerweise vom reflect-metadata
-Polyfill konsumiert, das sie dann über die Reflect.getMetadata()
-Funktionen zugänglich macht. Dies ist absolut entscheidend für fortgeschrittene Muster wie Dependency Injection, bei denen ein Container die Typen der Abhängigkeiten kennen muss, die eine Klasse benötigt, ohne explizite Konfiguration.
Fortgeschrittene Muster mit Dekoratoren
Dekoratoren können kombiniert und erweitert werden, um noch anspruchsvollere Muster zu erstellen.
1. Dekoratoren dekorieren (Higher-Order Decorators)
Sie können Dekoratoren erstellen, die andere Dekoratoren modifizieren oder zusammensetzen. Dies ist weniger verbreitet, zeigt aber die funktionale Natur von Dekoratoren.
// Ein Dekorator, der sicherstellt, dass eine Methode protokolliert wird und auch Admin-Rollen erfordert
function AdminAndLoggedMethod() {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Zuerst Authorization anwenden (innen)
Authorization(["admin"])(target, propertyKey, descriptor);
// Dann LogCall anwenden (außen)
LogCall(target, propertyKey, descriptor);
return descriptor; // Den modifizierten Deskriptor zurückgeben
};
}
class AdminPanel {
@AdminAndLoggedMethod()
deleteUserAccount(userId: string) {
console.log(`Lösche Benutzerkonto: ${userId}`);
return `Benutzer ${userId} gelöscht.`;
}
}
const adminPanel = new AdminPanel();
adminPanel.deleteUserAccount("user007");
/* Erwartete Ausgabe (angenommen Admin-Rolle):
[AUTH] Zugriff gewährt für deleteUserAccount
[LOG] Rufe deleteUserAccount auf mit Argumenten: [ 'user007' ]
Lösche Benutzerkonto: user007
[LOG] Methode deleteUserAccount gab zurück: Benutzer user007 gelöscht.
*/
Hier ist AdminAndLoggedMethod
eine Fabrik, die einen Dekorator zurückgibt, und innerhalb dieses Dekorators wendet sie zwei andere Dekoratoren an. Dieses Muster kann komplexe Dekorator-Kompositionen kapseln.
2. Verwendung von Dekoratoren für Mixins
Obwohl TypeScript andere Wege zur Implementierung von Mixins bietet, können Dekoratoren verwendet werden, um Fähigkeiten auf deklarative Weise in Klassen zu injizieren.
function ApplyMixins(constructors: Function[]) {
return function (derivedConstructor: Function) {
constructors.forEach(baseConstructor => {
Object.getOwnPropertyNames(baseConstructor.prototype).forEach(name => {
Object.defineProperty(
derivedConstructor.prototype,
name,
Object.getOwnPropertyDescriptor(baseConstructor.prototype, name) || Object.create(null)
);
});
});
};
}
class Disposable {
isDisposed: boolean = false;
dispose() {
this.isDisposed = true;
console.log("Objekt freigegeben.");
}
}
class Loggable {
log(message: string) {
console.log(`[Loggable] ${message}`);
}
}
@ApplyMixins([Disposable, Loggable])
class MyResource implements Disposable, Loggable {
// Diese Eigenschaften/Methoden werden vom Dekorator injiziert
isDisposed!: boolean;
dispose!: () => void;
log!: (message: string) => void;
constructor(public name: string) {
this.log(`Ressource ${this.name} erstellt.`);
}
cleanUp() {
this.dispose();
this.log(`Ressource ${this.name} bereinigt.`);
}
}
const resource = new MyResource("NetworkConnection");
console.log(`Ist freigegeben: ${resource.isDisposed}`);
resource.cleanUp();
console.log(`Ist freigegeben: ${resource.isDisposed}`);
Dieser @ApplyMixins
-Dekorator kopiert dynamisch Methoden und Eigenschaften von Basiskonstruktoren auf den Prototyp der abgeleiteten Klasse und "mischt" so Funktionalitäten effektiv hinein.
Fazit: Stärkung der modernen TypeScript-Entwicklung
TypeScript-Dekoratoren sind eine mächtige und ausdrucksstarke Funktion, die ein neues Paradigma der metadatengesteuerten und aspektorientierten Programmierung ermöglicht. Sie erlauben Entwicklern, deklarative Verhaltensweisen zu Klassen, Methoden, Eigenschaften, Accessoren und Parametern hinzuzufügen, zu modifizieren und zu erweitern, ohne deren Kernlogik zu verändern. Diese Trennung der Belange führt zu saubererem, wartbarerem und hochgradig wiederverwendbarem Code.
Von der Vereinfachung der Dependency Injection und der Implementierung robuster Validierungssysteme bis hin zum Hinzufügen von querschnittlichen Belangen wie Protokollierung und Leistungsüberwachung bieten Dekoratoren eine elegante Lösung für viele gängige Entwicklungsherausforderungen. Während ihr experimenteller Status zur Vorsicht mahnt, signalisiert ihre weite Verbreitung in großen Frameworks ihren praktischen Wert und ihre zukünftige Relevanz.
Indem Sie TypeScript-Dekoratoren meistern, erhalten Sie ein bedeutendes Werkzeug in Ihrem Arsenal, das es Ihnen ermöglicht, robustere, skalierbarere und intelligentere Anwendungen zu erstellen. Setzen Sie sie verantwortungsbewusst ein, verstehen Sie ihre Mechanik und erschließen Sie eine neue Ebene deklarativer Kraft in Ihren TypeScript-Projekten.