środa, 1 czerwca 2011

Koszt Dostawy

Odnośnie poprzedniego posta - Jacek L. zadał pytanie o przykład podejścia do liczenia kosztu dostawy.

Mamy kilka możliwości...

Załóżmy, że ma my klasę:

class OrderItem{
  //pola
  metodaBiznesowa1(){..}
  metodaBiznesowa2(){..}
  metodaBiznesowa3(){..}
}


Co jeżeli mamy różne rodzaje/odmiany itemów, gdzie zachowanie tych metod różni się - innymi słowy algorytmy są różne?

1. Rozwiązanie na switchach:

metodaBiznesowa1(){
  switch(jakisParametr){
    case x: return ...
    case y: return ...
  }
}

Konsekwencje: wszelkie zmiany wymuszają "grzebanie" w corowym kodzie biznesowym, czyli jego "brudzenie" czyli ponowne testowanie (nie jest to problem jeżeli mamy pokrycie testami automatycznymi:)

2. Rozwiązanie struktura i algorytmy danych
Generalnie podobne do poprzedniego z tym, że z wyniesieniem kodu biznesowego na zewnątrz - podane dla uzupełnienia wachlarza możliwości:

class OrderItem{
//gettery i settery
}

class BusinessService1{
  doSth(OrderItem oi){
    switch(oi.getSth())
    //...
  }
}

3. SŁABE (ale to zależy od szerszego kontekstu) rozwiązanie z dziedziczeniem:

Tworzymy specjalne klaski dziedziczące po OrderItem, w których nadpisujemy odpowiednie metody biznesowe.

Konsekwencja: mnożenie bytów.

Co jeżeli:
- OrderItem1 nadpisze metodę metodaBiznesowa1
- OrderItem2 nadpisze metodę metodaBiznesowa2
a OrderItemBum chce mieć obie metody biznesowe 1 i 2 takie jak klasy wymienione poprzednio?
Eksplozja kombinatoryczna!

Dziedziczenie jest dobre, ale wówczas gdy klasa ma ściśle określoną odpowiedzialność (jeden powód do zmiany). Wówczas nie ma możliwości zajścia eksplozji kombinatorycznej oraz mamy zwykle pewność zachowania Liskov Substitution Principle.

Co jeżeli klasy dziedziczące są wymagane ponieważ dodają nowe atrybuty?
Wówczas rozwiązanie z dziedziczeniem zaczyna się bronić. Jednak wciąż może dojść do eksplozji klas.
Wówczas lepiej łączyć rozwiązanie z dziedziczeniem z rozwiązaniem 4.

Innym podejściem jest unikanie dziedziczenia i zastosowanie Archetypu Biznesowego Product.

4. Wprowadzenie wzorca strategii.

class OrderItem{
  metodaBiznesowa1(){
    return strategiaBiznesowa1.go(...);
  }
  metodaBiznesowa2(){
    return strategiaBiznesowa2.run(...);
  }
  metodaBiznesowa3(){..}
}

StrategiaBiznesowa1,2 są interfejsami. Konkretne implementacje zajmują się szczegółami.
Unikamy eksplozji kombinatorycznej z podejścia trzeciego dlatego, że w tym przypadku ilość klas to suma możliwych strategi dla każdej metody biznesowej a nie ich iloczyn.

Ale zostawmy autystyczne miary ilościowe... Ten design się po prostu "czuje". Tak jak w naiwnym szkolnym przykładzie: samochód zawiera skrzynię biegów - pasującą do interfejsu (śruby, tryby). Samochód w żadnym wypadku nie dziedziczy po skrzyni biegów:)
//Chociaż kiedyś na rekrutacji ktoś na pytanie o nieksiążkowy przykład dziedziczenia odpowiedział mi: "telefonistka dziedziczy po telefonie".


Skąd OrderItem posiada strategieBiznesowe?

a) OrderItem pobiera z fabrykStrategii - niedobre ze względu na testability

b) OrderItem ma je "wstrzyknięte" z zewnątrz przez FabrykiItemów, które go tworzą lub przez RepozytiumItemów, które pobierają go z bazy.

ew. Fabryki/Repozytoria tworzą nadrzędny agregat (np Order), który zawiera w sobie itemy, ale to zależy od domeny - mechanizm jest ten sam.

To jaką staregięBiznesową "wstrzyknąć" jest decyzją biznesową, tak więc Fabryki/Repozytoria zawierają taką logikę odciążając coupling tworzenia od coplingu używania.
Więcej o rodzajach couplingu dowiemy się od ekspertów Google.

Warto zauważyć, że sama Strategia semantycznie jest niczym innym jak Funkcją (w kontekście programowania funkcyjnego) przekazaną na prawach First Class Citizen. Jedynie syntaktycznie mamy niezręczność wynikającą ze składni Javy;)

Wprowadzenie Wzorca Strategii zaczyna się nam opłacać pod warunkiem, że:
- spodziewamy się pojawiania się kolejnych strategii w przyszłości
- chcemy testować jednostkowo niezależnie logikę OrderItema od logiki StrategiiBiznesowych


Pośrednim rozwiązaniem prowadzącym w kierunku wzorca - dającym potencjał na łatwy refaktoring w przyszłości jest:
1. wyciągnięcie interfejsu
2. dostarczenie jedynej implementacji jako serwisu ze słczami w stylu rozwiązania drugiego.

//=========================================

O opłacalności wprowadzenia Strategi możemy mówić również na poziomie modelowania.

Np w DDD nie mówi się Strategia lecz Polityka. Polityki są częścią modelu - obok encji i kilku innych "klocków".
Skutkuje to tym, że zaczynamy pokazywać wariacje istotnych zachowań biznesowych w modelu (diagramach, itd) a co za tym idzie zaczynamy o nich mówić (wchodzą do Ubiquotus Language)
Co najważniejsze: zaczynamy zauważać te arcy-ważne z biznesowego punktu widzenia komponenty - już nie siedzą w słiczach w linijce numer 5000 w serwisie-ośmiotysięczniku;P

7 komentarzy:

Łukasz Lenart pisze...

Mógłbyś przygotować działający projekt, który obrazuje to co opisałeś nt. Strategii ?

Zawsze w tego typu opisach brakuje mi całościowego przykładu, aby zobaczyć szczegóły, bo jak to mówią: diabeł tkwi w szczegółach ...

Irek Matysiewicz pisze...

Ja bym dodał, że z użyciem Strategii też często mamy switcha, tylko gdzieś wyżej, np. w fabryce:
OrderIdem utwórzOrderIdem() {
switch(jakisParametr){
case x: return new OrderItem(new ImplementacjaStrategii1())
case y: return new OrderItem(new ImplementacjaStrategii2())
}
}

Strategia to dla mnie nic innego jak if czy switch, tylko przeniesiony za pomocą czegoś w rodzaju 'extract method' gdzie indziej, np. do fabryki.
Zresztą podobnie jest we wzorcu Wizytator, tylko tam te ify/switche czy czasem foreach przenoszone są nie do fabryki, tylko do metody/metod 'accept'.

Trudno omawiać to na blogu, bo przykłady blogowe muszą być proste by dało się je szybko ogarnąć, ale w prawdziwym kodzie w tak prostej sytuacji jak ta przedstawiona tutaj lepiej chyba dać sobie spokój ze strategią i zostawić te dwa switche. :-)

Michał Gruca pisze...

Niekoniecznie musi być if czy switch w naszych fabrykach. Jeśli mamy dziedziczenie / kompozycję jakiegoś rodzaju to fabryka może posiadać Map i jedynie jest get na mapie wywoływany.
Nie zawsze to jednak możliwe i generalnie to zgadzam się: całe IOC/DI to przeniesienie wszystkich ifów o kilka poziomów wyżej.

To czego dalej nie ustaliłem to jak uniknąć spuchnięcia warstwy services przy dużej ilości interfejsów wstrzykiwanych. W moich projektach niestety puchnie to z racji bardzo wielu ifaców jakie należy wstrzykiwać.

A wywołany w tekście Misko Hevery kiedyś stwierdził bardzo mądrą rzecz: klasy powiny być fabrykami albo zawierać logikę.
W uproszczeniu - tylko te pierwsze zawierają słowo kluczowe new

Sławek Sobótka pisze...

@Łukasz - w wolnej chwili stworzę projekt. Póki co być może kolejny post, który powstanie dziś wyjaśni kontekst, kiedy może mieć sens Strategia

@iirekm
Zgadza się, ten przykład jest strywializowany do maksimum i nie widać na nim kontekstu opłacalności.

Logika decyzyjna gdzieś w jakiejś (jak zauważył mgruca) musi istnieć, ale w tym przykładzie nic nie widać. Kolejny post nakreśli kontekst.

@mgruca
Być może opuchlizna wynika z tego, że po prostu złożoność problemu jest taka i prostsza nie będzie.
Zawsze można się jednak zastanowić czy aby na pewno wszystkie operacje muszą zajść podczas requestu?
Jakimś rozwiązaniem odcinania pobocznej logiki jest generowanie zdarzeń biznesowych, które są obsługiwane na boku, być może nawet w innym wątku - przy okazji porządkowania logiki zyskasz na wydajności:)
Możesz zobaczyć na mojej prezentacji o CqRS:
http://art-of-software.blogspot.com/2011/03/tydzien-segregacji.html

Co do odniesienia do Misko Hevery to będzie więcej w kolejnym poście.

Irek Matysiewicz pisze...

@mgruca

Haszmapę też często zastąpisz if-em, np.
if(klucz.equals("..."))
return ...
else if(klucz.equals("..."))
return ...

Każdy algorytm napiszesz za pomocą tylko :=, if, i while (albo rekursji), i stąd wynika tak "moc" if-ów. No tyle że jak można to nie zawsze znaczy że trzeba.

Paweł Badeński pisze...

Troszkę mi Sławek zabrakło informacji kiedy-które. Poczułem jakbyś przedstawił kilka sposobów i powiedział 4. jest zajebiste idzćie i stosujcie je wszyscy. A ja bym mimo wszystko bronił rozwiązań 1. oraz 2. (a trzeciego nie - bo jestem słabym programistą, dziedziczenia nie rozumiem i unikam). Warto zauważyć jaką tu mamy dynamikę przejścia 1. -> 2. -> 3. tzn. przechodzimy od rozwiązań, w których różne logiki są jak najbliżej siebie, a dodać zachowanie należy zmodyfikować strukturę nadrzędną do takich gdzie logiki znajdują się w różnych miejscach a zyskujemy elastyczność dodania nowego zachowania. No i taki też byłby kontekst dla którego poszczególne rozwiązania stosować, czyli... jak z jakichś przyczyn chcesz widzieć wszystko na raz i NIE chcesz, żeby zestaw zachowań był łatwo rozszerzalny to idziesz w stronę 1... jak wprost przeciwnie to się kierujesz w stronę 2. Nie podejmuję się stwierdzenia ile procentowo będzie którego w projektach, natomiast nie warto ze skrzynki narzędziowej wyrzucać paralizatora - też się czasem przyda.

A co do Liskov Substition Principle to ja bym nie był taki odważny z tą pewnością zachowania. Z przyczyn mi nieznanych projektanci języków obiektowych ignorują fakt, że LSP może być przestrzegane szczątkowo, jeśli język nie posiada Design by Contract. Mamy tylko i wyłącznie pozór LSP - bo ktoś tam gdzieś napisał "implements". Według mnie brak lub istnienie DbC w języku obietowym jest analogiczne do dynamicznej oraz statycznej typizacji - w tym sensie że bez DbC to ja sobie mogę dla każdej podklasy wymyślić kontrakt jaki mi się podoba i nikt mi nic nie zrobi.

Sławek Sobótka pisze...

Co do informacji które-kiedy i kontekstu, to nakreśliłem go wyraźniej w kolejnym poście.

Ten post odnosi się do konkretnego przykładu z książki. Natomiast gdy zaczniemy uogólniać to nie jest już tak prosto...

Często spotykam się z takim problemem, że mając na początku naiwną wiedzę na temat domeny dodajemy metody do pewnych klas. Później okazuje się, ze aby wykonać pewne obliczenia potrzeba kilku/nastu dodatkowych współpracowników (zwykle paczek danych). Powstaje masakryczny coupling. Wówczas na prawdę lepszy będzie serwis operujący na danych - anemicznych encjach dostarczających wejścia do procedury. Ale to zostało już poruszone w następnym poście.

Odnośnie języków to faktycznie - pozwalają na wiele zakładając, ze programista wie co robi:)

Można by się nad tym zastanowić... np. jakiś mechanizm constraintów konstrukcyjnych nad składnią języka, które można sobie zdejmować, jeżeli np dostaniemy pozwolenie:)