Wzorzec projektowy Dekorator - okładka

Wzorzec projektowy Dekorator

Opublikowano Kategorie Czysty kodCzas czytania 8min

Gdy po raz pierwszy zobaczyłem działanie wzorca projektowego Dekorator w praktyce, moja reakcja była mniej więcej taka jak reakcja w niegdyś popularnej reklamie pasztetu. W tym wpisie pokażę Ci dlaczego. Dowiesz się jaka idea stoi za wzorcem Dekorator i jak go zaimplementować w TypeScript. Poświęcę też kilka słów procesowi wdrożenia dekoratorów do standardu języka JavaScript.

Ten wpis jest kolejnym wpisem z serii o wzorcach projektowych. Jeśli chcesz poznać inne wzorce projektowe lub dowiedzieć się czym są wzorce, to koniecznie sprawdź mój wpis o wzorcach projektowych.

Charakterystyka wzorca projektowego Dekorator

Dekorator jest jednym ze strukturalnych wzorców projektowych. Jego zadaniem jest „udekorowanie” danej klasy, metody właściwości czy parametru. Przez „dekorowanie”  rozumiane jest rozszerzenie funkcjonalności dekorowanych komponentów w dynamiczny sposób. Ponadto dekoratory pozwalają na uniknięcie dziedziczenia, przez co fani zasady composition over inheritance (do których się zaliczam) powinni być zadowoleni.

Chcąc znaleźć analogie do życia codziennego, nie trzeba długo szukać:

  • dodawanie składników (mleko, cukier) do kawy;
  • dodawanie składników na pizzę;
  • pakowanie przedmiotu w kilka kartonowych pudełek;
  • matrioszki;
  • dekorowanie choinki na święta.

Poniżej przedstawiony został prosty diagram obrazujący ideę wzorca projektowego Dekorator.

Wzorzec projektowy dekorator - diagram

Wzorzec Dekorator pozwala na wielokrotne dekorowanie tego samego obiektu, przez co możliwe jest dynamiczne konstruowanie złożonych komponentów, z zachowaniem prostoty i spełniając jednocześnie zasadę Single Responsibility Principle. Nieistotne z punktu widzenia logiki biznesowej elementy aplikacji mogą zostać wydelegowane do dekoratorów.

Mówiąc o zaletach, warto wspomnieć też o wadach. Fragmenty kodu wykorzystujące kilka dekoratorów mogą zachowywać się inaczej lub nie działać poprawnie w zależności od kolejności dekorowania. Przeanalizuj poniższy przykład. Pierwszym wywołanym dekoratorem jest ten fizycznie najbliższy słowu kluczowemu function, a zachowanie udekorowanej funkcji jest podobne do zachowania złożeń funkcji w matematyce.


@make_bold() // Wraps text in <b> tag.
@add_prefix() // Adds [PREFIX] prefix.
function hello() {
    return 'Hello, World!';
}

@add_prefix()
@make_bold()
function hello2() {
    return 'Hello, World!';
}

console.log( hello() ) // '<b>[PREFIX]Hello, World!</b>'
console.log( hello2() ) // '[PREFIX]<b>Hello, World!</b>'

Funkcje dekorujące w powyższym przykładzie zaczynają się znakiem @. Jest to dość często spotykany syntactic sugar. Nie jest to konieczne, choć niewątpliwie zwiększa czytelność.  Inna, równie często spotykana w programowaniu obiektowym implementacja dekoratora, wykorzystuje przekazanie instancji obiektu do udekorowania jako parametr klasy dekorującej.


const decoratedHandler = new BasicAuthDecorator(
    new HttpGetHandlerDecorator(
        new Handler()
    )
);

Implementując dekoratory, warto zwrócić uwagę na czytelne nazwy. Dobrze dobrana nazwa dekoratora pozwala stwierdzić, co on robi. Dzięki temu nie ma konieczności zaglądania do niego.

Dekorowanie może również przysporzyć problemów podczas testowania dekorowanego fragmentu kodu. Tworząc testy dla funkcji hellohello2 konieczne będzie uwzględnienie działania dekoratorów w testowanym kodzie.

Przykłady wykorzystania wzorca Dekorator w praktyce

Szukanie przypadków użycia Dekoratora w programowaniu również nie jest szczególnie trudnym wyzwaniem. Przykładem frameworka czerpiącego z zalet wzorca Dekorator pełnymi garściami jest NestJS. Dla niewtajemniczonych w środowisko Node.js NestJS jest jednym z popularniejszych rozwiązań dedykowanych tworzeniu aplikacji backendowych w Node.js. Twórcy NestJS wykorzystują Dekoratory zarówno do dynamicznego rozszerzania tworzonych komponentów, jak i do unikania dziedziczenia. Fajnie to widać na przykładzie kodu przykładowego kontrolera.


import { Controller, Get } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Get()
  findAll(): string {
    return 'This action returns all cats';
  }
}

W NestJS Dekoratory rozpoczynają się znakiem @ i definiowane są nad linią, której dotyczą. Bazowa funkcjonalność kontrolera została ukryta w dekoratorze @Controller przyjmującego nazwę zasobu jako parametr. Dzięki temu rejestracja klasy CatsController w aplikacji i przypisanie kontrolerowi wymaganych metadanych nie wymaga tworzenia klasy bazowej Controller i dziedziczenia po niej.

Decorators associate classes with required metadata and enable Nest to create a routing map

Z kolei dekorator @Get rozszerza metodę findAll o informację o wykorzystywanej metodzie HTTP i tworzy handler dla dedykowanego endpointu HTTP.

The @Get() HTTP request method decorator before the findAll() method tells Nest to create a handler for a specific endpoint for HTTP requests.

Zachęcam Cię do sprawdzenia strony z dokumentacją dla kontrolerów w NestJS, jak również dokumentacji NestJS w ogóle. Nawet jeśli nie interesuje Cię sam framework, to przykłady w dokumentacji idealnie oddają ideę tego wzorca projektowego.

Dekoratory w JavaScript i TypeScript

Pierwsze wzmianki o chęci wprowadzenia dekoratorów jako standardu języka JavaScript pojawiły się w repozytorium TC39 już w 2017 roku. Na moment publikacji tego artykułu, według repozytorium TC39, dekoratory wciąż są w fazie kandydata (stage 3).

Chcąc skorzystać z dekoratorów w TypeScript, wystarczy włączyć je, korzystając z opcji konfiguracyjnej experimentalDecorators. Możesz przekazać ją przy transpilacji lub dodać klucz z wartością true w pliku tsconfig.json. Po włączeniu dekoratorów można z nich już korzystać, tak jak to opisano w dokumentacji TypeScript. Poniżej znajdziesz praktyczny przykład prostego Handlera, którego metoda handle została udekorowana metodą logującą dane wejściowe.


function logParams() {
  return function (
    target: unknown,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const originalMethod = descriptor.value;

    descriptor.value = function ( ...args: unknown[] ) {
      console.log(
        'Handler called with params:',
        args[ 0 ]
      );

      return originalMethod.apply( this, args );
    };
  };
}
 
class Handler {
  @logParams()
  handle( params: Record<string, unknown> ) {
    params.bar = 'baz';

    console.log( params );
  }
}

const handler = new Handler();

handler.handle( { foo: 'bar' } );

Funkcja zwracana przez funkcję dekorującą ma przekazane parametry, pozwalające na uzyskanie informacji skąd dekorator został wywołany. W przedstawionym przykładzie target to obiekt klasy Handler, propertyKey to nazwa udekorowanej metody a descriptor jest instancją property descriptora metody handle. Zwróć szczególną uwagę, że dekorator zwraca funkcję, która zostanie wywołana w momencie dekorowania. Wszystko powyżej słowa kluczowego return zostanie wywołane przed udekorowaniem metody handle. Przeanalizuj poniższy przykład i zobacz, w jakiej kolejności logowane są poszczególne wiadomości.


function decorator1() {
  console.log( 'decorator1 outer' );
 
  return function (
  target: unknown,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
    console.log( 'decorator1 inner' );
  };
}
 
function decorator2() {
  console.log( 'decorator2 outer' );
  
  return function (
    target: unknown,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    console.log( 'decorator2 inner' );
  };
}
 
class Handler {
  @decorator1()
  @decorator2()
  handle() {
    console.log( 'handler' );
  }
}

const handler = new Handler();

handler.handle();

// "decorator1 outer" 
// "decorator2 outer" 
// "decorator2 inner" 
// "decorator1 inner" 
// "handler" 

Aby udekorować parametry metod i właściwości klas w TypeScript, niezbędne będzie skorzystanie z metadata API, które na moment publikacji tego artykułu również znajduje się w fazie kandydata. Aby skorzystać z metadata API konieczna będzie instalacja biblioteki reflect-metadata, która jest polyfillem na wspomniane API. By skorzystać z metadata API konieczne będzie włączenie go flagą emitDecoratorMetadata w konfiguracji projektu lub przy transpilacji. Po zaimportowaniu polyfilla klauzulą import "reflect-metadata" możliwości przy definiowaniu dekoratorów są nieco większe. Dekorowanie parametrów i właściwości pozwala np. na przeniesienie ich walidacji do dekoratora. Innym praktycznym przykładem wykorzystania dekoratorów, z którym spotykam się na co dzień, jest definiowanie struktury wiadomości dla wiadomości serializowanych do formatu zgodnego z Protocol Buffers. Dekoratory wykorzystywane są do zdefiniowania indeksów pól oraz ich typów. Dzięki temu możliwa jest serializacja, przesłanie wiadomości z punktu A do punktu B, a następnie deserializacja wiadomości.

Podsumowanie

Mam nadzieję, że jeśli nie miałeś/aś jeszcze okazji skorzystać z dekoratorów, to ten wpis pokazał Ci, że warto dać im szansę. Przypadków, gdzie można wykorzystać Dekoratora w pracy programisty, da się znaleźć mnóstwo. Ciekawi mnie, jakie Ty znalazłeś/aś, daj znać o tym w komentarzu.

Książkę Design Patterns: Elements of Reusable Object-Oriented Software możesz kupić klikając w TEN link. Kupując z tego linku, wspierasz rozwój bloga.

Materiały dodatkowe i źródła

Dominik Szczepaniak

Zawodowo Senior Software Engineer w CKSource. Prywatnie bloger, fan włoskiej kuchni, miłośnik jazdy na rowerze i treningu siłowego.

Inne wpisy, które mogą Cię zainteresować

Zapisz się na mailing i odbierz e-booka

Zapisując się na mój mailing, otrzymasz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.

Subscribe
Powiadom o
guest

0 komentarzy
Inline Feedbacks
View all comments