wtorek, 31 marca 2009

GRASP a anemiczny model

Tydzień temu miałem przyjemność opowiadać o zasadach GRASP na forum grupy Warsaw Design Patterns Study Group. Nijak mają się one do tego co nieraz przychodzi nam robić w pracy, czyli rzeźbić w anemicznym modelu. Wiele osób nie rozumie dlaczego jest to antywzorzec i nie przekonuje ich analogia do Turbo Pascala (siła tradycji). W związku z tym postanowiłem napisać posta, który przekona opornych. Posłużę się w tym celu właśnie zasadami GRASP, które są zestawem reguł dyktowanych zdrowym rozsądkiem, przeznaczone dla młodych adeptów Object Oriented Design.

Żeby mieć się na czym wyżywać, rozpatrzmy następujący kod, który jest sztandarowym przykładem podążania ścieżką anemicznego modelu w pewnej biznesowej, warswtowej aplikacji.

Niech to będzie nasz komponent widoku:

class FakturyView {

void dodajPozycjeDoFaktury_onClick() {
int idProduktu = ... pobieranie z kontrolek gui
int iloscSztuk = ... pobieranie z kontrolek gui
String numerFaktury = ...numer bieżąco obsługiwanej faktury
fakturySerwis.dodajPozycjeDoFaktury(idProduktu, iloscSztuk);
... //obsługa wyswietlenia nowej pozycji
}

void obliczCene_onClick() {
String numerFaktury = ...numer bieżąco obsługiwanej faktury
double cena = fakturySerwis.obliczCene(numerFaktury);
... //wyświetlanie ceny na gui
}

}

Jak widać komponent widoku obsługuje dwa zdarzenia na GUI - wciśnięcie przycisku dodającego nową pozycję do faktury i wciśnięcie przycisku, który wyświetla użytkownikowi "total" faktury. W tym celu z widoku zbierane są potrzebne informacje (numer obsługiwanej faktury, identyfikator produktu, ilość sztuk itp.) i wywoływane są "metody" (procedury) realizujące logikę biznesową, a następnie widok jest aktualizowany.

Przejdźmy teraz do warstwy logiki biznesowej, gdzie mamy anemiczne obiekty biznesowe, które są jedynie nośnikami danych i nie mają żadnych metod prócz geterów i seterów. Oprócz tych "obiektów" biznesowych mamy jeszcze serwisy, które wypruwają flaki z obiektów biznesowych w celu wykonania logiki biznesowej. Poniższy kod ilustruje tą sytuację.

"Obiekty" biznesowe:

class Faktura {
getTamto ...

getSiamto ...

getOwamto ...


setTamto ...

setSiamto ...

setOwamto ...

... itd inne atrybuty ...
}

class PozycjaNaFakturze() {

getIlosc ...

getProdukt ...

... itd

}

class Klient {
... same getery setery atrybuty klienta ...
}

class Produkt {
getNazwa...

getCena ...

.... i inne ...
}

Serwisy, czasem zwane fasadami:


class FakturySerwis {

void dodajPozycjeDoFaktury(String numerFaktury, Integer idProduktu, Integer ilosc) {
Faktura faktura = fakturaDao.wyszukajPoNumerze(numerFaktury);
PozycjaNaFakturze pozycja = new PozycjaNaFakturze();
Produkt produkt = porduktDao.wyszukajPoId(idProduktu);
pozycja.setProdukt(produkt);
pozycja.setIlosc(ilosc);
List l = faktura.getListaPozycji();
l.add(pozycja);
falturaDao.zapisz(faktura);
}

double obliczCene(String numerFaktury) {
Faktura faktura = fakturaDao.wyszukajPoNumerze(numerFaktury);
double cena = 0.0;
for(PozycjaNaFakturze pozycja : faktura.getListaPozycji())
cena += pozycja.getProdukt().getCena() * pozycja.getIlosc();
for(Rabat rabat : faktura.getKlient().getListaRabatow())
switch(rabat.getRodzaj()) {
case LOJALNOSCIOWY:
cena -= ileś tam;
...
case SWIATECZNY
cena -= ileś tam;
... obsluga wszystkich typow rabatow ...
}
switch(faktura.getKlient().getRodzaj()) {
case FIRMA:
cena += podatek dla firmy
case OSOBA_PRYWATNA:
cena += podatek dla osobty pryw.
.... obsluga pozostalych rodzajow opodatkowania ....
}
return cena;
}

}

Właściwie serwisy są klasami, tylko dlatego, że w najpopularniejszych językach obiektowych nie da się pisać osobnych funkcji tak jak się to robiło w C++ czy Object Pascalu ;).

Powyższy serwis biznesowy zawiera logikę biznesową dodawania sprzedawanego produktu do faktury oraz bardziej skomplikowaną logikę obliczania ceny faktury. Serwisy odwołują się do najniższej warstwy dostępu do danych (dao). Przepraszam fakturzystki za zapewne dziecinne rozumienie procesu wystawiania faktur ;) Oczywiście w prawdziwych okolicznościach logika biznesowa jest 5 razy bardziej rozbudowana i skomplikowana, ale już na tym trywialnym przykładzie zobaczymy, że anemiczny model to nie jest programowanie obiektowe.

Zrobiliśmy program do wystawiania faktur - pięknie działa, więc teraz zróbmy code review pod kątem spełnienia zasad GRASP, czyli lepszej czytelności, rozszerzalności i utrzymywalności.

Zacznijmy od zasady kreatora, która każe aby obiekt A był tworzony przez B jeśli ten go przechowuje lub wywołuje jego metody lub ma informacje potrzebne do jego utworzenia. Jedynym miejscem w naszym kodzie, gdzie coś jest tworzone jest metoda dodajPozycjeDoFaktury, w której tworzymy pozycje faktury tylko po to aby dodać ją do faktury. Czy tak powinno być ? Nie ! GRASP kreator mówi nam żeby logikę tworzenia obiektów umieszczać w pierwszej kolejności w obiektach które będą przechowywać referencję do nowo tworzonych. W ten sposób zmniejszamy liczbę zależności. W naszym przypadku oznacza to, że logikę tworzenia pozycji faktury powinniśmy umieścić w klasie Faktura. Szybki refactoring i mamy coś takiego:

class Faktura {

List listaPozycji;

... inne atrybuty faktury ...

//wow wprowadzamy logike biznesowa do anemicznego dotad obiektu
void dodajPozycje(Produkt produkt, int ilosc) {
PozycjaFaktury pozycja = new PozycjaFaktury();
pozycja.setProdukt(produkt);
pozycja.setIlosc(ilosc);
listaPozycji.add(pozycja);
}

}

class FakturySerwis {

void dodajPozycjeDoFaktury(String numerFaktury, Integer idProduktu, Integer ilosc) {
Faktura faktura = fakturaDao.wyszukajPoNumerze(numerFaktury);
Produkt produkt = porduktDao.wyszukajPoId(idProduktu);
faktura.dodajPozycje(produkt, ilosc);
fakturaDao.zapisz(faktura);
}

}

Po powyższym refactoringu nasz, dotąd anemiczny, model dziedziny biznesowej zyskał trochę rumieńców. Przenieśliśmy trochę logiki biznesowej z serwisu do obiektu biznesowego - idziemy w kierunku rich domain model. Sprawdźmy co stanie się, gdy zastosujemy pozostałe zasady GRASP.

Pora na zasadę eksperta, która mówi, żeby odpowiedzialność umieszczać w obiekcie, który posiada najwięcej informacji do jej wypełnienia. Analizując tę zasadę na naszym przykładzie widzimy, że nasz model ciągle nie domaga - FakturySerwis wykonuje całą pracę zw. z obliczaniem ceny, a nie ma żadnych informacji do tego potrzebnych ! Wszystkie informacje pobiera (wypruwa flaki) z innych obiektów łamiąc w ten sposób elementarną zasadę OOP - enkapsulację. Czym prędzej refaktoryzujemy:
class Faktura {

List listaPozycji;

Klient klient;

... inne atrybuty faktury ...


void dodajPozycje(Produkt produkt, int ilosc) {
PozycjaFaktury pozycja = new PozycjaFaktury();
pozycja.setProdukt(produkt);
pozycja.setIlosc(ilosc);
listaPozycji.add(pozycja);
}

double obliczCene() {
double cena = 0.0;
for(PozycjaNaFakturze pozycja : listaPozycji)
cena += pozycja.getProdukt().getCena() * pozycja.getIlosc();
for(Rabat rabat : klient.getListaRabatow())
switch(rabat.getRodzaj()) {
case LOJALNOSCIOWY:
cena -= ileś tam;
...
case SWIATECZNY
cena -= ileś tam;
... obsluga wszystkich typow rabatow ...
}
switch(klient.getRodzaj()) {
case FIRMA:
cena += podatek dla firmy
case OSOBA_PRYWATNA:
cena += podatek dla osobty pryw.
.... obsluga pozostalych rodzajow opodatkowania ....
}
return cena;
}

}

class FakturySerwis {

void dodajPozycjeDoFaktury(String numerFaktury, Integer idProduktu, Integer ilosc) {
Faktura faktura = fakturaDao.wyszukajPoNumerze(numerFaktury);
Produkt produkt = porduktDao.wyszukajPoId(idProduktu);
faktura.dodajPozycje(produkt, ilosc);
fakturaDao.zapisz(faktura);
}

double obliczCene(String numerFaktury) {
Faktura faktura = fakturaDao.wyszukajPoNumerze(numerFaktury);
return faktura.obliczCene();
}

}

W końcu zaczyna to jakoś wyglądać. Nieszczęsny serwis biznesowy nam się odchudził, logika biznesowa jest tam gdzie powinna być. Ale zaraz zaraz, czy wszystko jest już ok ? Niestety nie - ta Faktura z anemii poszła w otyłość ;). Stare przysłowie mówi - co za dużo to nie zdrowo - zasada eksperta ciągle nie jest spełniona - Faktura wypruwa flaki z innych obiektów i ogólnie ma za dużo odpowiedzialności (narusza kolejne zasady GRASP - high cohesion i low coupling). Nie poddajemy się jednak i refaktoryzujemy dalej - musimy zlikwidować łańcuszki: o.getA().getB() - don't talk to strangers ! W tym celu stosujemy starą, dobrą zasadę eksperta i przenosimy odpowiedzialności do klas PozycjaNaFakturze, Rabat i Klient:

class PozycjaNaFakturze {

Produkt produkt;

int ilosc;

double obliczCene() {
return ilosc * produkt.getCena();
}

}

class Rabat {

int rodzaj;

public double dlaCeny(double cena) {
switch(rodzaj) {
case LOJALNOSCIOWY:
cena -= ileś tam;
...
case SWIATECZNY
cena -= ileś tam;
... obsluga wszystkich typow rabatow ...
}
return cena;
}

}

class Klient {

List rabaty;

int rodzajKlienta;

double udzielRabatu(double cena) {
for(Rabat rabat : rabaty)
cena -= rabat.dlaCeny(cena);
return cena;
}

double naliczPodatek(double cena) {
switch(rodzajKlienta) {
case FIRMA:
cena += podatek dla firmy
case OSOBA_PRYWATNA:
cena += podatek dla osobty pryw.
.... obsluga pozostalych rodzajow opodatkowania ....
}
return cena;
}

}

Po tych zabiegach Faktura wygląda tak:

class Faktura {

List listaPozycji;

Klient klient;


... inne atrybuty faktury ...

double obliczCene() {
double cena = 0.0;
for(PozycjaNaFakturze pozycja : listaPozycji)
cena += pozycja.obliczCene();
cena = klient.udzielRabatu(cena);
cena = klient.naliczPodatek(cena);
return cena;
}

}

Teraz to już zaczyna wyglądać, jakby co najmniej leżało koło kodu Object Oriented, ale jak patrze na te switche to przypomina mi się koszmar programowania w C ;). Spróbujemy je usunąć stosując kolejną zasadę GRASP - polimorfizm. Dwa refactoringi wstecz programowaliśmy proceduralnie, więc polimorfizm był poza naszym zasięgiem, ale teraz, to co innego. Zasada polimorfizmu mówi, żeby zachowania zależne od typu umieszczać w osobnych klasach. W naszym kodzie te zachowania zależne od typu to udzielanie rabatu i naliczanie podatku. W zależności od typu rabatu i typu klienta te odpowiedzialności implementuje się inaczej, zatem zasada polimorfizmu będzie tutaj idealna do usunięcia tych paskudnych switchy. W tym celu z klasy Rabat robimy interfejs i dla każdej gałęzi switcha wprowadzamy nową implementację:

interface Rabat {

double dlaCeny(double cena);

}

class RabatSwiateczny implements Rabat {

double dlaCeny(double cena) {
//logika oblicznaia rabatu swiatecznego
}

}

class RabatZaLojalnosc implements Rabat {

double dlaCeny(double cena) {
//logika oblicznaia rabatu dla stalych klientow
}

}

Za pewne dosyć rozbudowana przez switcha klasa Rabat o niskiej kohezji została zamieniona na interfejs i szereg klas implementujących skupionych na jednym zadaniu - obliczaniu rabatu jednego typu. Kod faktury nie zmienił się. Zmieniła się na pewno logika tworzenia rabatu, ale jej nie rozpatrujemy w naszym przykładzie. Dzięki temu przekształceniu uzyskaliśmy wysoką kohezję i dużą łatwość dodawania nowych rabatów bez potrzeby zmiany istniejącego kodu.

Pozostaje jeszcze usunąć switcha decydującego jak naliczyć podatek. Moglibyśmy w tym celu zrobić z klasy Klient klasę abstrakcyjną i dla każdego typu klienta (prywatny, firma, ...) wprowadzić podklasę implementującą abstrakcyjną metodę oblicz podatek. Hmm... Klient zaczyna mieć za dużo odpowiedzialności - już oblicza sobie rabat i pewnie wie/robi parę innych rzeczy, których tutaj nie rozpatrujemy - spada wysoka kohezja. Tak nie może być - zastosujemy zasadę GRASP pure fabrication (czysta improwizacja) i wprowadzimy strategię (GoF) obliczania podatku. Dlaczego improwizacja ? A dlatego, że "strategia obliczania podatku", to nie jest coś, co występuje w świecie rzeczywistym - jest to byt czysto softwareowy:

class Klient {

List rabaty;

KalkulatorPodatku kalkulatorPodatku;

double udzielRabatu(double cena) {
for(Rabat rabat : rabaty)
cena -= rabat.dlaCeny(cena);
return cena;
}

double naliczPodatek(double cena) {
return kalkulatorPodatku.naliczPodatek(cena);
}

}

interface KalkulatorPodatku {

double naliczPodatek(double cena);

}

class KalkulatorPodatkuOdOsobyFizycznej implements KalkulatorPodatku {

double naliczPodatek(double cena) {
//logika nalicznia podatku
}

}

class KalkulatorPodatkuOdFirmy implements KalkulatorPodatku {

double naliczPodatek(double cena) {
//logika nalicznia podatku
}

}

Rozumując w ten sam sposób należałoby z Klienta usunąć pętlę obliczającą rabat wprowadzając klasę KalkulatorRabatu (pure fabrication), ale ten post robi się już przydługi, więc pozostawiam to czytelnikom jako ćwiczenie ;D.

Wypadałoby z tego wszystkiego wyciągnąc jakieś wnioski - anemiczny model to nie jest programowanie obiektowe, ponieważ, jak pokazałem, całkowicie gwałci podstawowe zasady GRASP: kreator, ekspert, polimorfizm, high cohesion, low coupling. Dopiero seria refactoringów w kierunku zachowania tych zasad, doprowadziła do bardziej czytelnego, rozszerzalnego i utrzymywalnego kodu zorientowanego obiektowo. Nie bójcie się więc PMów, którzy każą Wam napier.... byle szybciej i refaktoryzujcie !

7 komentarzy:

  1. Wspaniały wpis, który dotyczy prawdziwej sztuki programowania (zamiast gonić za coraz bogatszym arsenałem dostępnych gotowych rozwiązań, często przesłaniających, po co uczyliśmy się programowania). Jak napisałeś, blog jest dobrym miejscem na tego typu dywagacje, ale czytanie go nie jest tak przyjemne jak chociażby możliwość wysłuchania, np. podczas spotkania Warszawa JUG albo (ostatecznie) nagraniu filmu. Za drugie się szybko nie zabierzesz, ale ta pierwsza propozycja z WJUGiem jest w zasięgu ręki od razu. Piszesz się? Akurat temat na 1,5h. Piszę się jako słuchacz. Tego typu wiedzy nigdy nie za wiele.

    Jacek
    Notatnik Projektanta Java EE

    OdpowiedzUsuń
  2. Dzięki, naprawdę fajny przykład.
    Mam właśnie do przekonania grupę, w której sporo ludzi "nie rozumie dlaczego jest to antywzorzec i nie przekonuje ich analogia do Turbo Pascala", co mnie odrobinę przeraża (czego tu nie rozumieć?). Może Twój przykład otworzy im trochę oczy.

    OdpowiedzUsuń
  3. Dzięki Panowie. Miło mi, że zechcieliście to przeczytać ;)

    @Jacek
    Czemu nie mogę się trochę publicznie poznęcać nad anemicznym modelem.

    @Jakub
    Powodzenia ! Daj znać jeśli ten post pomoże kogoś przekonać ;)

    OdpowiedzUsuń
  4. Brawo, brawo!
    Widze, że lubelska szkoła programowania nie poszła w las i teraz niesiesz kaganek oświaty do samej stolicy:)

    Piękny cytat:
    "Dwa refactoringi wstecz programowaliśmy proceduralnie, więc polimorfizm był poza naszym zasięgiem, ale teraz, to co innego." - dokładnie tak... stosując podejście proceduralne odcinamy sobie drogę do PODSTAWOWYCH technik Object Oriented.

    Odnośnie pure fabricate: DDD próbuje nadać strategiom nieco bardziej namacalną formę (bardziej przystępną dla analityków) i nazywa je politykami - heh wystarczy zmiana nazwy aby zmienić nastawienie.

    I jeszcze odnośnie procedur sztucznie upchanych w klasy aby nie walały się po pamięci i dumnie nazywanych metodami: niestety czasem tak jest, że domena jest proceduralna - np na najwyższym poziomie gdzie operujemy pojęciami procesów, akcji, tasków. Zresztą - ludzie biznesu myslą proceduralnie (procesowo). Wg mnie procedury są czasem potrzebne a niestety język nie pozwala na ich implmentację zmuszając nas do pisania dziwolągów, którymi są dla mnie bezstanowe klasy oraz bezesensownego tworzenia obiektów - instacnji tych klas tylko po to aby móc z nich odpalić procedury. Totalny bezsens i brak optymalizacji na podstawowym poziomie. Heh nazwijmy to sobie może jeszcze bezstanowym komponentem i zacznijmy sprzedawać technologię hehehe.
    Scala na ten przykład pozwala pisać sobie radośnie te nieszczęsne procedurki.

    OdpowiedzUsuń
  5. Bardzo wartościowy wpis jako kolejny głos za tym by nie tworzyć takich anemicznych modeli. Pozwolę sobie "dołączyć" do tego swoje poględy :)
    http://it-consulting.pl/autoinstalator/wordpress/index.php/2011/01/18/cholerny-diagram-klas/

    OdpowiedzUsuń
  6. Dobry artykuł
    Natchnąłeś mnie, żeby zgłębić GRASP-a.

    OdpowiedzUsuń
  7. tyle lat minęło ;), mamy teraz łatwo dostępne bazy dokumentowe, mnie życie nauczyło, że doskonale sprawdza się to, że faktura to wartość atrybutu obiektu repozytorium, a 100% logiki tworzącej treść faktury jest poza klasą Faktura .. w Fabryce faktur..

    OdpowiedzUsuń