# Chain of responsibility
Keďže sme si ukázali, že modularita má pre náš kód množstvo výhod, v jej zvyšovaní budeme pokračovať aj v tejto kapitole.
Predstavme si, že máme nejakú úlohu. My na jej riešenie vypracujeme Plán A, no v prípade núdze potrebujeme aj zálohu, preto máme pripravený aj Plán B. Keď príde na riešenie úlohy, Plán A môže byť úspešný a nepotrebujeme už nič viacej. No niekedy sa stane, že pre jeho neúspech siahneme po záložnom Pláne B. Ten môže opäť uspieť, resp. zlyhať. V prípade neúspechu však už niet alternatívy, preto by nám neostalo nič iné, len priznať definitívnu porážku.
Reťazec zodpovednosti (angl. chain of responsibility) je práve ten návrhový vzor, ktorý rieši delegáciu problému na ďalší spoj v reťazci, predčasné skončenie delegácie a taktiež aj ošetrenie posledného spoja.
# Modelová situácia
Pri pracovaní s dátami od používateľa platí dôležité pravidlo - nikdy never vstupu. Keď vám používateľ pošle na server dáta, vy sa musíte uistiť o ich správnosti a integrite, než ich budete ďalej spracovávať a ukladať.
Poďme sa pozrieť na jednoduchý príklad, ktorý sme tu už mali - blogovací systém. Začnime dátovou štruktúrou, ktorá bude reprezentovať používateľom vytvorený článok:
public class Article {
private final String author;
private final String title;
private final String content;
private final String slug;
private final Set<String> categories;
// Constructor and getters omitted for brevity
}
Pre atribúty tejto štruktúry platí nasledovné:
Atribút | Príklad | Podmienky |
---|---|---|
author | Jozef Mrkvička | Nenulový, neprázdny |
title | Môj prvý článok | Nenulový, neprázdny |
content | Toto je môj prvý článok | Nenulový, neprázdny |
slug | moj-prvy-clanok | Nenulový, neprázdny, regex: ^[a-z0-9-]+$ |
categories | [personal, food] | Nenulový, neprázdny, hodnoty len z: uncategorized, personal, food |
Ak by sme chceli vytvoriť validačnú funkciu len s použitím podmienok, zistili by sme, že je to neprehľadné, náročné na údržbu a ťažkopádne rozšíriteľné:
public boolean validateArticle(Article article) {
if (article == null) {
return false;
}
if (article.getAuthor() == null || article.getAuthor().isEmpty()) {
return false;
}
if (article.getTitle() == null || article.getTitle().isEmpty()) {
return false;
}
if (article.getContent() == null || article.getContent().isEmpty()) {
return false;
}
if (article.getSlug() == null || !article.getSlug().matches("^[a-z0-9-]+$")) {
return false;
}
Set<String> categories = Set.of("uncategorized", "personal", "food");
if (article.getCategories() == null || categories.containsAll(article.getCategories())) {
return false;
}
return true;
}
Poďme sa preto snažiť vytvoriť validátor, ktorému nadefinujeme pravidlá a následne bude môcť konzumovať objekt článku, ktorý overí jeho validitu.
Začnime veľmi jednoducho. V štandardnej knižnici Javy máme triedu Predicate<T>
, ktorá akceptuje atribút typu T
a vráti boolovskú hodnotu. Nebudeme však používať priamo ju, ale zabalíme ju do triedy Validator<T>
. Implementácia by mohla vyzerať nasledovne:
public class Validator<T> {
private final Predicate<T> predicate;
public Validator(Predicate<T> predicate) {
this.predicate = Objects.requireNonNull(predicate);
}
}
Samozrejme, predikát musíme vedieť aj zavolať. Preto vytvoríme metódu validate
, ktorá mu bude toto volanie delegovať:
public boolean validate(T element) {
return predicate.test(element);
}
Túto zatiaľ primitívnu implementáciu si môžeme vyskúšať v JShell-i:
> Validator<String> validator = new Validator<String>(string -> string != null);
Defined field Validator<String> validator = sk.tuke.fei.kpi.oop.chapters.behavioral.chainOfResponsibility.Validator@329c3aeb
> validator.validate(null);
false
> validator.validate("string");
true
Aby ste zápis o niečo málo skrášlili (a aby sme sa trošku pripravili na ďalšie rozšírenie), konštruktor urobíme privátnym a skryjeme ho do novej statickej factory metódy rule
:
public class Validator<T> {
private Validator(Predicate<T> predicate) {
this.predicate = Objects.requireNonNull(predicate);
}
public static <T> Validator<T> rule(Predicate<T> validator) {
return new Validator<>(validator);
}
// rest omitted for brevity
}
Vytvorenie validátora potom bude vyzerať nasledovne:
> Validator<String> validator = Validator.<String>rule(string -> string != null);
Defined field Validator<String> validator = sk.tuke.fei.kpi.oop.chapters.behavioral.chainOfResponsibility.Validator@329c3aeb
> validator.validate(null);
false
> validator.validate("string");
true
Poznámka
Lambdu string -> string != null
, kontrolujúcu, či objekt nenabúda hodnotu nulovú referenciu, vieme nahradiť referenciou na statickú metódu Objects::nonNull
, ktorá je súčasťou štandardnej knižnice Javy. Tým sa nám zápis ešte o niečo skrášli na tvar Validator.<String>rule(Objects::nonNull)
.
V tejto chvíli by sme sa mohli pýtať, načo nám je dobrý takýto validátor, ktorý vlastne pôvodný problém len sťažuje. A mali by sme pravdu, ak ho ďalej nevylepšíme, je nám zbytočný. V tomto kroku preto aplikujeme návrhový vzor chain of responsibility, ktorý nám umožní spojiť viacero validátorov dokopy.
V princípe musíme implementovať akúsi verziu spájaného zoznamu. To znamená, že každý validátor bude mať referenciu na ďalší validátor. Najprv otestuje vstup na svojom predikáte, a pokiaľ je všetko správne, deleguje validáciu na ďalší validátor v poradí. Poďme sa pozrieť na zmeny v triede Validator
:
public class Validator<T> {
private final Predicate<T> predicate;
private final Validator<T> next;
private Validator(Predicate<T> predicate, Validator<T> next) {
this.predicate = Objects.requireNonNull(predicate);
this.next = next;
}
public static <T> Validator<T> rule(Predicate<T> validator) {
return new Validator<>(validator, null);
}
public Validator<T> and(Predicate<T> validator) {
return new Validator<>(validator, this);
}
public boolean validate(T element) {
if (!predicate.test(element)) {
return false;
}
return next == null || next.validate(element);
}
}
Prvá zmena je, že sme pridali nový nemenný atribút next
, ktorý slúži ako ukazovateľ na ďalší spoj v reťazci validátorov.
Druhá zmena je pridanie metódy and
, ktorá (podobne ako cons
vo funkcionálnom liste) pridá na začiatok zoznamu nový validátor.
Tretia a posledná zmena je prepísanie metódy validate
, aby obsahovala logiku delegácie, ktorú sme si už popísali vyššie.
Vylepšený validátor si môžeme vyskúšať na overenie, že reťazec znakov nie je nulový a ani prázdny:
> Validator<String> notNullNotEmptyValidator = Validator
.<String>rule(Objects::nonNull)
.and(string -> !string.isEmpty());
Defined field Validator<String> validator = sk.tuke.fei.kpi.oop.chapters.behavioral.chainOfResponsibility.Validator@3fd9c32e4
> notNullNotEmptyValidator.validate(null);
java.lang.NullPointerException thrown
Ako si však môžeme všimnúť, keď sme definovali pravidlá v poradí 1. nenulový a 2. neprázdny, dostali sme výnimku NullPointerException
. To preto, lebo keďže metóda and
pripája nový validátor na začiatok reťazca, overenie neprázdnosti bude spustené pred overením nenulovosti.
Upravený validátor, ktorý zachová poradie overení, budete môcť implementovať v rámci úloh. Nateraz urobíme jednoduchú zmenu - len prehodíme poradie jednotlivých validačných krokov a všetko bude fungovať tak, ako má:
> Validator<String> notNullNotEmptyValidator = Validator
.<String>rule(string -> !string.isEmpty())
.and(Objects::nonNull);
Defined field Validator<String> validator = sk.tuke.fei.kpi.oop.chapters.behavioral.chainOfResponsibility.Validator@3fd9c32e4
> notNullNotEmptyValidator.validate(null);
false
> notNullNotEmptyValidator.validate("");
false
> notNullNotEmptyValidator.validate("string");
true
Keď už máme všetko, čo potrebujeme, poďme si pripraviť čiastkové validátory. Overenie nenulovosti a neprázdnosti pre atribúty author
, title
a content
už máme, ostáva nám overenie atribútu slug
a categories
. Všetky tieto validátory vyzerajú nasledovne:
Validator<String> notNullNotEmptyValidator = Validator
.<String>rule(object -> !object.isEmpty())
.and(Objects::nonNull);
Validator<String> slugValidator = Validator
.<String>rule(slug -> slug.matches("^[a-z0-9-]+$"))
.and(Objects::nonNull);
Set<String> categories = Set.of("uncategorized", "personal", "food");
Validator<Set<String>> categoriesValidator = Validator
.<Set<String>>rule(categories::containsAll)
.and(Objects::nonNull);
V tejto chvíli by sme sa mohli uspokojiť a použiť už tieto implementácie, no tým by sme stále ostali len na podmienkovaní. Riešenie? Ďalší, nadradený validátor! V ňom postupne overíme každý atribút príslušným dcérskym validátorom:
Validator<Article> articleValidator = Validator
.<Article>rule(a -> notNullNotEmptyValidator.validate(a.getAuthor()))
.and(a -> notNullNotEmptyValidator.validate(a.getTitle()))
.and(a -> notNullNotEmptyValidator.validate(a.getContent()))
.and(a -> slugValidator.validate(a.getSlug()))
.and(a -> categoriesValidator.validate(a.getCategories()))
.and(Objects::nonNull);
A to je všetko! Pomocou návrhového vzoru chain of responsibility sme vytvorili modulárny, znovupoužiteľný validátor, ktorý je jednoduchý na údržbu a rozšírenie. Na záver si ukážme overenie jedného ukážkového článku:
> Article article = new Article(
"John Doe",
"Example Article",
"My example article.",
"example-article",
Set.of("food"));
Defined field Article article = sk.tuke.fei.kpi.oop.chapters.behavioral.chainOfResponsibility.Article@aa27ca28f
> articleValidator.validate(article);
true
Nechávame už na vás, aby ste si vyskúšali validátor všemožne otestovať.
# Best practices
- Namiesto nulovej referencie posledného spoja preferujte nulový objekt
- Ak je to možné, preferujte funkcionálne rozhrania nad triedami
# Úlohy
# 1. Nahraďte nulovú referenciu posledného validátora nulovým objektom
Metóda validate
v triede Validator<T>
momentálne musí kontrolovať, či ďalší spoj existuje, alebo nie. Keďže nulovú referenciu nemáme radi a snažíme sa jej vyhnúť, vytvorte objekt nulového validátora, ktorý sa bude vkladať v metóde rule
ako koniec reťazca, a ktorého metóda validate
vždy vráti hodnotu true
.
# 2. Prerobte Validátor na funkcionálne rozhranie
V tejto chvíli je náš validátor akási jemná vrstva nad funkcionálnym rozhraním Predicate<T>
. Vašou úlohou bude vytvoriť funkcionálne rozhranie FunctionalValidator<T>
, pre ktoré bude platiť:
- Zachová si metódy
validate
,rule
aand
(samozrejme, s upravenou implementáciou) - Zbaví sa potreby referencie na ďalší validátor v poradí a taktiež aj nulového ukončujucého validátora
- Metóda
and
už bude validátory priradzovať na koniec reťazca, nie na začiatok, ako to robíValidator<T>
# 3. Vytvorte skupinu tzv. middleware-ov, ktoré budú spracovávať HTTP požiadavku
Jedna z vlastností nášho validátora, ktorú sme si nehovorili, je to, že logiku návrhového vzoru (teda vytváranie reťaze validátorov a delegovanie na ďalší spoj), tzv. orchestráciu, rieši exkluzívne on. Na celú réžiu teda potrebujete len jednu samostatnú triedu.
Alternatívou je, že jednotlivé spoje by vôbec nevedeli, kde sa nachádzajú, ani čo po nich nasleduje, vedela by to len osobitná časť kódu, ktorá by len celú reťaz orchestrovala, ale naopak by nevedela, čo sa konkrétne v jednotlivých častiach deje, zaujímal by ju len konečný výsledok. Tento spôsob je veľmi populárny napr. v backendovom web developmente v tzv. middlewaroch.
Každá HTTP požiadavka (HTTP request), ktorú server prijíma, sa posiela na príslušný ovládač (controller), ktorý na základe nej vykoná nejakú akciu, resp. vráti nejaké dáta. Výsledok operácie vráti ako HTTP odpoveď (HTTP response). Celý proces vyzerá takto:
Častokrát sa však stáva, že máme bežnú funkcionalitu, ktorú potrebujeme medzi viacerými ovládačmi zdieľať. Typický príklad je autentikácia, no taktiež aj logovanie. Na to používame tzv. middleware-y - kusy izolovanej logiky, ktorú aplikujeme na prichádzajúcu požiadavku, aby sme ju buď transformovali, alebo vykonali nejakú funkcionalitu. Taktiež, napr. v prípade neúspešnej autentikácie, môžeme reťaz a vykonávanie zastaviť skôr, ako sa požiadavka stihne dostať k ovládaču. Upravený proces vyzerá takto:
Poďme sa pozrieť na to, čo už máme pripravené. Začneme triedou Request
, ktorá reprezentuje zjednodušenú HTTP požiadavku:
public class Request {
private Method method;
private String uri;
private String body;
private String locale;
private Map<String, String> headers;
public enum Method {
GET, POST, PUT, DELETE, PATCH,
HEAD, TRACE, OPTIONS, CONNECT
}
// Constructor, getters and setters omitted for brevity
}
Všimnime si niekoľko vecí:
- Je menná (mutable), aby sme ju vedeli v middlewaroch transformovať
- Obsahuje nasledovné atribúty:
method
- HTTP metóda požiadavky, napr.GET
uri
- cesta k zdroju/akcii na serveri, napr.auth/login
body
- telo požiadavky, obsahuje dáta (napr. meno a heslo pri prihlásení)locale
- aktuálny jazyk, môže slúžiť na prispôsobené správy pre konkrétne krajiny a podobneheaders
- mapa hlavičiek a ich hodnôt, slúžia ako metadáta požiadavky
Na každú požiadavku server vráti HTTP odpoveď. Pre účely tohto príkladu to nebudeme komplikovať a vytvoríme na to len jednoduchú nemennú (immutable) triedu Response
, ktorá bude obsahovať jeden atribút - telo odpovede:
public class Response {
private final String body;
public Response(String body) {
this.body = body;
}
public String getBody() {
return body;
}
}
Poznámka
Ako bonusovú úlohu by ste mohli triedu odpovede Response
rozšíriť ešte aspoň o tzv. HTTP status kód - číslo, ktoré nám jednoznačne povie, aký typ odpovede server posiela, napr. či ide o úspech, presmerovanie, chybu požiadavky alebo chybu servera.
Posledná vec, ktorá nám chýba, je ovládač, teda komponent, ktorý transformuje požiadavku na odpoveď. V našom prípade použijeme len jednoduchý PageController
, ktorý v odpovedi vráti jazyk a telo požiadavky:
public class PageController {
public Response getPage(Request request) {
String body = "Locale = " + request.getLocale() + "; Body = " + request.getBody();
return new Response(body);
}
}
Ostáva nám zadefinovať si rozhranie middleware-u. To bude obsahovať metódu handle
, ktorá akceptuje objekt požiadavky a funkciu, ktorá zavolá ďalší spoj v reťazi a vráti odpoveď. Vyzerať bude nasledovne:
public interface Middleware {
Response handle(Request request, Function<Request, Response> next);
}
Ako by sa mala táto metóda používať? Middleware by mal po zavolaní vykonať svoju logiku, a potom sa rozhodnúť, či chce umožniť reťazcu pokračovať (volaním metódu apply
funkcie next
s parametrom požiadavky), alebo či vráti svoju vlastnú odpoveď. Pokiaľ sa rozhodne pre prvú možnosť, má na výber, či návratovú hodnotu (odpoveď) vráti okamžite, alebo si ju uloží do lokálnej premennej a urobí po vykonaní zvyšku reťazca tzv. post-processing.
Funkcia next
je dodávaná práve orchestrátorom, ktorý v nej rozhodne, čo sa bude diať ďalej vo vykonávaní. Jednotlivým middleware-om je teda jedno, či po nich nasleduje ďalší middleware, alebo už priamo ovládač. Ak vás zaujíma, ako je táto orchestrácia implementovaná, pozrite si na príklade nižšie metódy runWithMiddlwares
a nextHandler
.
Vaša úloha spočíva vo vytvorení nasledovných middleware-ov:
LoggerMiddleware
- pomôcka pre developerov, aby v konzole videli, čo a ako rýchlo sa vykonáva- Na začiatku vykonávania do konzoly vypíše metódu a URI požiadavky
- Na konci vykonávania do konzoly vypíše, ako dlho sa požiadavka spracovávala v milisekundách. Nakoľko pôjde o bleskurýchle výpisy, použite na to statickú metódu
System.nanoTime()
.
LocaleMiddleware
- nastaví hodnotu jazyk požiadavky na taký, aký je uvedený v hlavičke s kľúčomLocale
. Ak daná hlavička neexistuje, nech nastaví jazyk naen
.TrimMiddleware
- zabezpečí, aby boli z tela požiadavky odstránené medzery pred a za textom.Text
by tak premenil naText
. Nezabudnite, že telo môže nadobúdať hodnotunull
.AuthMiddleware
- bude zabezpečovať ovládač pred neautorizovaným vykonaním- Pokračovanie reťazcu umožní len v tom prípade, že hlavička obsahuje kľúč
Authenticate
s hodnotouTKN345
- Pokiaľ taký kľúč neexistuje, resp. je hodnota zlá, vráti odpoveď s textom
Not authenticated!
- Pokiaľ je požiadavka vykonávaná na URI
auth/login
, kontrola sa nebude vykonávať a požiadavka sa pošle ďalej v reťazci
- Pokračovanie reťazcu umožní len v tom prípade, že hlavička obsahuje kľúč
Na otestovanie máte k dispozícii nasledujúci príklad s hotovým orchestrátorom. Vami vytvorené middleware-y musíte vložiť do metódy middlewares
v správnom poradí. Následne môžete použiť statické metódy example[1-4]
v JShell na overenie vašej implementácie - riadťe sa pokynmi v JavaDoc nad jednotlivými príkladmi.
public class MiddlewareExample {
/**
* Scenario: Attempt to log in to the system.
* Expected output (exact time will vary):
*
* Request for POST auth/login
* Request finished. Time 0.118638 milliseconds.
* Locale = en; Body = user=example&password=password
*/
public static void example1() {
Request request = new Request(
Request.Method.POST,
"auth/login",
"user=example&password=password",
null,
Collections.emptyMap()
);
Response response = runWithMiddlewares(request, middlewares());
System.out.println(response.getBody());
}
/**
* Scenario: Attempt to access page without being authenticated
* Expected output (exact time will vary):
*
* Request for GET dashboard
* Request finished. Time 0.035112 milliseconds.
* Not authenticated!
*/
public static void example2() {
Request request = new Request(
Request.Method.GET,
"dashboard",
null,
null,
Collections.emptyMap()
);
Response response = runWithMiddlewares(request, middlewares());
System.out.println(response.getBody());
}
/**
* Scenario: Attempt to access page with incorrect authentication token
* Expected output (exact time will vary):
*
* Request for GET dashboard
* Request finished. Time 0.002819 milliseconds.
* Not authenticated!
*/
public static void example3() {
Map<String, String> headers = new HashMap<String, String>(){
{
put("Authenticate", "BD879");
}
};
Request request = new Request(
Request.Method.GET,
"dashboard",
null,
null,
headers
);
Response response = runWithMiddlewares(request, middlewares());
System.out.println(response.getBody());
}
/**
* Scenario: Attempt to access page with correct authentication token and custom locale preference
* Expected output (exact time will vary):
*
* Request for GET dashboard
* Request finished. Time 0.010345 milliseconds.
* Locale = sk; Body = Body with spaces
*/
public static void example4() {
Map<String, String> headers = new HashMap<String, String>(){
{
put("Authenticate", "TKN345");
put("Locale", "sk");
}
};
Request request = new Request(
Request.Method.GET,
"dashboard",
" Body with spaces ",
null,
headers
);
Response response = runWithMiddlewares(request, middlewares());
System.out.println(response.getBody());
}
private static List<Middleware> middlewares() {
return Arrays.asList(
// TODO: add your middlewares in the correct order!
);
}
private static Response runWithMiddlewares(Request request, List<Middleware> middlewares) {
// This is just a preparation for the first call of recursive `nextHandler` method
Middleware currentMiddleware = middlewares.isEmpty() ? null : middlewares.get(0);
return nextHandler(request, 0, middlewares, currentMiddleware);
}
private static Response nextHandler(Request request, int currentIndex,
List<Middleware> allMiddlewares, Middleware currentMiddleware) {
// The last middleware should finish by triggering
// controller instead of next middleware
if (currentIndex + 1 >= allMiddlewares.size()) {
return currentMiddleware.handle(
request,
currentRequest -> new PageController().getPage(request)
);
}
// If we are in the middle of the chain, then middleware
// should be able to call the next one in the chain
int nextIndex = currentIndex + 1;
Middleware nextMiddleware = allMiddlewares.get(nextIndex);
return currentMiddleware.handle(
request,
// The reason we are accepting different request than the one
// received from `runWIthMiddlewares` is that we want to allow
// middlewares to not only change, but also completely replace
// requests if they feel it's necessary to do so
currentRequest -> nextHandler(currentRequest, nextIndex, allMiddlewares, nextMiddleware)
);
}
}
# Riešenia úloh
Nasledujúce kódy obsahujú riešenia zo všetkých úloh.
- Validátor
- Middleware
# Pozri tiež
- Metódy HTTP požiadavky (MDN web docs)
- HTTP hlavičky (MDN web docs)
- Status kódy HTTP odpovede (MDN web docs)
# Diskusia
← Observer