Wzorzec Chain of Responsibility - okładka

Wzorzec projektowy Chain of Responsibility

Opublikowano Kategorie Czysty kodCzas czytania 7min

Wzorzec projektowy Chain of Responsibility lub też Łańcuch zobowiązań to jeden ze wzorców projektowych opisanych przez Gang of Four. Został przez nich zakwalifikowany do kategorii wzorców behawioralnych. W tym artykule przedstawię Ci zasadę działania tego wzorca oraz jakie przykładowe zastosowania można dla niego znaleźć.

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.

Opis wzorca Chain of Responsibility

W przypadku wzorca Chain of Responsibility już sama jego nazwa mówi sporo. Zasadę działania wzorca można przedstawić w postaci łańcucha, gdzie każde z ogniw łańcucha stanowi osobną odpowiedzialność.

Diagram przedstawiający działanie wzorca Chain of Responsibility

Celem takiego zabiegu jest wydzielenie pojedynczych odpowiedzialności do osobnych ogniw łańcucha. Daje to kilka zalet. Pierwsza z nich to zachowanie zasady pojedynczej odpowiedzialności (SRP). Druga z nich to możliwość rozszerzania łańcucha o kolejne ogniwa bez konieczności ingerencji w już istniejące. Spełniona tu zostaje zasada otwarte-zamknięte (OCP). Jeśli te dwie zasady są Ci obce, to zachęcam Cię do zapoznania się z artykułem o zasadach SOLID. Spełnienie tych dwóch zasad sprawia, że takie rozwiązanie łatwo może być rozszerzone o kolejne możliwości.

Intencję omawianego wzorca Gang of Four definiuje następująco:

Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request. Chain the receiving objects and pass the request along the chain until an object handles it.

Przykłady zastosowania

Pierwszym przykładem wykorzystania, jaki przychodzi mi do głowy, jest mechanizm middleware, znany z takich frameworków jak Express.js czy Next.js. Middleware-y pozwalają na zaimplementowanie dodatkowych akcji przed wywołaniem logiki przypisanej do odpowiedniej ścieżki. Może to być na przykład uwierzytelnienie, autoryzacja czy walidacja. Możliwości jest tu sporo, a ogranicza nas tu głównie kreatywność. Flow w przykładowej aplikacji, po wysłaniu zapytania pod określoną ścieżkę mógłby wyglądać następująco.

Przykład wykorzystania wzorca Chain of Responsibility w aplikacjach HTTP

Każdy middleware to pojedyncze ogniwo łańcucha. Dowolne ogniwo może zdecydować o zerwaniu łańcucha, np. w sytuacji, gdy użytkownik próbuje wykonać akcję w systemie, do której wykonania nie ma uprawnień. Skutkować to wtedy błędem a pozostałe middleware-y nie zostaną wywołane. Middleware może też po prostu przekazać żądanie dalej np. w sytuacji, gdy użytkownik odpytuje publicznie dostępny endpoint bez parametrów.

Każdy middleware (ogniwo) ma referencję do kolejnego ogniwa. Przykładowo w Express.js jest to parametr next:


app.use( ( req, res, next ) => {
  console.log( 'Time:', Date.now() );
  next();
} )

Innym przykładem zastosowania, często spotykanym w literaturze jest przepływ zdarzeń w kodzie interfejsów graficznych. Jeśli np. zarejestrowano zdarzenie kliknięcia przycisku w modalu, to zdarzenie kliknięcia przycisku jest również zdarzeniem kliknięcia okna modalu, co możliwia przekazanie zdarzenia i ewentualnego kontekstu do kolejnych elementów UI. Dany łańcuch ogniwa może obsłużyć zdarzenie, przekazać je dalej lub zerwać łańcuch.

Problemy z wzorcem Chain of Responsibility

Główny problemem, jaki dostrzegam we wzorcu Chain of Responsibility jest to, że może potencjalny zysk w postaci niskiego couplingu może (ale nie musi) zostać zniweczony przez implementację samych ogniw. Przeanalizuj kod z przykładową implementacją dwóch middleware. Aplikacja w momencie wywołania ścieżki wykonuje metodę exec dla zarejestrowanych middleware-ów zgodnie z kolejnością ich rejestracji.


class AuthenticationMiddleware {
  public constructor(
    private readonly _authService: IAuthService
  ) {}
  
  public async exec( req, res, next ) {
    const user: IUser = await this._authService.authenticate(
      req.headers.securityToken
    );
    
    req.user = user;
    next();
  }
}

class PermissionValidationMiddleware {
  public constructor(
    private readonly _permissionValidationService: IPermissionValidationService
  ) {}
  
  public async exec( req, res, next ) {
    await this._permissionValidationService.validate(
      req.handler,
      req.user
    );
    
    next();
  }
}

W powyższym przykładzie pierwszy middleware weryfikuje securityToken otrzymany w nagłówku zapytania i na jego podstawie przypisuje obiekt użytkownika do obiektu requestu. Drugi middleware weryfikuje, czy dany użytkownik ma uprawnienia do wywołania danej akcji w systemie.

Problem, jaki istnieje w powyższym przykładzie to coupling na poziomie logiki kodu. Zachodzi tutaj konieczność rejestracji middleware-ów w określonej kolejności i wywołania logiki zawartej w AuthenticationMiddleware przed wywołaniem kodu z PermissionValidationMiddleware. Jest tak, ponieważ drugi middleware bazuje na obiekcie user, który przypisywany do obiektu requestu jest w pierwszym middleware. Jest to logiczne, by rozdzielić odpowiedzialności uwierzytelniania i autoryzacji jednak wymaga to wtedy wywołania (rejestracji) middleware-ów w określonej kolejności.

Rozwiązań widzę kilka. Jednym sposobem jest dodatkowy warunek sprawdzający, czy obiekt user został przypisany do obiektu requestu. Wymusi to zachowanie określonej kolejności rejestracji middleware-ów. Można również połączyć dwa middleware w jeden i stracić główną zaletę, jaką daje omawiany w tym artykule wzorzec. Można również spróbować dostosować logikę PermissionValidationService tak, by metoda validate oczekiwała przekazania tokenu zamiast obiektu user. Wtedy obiekt user mógłby być pozyskiwany bezpośrednio w kodzie klasy PermissionValidationService np. z wykorzystaniem wstrzykniętej instancji AuthService. W praktyce jednak powoduje to przeniesienie logiki z AuthenticationMiddleware do PermissionValidationService, co również nie wygląda na idealne rozwiązanie. Jeśli masz inne pomysły na rozwiązanie takiego przypadku, to daj znać w komentarzu 🙂

Dodatkowy problem, jaki widzę we wzorcu Chain of Responsibility, to brak gwarancji, że każde ogniwo zostanie wywołane. Załóżmy, że po dwóch przedstawionych wcześniej middleware-ach wołany jest kolejny, który loguje informację o otrzymaniu zapytania. Jeśli użytkownik z niepoprawnym tokenem odpyta aplikację, to w logach nie będzie o tym informacji. Rozwiązaniem jest tutaj zamiana kolejności rejestracji middleware-ów. Nie zawsze jednak problem będzie tak oczywisty i prosty do rozwiązania. Nie zawsze również taki sposób działania opisanego mechanizmu będzie stanowił problem. Może to również stanowić zaletę w sytuacji, gdy np. któryś z kolei middleware jest zasobożerny lub istotnie obciąża aplikację. Wtedy fakt jego niewywołania dla nieuwierzytelnionego użytkownika będzie pożądany.

Podsumowanie

Dwa przykłady zastosowania wzorca Chain of Responsibility, które przedstawiałem, dość często występują w literaturze. Zarówno z jednym, jak i drugim miałem styczność w swojej pracy. Bardzo mnie ciekawi jednak jakie inne przykłady wykorzystania tego wzorca ty znasz. Jeśli podzielisz się tym w komentarzu, przyda się to nie tylko mi, ale też innym czytelnikom. Gorąco zachęcam też do zapoznania się ze źródłami i materiałami dodatkowymi, w szczególności z pozycją autorstwa Gang of Four.

Źródła i materiały dodatkowe

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