Mam takie pytanie teoretyczne, które od czasu do czasu pojawia się w moich projektach. Najlepiej chyba będzie opisać na przykładzie.
Piszę aplikację sportową (konkretnie dla pływaków) we frameworku Symfony i w niej mam między innymi dwa obiekty Entity:
- User
- Interval
Użytkownik ma pewne cechy definiowalne - np. prędkość bazową (dajmy na to 2:00/100m), modyfikatory prędkości (np. gdy ma płetwy to płynie 10s/100m szybciej niż bez etc).
Interwał to cegiełka z której buduję treningi do wykonania. Czyli każdy interwał posiada między innymi:
- dystans do pokonania
- prędkość wyrażoną jako prdkość bazowa +/- x sekund.
- listę modyfikatorów do zastosowania - czyli np. dany interwał użytkownik ma płynąć w płetwach
I teraz dochodzimy do tego co mnie interesuje. Mam konkretny interwał w którym jest płyń z prędkością bazową - 5s oraz użyj płetw. Mam konkretnego użytkownika, dla którego prędkość bazowa to 2:00, a modyfikator płetw daje - 10s. Czyli finalnie ten konkretny użytkownik ma popłynąć ten konkretny interwał w tempie 2:00 (prędkość bazowa użytkownika) - 5s (ustawienie interwału) - 10s (bonus za płetwy) - razem daje 1:45/100m.
Pytanie brzmi - jaki obiekt powinien obliczyć tą konkretną wartość, żeby było prawidłowo i zgodnie ze sztuką.
Interwał raczej nie powinien wiedzieć o użytkowniku i vice versa. Robienie tych samych obliczeń w 50 miejscach (np. w kontrolerach) jest bez sensu i trudne w utrzymaniu. Osobiście w tym celu stosuję usługę, którą sobie nazwałem WorkoutService i mam tam takie funkcje jak np getIntevalPace.
Zastanawiam się czy to jest dobre rozwiązanie, czy może jakoś inaczej powinienem to ogarniać.
Crozin - no i właśnie tego nie rozumiem ;-) Tzn rozumiem ideę, nie wykrozystuję encji do tego etc. Tylko nie wiem jak to poprawnie rozwiązać.
Na tą chwilę dokładnie działa to u mnie tak, że mam Workout (trening) -> składający się z bloków (blocks) -> bloki składają się z zadań (task) -> zadania składają się z interwałów (interval).
Mam usługę o nazwie Workout, która odpowiada za wszelkie operacje na treningu. Czyli np. obliczanie długości trening/bloku, obliczanie temp dla danego użytkownika etc.
Mam też obiekt VCSettings w którym przechowuje ustawienia potrzebne do przekształcenia abstrakcyjnego treningu w trening dla danego użytkownika - czyli w moim przypadku obiekt ten przechowuje informacje takie jak długość basenu, tempo bazowe, modyfikatory itd itp.
Wywołania u mnie wyglądają tak
$workoutService->getIntervalPace($interval, $vcsettings);
$workoutService->getWorkoutDuration($workout, $vcsettings);
itd itp.
To jest ok, czy powinno to się odbywać jakoś inaczej. Jeśli tak to konkretnie jak? Tak jak pisze rozumiem ideę, ale nie jestem pewien jak to powinno wyglądać w praktyce.
Wg Ciebie powinienem mieć klasę typu Interval, który powiedzmy powinna mieć np. przez konstruktor wrzucone $interval(encję) + np. mój obiekt vcSettings?
Sorry, że tak drążę, ale chciałbym dobrze temat zrozumieć.
1. Szczerze mówiąc nie jestem dokładnie pewien czy w pełni poprawnie rozumiem problem (tak patrząc całościowo) - to na start. Nie wiem też czy przykłady jakie podałeś wyczerpują różnorodność detali czy właściwie na tym kończą się ich wariacje. Fajnie jakbyś może pokazał nawet jakąś okrojoną wersję kodu źródłowego (szczególnie $workoutService'u) - nieraz mówi więcej niż jego opis.
2. Bardzo ważne jest to jak bardzo chcesz, czy właściwie jak bardzo potrzebujesz "rozdmuchać" obsługę tego problemu. Chodzi o to by jednak potencjalnie prostego problemu nie zacząć rozwiązywać w niepotrzebnie rozbudowany, skomplikowany i obszerny sposób (takie ang. overengineering).
3. Wstępnie wygląda to tak, że obiekty pod $interval, $vcsettings czy $workout to wyłącznie wory do przenoszenia złożonych danych (DTO), a obliczenia z nimi związane zamiast być blisko danych w postaci metod (jak to jest przewidziane w OOP) są wyniesione gdzieś zupełnie indziej w kodzie.
4. Na początek spróbuj może troszkę odwrócić relację pomiędzy obiektami. Zamiast $workoutService->getDuration($workout, $vcsettings) spróbuj do tego podejść jako $workout->computeDuration($vcsettings??, $workoutService??). Być może te $vcsettings powinny być przekazane już w konstruktorze, a ten drugi argument w ogóle stanie się już niepotrzebny?
5. Ten fragment kodu (związany z samą logiką aplikacji) w miarę możliwości nie powinien mieć nawet świadomości Symfony, Doctrine'a (encji) czy innych frameworków. Dlaczego? Bo takie webowe FW nie chcą rozwiązywać konkretnych problemów, specyficznych dla Twojej aplikacji. Mają dać Ci szybką, solidną i wygodną "otoczkę" niezwiązaną bezpośrednio z celem działania samej apki (tutaj: wyliczania terningów), a związaną z całym tym "syfem" potrzebnym do tego by to gdzieś/jakoś/komuś uruchomić. Pozwól im skupić się na tym co faktycznie potrafią robić, samemu skupiając się mocno na swoim problemie - którego rozwiązanie już leży po Twojej stronie.
Warto się zastanowić czy Interwał to w ogóle encja. Czasami lepiej jest uprościć schemat danych i zamiast osobnej tabeli, trzymać pewne dane w kolumnie typu np. JSON. Wszystko zależy od tego w jaki sposób chcesz wyszukiwać i grupować te dane. Na pytanie kto powinien zajmować się obliczaniem interwałów, odpowiedziałbym że albo osobna klasa, albo klasa Interwału. Nie ma nic złego w tym, że Interwał zna szczegóły Użytkownika.
@Crozin - kod na tą chwilę: https://www.dropbox.com/s/jn5amv1dk12pwel/workoutService.php?dl=0
Nie wiem jednak czy zrozumiesz kod na podstawie tej jednej klasy. W każdym razie powiem tylko, że są w niej ogólnie 3 typy metod:
- podstawowe typu klonowanie poszczególnych części
- obliczanie temp/długości interwałów w zależności od ustawień vcsettings
- obliczanie generycznych temp. w Skrócie obliczenie długości treningu jest dość skomplikowane więc obliczam 3 czynniki istotne aby je cachować - tj. mnożnik metrów płyniętych w tempie bazowym, mnożnik zmienności tempa, mnożniki dla modyfikatorów. Dzięki temu potem mogę na liście łatwo podstawić te mnożnik pod danego użytkownika i obliczyć czas treningu bez zażynania basy setkami zapytań.
Co do pozostałych Twoich punktów mam pewne przemyślenia, ale na razie ich nie opisuję, żeby nie mnożyć wątków. Z częścią się zgadzam, z częścią mam wrażenie, że byłoby to dokładnie to o czym piszesz, czyli zbytnie rozbudowanie w sumie prostej rzeczy. Ale na razie nie wchodzę w szczegóły bo zależy mi bardziej na dyskusji "akademickiej" niż tym konkretnym przypadku.
Jestem ciekaw jak sam WorkutService ocenisz - mi się tam już teraz pewne rzeczy nie podobają, ale nie do końca wiem jak je sensownie poprawić.
@SmokAnalog - ale tak komunikacja user <=> interval to chyba raczej nie powinna być w encji? Na szybko wiadomo byłoby to najwygodniejsze, ale utrzymanie tego kodu to by było piekiełko potem, bo user pojawiałby się w kilkunastu klasach projektu i każda zmiana w klasie user byłaby potem koszmarem. Wydzielenie wspólnych relacji do osobnej klasy wydaje mi się sensowne. Klasy user/vcsettings już wcześniej rozbudowywałem kilka razy o nowe funkcje i świadomość tego, że mam ściśle określone punkty styku z innymi obiektami sporo upraszcza.
1. Przede wszystkim problemem tej klasy jest to, że musiałeś użyć aż trójelementowej listy by opisać co ona w ogóle robi. A robi zdecydowanie za dużo i powinna zostać rozbita na wyspecjalizowane obiekty.
2. Taka trochę ogólna uwaga co do tworzenia całych grafów encji, np. w createNewWorkoutBlock.
Tyle wystarczy, logika wyliczania pozycji i innych tego typu rzeczy może spokojnie zostać zamknięta w encji nadrzędnej.
$block=new WorkoutBlock(); $task=new WorkoutTask(); $interval=new WorkoutInterval(); $rest=new WorkoutInterval(); $rest->setType(WorkoutInterval::TYPE_REST); $rest->setDuration(10); $task->addInterval($interval); // wylicza m. in. pozycję oraz ustawia dwu/jednostronną referencję $task->addInterval($rest); // j/w $block->addTask($task); // j/w $workout->addBlock($block); // j/w // raczej nie powinno znajdować się to już w tej metodzie, ale jak już to wystarczy $this->em->persist($block); $this->em->flush(); return $block;
Crozin tak jak już pisałem Ci na PW, jeszcze raz dzięki za tak rozbudowaną odpowiedź.
Skłoniła mnie ona do przeanalizowania tematu i trochę poczytałem... Szczerze to mam teraz mały maindfuck w głowie.
Zanim zacznę wchodzić w szczegóły tematu to chyba trzeba zacząć od ogółu. Otóż moje podejście do ww klasy chyba wynika z ogólnie błędnego paradygmatu na którym się oparłem. Gdzieś tam w trakcie nauki Symfony natrafiłem na materiały, które być może trochę opacznie zrozumiałem, ale ogólnie zrozumiałem z nich tyle:
- klasy entity to prosta warstwa dostępu do danych, która powinna w zasadzie w 95% mieć gettery/settery + 5% jakiejś bardzo prostej logiki obiektu.
- cała logika biznesowa powinna trafić do bezstanowych klas serwisowych.
Po dyskusji w tym poście zacząłem temat drążyć i faktycznie jest takie podejście - nie jest to mój wymysł (uff) i nazywa się to klasami anemicznymi. Wiele osób jednak krytykuje to podejście jako zaprzeczenie idei obiektywności. I tak zacząłem sobie to przemyśliwać - faktycznie klasie serwisowej bliżej do programowania strukturalnego niż obiektowego. Przy relatywnie małej aplikacji, takiej jak moja jest to nawet dość wygodne podejście - cała logika biznesowa znajduje się w kilku klasach serwisowych - jest to dość łatwe do poprawiania, rozwijania i nie tworzy dużej ilości kodu - jednym słowem nie jest to do końca głupie podejście...
Z drugiej strony jednak faktycznie jest to zaprzeczenie obiektowości - w tym podejściu obiekty są tak na prawdę strukturami danych a nie obiektami.
Ogólnie więc rozumiem w czym jest problem, ale nie wiem jak w praktyce to poprawić. Trochę zaczyna mi się mieszać co powinno być bezstanowym serwisem, a co powinno być rolą obiektu. Czy moje Entity powinny zostać "anemiczną strukturą danych" i powinna pojawić się jeszcze jedna warstwa, czy powinienem dodać logikę do samych klas entity. Załóżmy, że tworzę nową warstwę - to by oznaczało, że każdy istotny obiekt entity powinienem powielić? Przykładowo w moim przypadku powinienem stworzyć kolejną klasę Workout, która w konstruktorze przyjmowała by Entity Workout + serwisy, które są potrzebne do logiki biznesowej?
Przykładowo czy taki obiekt powinien móc sam się zapisać (czyli mieć dostęp do EntityMenagera w Symfony)?
W innej części aplikacji mam np. bardziej rozbudowany serwis służący do generowania komend głosowych - korzysta on z serwisu zewnętrznego do generowania komend głosowych, z serwisu dokonującego tłumaczeń itp. Czy teraz obiekt workout powinien także takie rzeczy robić jak generować pliki dźwiękowe?
Ogólnie mam spory problem aby ogarnąć podział obowiązków w praktycznym przykładzie. Czytając trywialne przykłady omawiające różne wzorce projektowe, gdzie klasy są na 5 linijek, a przykłady mocno uproszczone wszystko wydaje się oczywiste - ale jak trafia na normalną aplikację, to to wszystko przestaje być już oczywiste.
Chętnie bym rozrysował graf zależności w mojej aplikacji jeśli znalazłby się ktoś, kto by poradził jak to wszystko dobrze poukładać i rozrysował jak krowie na rowie, jak to powinno być. Wiem, że nie ma jednego dobrego rozwiązania, ale chętnie bym poznał różnej podejścia. Znalazłby się ktoś chętny do dyskusji w takim temacie?
1. Nie bój się tworzyć wielu dedykowanych klas do rozwiązywania wielu różnych problemów w aplikacji. Unikaj raczej tworzenia "obiektu pod wszystkie możliwie zadania", bo to się nie sprawdza.
2. Staraj się by raczej detal implementacji warstwy zapisu danych (tutaj: baza danych + doctrine) nie wyciekał do innych warstw aplikacji. Innymi słowy obiekty domenowe same w sobie raczej nie powinny - bo i po co - mieć wiedzy n/t Doctrine'a.
Ok poczytałem trochę i mniej więcej mam już wizję tego jak mógłbym opisać strukturę swoich obiektów. Zaczynam już rozumieć ideę kompozycji i wydaje mi się, że powinienem skorzystać z wzorca strategii. Widzę już oczyma wyobraźni piękną strukturę z klasą Interval z której dziedziczą poszczególne typy interwałów typu FixedTimeInterval, TempoInterval itd. Oczyma wyobraźni projektuje już interfejsy do poszczególnych cech interwałów typu DurationInterface z którego powstają rzeczywiste klasy zachowań zwracające długość trwania danego interwału w zależności od parametrów danej implementacji interwału...
Jednego tylko kurde nie mogę rozkminić - jak to wszytko ująć w ramach Symfony, tj jak później taki obiekt zapisać do bazy, jak utworzyć na podstawie przetworzonego formularza etc. Wszystko jest dość mocno powiązane z Entity i jakby filozofia frameworka jest na tym oparta. Te klasy typu FixedTimeInterval powinny dziedziczyć z Entity, żeby przy okazji być samym Entity?
Tak jak już (wydaje mi się) rozumiem duży obraz, tak chyba bez fragmentu kodu nie pojmuję jak to zaimplementować w Symfony. Pewnie jest jakieś eleganckie i proste rozwiązanie, ale mi to umyka...
Bezstanowe serwisy są tu dość prostą koncepcją dodającą po prostu zachowania do Entity i ich relacje... Nie mam pojęcia jak przejść z takiego modelu na to o czym piszesz Ty ;-( Znasz może jakiś prosty projekt OS gdzie mógłbym to o czym piszesz zobaczyć?
Crozin nie mam żadnego fragmentu kodu, bo kompletnie nie wiem jak się do tego zabrać. Niestety tak jak piszesz wszelkie "best practices" czy "demo project" w Symfony są tworzone tak jak ja to robię, czyli Entyty + Serwisy ;(
Sprowadzając to do najbardziej uproszczonego przypadku.
Mam Entity Interval + tworzę obiekt domenowy Interval (docelowo interwał byłby klasą abstrakcyjną po której dziedziczyłyby różne typy interwałów, ale tutaj załóżmy, że jest to od razu konkretna klasa).
Pytania jakie mnie nachodzą:
- obiekt interwał ma jakieś properties, w dużej mierze takie same jak samo Entity. Czy zatem należy stworzyć konstruktor, który "przepisze" te wartości z entity, czy po prostu Entity dać jako element Interwału?
- załóżmy, że wykonałem jakieś operacje na Interval, które chce teraz zapisać do bazy. Znów - w którym miejscu kodu przekazać moje zmiany do Entity?
Takich pytań mam więcej - np. jak obsługiwać relacje etc. Bo teraz jedyne rozwiązania jakie mi przychodzą do głowy to ogromna redundancja kodu aby w obiekcie Interval odtworzyć sporo zachowań, które w Entity (dzięki Doctrine) mam z automatu.
No po prostu utknąłem w takim punkcie, że nie wiem jak zacząć i z chęcią zobaczył bym jakiś projekt w Symfony, gdzie jest to rozwiązane prawidłowo, żeby zaskoczył. Przypuszczam, że po prostu blokuję się w jakimś miejscu na złym rozwiązaniu.
Wydaje mi się, że za bardzo skupiasz się w tej chwili na Symfony/Doctrine/innych bibliotekach oraz na problemach których jeszcze nie masz tworząc z relatywnie prostego problemu rozdmuchanego potwora, do którego faktycznie trudno podejść. Wcześniej pisałeś o tym jak już oczami wyobraźni widzisz strukturę i kontrakty pomiędzy obiektami, teraz piszesz o nieistotnych z punktu widzenia tego problemu bzdetach. Zignoruj na chwilę problem obsługi bazy danych czy ogólnie ich składowania. Olej problem wprowadzania danych czyli formularze. Wypnij cztery litery na tworzenie serwisów w kontenerze zależności zrób to manualnie na start.
W skrócie: utwórz sobie prosty, śmieciowy kontroler z jedną akcją jako punkt wejścia do kodu, wpisz do niego ręcznie jakiś stan początkowy dla danych, uruchom kod który ma te dane przetworzyć i na koniec nawet najprostszym Symfonowym dump() wyświetl rezultat by sprawdzić czy otrzymane dane są prawidłowe.
class DummyController { public function runAction(): Response { // Dane początkowe $staminaCalculator = new JakasDodatkowaUsluga(); $intervalA = new AbcInterval(new DateInterval('PT5M')); $intervalB = new RestInterval(new DateInterval('PT1M')); $intervalC = ...; $athlete = new Athlete(endurance = 5, stamina = 10); $blocks = .... $vcsettings = ... $workout = new Workout($blocks, $vcsettings) // uruchom istotny kod, który w końcu coś robi $result = $workout->calculateDuration($athlete, $staminaCalculator); // przykladowo coś takiego // wyświetl sobie dane by zweryfikować czy calculateDuration() działa prawidłowo dump($result); http://www.php.net/die('a olać co tam by się chciało dalej dziać - nie ma znaczenia teraz'); } }
Sorki, że tak długo się nie odzywałem, ale splotem bardzo dziwnych okoliczności trafiłem na człowieka, który po kolei wprowadza mnie w meandry OOP.
Zrozumiałem, że to co ja uważałem za OOP nie miało nic wspólnego z OOP ;-) W każdym razie już teraz rozumiem o czym piszesz. Chyba po prostu miałem wcześniej zbyt małe podstawy i brakowało mi kilku elementów układanki. Dzięki za poświęcony czas.
Powered by Invision Power Board (http://www.invisionboard.com)
© Invision Power Services (http://www.invisionpower.com)