Tytułowy wynalazek ma roboczą nazwę DSQ, którą triumfalnie nadałem mu kojarząc dwie modne techniki: Domain Specific Language i Command-query Separation.
NIEKRÓTKIE WYJAŚNIENIE
O CQS pisałem 2 miesiące temu w >tym< poście. W skrócie jest to styl architektoniczny, który zakłada separację API systemu na dwie wyraźne części. Jedna obsługuje (przyjmuje wysyłane do API) Command - rozkazy wykonania operacji. Druga natomiast obsługuje Query - obiekty specyfikujące kryteria pobierania/wyszukiwania danych.
W dzisiejszym poście skupimy się właśnie na tej drugiej części, czyli ogólnie mówiąc na wyszukiwarkach danych. Wyszukiwarki są dosyć proste - ot zwykłe servisy, pod którymi nie znajdują się już żadne kolejne warstwy - chyba, że potrzebujemy jeszcze "dodać coś od siebie" do pobranych z pewnego źródła danych.
DSL to koncepcja dużo bardziej popularna (w sensie ilości publikacji) niż CQS, więc nie będę się zbytnio rozpisywał na jej temat. Ogólnie rzecz ujmując chodzi o to aby kod logiki biznesowej czytać niemal jak język naturalny. Definiujemy sobie język zorientowany na konkretną domenę biznesową a później to już fraszka - program piszę się niemal sam;)
W statycznych językach typu Java nie mamy zbyt szerokiego pola do popisu jeżeli chodzi o zdefiniowanie własnego języka domenowego.
Jedyne co możemy zrobić w Javie to zastosować Fluent Interface i opisowe (samoopisujące wręcz) nazwy metod. Czyli jest to jedynie namiastka DSL, ale cieszy:)
Koncepcja Fluent Interface jest prosta: chodzi o to aby metody danej klasy zwracały instancję obiektu na rzecz którego zostały wykonane. Dzięki temu możemy zamiast pisać tradycyjnie:
Klasa obiekt = new Klasa();
obiekt.metoda1();
obiekt.metoda2();
obiekt.metoda3();
możemy zmienić styl na bardziej fluent:
Klasa obiekt = new Klasa();
obiekt.metoda1().metoda2().metoda3();
Jeżeli teraz zamiast nadawać metodom autystyczne nazwy (w stylu Bruce Eckela), wykażemy się odrobiną inwencji i słowiańskiej fantazji możemy pisać niemalże poetycko:
FabrykaWierszy.stworzUtwor().subtelny().zNutkaDekadencji().rymyNiechBedaCzestochowskie().strof(10);
Chyba lepiej czyta się kod w takim stylu...
DO RZECZY
Po tym przydługim wstępie powinniśmy już mniej więcej czuć czym jest CQS i DSL...
Wyobraźmy sobie następujący problem :
W systemie przechowujemy dokumenty. Dokumenty mają szereg atrybutów: kilka typów statusów (związanych z przepływem dok. jak i technicznych), różnego rodzaju daty, przypisanie do różnych zasobów itp.
Domena biznesowa standardowa i może niezbyt wyszukana, ale dzięki temu wiadomo o co chodzi i możemy skupić się na samej technice.
Serwis, który miałby wyszukiwać dokumenty musiałby mieć metodę o kilkunastu parametrach. Pierwszym problem z taką metodą to wysoki ujemny współczynnik czytalności. Drugi problem to ciągłe zmiany sygnatury metody gdy pojawiają się nowe atrybuty dokumentu.
Zatem dosyć oczywiste jest, że stworzymy sobie klasę przechowującą atrybuty wyszukiwania a nasz serwis będzie miał metodę, do której przekazujemy paczkę kryteriów - po prostu jakiś rodzaj Query z CQS:
public class SearchDocumentsQuery implements Serializable{
//atrybuty wyszukiwania + get/set
}
public class DocumentsFinder{
public Collection<Document> search(SearchDocumentsQuery criteria){
//..
}
Jak do tej pory standard. (Query najpewniej będzie prędzej czy później serializowany.)
Teraz idąc dalej wg utartych szablonów dodalibyśmy do SearchDocumentsQuery kilkanaście pół prywatnych oraz oczywiście nasze ulubione gettery i settery.
Kod klasy klienckiej przy pomocy setterów ustawia kryteria wyszukiwania a kod metody DocumentsFinder.search przy pomocy getterów pobiera te parametry i klei sobie zapytanie w SQL, HQL tudzież mój ulubiony Hibernate Criteria API.
A gdzie miejsce dla DSL? No więc właśnie przy tak ordynarnym podejściu nie uświadczymy.
Załóżmy teraz, że chcemy hermetyzować kod klas klienckich (wywołujących DocumentsFinder)od szczegółów atrybutów Dokumentu.
Klient zamiast operować szczegółami:
SearchDocumentsQuery criteria = new SearchDocumentsQuery();
criteria.setStatus(Status.ACTIVE);
criteria.setCreateDate(...)
criteria.set...
...
Collection<Document> result = documentsFinder.search(criteria);
Wyraża jedynie intencje co do chęci otrzymania dokumentów spełniających zestaw określonych specyfikacji biznesowych:
SearchDocumentsQuery criteria = new SearchDocumentsQuery();
criteria.current().contains("lorem ipsum");
Collection<Document> result = documentsFinder.search(criteria);
Gdzie przykładowo metody DSL wyglądają mniej więcej tak:
public class SearchDocumentsQuery implements Serializable{
private Status status;
private Date epiryDate;
private String[] titleWords;
private String[] contentWords;
public SearchDocumentsQuery current(){
status = Status.ACTIVE;
expiryDate = //data jutrzejsza
return this;
}
public SearchDocumentsQuery contains(String phrase){
String[] words = phrase.split(" ");//lub bardziej wyrafinowy split
titleWords = words;
contentWords = words;
return this;
}
}
Jak interpretować powyższe metody ustawiające kryteria:
current - oznacza, ze życzymy sobie dokumentów będących aktualnie w obiegu.
Co to znaczy od strony modelu: że maja określony status i że wygasają np najwcześniej jutro.
contains - oznacza, że dokument ma zawierać określoną frazę. Z punktu widzenia modelu oznacza to, że tytuł lub treść dokumentu zawiera wszystkie słowa frazy.
Cała idea polega na tym, że klient nie wie nic o polach status, expiryDate, words, ...
Klient wyraża intencję.
Natomiast pola te są dostępne przez gettery dla DocumentsFinder, który to analizuje je i odpowiedzi składa zapytanie. Aby osiągnąć pożądaną widoczność getterów należałoby zdefiniować SearchDocumentsQuery jako klasę statyczną wewnętrzną w DocumentsFinder. Wówczas DocumentsFinder będzie widział metody prywatne, bo takie powinny być rozważane gettery. Statyczną ponieważ chcemy tworzyć Query bez "wywodzenia" jej z instancji Findera.
public class DocumentsFinder{
public static class SearchDocumentsQuery implements Serializable{
private Status status;
private Date epiryDate;
private String[] titleWords;
private String[] contentWords;
//metoda contains i current - pominięto
private Status getStatus(){
return status;
}
}
public Collection<Document> search(SearchDocumentsQuery criteria){
if (criteria.getStatus() != null)//wywołanie prywatnej metody
//dołożenie do zapytania warunku na status
...
}
}
Kuszące może wydawać się dodanie do SearchDocumentsQuery metody buildEecutable(), która produkuje na podstawie stanu swoich wewnętrznych pól (statys, words,...) coś co można wykonać. Tym "czymś" może być PreparedStatement z JDBC, Criteria z Criteria API Hibenrate, Query z JPA, czy zwykły String zawierający SQL albo HQL.
Ale zastanówmy się: czy klasa, która jest widoczna przez klienta (np zdalnego) powinna mieć w sobie kod, który operuje na jakimś API np Hibernate (Criteria API) albo czy powinna wiedzieć coś o jakimś SQL? Przecież Finder może szukać w XMLu lub poprzez web service. Klient nie może tego wiedzieć - ba nie powinien.
//====================
Pomysł może mało odkrywczy i zapewne wiele osób wpadło na niego równolegle, ale jeszcze nie spotkałem nigdzie opisu.
Pomysł oczywiście nie będzie miał zastosowania w aplikacjach z małą abstrakcją pomiędzy bazą danych a GUI - tak zwanych przeglądarkach danych. W tego typu aplikacjach kontrolki na GUI (w tym pola filtrujące) odpowiadają zwykle niemal dosłownie kolumnom w bazie. W tego typu przypadkach próba abstrahowania od parametrów modelu jest niepotrzebnym uprawianiem dyscypliny pod tytułem nakładanie gaci przez głowę;)
Czekam na opinie, sugestie, krytykę - może być niekonstruktywna:)
14 komentarzy:
Do mnie chyba dociera, czy to znaczy, że należy stworzyć klasy(będące rodzajem interfejsu), których metody będą stanowiły niejako pewien "język" komunikacji z resztą implementacji?
Hmm... fajny pomysł, tylko wydaje mi się że zapomniałeś wspomnieć o jednej z najważniejszych zalet jakie ze sobą niesie ;)
Każdy leniwy programista stwierdzi, że nie ma sensu tworzyć fluent interfejsu dla "prostej" klasy SearchDocumentsQuery - lepiej stworzyć tych kilkanaście pól i wygenerować settery i gettery :)
Dopiero z upływem czasu może okazać się, że to był błąd, dlaczego?
Załóżmy że SearchDocumentsQuery będzie tworzony i wypełniany (poprzez settery) przez wiele innych klas i każda z tych klas chce dostać bieżące dokumenty. Wszystko pięknie aż do pewnego dnia w którym okazuje się, że bieżące dokumenty mogą być w statusie MODIFY. Wtedy musimy przeglądać wszystkie settery dla każdej z klas która tworzących dany obiekt. Następnie sprawdzać czy przypadkiem w danym miejscu ktoś nie miał na myśli dokumentów bieżących ustawiając dane pole na daną wartość. Stosując twoje rozwiązanie cała logika odnośnie tworzenia zapytania znajduje się tam, gdzie powinna i przy opisanym problemie zmiana będzie tylko w jednej klasie! Czasami warto pomyśleć głębiej o programowaniu OO, GRASP czy SOLID a nie tworzyć proste data sety...
Mam nadzieje, że dobrze zrozumiałem twoje rozwiązanie ;)
Bardzo ładne zastosowanie enkapsulacji. Elementy które mogą się zmieniać są ukryte w kilku metodach składających się na swoisty język domeny. Nice
@Sławek ta leniwość ("nie będę klepać takiego samego kodu po raz setny") programistów czasem pomaga stworzyć śliczne rozwiązania które wymagają mniej klepania ;-) Ale z drugiej strony są programiści leniwi (mam to w dupie, nie chce mi się czytać jakichś głupich książek, chce tylko żeby konto zostało zasilone) którzy sprawiają że reszta programistów płacze gdy zobaczy kod który spłodzili ;-) Tym drugim nie da się wytłumaczyć że zastanowienie się chwilę nad "domeną" przyniesie korzyści, gettery i settery znajdziemy w każdych klasach jakie tylko zostały stworzone w systemie - "no przecież zawsze mogą się przydać" ;-)
Ostatnio podczas refaktoringu w pewnym projekcie (aplikacja webowa) znalazłem pole "address" w serwisie biznesowym - singletonie springowym ;-)
Ladne polaczenie builder i fluent interface. Mam pare sugestii:
1. Laczenie wielu builderow ze soba moze jeszcze bardziej ulatwic zycie. Jako punkt wejscia uzywamy nadal metod statycznych (mozna uzyc import static zeby miec ladna skladnie ala DSL), np:
wyszukaj(dokument()
.komentowanyPrzez(uzytkownika().oImieniu("bob"))
.komentowanyPrzez(uzytkownika().oReputacji(100)));
wyszukaj(uzytkownika().oImieniu("alice"));
zamiast interfejsu o 100 metodach, mamy 2 o 50, z ktorych kazdy moze byc uzyty jako wyszukiwany korzen, lub jako kryterium zagniezdzone w innym wyszukaniu.
2. Co do budowania kryteriow to jest to bardzo ciekawe zastosowanie. Jednak wg mnie duzo lepiej niz tworzyc hierarchie dziedziczenia kryteriow (BaseCriteria, UserCriteria, DocumentCriteria, itd.) DUZO lepiej jest stworzyc jednen interfejs kryterium, wraz z jedna sluszna implementacja, czyli taka ktora wszystko przekazuje do hibernatowej Criteria :) oraz wiele sposob na budowanie jej.
Hierarchia dziedziczenia kryteriow to ogolnie chory pomysl (nie widze mozliwosci uzycia np. polimorfizmu), ale podalem go bo sam bylem swiatkiem proby zaimplementowania go w komercyjnym projekcie...
3. Pomim ze niezmiernie podoba mi ten pomsyl widze jednak problem. O ile uzycie takiego DSLa sprawdza sie super przyjemnie podczas pisania np. testow jednostkowych, nie widze za bardzo mozliwosci zastosowania go w samej aplikacji... Nie jestem pewien gdzie moznaby to kryterium budowac:
a) w warstwie aplikacji: parametry ktore przylecialy z GUI beda wcisniete zawsze w to samo miejsce w lancuchu wywolan ktory raczej nie bedzie sie zmienial. Tracimy wiec duza zalete tego rozwiazania czyli wyrazanie swojej intencji przez to ze wywolanie jakies metody w lancuchu pojawi sie lub nie... Jeszcze gorzej gdy bedzie to wygladalo tak:
if(queryZGui.maBycZnutkaDekadencji()){
queryBuilder.zNutkaDekadencji();
}
b) budowane na GUI(chyba nie o to chodzilo?:)): podobnie jak wyzej. Builder bedzie musial byc gdzies zapamietany zanim nacisniemy ostatecznie submit i bedzie sie zmienial w zaleznosci od stanu przyciskow GUI (czyli cos podobnego jak przerazajacy przyklad powyzej).
c) warstwa aplikacji ktora nie bedzie mapowana 1:1 na baze, np.
Wierz stworzWierszSzekspirowski(){
return FabrykaWierszy.sonet().bezRymow().oMilosci().oPolityce().oMoralnosci();
}
Wyglada w porzadku, ale wydaje mi sie ze uzycie bedzie jednak bardzo ograniczone...
Z drugiej strony takie cos swietnie nadaje sie do np. budowania asercji do testow jednostkowych (zainteresowanych odsylam do biblioteki Hamcrest).
Na koniec dodam, ze bardzo chcialbym uzyc takiego polaczenia buildera z FI w prawdziwym projekcie, wiec jezeli jest ktos kto jest w stanie wskazac mi jakis przyklad uzycia w prawdziwym projekcie bede wdzieczny.
Pozdrawiam
@Jarek: tak, można tak to ująć.
Klient "mówi" czego chce i nie wnika co to znaczy w języku implementacji detali (w tym wypadku atrybutów).
@Sławek Chmiel: trafiłeś w samo sendo! Być może z czasem "current" będzie znaczyło coś innego po stronie modelu (np nowy atrybut isHidden sie w nim pojawi, a current ustawi co go false).
@Marek: adres - ehh bez komentarza...
@Rafał: Jako przykład system, gdzie stawiamy na usability:
- na gui mamy jedno pole do wpisywana frazy (nie wazne gdzie ona występuje, po prostu ma być gdzies w dokumencie: tytul, sygnatura, tresc,...)
- opcja current: nawet moze byc domyslnie zawsze włączona w critera gdy wykonuje je zwykły user (nie admin)
- moze byc sobie na gui czekbos: "wygasające", co znaczy ze mają jakieś tam statusy i date wygsniecia w ciagu co najwyzej 7 dni.
Natomiast szczegolowe atrybuty (nie przykryte FL) moga byc uzywane w innych miejscach systemu do okreslania szczegolow przeplywu czy uprawnien.
A tak z innej beczki: w przykładach kodu Syntax Highlighter ci porobił cuda z < i >.
Trzeba po prostu < pozamieniać na < a > na >
Kurcze jeszcze raz:
A tak z innej beczki: w przykładach kodu Syntax Highlighter ci porobił cuda z < i >.
Trzeba po prostu < pozamieniać na &< a > na &>
Teraz dobrze:
A tak z innej beczki: w przykładach kodu Syntax Highlighter ci porobił cuda z < i >.
Trzeba po prostu < pozamieniać na < a > na >
Dzieki za zwrocenie uwagi Irek - przeoczylem to. Myslalem, ze znacznik pre sie tym zajmie i juz nie sprawdzalem.
Sławku, chyba ja czegoś nie widzę ale Twój DocumentsFinder sprowadzi się do implementacji DAO, z metodą search(), budującą zapytanie na podstawie kryteriów. Najlepiej z tylko tą metodą :)
Czy mając DB, przykryte DAO i zaimplementowane powiedzmy przy użyciu jakiegoś ORM naprawdę chcemy babrać się w gąszczu try/catch java.sql API? - poza paroma szczególnymi przypadkami w systemie gdzie wydajność na pierwszeństwo.
Skoro zbudowaliśmy sobie klasy z których repozytorium złoży nam nasze Encje(takie przez E) to czemu ich nie wykorzystać?
Jeśli miała by to być osobna klasa obok DAO, to jakbyś to widział?
Pomysł z zadaniem pytania/wymagan przy pomocy API zgodnie z FI very nice, dobrze obrazuje o co w tym chodzi.
Pozdrawiam
@Maciej
W "klasycznym" podejściu można sobie metodę szukającą przenieść do DAO.
Ale zauważ, że treść posta jest osadzona w kontekście Command-query Separation. Oparłem się tutaj na koncepcji Grega Younga, który CqS implementuje jako system owszem warstwowy, ALE o architekturze 2 stosów warstw.
http://art-of-software.blogspot.com/2009/09/lazy-loading-w-kontekscie-paradygmatu.html
Jeden stos obsługujący Commandy jest mniej więcej "klasyczny". Z tym, że jeżeli używamy DDD to zamiast DAO mamy Repozytorium zwracające Encje DDD. Repo ma metody zwracające Encje po id oraz save, delete.
Natomiast ten drugi stos - obsługujący Query jest już nieco bardziej strywializowany ponieważ zwykle jedyne co robi to przeszukuje dane. Dodam, że u Grega 2 stosy pracują z pewnych względów na 2 bazach, ale to temat na osobne rozważania.
Teraz JEŻELI ten nasz drugi stos zwykle będzie zwracał dane "przekrojowe" (niemal raportowe) to nie ma sensu używać ORM ponieważ nie nadaje się on do takich zadań.
http://art-of-software.blogspot.com/2009/04/lazy-loading-sprawa-wydajnosci.html
Co do babrania się z technikalami dostępu do dany to wystarczy zagregować obiekt będący "koniem roboczym" - jakiś DirtyExecutor, który się tym zajmie.
Owszem opisane podejście jest nieco na wyrost i nie warto go stosować w małych i prostych systemach. Jednak opisałem go w ramach research aby nie pisać po raz tysięczny o tym samym;)
Odpisałem "na szybko", gdybyś miał jakieś pytania albo potrzebował wyjaśnienia jakiejś koncepcji to pisz śmiało - wieczorem odpiszę.
Hej Sławku,
Dzięki za odpowiedź, interesuje mnie może nie typowe, klasyczne podejście tylko pewna hybryda, próba pożenienia artefaktów DDD w systemie napisanym w klasyczny bez "" sposób. Implementacja drugie stosu na razie nie wchodzi w grę, wiec zostaje skorzystanie z obecnej trzódki klas. Decydujemy się na zwracanie przez Query jakiś DTO, czy w takim przypadku warto pisać kolejne klasy dostępu do bazy? Czy dodać wyszukanie do obiektów obecnie przykrywających bazę. Idąc pierwszym tropem tworzymy nowe, kolejne DAO, które nie za wiele będzie się różnić od istniejących, jeśli dodatkowo będziemy chcieli skorzystać z posiadanego ORM, idąc drugim tropem nie wiem czy nie za bardzo przeładujemy obecne implementacje DAO odpowiedzialnościami, w końcu chcemy podążać za SRP.
Pzdr
Chcesz zwracać DTO zatem domyślam się, że doszliście do sytuacji gdzie encje zamapowane na bazę nie są odpowiednimi nośnikami danych do klienta (klient w sensie aplikacja webowa albo zdalna aplikacja).
Teraz zwykle robi się tak, że jakaś warstwa servisów woła DAO i przepakowuje zwracane przez DAO Encje JPA na DTO. Sam tak robiłem i wiem, że tak się często robi ale nie ma to zbytnio sensu w wielu przypadkach.
Nie ma sensu z uwagi na to, że pobieramy z bazy niepotrzebne (zamapowane) dane, przepakowujemy, a później zwracamy DTO, ktore odpowiadada potrzebom konkretnego Use Case.
Jest to żmudne, ale zawsze ktoś to może zrobić metodami siłowymi. Natomiast problem pojawi się z wydajnością. Szczególnie gdy będzie polegać na lazy loadingu - wówczas "n+1 slect problem" murowany!
Dlatego można zastanowić się nad klasami, które pobierają z bazy to co jest na prawdę potrzebne i zwracają wprost DTO.
Można bawić się z Hibernate i pobieraniem poszczególnych pól, można bawić się z konstrukcją SELECT NEW KONSTRUKTOR_KLASY()
ale przy jakiś super złożonych zapytaniach skończy się i tak na SQL lub lepiej procedurce składowanej. Abstrakcję od SQL można osiągnąć np dzięki iBatis.
I teraz odpowiadając na pytanie wreszcie: jeżeli wywnioskujesz, że lepiej pytać wprost o potrzebne dane (bez przepakowania encji z ORM) to droga jest raczej jedna. Osobne klasy "finderów". Ponieważ niezbyt dobrze będzie wyglądać DAO, którego implementacja używa zarówno ORM jak i jakiegoś "wykonawcę" SQL (jbbc, ibatis). Wiem co mówię - robiłem takie koszmarki dawno temu;P
Zastanawia mnie jeszcze jedna kwestia: piszesz, że chcecie pożenić DDD i zaszły system (w tym jego DAO). W DDD dostęp do danych odbywa się przez Repozytorium. Różnica nie jest jedynie z nazwy - co próbuje nam wmówić Spring;P
Różnica jest też koncepcyjna.
Repo zwraca Agregaty oraz pozwala na ich modyfikację. Jeżeli Repo ma jakieś wyszukiwarki to tylko wynikające z reguł biznesowych w pewnych procesach. Wyszukiwarki potrzebne do różnego rodzaju gridów na gui nie powinny zaśmiecać Repo - stąd właśnie opisywany Findery (najlepiej jeszcze w osobnym stosie;P)
Napisz może więcej na temat jak dokładnie widzisz taką architekturę. Domyślam się, że może to być tajemnica firmowa i nie koniecznie powinno pisać się o tym na forum publicznym. Jeżeli tak to pisz na mojego prywatnego maila.
Witam ponownie, troch innych obowiązku nie pozwoliło na odpisanie od zaraz ale do rzeczy.
Architektura przedstawia się mniej więcej tak iż funkcjonalności biznesowe są przykryte warstwą managerów które to są implementowane w ejb2 oraz ejb3, modele także zostały zmapowane z bazy przy wykorzystaniu ww. technologi. Generalnie jeśli chcę powiedzmy, żeby trzymać się przykładu z dokumentami, zmienić mu status na nowy, wywołuje managera dokumentów który zachowując transakcyjność wykonuje polecenie. Problem w tym iż ów manager, korzystając z implementacji DAO, zwraca mi także listę np. aktywnych dokumentów. Aby coś z tym zrobić uważam iż należy oddzielić funkcjonalność techniczną aplikacji - wyświetlenie listy dok. wyświetlenie szczegółów dok. itd od funkcjonalności biznesowej stąd pomysł na cqs. Pokusa pojawia się gdy np lista dok. odpowiada obecnie zamapowanym encjom i szybciej jest zwrócić encje nawet nadmiarowe niż szlifować od zera sql'a na dany use case, wówczas niejako naturalnie implementacją findera staje sie DAO dając przy okazji abstrakcję od źródła danych co moim zdaniem nie jest złym rozwiązanie. Ale co z zapytaniem o pojedyńczy rekord, czy skorzystać z DAO (findByPK) czy w finderze dodać metodę zwracająca jeden rekord na podstawie kryteriów.
Idąc dalej mają doczynienia z wykorzystaniem tych samych funkcjonalności przez zarówno aplikacje webowe jak i standalone czy w nich przykrywać oraz grupować wszystkie funkcjnalność z BusinessDelegach czy zwracać się bezpośrednie do managerów i finderów?
Prześlij komentarz