Strategia i Metoda Fabryczna – nieodłączni przyjaciele

Skąd ten artykuł

Podczas live’a mówiącego o wzorcach projektowych (https://devenv.pl/wzorce-projektowe-ktore-uratowaly-nasze-projekty-live/ ) poproszono mnie, bym pokazał kod z prezentacji – przydatny zrost wzorców projektowych: metoda fabryczna ze strategią

Niestety, oryginalny kod jest dość trudny do zrozumienia bez wiedzy o domenie, więc stworzyłem prosty przykład demonstrujący jak taki zrost działa i po co on jest.

W tym artykule pokażę dwa różne zrosty fabryki ze strategią:

  • Typowy: if X -> zwróć strategię Y.
  • Mniej typowy: na bazie danych z pliku wejściowego wybierz odpowiednią strategię budowania obiektu.

Przy okazji:

  • Pokażę i wyjaśnię, co to jest Fabryka (i jak ją wykorzystać)
  • Pokażę i wyjaśnię, co to jest Strategia (i jak ją wykorzystać)

Więc jeśli nie znacie dobrze tych wzorców projektowych, nie musicie zamykać taba w przeglądarce. Wyjaśnię w trakcie pokazywania kodu.

Gotowi?

Spójrzmy więc na domenę

Henstagram.

Nie możesz być największym kogutem w kurniku, jeśli nie prowadzisz swojego profilu na Henstagramie – miejscu, gdzie brutalnie oceniana jest jakość Twojego kurnika, Twoich kur oraz Twojej naturalnej charyzmy.

W tym okrutnym świecie Gladiatorów Henstagrama jesteś albo w pierwszej trójce, niezależnie od ilości uczestników, albo giniesz – czwarte miejsce się nie liczy.

Dla pierwszych trzech gladiatorów sława i nagrody pieniężne, dla pozostałych – robienie zdjęć kurom i sprzedawanie tych zdjęć by dostać zupę w proszku. A kupują zdjęcia z litości.

Czy masz wolę twardą niczym dziób koguci by dołączyć do świata HenstagramWars? 

Każdy gladiator ma Imię oraz trzy cechy:

  • Jakość kury
  • Jakość kurnika
  • Charyzma

I sędziowie będą oceniać na bazie tych cech którzy z naszych gladiatorów zasługują na podium.

Co zatem mamy do dyspozycji?

Mamy więc napisaną na szybko aplikację w C# (dokładniej: konsolowa C# .NET Core 3.1), która:

  • Pobiera dane wejściowe o gladiatorach Henstagrama („Competitors”) z plików tekstowych 
  • Dane o gladiatorach mogą być w jednym z wielu formatów / schematów („Schema”)
  • Musi być możliwość automatycznego wykrycia schematu i właściwej konstrukcji gladiatora
  • Po czym na bazie wybranych przez użytkownika metod oceny sędziowie określą, który gladiator otrzyma najwięcej punktów.

Ta aplikacja wykorzystuje różne zrosty Fabryk ze Strategiami.

Czyli mniej więcej taki przepływ:

  • W kolorze żółtym mamy do czynienia ze zwykłym zrostem fabryki i strategii. 
  • W kolorze pomarańczowym – fabryka, która ma kolekcję zarejestrowanych strategii i sama wybiera której strategii użyć w zależności od wejściowego pliku (czyli to, o co mnie poprosiliście na live).
  • W kolorze niebieskim – miejsce, w którym potencjalnie można użyć strategii, by wprowadzić warianty i rozłączność (poza zakresem tego artykułu).

A w kodzie wygląda to tak:

Główny przepływ aplikacji
https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/Program.cs

Przełożenie danych (różne pliki wejściowe w różnych schematach) na klasę Competitor (czyli nasz Gladiator Henstagrama) wygląda w taki sposób:

3 pliki: klasa Competitor, 2 Gladiatorów
https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/Competitors/Competitor.cs https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/_Data/MichaelRook.md https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/_Data/ArthurChickenson.md

Czyli te dwa różne pliki po prawej stronie w dwóch różnych schematach mapują się na klasę po lewej stronie.

Cały kod jest dostępny w repozytorium na gitlabie pod linkiem: https://gitlab.com/completrics-public/henstagram-wars/-/tree/master/HenstagramWars

Nie musicie tam wchodzić; będę tłumaczył co się tam dzieje używając screenshotów. Acz jeśli chcecie zobaczyć całokształt i sobie uruchomić ów program, zapraszam.

Na początku – zacznijmy od klasycznego zrostu fabryki ze strategią

Wpierw – co to jest fabryka?

Czym – jako wzorzec projektowy – jest fabryka?

Z perspektywy analogii do świata rzeczywistego, to trochę tak jak gdybyście poszli do restauracji:

  • Podajecie kelnerowi stringa „wątróbka z jabłkiem” i w odpowiedzi dostajecie obiekt obiadu zawierający wątróbkę, jabłko i być może ziemniaki.
  • Podajecie kelnerowi stringa „żurek” i w odpowiedzi dostajecie obiekt obiadu zawierający żurek.

Fabryka to outsourcing konstruktora. Odpowiada za to, by:

  • Prawidłowo zbudować obiekt w zależności od oczekiwań
  • Prawidłowo skonfigurować obiekt
  • Przejąć na siebie walidację danych wejściowych
  • Przejąć na siebie skomplikowane budowanie obiektu 

Czyli fabryka – w zależności od tego o co poprosimy – zbuduje nam odpowiedni obiekt i odpowiada za to, by był poprawnie skonstruowany i zwalidowany.

W kodzie nasza fabryka wygląda w taki sposób:

Fabryka strategii porównywania
https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/Contests/Comparators/ComparisonStrategyFactory.cs

Innymi słowy, w zależności od tego o jaki komparator (IComparisonStrategy) poprosimy naszą fabrykę, zwróci nam poprawną:

  • Jeśli poproszę o „RandomlessCharismaLove”, dostanę obiekt typu „RandomlessCharismaLoveComparisonStrategy”
  • Jeśli poproszę o „RandomlessSum”, dostanę obiekt typu „RandomlessSumComparisonStrategy”

Niezbyt skomplikowane, prawda? Zbuduj odpowiedni obiekt w zależności od wymagań z zewnątrz.

No dobrze – czym jest strategia?

Dla przykładu ze świata rzeczywistego, jakie mamy strategie zdobycia obiadu?

  • Strategia 1: zrobię obiad
  • Strategia 2: zamówię obiad
  • Strategia 3: poproszę żonę, żeby zrobiła mi obiad

Wszystkie trzy strategie mają te same parametry na wejściu i wyjściu (m.in. na wyjściu powinny kończyć się obiadem). Jednak każda z tych strategii realizowana jest inaczej.

Czyli strategia to wariant („zrób to samo w inny sposób”), alternatywny sposób zrobienia tej samej rzeczy. Ten wzorzec projektowy odpowiada za to, by:

  • Umożliwić wymienność rozwiązań
  • Wyizolować każdy wariant rozwiązania do osobnego miejsca
  • Dać opcję łatwego dodawania nowych wariantów rozwiązań

Teraz spójrzmy na kod:

2 strategie porównywania, by pokazać jak działają
https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/Contests/Comparators/RandomlessCharismaLoveComparisonStrategy.cs https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/Contests/Comparators/RandomlessSumComparisonStrategy.cs

Mamy powyżej dwie strategie sędziowania gladiatorów. Każda z nich posiada metodę CalculateWinner, ale ta metoda realizowana jest w inny sposób:

  • RandomlessSumComparison: sumuje wszystkie cechy gladiatora i określa jako zwycięzcę tego gladiatora, który ma wyższą sumę tych cech.
  • RandomlessCharismaLove: patrzy tylko na jedną cechę – charyzmę. Ten gladiator, który ma wyższą charyzmę, ten wygrywa.

Czyli jeśli mamy dwóch gladiatorów:

  • Adam (5, 4, charisma:2) -> suma 11, charyzma 2
  • Barbara (3, 3, charisma:3) -> suma 9, charyzma 3

To RandomlessSumComparison uzna, że zwyciężył Adam a RandomlessCharismaLove uzna, że zwyciężyła Barbara.

Jak więc widzicie, obie strategie robią to samo (porównywanie który z dwóch gladiatorów jest wyżej), ale w inny sposób (jedna patrzy z perspektywy sumy cech, druga z perspektywy samej charyzmy).

Czemu ten prosty zrost fabryki i strategii jest przydatny?

Spójrzmy jeszcze raz na wykorzystanie tego kodu:

Pokazanie miejsca, gdzie się znajdujemy w przepływie - konfiguracja.

Fabryka strategii porównywania
https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/Contests/Comparators/ComparisonStrategyFactory.cs
  • Nieważne, jaką strategię wybiorę z perspektywy algorytmu wysokopoziomowego który widzicie na rysunku powyżej, za każdym razem to zadziała.
  • Mogę spokojnie dodać nowy typ strategii do fabryki (tam gdzie jest ów switch) i nadal wszystko będzie funkcjonowało poprawnie. Czyli mam łatwą rozszerzalność kodu.
  • Mogę testować każdą strategię z osobna, wszystko mam mocno wyizolowane:
Test RSCS_SelectHigherSum_For2Competitors
https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWarsTest/Contests/TestComparisonStrategies.cs

Nie przez przypadek taki zrost Metody Fabrycznej i Strategii jak powyżej to jeden z najczęściej pojawiających się zrostów – ma bardzo mało niepożądanych efektów ubocznych i pozwala na wyizolowanie jednego miejsca na kreację obiektów.

Dobrze. Chodźmy do ciekawszego wariantu – jak zrobić  fabrykę, która sama identyfikuje której strategii parsowania powinna użyć.

Ciekawszy wariant zrostu

Definicja problemu

Dla przypomnienia, jesteśmy tu:

Gdzie jesteśmy - wczytanie gladiatorów

Obiecałem Wam fabrykę, która sama rozpoznaje z którym plikiem ma do czynienia i odpowiednio buduje obiekt Competitor. Ale na czym polega problem? Spójrzmy na dwa potencjalne schematy, z których składamy Gladiatora:

Pokazanie 3 plików: klasy Competitor, dwóch typów danych.
https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/Competitors/Competitor.cs https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/_Data/MichaelRook.md https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/_Data/ArthurChickenson.md
  • Nasz Competitor ma cztery pola: Name, HenQuality, Henhouse, Charisma.
  • Jeden schemat używa:
    • My name is: <ważny tekst>
    • I am a: <ważny tekst>
  • Drugi schemat używa klasycznego markdowna z podziałem na headery (### xxx) i listę (* xxxx).

I załóżmy teraz, że mając powyższe dane – przykładowo, 100 plików – chcemy zrobić tak, by nasza fabryka sama decydowała w jaki sposób wyciągnąć dane z plików po prawej do obiektu Competitor.

Jak to ruszyć?

Rozwiązanie, część 1: parsowanie różnych plików

Mamy różne schematy plików. Chcemy wykonać tą samą operację – parsowanie – na różne sposoby, w zależności od tego w jakim schemacie mamy dane pliki. Czy to się Wam z czymś kojarzy?

Strategia.

Interfejs strategii
https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/Competitors/Schemas/ISchemaParsingStrategy.cs

Czyli nasza fabryka (CompetitorFactory) wykorzystuje jeden z parserów (AspectSchemaParsingStrategy lub EnumerationSchemaParsingStrategy). A nasze strategie mają po dwie metody: Parse lub CalculateFitness. 

Na razie skupmy się jednak tylko na pierwszej metodzie, Parse. Jak to zaimplementować?

Weźmy przykład schematu opartego o Aspekty i czysty markdown:

AspectSchemaParsingStrategy oraz ArthurChickenson
https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/Competitors/Schemas/AspectSchemaParsingStrategy.cs https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/_Data/ArthurChickenson.md

Po lewej stronie powyżej widzicie zawartość pliku, po prawej widzicie jak jest parsowany:

  • Używając wyrażeń regularnych znajdź tekst od „#### Body” do następnego „####”, po czym wyciągnij to co tam jest pod spodem
  • Następnie przekształć tamten tekst w listę aspektów (fraz typu „good looks”) 
  • Następnie policz, ile takich aspektów jest na liście. Ta ilość to nasza kategoria (tu: Body mapuje się na Charisma)

Same szczegóły parsowania nie mają tu znaczenia; macie dostęp do kodu a wyrażenia regularne odbierają 10 punktów poczytalności jak się na nie patrzy.

To, co tu jest ważne – widzicie tu jedną strategię parsowania plików typu AspectSchema.

Dla porównania, druga strategia parsowania plików typu EnumerationSchema:

EnumerationSchemaParsingStrategy i Michael Rook
https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/Competitors/Schemas/EnumerationSchemaParsingStrategy.cs https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/_Data/MichaelRook.md
  • Używając wyrażeń regularnych znajdź tekst od „I am a:” do końca linijki, po czym go wyciągnij
  • Następnie podziel wynik po przecinku na aspekty („pretty, exhaustive” -> [„pretty”, „exhaustive”]) 
  • Następnie policz, ile takich aspektów jest na liście. Ta ilość to nasza kategoria (tu: Body mapuje się na Charisma)

Podejrzewam, że widzicie zarówno duże podobieństwa w strukturze kodu obu strategii, jak i zdecydowane różnice (szukają innych wzorów w tekście).

Zauważcie, że bardzo łatwo można napisać test sprawdzający czy nasza strategia parsowania działa, w izolacji od reszty aplikacji:

Test ProperlyParseValidRecord w TestEnumerationSchemaParsers
https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWarsTest/CompetitorConstruction/TestEnumerationSchemaParsers.cs

Rozwiązanie, część 2: rejestracja strategii w fabryce

Mamy więc nasze strategie, które prawidłowo parsują odpowiednie pliki. Ale w jaki sposób sprawić, by te strategie były faktycznie wykorzystane w kodzie fabryki?

CompetitorFactory, konstruktor
https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/Competitors/CompetitorFactory.cs

Mamy listę potencjalnych strategii, których możemy użyć. I w konstruktorze CompetitorFactory dodajemy wszystkie strategie, które chcemy obsługiwać.

Następnie w metodzie BuildCompetitors chcemy przypisać odpowiednią strategię do pliku w taki sposób, by odpowiednia strategia sparsowała odpowiedni plik.

Zauważyliście, jak łatwo jest dodać nowy format schematu do takiego zrostu strategii i fabryki?

  • Dodajecie nowy obiekt strategii odpowiadający nowemu schematowi
  • Zapewniacie, by obiekt strategii miał te same metody i te same wejścia i wyjścia w metodach
  • Dodajecie ten obiekt strategii do powyższej listy w fabryce
  • Wszystko działa. Brak dodatkowej ingerencji w kod. Nowy schemat -> dodanie nowej strategii parsowania do listy w fabryce. Skasowanie schematu -> usunięcie jednej strategii parsowania z tej listy.

Ale jak sprawić, by „właściwa” strategia parsowała „właściwy” plik?

Rozwiązanie, część 3: którą strategią sparsować ten plik?

Fitness function, lub funkcja dopasowania. Skąd my – ludzie – wiemy której strategii użyć do parsowania tych plików? Patrzymy na wzór i „który sposób parsowania najbardziej pasuje”.

Dokładnie ten sam mechanizm występuje w naszych strategiach parsowania. To jest ta druga funkcja znajdująca się w strategii, CalculateFitness.

Popatrzmy na test demonstrujący to zjawisko:

Test: CalculateFitnessForMinimumValidRecord
https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWarsTest/CompetitorConstruction/TestEnumerationSchemaParsers.cs

Nasz Gladiator ma cztery parametry: Name, Hen, Henhouse, Charisma. W wypadku powyższego testu jedynie jeden parametr udało się poprawnie sparsować: Name. Tak więc z czterech parametrów udało się nam sparsować jeden parametr. Dopasowanie (fitness) wynosi więc 1/4, czyli 0.25.

Innymi słowy, na czym polega sprawdzenie, która strategia powinna sparsować dany plik?

  • Rzucamy wszystkie strategie na każdy plik obliczając Dopasowanie
  • Wybieramy tą strategię, która ma najwyższe Dopasowanie i tylko tą strategią parsujemy dany plik
  • Jeśli plik jest uszkodzony (żadna strategia sobie nie radzi), nie dodajemy Gladiatora do listy.

I teraz mogę pokazać dokładnie ten mechanizm w kodzie fabryki:

CompetitorFactory, metoda BuildCompetitors
https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWars/Competitors/CompetitorFactory.cs

Czyli, idąc jeszcze raz za powyższym algorytmem:

  • Nasza fabryka nie buduje jednego Gladiatora – buduje kolekcję Gladiatorów.
  • Z podanego folderu (tu: „./Data”) wczytaj wszystkie zawartości w formie tekstu, takiego jak w testach które pokazywałem powyżej.
  • Dla każdego tekstu wykonaj wszystkie operacje:
  • Rzuć na tekst wszystkie strategie, znajdź najlepszą
  • Po znalezieniu najlepszej strategii, sparsuj tekst ową strategią
  • Jeśli to null, nie dodawaj tego do kolekcji.

Zauważcie, że dzięki temu że pracuję na kolekcjach a nie pojedynczych plikach to nie mam połowy problemów wynikających z „if null”. Kolekcja nullem nigdy nie będzie, mogę najwyżej mieć listę bez ani jednego elementu w środku.

Ten zrost fabryki i strategii jest bardziej skomplikowany niż poprzedni.

Ten odpowiada za prawidłową konstrukcję i konfigurację kolekcji obiektów typu Competitor na bazie nieoznaczonych danych zewnętrznych. A bardzo często budowanie obiektów wymaga dużej ilości zachodu, korzystania z innych usług (service) i ratowania się wartościami domyślnymi (default).

Gdybym konstrukcję obiektu Competitor miał w konstruktorze, dość ciężko byłoby poradzić sobie z uszkodzonym plikiem – jesteś w środku konstruktora i nie możesz zbudować obiektu: co robisz?. Może wyjątek? 😉

A tak, dzięki użyciu fabryki konfigurującej listę Competitorów – mam gwarancję, że każdy Competitor jest dobrze zbudowany, przeszedł walidację (i łatwo zbudować go do testów innych fragmentów systemu):

Test RSCS_SelectsHigherSum_For2Competitors
https://gitlab.com/completrics-public/henstagram-wars/-/blob/4581fd546b696e848d7146dedc0b1a1b6cad5757/HenstagramWarsTest/Contests/TestComparisonStrategies.cs

(Wyobraźcie sobie, że w powyższym kodzie do zbudowania Competitora potrzebny mi jest plik tekstowy i cała ta magia budująca będzie miała miejsce w konstruktorze a nie w fabryce. Niefortunne, nie? No i troszkę trudniej zbudować test.)

Podsumowując

Fabryka – odpowiada za właściwą konstrukcję i konfigurację budowanego obiektu.

Strategia – odpowiada za robienie „tego samego” w „inny sposób”.

Zrost fabryki i strategii daje dzięki temu całkiem sporo możliwości.

Klasyczny zrost (prosty, taki jak ten dla strategii sędziowania / porównywania gladiatorów) pozwala Wam na:

  • Łatwą testowalność każdej strategii z osobna
  • Łatwe dodanie nowej strategii by cały system z nią działał (przez dodanie do fabryki)
  • Kod budowy obiektu (często trudny i nieporęczny) jest w innym miejscu niż kod „biznesowy” obiektu, dzięki czemu nie mamy zaśmieconego kodu konstrukcją

Bardziej zaawansowany zrost, jak ten dla strategii parsowania pozwala Wam na:

  • Automatyczne wykrycie typu obiektu przy użyciu zarejestrowanej strategii
  • Dynamiczne dodawanie i odejmowanie strategii parsowania do fabryki, w trakcie działania programu, przez modyfikację listy (tu niepotrzebne, ale może się przydać np. jeśli włączysz DLC w grze komputerowej)

Swoją drogą, zdecydowanie nie polecam wykorzystywania tego bardziej zaawansowanego zrostu jeśli nie macie problemu który tego wymaga. Im kod prostszy i czytelniejszy, tym lepiej.

Chciałbym jednak, byście zobaczyli, że zrost tych samych wzorców projektowych może mieć zupełnie inne implementacje w zależności od potrzeb i że to nie zawsze wygląda identycznie.

PODZIEL SIĘ