Odkryj moc dekoratorów TypeScript w programowaniu metadanych, programowaniu zorientowanym na aspekty i usprawnianiu kodu za pomocą wzorców deklaratywnych. Kompleksowy przewodnik dla globalnych deweloperów.
Dekoratory TypeScript: Opanowanie wzorców programowania metadanych dla solidnych aplikacji
W rozległym krajobrazie nowoczesnego tworzenia oprogramowania, utrzymanie czystych, skalowalnych i zarządzalnych baz kodu jest sprawą najwyższej wagi. TypeScript, dzięki swojemu potężnemu systemowi typów i zaawansowanym funkcjom, dostarcza programistom narzędzi do osiągnięcia tego celu. Wśród jego najbardziej intrygujących i transformujących funkcji znajdują się Dekoratory. Chociaż nadal są funkcją eksperymentalną w momencie pisania tego tekstu (propozycja Stage 3 dla ECMAScript), dekoratory są szeroko stosowane w frameworkach takich jak Angular i TypeORM, fundamentalnie zmieniając sposób, w jaki podchodzimy do wzorców projektowych, programowania metadanych i programowania zorientowanego na aspekty (AOP).
Ten kompleksowy przewodnik zagłębi się w dekoratory TypeScript, badając ich mechanizmy, różne typy, praktyczne zastosowania i najlepsze praktyki. Niezależnie od tego, czy tworzysz aplikacje korporacyjne na dużą skalę, mikrousługi, czy interfejsy internetowe po stronie klienta, zrozumienie dekoratorów pozwoli Ci pisać bardziej deklaratywny, łatwiejszy w utrzymaniu i potężniejszy kod TypeScript.
Zrozumienie podstawowej koncepcji: Co to jest dekorator?
U podstaw dekorator jest specjalnym rodzajem deklaracji, którą można dołączyć do deklaracji klasy, metody, akcesora, właściwości lub parametru. Dekoratory to funkcje, które zwracają nową wartość (lub modyfikują istniejącą) dla celu, który dekorują. Ich głównym celem jest dodawanie metadanych lub zmiana zachowania deklaracji, do której są dołączone, bez bezpośredniej modyfikacji bazowej struktury kodu. Ten zewnętrzny, deklaratywny sposób wzbogacania kodu jest niezwykle potężny.
Traktuj dekoratory jako adnotacje lub etykiety, które nakładasz na fragmenty swojego kodu. Etykiety te mogą być następnie odczytywane lub przetwarzane przez inne części Twojej aplikacji lub przez frameworki, często w czasie wykonywania, w celu zapewnienia dodatkowej funkcjonalności lub konfiguracji.
Składnia dekoratora
Dekoratory są poprzedzone symbolem @
, po którym następuje nazwa funkcji dekoratora. Są one umieszczane bezpośrednio przed deklaracją, którą dekorują.
@MyDecorator
class MyClass {
@AnotherDecorator
myMethod() {
// ...
}
}
Włączanie dekoratorów w TypeScript
Zanim będziesz mógł używać dekoratorów, musisz włączyć opcję kompilatora experimentalDecorators
w swoim pliku tsconfig.json
. Dodatkowo, dla zaawansowanych możliwości odbicia metadanych (często używanych przez frameworki), będziesz również potrzebować emitDecoratorMetadata
i polifillu reflect-metadata
.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
Musisz również zainstalować reflect-metadata
:
npm install reflect-metadata --save
# lub
yarn add reflect-metadata
I zaimportować go na samym początku punktu wejścia aplikacji (np. main.ts
lub app.ts
):
import "reflect-metadata";
// Twój kod aplikacji następuje później
Fabryki dekoratorów: Dostosowanie na wyciągnięcie ręki
Chociaż podstawowy dekorator jest funkcją, często trzeba przekazywać argumenty do dekoratora, aby skonfigurować jego zachowanie. Osiąga się to za pomocą fabryki dekoratorów. Fabryka dekoratorów to funkcja, która zwraca faktyczną funkcję dekoratora. Kiedy stosujesz fabrykę dekoratorów, wywołujesz ją z jej argumentami, a następnie zwraca ona funkcję dekoratora, którą TypeScript stosuje do Twojego kodu.
Tworzenie prostego przykładu fabryki dekoratorów
Stwórzmy fabrykę dla dekoratora Logger
, który może logować komunikaty z różnymi prefiksami.
function Logger(prefix: string) {
return function (target: Function) {
console.log(`[${prefix}] Klasa ${target.name} została zdefiniowana.`);
};
}
@Logger("APP_INIT")
class ApplicationBootstrap {
constructor() {
console.log("Aplikacja się uruchamia...");
}
}
const app = new ApplicationBootstrap();
// Wyjście:
// [APP_INIT] Klasa ApplicationBootstrap została zdefiniowana.
// Aplikacja się uruchamia...
W tym przykładzie Logger("APP_INIT")
jest wywołaniem fabryki dekoratorów. Zwraca ona faktyczną funkcję dekoratora, która przyjmuje target: Function
(konstruktor klasy) jako argument. Pozwala to na dynamiczną konfigurację zachowania dekoratora.
Typy dekoratorów w TypeScript
TypeScript obsługuje pięć różnych typów dekoratorów, z których każdy ma zastosowanie do określonego rodzaju deklaracji. Sygnatura funkcji dekoratora różni się w zależności od kontekstu, w którym jest stosowana.
1. Dekoratory klas
Dekoratory klas są stosowane do deklaracji klas. Funkcja dekoratora otrzymuje konstruktor klasy jako jedyny argument. Dekorator klasy może obserwować, modyfikować, a nawet zastępować definicję klasy.
Sygnatura:
function ClassDecorator(target: Function) { ... }
Wartość zwracana:
Jeśli dekorator klasy zwróci wartość, zastąpi deklarację klasy podanym konstruktorem. Jest to potężna funkcja, często używana do mixinów lub rozszerzeń klas. Jeśli żadna wartość nie zostanie zwrócona, używana jest oryginalna klasa.
Przypadki użycia:
- Rejestrowanie klas w kontenerze wstrzykiwania zależności.
- Stosowanie mixinów lub dodatkowych funkcjonalności do klasy.
- Konfiguracje specyficzne dla frameworków (np. routing w frameworku webowym).
- Dodawanie haków cyklu życia do klas.
Przykład dekoratora klasy: Wstrzykiwanie usługi
Wyobraź sobie prosty scenariusz wstrzykiwania zależności, gdzie chcesz oznaczyć klasę jako "możliwą do wstrzyknięcia" i opcjonalnie podać jej nazwę w kontenerze.
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(`Zarejestrowano usługę: ${serviceName}`);
// Opcjonalnie możesz zwrócić nową klasę tutaj, aby rozszerzyć zachowanie
return class extends constructor {
createdAt = new Date();
// Dodatkowe właściwości lub metody dla wszystkich wstrzykiwanych usług
};
};
}
@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: "Mysz" }];
}
}
console.log("--- Usługi zarejestrowane ---");
console.log(Array.from(InjectableServiceRegistry.keys()));
const userServiceConstructor = InjectableServiceRegistry.get("UserService");
if (userServiceConstructor) {
const userServiceInstance = new userServiceConstructor();
console.log("Użytkownicy:", userServiceInstance.getUsers());
// console.log("UserService utworzony o:", userServiceInstance.createdAt); // Jeśli użyto zwróconej klasy
}
Ten przykład pokazuje, jak dekorator klasy może zarejestrować klasę, a nawet zmodyfikować jej konstruktor. Dekorator Injectable
czyni klasę odkrywalną przez hipotetyczny system wstrzykiwania zależności.
2. Dekoratory metod
Dekoratory metod są stosowane do deklaracji metod. Otrzymują trzy argumenty: obiekt docelowy (dla członków statycznych, funkcję konstruktora; dla członków instancji, prototyp klasy), nazwę metody i deskryptor właściwości metody.
Sygnatura:
function MethodDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }
Wartość zwracana:
Dekorator metody może zwrócić nowy PropertyDescriptor
. Jeśli to zrobi, ten deskryptor zostanie użyty do zdefiniowania metody. Pozwala to na modyfikację lub zastąpienie oryginalnej implementacji metody, co czyni ją niezwykle potężną dla AOP.
Przypadki użycia:
- Logowanie wywołań metod i ich argumentów/wyników.
- Cache'owanie wyników metod w celu poprawy wydajności.
- Stosowanie sprawdzeń autoryzacji przed wykonaniem metody.
- Pomiar czasu wykonania metody.
- Debouncing lub throttling wywołań metod.
Przykład dekoratora metody: Monitorowanie wydajności
Stwórzmy dekorator MeasurePerformance
, aby logować czas wykonania metody.
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(`Metoda "${propertyKey}" wykonana w ${duration.toFixed(2)} ms`);
return result;
};
return descriptor;
}
class DataProcessor {
@MeasurePerformance
processData(data: number[]): number[] {
// Symulacja złożonej, czasochłonnej operacji
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(`Dane dla ID: ${id}`);
}, 500);
});
}
}
const processor = new DataProcessor();
processor.processData([1, 2, 3]);
processor.fetchRemoteData("abc").then(result => console.log(result));
Dekorator MeasurePerformance
opakowuje oryginalną metodę logiką pomiaru czasu, drukując czas wykonania bez zaśmiecania logiki biznesowej w samej metodzie. Jest to klasyczny przykład programowania zorientowanego na aspekty (AOP).
3. Dekoratory akcesorów
Dekoratory akcesorów są stosowane do deklaracji akcesorów (get
i set
). Podobnie jak dekoratory metod, otrzymują one obiekt docelowy, nazwę akcesora i jego deskryptor właściwości.
Sygnatura:
function AccessorDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }
Wartość zwracana:
Dekorator akcesora może zwrócić nowy PropertyDescriptor
, który zostanie użyty do zdefiniowania akcesora.
Przypadki użycia:
- Walidacja podczas ustawiania właściwości.
- Transformacja wartości przed jej ustawieniem lub po jej pobraniu.
- Kontrola uprawnień dostępu do właściwości.
Przykład dekoratora akcesora: Cache'owanie getterów
Stwórzmy dekorator, który cache'uje wynik kosztownego obliczenia getteru.
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] Obliczanie wartości dla ${String(propertyKey)}`);
this[cacheKey] = originalGetter.apply(this);
} else {
console.log(`[Cache Hit] Używanie cache'owanej wartości dla ${String(propertyKey)}`);
}
return this[cacheKey];
};
}
return descriptor;
}
class ReportGenerator {
private data: number[];
constructor(data: number[]) {
this.data = data;
}
// Symuluje kosztowne obliczenie
@CachedGetter
get expensiveSummary(): number {
console.log("Wykonywanie kosztownego obliczenia podsumowania...");
return this.data.reduce((sum, current) => sum + current, 0) / this.data.length;
}
}
const generator = new ReportGenerator([10, 20, 30, 40, 50]);
console.log("Pierwszy dostęp:", generator.expensiveSummary);
console.log("Drugi dostęp:", generator.expensiveSummary);
console.log("Trzeci dostęp:", generator.expensiveSummary);
Ten dekorator zapewnia, że obliczenie getteru expensiveSummary
jest wykonywane tylko raz, kolejne wywołania zwracają cache'owaną wartość. Ten wzorzec jest bardzo przydatny do optymalizacji wydajności, gdy dostęp do właściwości obejmuje ciężkie obliczenia lub wywołania zewnętrzne.
4. Dekoratory właściwości
Dekoratory właściwości są stosowane do deklaracji właściwości. Otrzymują dwa argumenty: obiekt docelowy (dla członków statycznych, funkcję konstruktora; dla członków instancji, prototyp klasy) i nazwę właściwości.
Sygnatura:
function PropertyDecorator(target: Object, propertyKey: string | symbol) { ... }
Wartość zwracana:
Dekoratory właściwości nie mogą zwracać żadnej wartości. Ich głównym zastosowaniem jest rejestrowanie metadanych dotyczących właściwości. Nie mogą one bezpośrednio zmieniać wartości właściwości ani jej deskryptora w momencie dekoracji, ponieważ deskryptor dla właściwości nie jest jeszcze w pełni zdefiniowany, gdy uruchamiane są dekoratory właściwości.
Przypadki użycia:
- Rejestrowanie właściwości do serializacji/deserializacji.
- Stosowanie reguł walidacji do właściwości.
- Ustawianie wartości domyślnych lub konfiguracji dla właściwości.
- Mapowanie kolumn ORM (Object-Relational Mapping) (np.
@Column()
w TypeORM).
Przykład dekoratora właściwości: Walidacja wymaganego pola
Stwórzmy dekorator, aby oznaczyć właściwość jako "wymaganą", a następnie zweryfikować ją w czasie wykonywania.
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)} jest wymagane.`
});
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("Błędy walidacji użytkownika 1:", validate(user1)); // []
const user2 = new UserProfile("", "Smith");
console.log("Błędy walidacji użytkownika 2:", validate(user2)); // ["firstName jest wymagane."]
const user3 = new UserProfile("Alice", "");
console.log("Błędy walidacji użytkownika 3:", validate(user3)); // ["lastName jest wymagane."]
Dekorator Required
po prostu rejestruje regułę walidacji w centralnej mapie validationRules
. Oddzielna funkcja validate
następnie wykorzystuje te metadane do sprawdzania instancji w czasie wykonywania. Ten wzorzec oddziela logikę walidacji od definicji danych, czyniąc ją wielokrotnego użytku i czystą.
5. Dekoratory parametrów
Dekoratory parametrów są stosowane do parametrów w konstruktorze klasy lub metodzie. Otrzymują one trzy argumenty: obiekt docelowy (dla członków statycznych, funkcję konstruktora; dla członków instancji, prototyp klasy), nazwę metody (lub undefined
dla parametrów konstruktora) i indeks kolejności parametru w liście parametrów funkcji.
Sygnatura:
function ParameterDecorator(target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) { ... }
Wartość zwracana:
Dekoratory parametrów nie mogą zwracać żadnej wartości. Podobnie jak dekoratory właściwości, ich główną rolą jest dodawanie metadanych dotyczących parametru.
Przypadki użycia:
- Rejestrowanie typów parametrów dla wstrzykiwania zależności (np.
@Inject()
w Angularze). - Stosowanie walidacji lub transformacji do określonych parametrów.
- Ekstrakcja metadanych dotyczących parametrów żądań API w frameworkach webowych.
Przykład dekoratora parametru: Wstrzykiwanie danych żądania
Zasymulujmy, jak framework webowy może używać dekoratorów parametrów do wstrzykiwania konkretnych danych do parametru metody, takich jak identyfikator użytkownika z żądania.
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);
};
}
// Hipotetyczna funkcja frameworku do wywoływania metody z rozwiązanymi parametrami
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(`Pobieranie użytkownika o ID: ${userId}, Token: ${authToken || "N/A"}`);
return { id: userId, name: "Jane Doe" };
}
deleteUser(@RequestParam("id") userId: string) {
console.log(`Usuwanie użytkownika o ID: ${userId}`);
return { status: "deleted", id: userId };
}
}
const userController = new UserController();
// Symulacja przychodzącego żądania
const mockRequest = {
id: "user123",
token: "abc-123",
someOtherProp: "xyz"
};
console.log("
--- Wykonywanie getUser ---");
executeWithParams(userController, "getUser", mockRequest);
console.log("
--- Wykonywanie deleteUser ---");
executeWithParams(userController, "deleteUser", { id: "user456" });
Ten przykład pokazuje, jak dekoratory parametrów mogą zbierać informacje o wymaganych parametrach metod. Framework może następnie wykorzystać te zebrane metadane do automatycznego rozwiązywania i wstrzykiwania odpowiednich wartości podczas wywoływania metody, znacznie upraszczając logikę kontrolera lub usługi.
Kompozycja dekoratorów i kolejność wykonania
Dekoratory mogą być stosowane w różnych kombinacjach, a zrozumienie ich kolejności wykonania jest kluczowe dla przewidywania zachowania i unikania nieoczekiwanych problemów.
Wiele dekoratorów na jednym celu
Gdy wiele dekoratorów jest stosowanych do jednej deklaracji (np. klasy, metody lub właściwości), są one wykonywane w określonej kolejności: od dołu do góry, lub od prawej do lewej podczas ich ewaluacji. Jednak ich wyniki są stosowane w odwrotnej kolejności.
@DecoratorA
@DecoratorB
class MyClass {
// ...
}
Tutaj DecoratorB
zostanie ewaluowany jako pierwszy, a następnie DecoratorA
. Jeśli modyfikują klasę (np. zwracając nowy konstruktor), modyfikacja z DecoratorA
opakuje lub nałoży się na modyfikację z DecoratorB
.
Przykład: Łączenie dekoratorów metod
Rozważmy dwa dekoratory metod: LogCall
i Authorization
.
function LogCall(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`[LOG] Wywołanie ${String(propertyKey)} z argumentami:`, args);
const result = originalMethod.apply(this, args);
console.log(`[LOG] Metoda ${String(propertyKey)} zwróciła:`, 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"]; // Symulacja pobrania ról bieżącego użytkownika
const authorized = roles.some(role => currentUserRoles.includes(role));
if (!authorized) {
console.warn(`[AUTH] Dostęp zabroniony dla ${String(propertyKey)}. Wymagane role: ${roles.join(", ")}`);
throw new Error("Nieautoryzowany dostęp");
}
console.log(`[AUTH] Dostęp przyznany dla ${String(propertyKey)}`);
return originalMethod.apply(this, args);
};
return descriptor;
};
}
class SecureService {
@LogCall
@Authorization(["admin"])
deleteSensitiveData(id: string) {
console.log(`Usuwanie poufnych danych dla ID: ${id}`);
return `Dane ID ${id} usunięte.`;
}
@Authorization(["user"])
@LogCall // Kolejność zmieniona tutaj
fetchPublicData(query: string) {
console.log(`Pobieranie danych publicznych z zapytaniem: ${query}`);
return `Dane publiczne dla zapytania: ${query}`;
}
}
const service = new SecureService();
try {
console.log("
--- Wywołanie deleteSensitiveData (Użytkownik Admin) ---");
service.deleteSensitiveData("record123");
} catch (error: any) {
console.error(error.message);
}
try {
console.log("
--- Wywołanie fetchPublicData (Użytkownik nie-admin) ---");
// Symulacja próby dostępu nie-administratora do fetchPublicData, która wymaga roli 'user'
const mockUserRoles = ["guest"]; // To spowoduje błąd autoryzacji
// Aby uczynić to dynamicznym, potrzebowalibyśmy systemu DI lub statycznego kontekstu dla ról bieżącego użytkownika.
// Dla uproszczenia, zakładamy, że dekorator Authorization ma dostęp do kontekstu bieżącego użytkownika.
// Dostosujmy dekorator Authorization, aby zawsze zakładał 'admin' dla celów demonstracyjnych,
// aby pierwsze wywołanie się powiodło, a drugie nie, pokazując różne ścieżki.
// Ponowne uruchomienie z rolą użytkownika dla fetchPublicData, aby się powiodło.
// Wyobraźmy sobie, że currentUserRoles w Authorization staje się: ['user']
// Dla tego przykładu, zachowajmy prostotę i pokażmy efekt kolejności.
service.fetchPublicData("szukany termin"); // To wykona Auth -> Log
} catch (error: any) {
console.error(error.message);
}
/* Oczekiwane wyjście dla deleteSensitiveData:
[AUTH] Dostęp przyznany dla deleteSensitiveData
[LOG] Wywołanie deleteSensitiveData z argumentami: [ 'record123' ]
Usuwanie poufnych danych dla ID: record123
[LOG] Metoda deleteSensitiveData zwróciła: Dane ID record123 usunięte.
*/
/* Oczekiwane wyjście dla fetchPublicData (jeśli użytkownik ma rolę 'user'):
[LOG] Wywołanie fetchPublicData z argumentami: [ 'szukany termin' ]
[AUTH] Dostęp przyznany dla fetchPublicData
Pobieranie danych publicznych z zapytaniem: szukany termin
[LOG] Metoda fetchPublicData zwróciła: Dane publiczne dla zapytania: szukany termin
*/
Zwróć uwagę na kolejność: dla deleteSensitiveData
, Authorization
(na dole) działa najpierw, a następnie LogCall
(na górze) opakowuje ją. Wewnętrzna logika Authorization
jest wykonywana najpierw. Dla fetchPublicData
, LogCall
(na dole) działa najpierw, a następnie Authorization
(na górze) opakowuje ją. Oznacza to, że aspekt LogCall
będzie znajdował się poza aspektem Authorization
. Ta różnica jest kluczowa dla aspektów przekrojowych, takich jak logowanie lub obsługa błędów, gdzie kolejność wykonania może znacząco wpływać na zachowanie.
Kolejność wykonania dla różnych celów
Gdy klasa, jej członkowie i parametry mają dekoratory, kolejność wykonania jest jasno zdefiniowana:
- Dekoratory parametrów są stosowane najpierw, dla każdego parametru, od ostatniego parametru do pierwszego.
- Następnie stosowane są Dekoratory metod, akcesorów lub właściwości dla każdego członka.
- Na koniec Dekoratory klas są stosowane do samej klasy.
W ramach każdej kategorii, wiele dekoratorów na tym samym celu jest stosowanych od dołu do góry (lub od prawej do lewej).
Przykład: Pełna kolejność wykonania
function log(message: string) {
return function (target: any, propertyKey: string | symbol | undefined, descriptorOrIndex?: PropertyDescriptor | number) {
if (typeof descriptorOrIndex === 'number') {
console.log(`Dekorator parametru: ${message} na parametrze #${descriptorOrIndex} z ${String(propertyKey || "constructor")}`);
} else if (typeof propertyKey === 'string' || typeof propertyKey === 'symbol') {
if (descriptorOrIndex && 'value' in descriptorOrIndex && typeof descriptorOrIndex.value === 'function') {
console.log(`Dekorator metody/akcesora: ${message} na ${String(propertyKey)}`);
} else {
console.log(`Dekorator właściwości: ${message} na ${String(propertyKey)}`);
}
} else {
console.log(`Dekorator klasy: ${message} na ${target.name}`);
}
return descriptorOrIndex; // Zwróć deskryptor dla metody/akcesora, undefined dla innych
};
}
@log("Poziom klasy D")
@log("Poziom klasy C")
class MyDecoratedClass {
@log("Właściwość statyczna A")
static staticProp: string = "";
@log("Właściwość instancji B")
instanceProp: number = 0;
@log("Metoda D")
@log("Metoda C")
myMethod(
@log("Parametr Z") paramZ: string,
@log("Parametr Y") paramY: number
) {
console.log("Metoda myMethod wykonana.");
}
@log("Akcesor F")
get myAccessor() {
return "";
}
set myAccessor(value: string) {
//...
}
constructor() {
console.log("Konstruktor wykonany.");
}
}
new MyDecoratedClass();
// Wywołaj metodę, aby wywołać dekorator metody
new MyDecoratedClass().myMethod("cześć", 123);
/* Przewidywana kolejność wyjścia (przybliżona, w zależności od konkretnej wersji TypeScript i kompilacji):
Dekorator parametru: Parametr Y na parametrze #1 z myMethod
Dekorator parametru: Parametr Z na parametrze #0 z myMethod
Dekorator właściwości: Właściwość statyczna A na staticProp
Dekorator właściwości: Właściwość instancji B na instanceProp
Dekorator metody/akcesora: Akcesor F na myAccessor
Dekorator metody/akcesora: Metoda C na myMethod
Dekorator metody/akcesora: Metoda D na myMethod
Dekorator klasy: Poziom klasy C na MyDecoratedClass
Dekorator klasy: Poziom klasy D na MyDecoratedClass
Konstruktor wykonany.
Metoda myMethod wykonana.
*/
Dokładny czas logowania konsoli może się nieznacznie różnić w zależności od tego, kiedy konstruktor lub metoda jest wywoływana, ale kolejność, w jakiej same funkcje dekoratorów są wykonywane (i w związku z tym ich efekty uboczne lub zwrócone wartości są stosowane), jest zgodna z powyższymi zasadami.
Praktyczne zastosowania i wzorce projektowe z dekoratorami
Dekoratory, zwłaszcza w połączeniu z polifillem reflect-metadata
, otwierają nowe królestwo programowania sterowanego metadanymi. Pozwala to na potężne wzorce projektowe, które abstrahują od kodu powtarzalnego i aspektów przekrojowych.
1. Wstrzykiwanie zależności (DI)
Jednym z najbardziej znanych zastosowań dekoratorów są frameworki wstrzykiwania zależności (jak @Injectable()
, @Component()
itp. w Angularze lub szerokie użycie DI w NestJS). Dekoratory pozwalają na deklarowanie zależności bezpośrednio na konstruktorach lub właściwościach, umożliwiając frameworkowi automatyczne instancjonowanie i dostarczanie poprawnych usług.
Przykład: Uproszczone wstrzykiwanie usług
import "reflect-metadata"; // Niezbędne dla 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(`Klasa ${target.name} nie jest oznaczona jako @Injectable.`);
}
// Pobierz typy parametrów konstruktora (wymaga emitDecoratorMetadata)
const paramTypes: any[] = Reflect.getMetadata("design:paramtypes", target) || [];
const explicitInjections: any[] = Reflect.getMetadata(INJECT_METADATA_KEY, target) || [];
const dependencies = paramTypes.map((paramType, index) => {
// Użyj jawnego tokenu @Inject, jeśli jest podany, w przeciwnym razie wywnioskuj typ
const token = explicitInjections[index] || paramType;
if (token === undefined) {
throw new Error(`Nie można rozwiązać parametru pod indeksem ${index} dla ${target.name}. Może to być cykliczna zależność lub typ prymitywny bez jawnego @Inject.`);
}
return Container.resolve(token);
});
const instance = new target(...dependencies);
Container.instances.set(target, instance);
return instance;
}
}
// Definicja usług
@Injectable()
class DatabaseService {
connect() {
console.log("Łączenie z bazą danych...");
return "Połączenie z bazą danych";
}
}
@Injectable()
class AuthService {
private db: DatabaseService;
constructor(db: DatabaseService) {
this.db = db;
}
login() {
console.log(`AuthService: Autoryzacja przy użyciu ${this.db.connect()}`);
return "Użytkownik zalogowany";
}
}
@Injectable()
class UserService {
private authService: AuthService;
private dbService: DatabaseService; // Przykład wstrzykiwania przez właściwość przy użyciu niestandardowego dekoratora lub funkcji frameworka
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: Pobieranie profilu użytkownika...");
return { id: 1, name: "Globalny Użytkownik" };
}
}
// Rozwiąż główną usługę
console.log("--- Rozwiązywanie UserService ---");
const userService = Container.resolve(UserService);
console.log(userService.getUserProfile());
console.log("
--- Rozwiązywanie AuthService (powinno być cache'owane) ---");
const authService = Container.resolve(AuthService);
authService.login();
Ten rozbudowany przykład pokazuje, jak dekoratory @Injectable
i @Inject
, w połączeniu z reflect-metadata
, pozwalają niestandardowemu Container
na automatyczne rozwiązywanie i dostarczanie zależności. Metadane design:paramtypes
automatycznie emitowane przez TypeScript (gdy emitDecoratorMetadata
jest włączone) są tutaj kluczowe.
2. Programowanie zorientowane na aspekty (AOP)
AOP koncentruje się na modularizacji aspektów przekrojowych (np. logowanie, bezpieczeństwo, transakcje), które przenikają wiele klas i modułów. Dekoratory doskonale nadają się do implementacji koncepcji AOP w TypeScript.
Przykład: Logowanie za pomocą dekoratora metody
Powracając do dekoratora LogCall
, jest to doskonały przykład AOP. Dodaje on funkcjonalność logowania do dowolnej metody bez modyfikowania oryginalnego kodu metody. Oddziela to „co zrobić” (logika biznesowa) od „jak to zrobić” (logowanie, monitorowanie wydajności itp.).
function LogMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`[LOG AOP] Wejście do metody: ${String(propertyKey)} z argumentami:`, args);
try {
const result = originalMethod.apply(this, args);
console.log(`[LOG AOP] Wyjście z metody: ${String(propertyKey)} z wynikiem:`, result);
return result;
} catch (error: any) {
console.error(`[LOG AOP] Błąd w metodzie ${String(propertyKey)}:`, error.message);
throw error;
}
};
return descriptor;
}
class PaymentProcessor {
@LogMethod
processPayment(amount: number, currency: string) {
if (amount <= 0) {
throw new Error("Kwota płatności musi być dodatnia.");
}
console.log(`Przetwarzanie płatności ${amount} ${currency}...`);
return `Płatność ${amount} ${currency} przetworzona pomyślnie.`;
}
@LogMethod
refundPayment(transactionId: string) {
console.log(`Zwrot płatności dla ID transakcji: ${transactionId}...`);
return `Zwrot zainicjowany dla ${transactionId}.`;
}
}
const processor = new PaymentProcessor();
processor.processPayment(100, "USD");
try {
processor.processPayment(-50, "EUR");
} catch (error: any) {
console.error("Złapany błąd:", error.message);
}
To podejście sprawia, że klasa PaymentProcessor
skupia się wyłącznie na logice płatności, podczas gdy dekorator LogMethod
obsługuje przekrojowy aspekt logowania.
3. Walidacja i transformacja
Dekoratory są niezwykle użyteczne do definiowania reguł walidacji bezpośrednio na właściwościach lub do transformacji danych podczas serializacji/deserializacji.
Przykład: Walidacja danych za pomocą dekoratorów właściwości
Przykład @Required
z wcześniejszego przykładu już to zademonstrował. Oto kolejny przykład z walidacją zakresu numerycznego.
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)} musi być dodatnią liczbą.`);
}
function MaxLength(maxLength: number) {
return function (target: Object, propertyKey: string | symbol) {
addValidationRule(target, propertyKey, (value: string) => value.length <= maxLength, `${String(propertyKey)} musi mieć co najwyżej ${maxLength} znaków.`);
};
}
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("Błędy produktu 1:", Product.validate(product1)); // []
const product2 = new Product("Bardzo długa nazwa produktu przekraczająca limit pięćdziesięciu znaków dla celów testowych", 50);
console.log("Błędy produktu 2:", Product.validate(product2)); // ["name musi mieć co najwyżej 50 znaków."]
const product3 = new Product("Książka", -10);
console.log("Błędy produktu 3:", Product.validate(product3)); // ["price musi być dodatnią liczbą."]
Ten zestaw pozwala deklaratywnie definiować reguły walidacji dla właściwości modeli, czyniąc Twoje modele danych samoopisującymi się pod względem ich ograniczeń.
Najlepsze praktyki i rozważania
Chociaż dekoratory są potężne, należy ich używać rozważnie. Niewłaściwe ich użycie może prowadzić do kodu, który jest trudniejszy do debugowania lub zrozumienia.
Kiedy używać dekoratorów (i kiedy nie)
- Używaj ich do:
- Aspektów przekrojowych: Logowanie, cache'owanie, autoryzacja, zarządzanie transakcjami.
- Deklaracji metadanych: Definiowanie schematów dla ORM, reguł walidacji, konfiguracji DI.
- Integracji z frameworkami: Podczas tworzenia lub używania frameworków wykorzystujących metadane.
- Redukcji boilerplate'u: Abstrahowanie powtarzalnych wzorców kodu.
- Unikaj ich do:
- Prostych wywołań funkcji: Jeśli proste wywołanie funkcji może osiągnąć ten sam wynik w sposób jasny, preferuj to.
- Logiki biznesowej: Dekoratory powinny wzbogacać, a nie definiować, podstawową logikę biznesową.
- Nadmiernego komplikowania: Jeśli użycie dekoratora sprawia, że kod jest mniej czytelny lub trudniejszy do testowania, przemyśl to ponownie.
Implikacje wydajnościowe
Dekoratory są wykonywane w czasie kompilacji (lub w czasie definiowania w środowisku wykonawczym JavaScript, jeśli zostały przetransformowane). Transformacja lub zbieranie metadanych odbywa się, gdy klasa/metoda jest definiowana, a nie przy każdym wywołaniu. Dlatego wpływ dekoratorów na wydajność w czasie wykonywania jest minimalny. Jednak logika wewnątrz dekoratorów może mieć wpływ na wydajność, zwłaszcza jeśli wykonują one kosztowne operacje przy każdym wywołaniu metody (np. złożone obliczenia w dekoratorze metody).
Utrzymanie i czytelność
Dekoratory, gdy są używane prawidłowo, mogą znacząco poprawić czytelność, przenosząc boilerplate poza główną logikę. Jednak jeśli wykonują złożone, ukryte transformacje, debugowanie może stać się trudne. Upewnij się, że Twoje dekoratory są dobrze udokumentowane, a ich zachowanie jest przewidywalne.
Eksperymentalny status i przyszłość dekoratorów
Ważne jest, aby powtórzyć, że dekoratory TypeScript opierają się na propozycji TC39 Stage 3. Oznacza to, że specyfikacja jest w dużej mierze stabilna, ale może jeszcze ulec niewielkim zmianom, zanim stanie się częścią oficjalnego standardu ECMAScript. Frameworki takie jak Angular przyjęły je, stawiając na ich ostateczną standaryzację. Oznacza to pewien poziom ryzyka, chociaż biorąc pod uwagę ich szerokie zastosowanie, znaczące łamiące zmiany są mało prawdopodobne.
Propozycja TC39 ewoluowała. Implementacja TypeScript jest oparta na starszej wersji propozycji. Istnieje rozróżnienie między „Dekoratorami dziedziczonymi” a „Standardowymi Dekoratorami”. Gdy pojawi się oficjalny standard, TypeScript prawdopodobnie zaktualizuje swoją implementację. Dla większości programistów korzystających z frameworków, ta transformacja zostanie zarządzana przez sam framework. Dla autorów bibliotek, zrozumienie subtelnych różnic między dekoratorami dziedziczonymi a przyszłymi standardowymi może stać się konieczne.
Opcja kompilatora emitDecoratorMetadata
Ta opcja, gdy ustawiona na true
w tsconfig.json
, instruuje kompilator TypeScript, aby emitował pewne metadane typów czasu projektowania do skompilowanego kodu JavaScript. Metadane te obejmują typ parametrów konstruktora (design:paramtypes
), typ zwracany przez metody (design:returntype
) i typ właściwości (design:type
).
Te emitowane metadane nie są częścią standardowego środowiska wykonawczego JavaScript. Są one zazwyczaj konsumowane przez polifill reflect-metadata
, który następnie udostępnia je za pomocą funkcji Reflect.getMetadata()
. Jest to absolutnie kluczowe dla zaawansowanych wzorców, takich jak wstrzykiwanie zależności, gdzie kontener musi znać typy zależności, których wymaga klasa, bez jawnej konfiguracji.
Zaawansowane wzorce z dekoratorami
Dekoratory można łączyć i rozszerzać, aby budować jeszcze bardziej wyrafinowane wzorce.
1. Dekorowanie dekoratorów (dekoratory wyższego rzędu)
Możesz tworzyć dekoratory, które modyfikują lub komponują inne dekoratory. Jest to mniej powszechne, ale demonstruje funkcjonalną naturę dekoratorów.
// Dekorator, który zapewnia, że metoda jest logowana i wymaga również ról administratora
function AdminAndLoggedMethod() {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Zastosuj autoryzację najpierw (wewnętrznie)
Authorization(["admin"])(target, propertyKey, descriptor);
// Następnie zastosuj LogCall (zewnętrznie)
LogCall(target, propertyKey, descriptor);
return descriptor; // Zwróć zmodyfikowany deskryptor
};
}
class AdminPanel {
@AdminAndLoggedMethod()
deleteUserAccount(userId: string) {
console.log(`Usuwanie konta użytkownika: ${userId}`);
return `Użytkownik ${userId} usunięty.`;
}
}
const adminPanel = new AdminPanel();
adminPanel.deleteUserAccount("user007");
/* Oczekiwane wyjście (zakładając rolę administratora):
[AUTH] Dostęp przyznany dla deleteUserAccount
[LOG] Wywołanie deleteUserAccount z argumentami: [ 'user007' ]
Usuwanie konta użytkownika: user007
[LOG] Metoda deleteUserAccount zwróciła: Użytkownik user007 usunięty.
*/
Tutaj AdminAndLoggedMethod
jest fabryką, która zwraca dekorator, a wewnątrz tego dekoratora stosuje dwa inne dekoratory. Ten wzorzec może enkapsulować złożone kompozycje dekoratorów.
2. Używanie dekoratorów do mixinów
Chociaż TypeScript oferuje inne sposoby implementacji mixinów, dekoratory mogą być używane do deklaratywnego wstrzykiwania możliwości do klas.
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("Obiekt został zwolniony.");
}
}
class Loggable {
log(message: string) {
console.log(`[Loggable] ${message}`);
}
}
@ApplyMixins([Disposable, Loggable])
class MyResource implements Disposable, Loggable {
// Te właściwości/metody są wstrzykiwane przez dekorator
isDisposed!: boolean;
dispose!: () => void;
log!: (message: string) => void;
constructor(public name: string) {
this.log(`Zasób ${this.name} utworzony.`);
}
cleanUp() {
this.dispose();
this.log(`Zasób ${this.name} został posprzątany.`);
}
}
const resource = new MyResource("PołączenieSieciowe");
console.log(`Czy zwolniony: ${resource.isDisposed}`);
resource.cleanUp();
console.log(`Czy zwolniony: ${resource.isDisposed}`);
Ten dekorator @ApplyMixins
dynamicznie kopiuje metody i właściwości z konstruktorów bazowych do prototypu klasy pochodnej, skutecznie "mieszając" funkcjonalności.
Wniosek: Wzmacnianie nowoczesnego rozwoju TypeScript
Dekoratory TypeScript to potężna i ekspresywna funkcja, która umożliwia nowy paradygmat programowania sterowanego metadanymi i zorientowanego na aspekty. Pozwalają one programistom wzbogacać, modyfikować i dodawać deklaratywne zachowania do klas, metod, właściwości, akcesorów i parametrów bez zmiany ich podstawowej logiki. To rozdzielenie odpowiedzialności prowadzi do czystszego, łatwiejszego w utrzymaniu i wysoce reużywalnego kodu.
Od upraszczania wstrzykiwania zależności i implementowania solidnych systemów walidacji, po dodawanie aspektów przekrojowych, takich jak logowanie i monitorowanie wydajności, dekoratory zapewniają eleganckie rozwiązanie wielu powszechnych wyzwań w tworzeniu oprogramowania. Chociaż ich eksperymentalny status uzasadnia świadomość, ich szerokie przyjęcie w głównych frameworkach oznacza ich praktyczną wartość i przyszłą trafność.
Opanowując dekoratory TypeScript, zyskujesz znaczące narzędzie w swoim arsenale, umożliwiające budowanie bardziej solidnych, skalowalnych i inteligentnych aplikacji. Przyjmij je odpowiedzialnie, zrozum ich mechanizmy i odblokuj nowy poziom deklaratywnej mocy w swoich projektach TypeScript.