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ść.
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.
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.
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.
Źródła i materiały dodatkowe
- Design Patterns: Elements of Reusable Object-Oriented Software – Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides (1994) str. 251
- SOLID, KISS i DRY
- Wzorce projektowe – czym są i dlaczego warto je znać?
- Chain Of Responsibility (Łańcuch zobowiązań) – Koddlo
- Łańcuch zobowiązań – Refactoring Guru
- Implementing a Chain of Responsibility Design Pattern in Middleware
- Is the Chain of Responsibility used in the .NET Framework?
- Wzorce projektowe C#: Chain of Responsibility i potwory
- Wzorzec projektowy Łańcuch zobowiązań Łańcuch odpowiedzialności, ang. Chain of Responsibility
Zapisz się na mailing i odbierz e-booka
Odbierz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer i realnie zwiększ swoje szanse na rozmowie rekrutacyjnej! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.