W ostatni poniedziałek (24.10) miałem przyjemność brać udział w Łódź User Ruby Group jako prelegent (już drugi raz, o pierwszym – czyli czemu nie warto pisać aplikacji bez testów – przeczytaj tutaj). Jeśli jesteś początkującym programistą webowym (lub programistką, of kors;-) ), to zapraszam do lektury. W sumie najlepiej, jeśli masz do czynienia z Ruby on Rails, ale nie jest to konieczne, ze względu na to, że część prezentacji miała wymiar „filozoficzny”.
Istotą wszystkiego i pytaniem, które spowodowało powstanie prezentacji był dylemat – gdzie w aplikacjach webowych umieszczać logikę? No to do dzieła!
(Prezentacja w PDF została umieszczona w materiałach, czyli tutaj).
Model-View-Controller (MVC)
Jeśli tworzysz już aplikacje webowe, to pewnie wiesz co to jest MVC. Jeśli tak rzeczywiście jest, śmiało ruszaj do punktu następnego. Jeśli jednak nie jest to dla Ciebie jasne – pozwól, że w prostych, żołnierskich słowach wyjaśnię „z czym to się je”.
Model View Controller – wzorzec architektoniczny, służący do organizowania struktury aplikacji posiadających graficzne interfejsy użytkownika
Tyle jeśli chodzi o oficjalną definicję. Jeśli chodzi o praktykę, w skrócie MVC to sposób dzielenia kodu, który tworzysz między 3 warstwy – Widok (czyli View – plik, który w efekcie daje Ci stronę, którą możesz oglądać) z jednej strony. Przeciwny biegun zajmuje Model – czyli pewna reprezentacja tabeli w bazie danych, same „bebechy” aplikacji (np. klasa, która reprezentuje tabelę – w niej określamy relację z innymi tabelami, czy walidacje na konkretne pola). Łączy je Kontroler (controller), który dokonuje niezbędnych operacji (jak na przykład znalezienie jakiegoś obiektu i przekazanie go do widoku).
Po co taki podział? Daje nam bardzo wiele korzyści. Przede wszystkim porządkuje, wprowadza wyższy poziom bezpieczeństwa, oraz sprawia, że w kodzie można się połapać (kiedy wiesz czego gdzie szukać, rozwijać aplikację jest dużo prościej).
Gdzie w MVC umieszczać logikę i dlaczego tego nie robić?
Podstawowe pytanie brzmi – w której z tych warstw (Modelu, Kontrolerze, czy Widoku) umieścić logikę?
- Widok (View) – mam nadzieję, że tutaj sprawa dla Ciebie jest jasna. Kod widoku powinien być możliwie pozbyty jakiejkolwiek logiki. Jedyne wyjątki to each (pętla, którą wywołujemy na kolekcji), ewentualnie instrukcja if. Pamiętaj jednak, że i tu każde użycie tego typu rzeczy powinno być uzasadnione. Nie stosujemy logiki w widoku, ponieważ ma to być przestrzeń tylko dla wizualizacji danych, tworząc go skupiamy się na tym, żeby aplikacja była możliwie ładna i przejrzysta, a nie żeby porządnie działała w sensie inżynierskim. Pozwala to też podzielić zadania w drużynie, oraz ułatwia zachowanie Prawa Demeter.
- Kontroler (Controller) – kiedy spytałem na prezentacji o to, gdzie powinna być umieszczona logika, jeden z słuchaczy zaproponował właśnie kontroler. Na pierwszy rzut oka wydaje się to być całkiem niezły pomysł – w końcu kontroler ma pośredniczyć, między danymi z modelu, a widokiem. Nie jest to jednak wszystko takie różowe – powinniśmy się starać, żeby akcje (metody) kontrolera były możliwie chude i przejrzyste. Jeśli trzeba zastosować tam jakąś logikę, to warto przemyśleć „wypchnięcie” jej do innego pliku. W przeciwnym wypadku kontroler po niedługim czasie robi się niebywale opasły, a jego akcje pełnią bardzo dużo funkcji, do których nie zostały przeznaczone. Z resztą – przez wiele miesięcy sam pisałem logikę w kontrolerach (na początku mojej pracy zawodowej), więc wiem co mówię. Nie rób tego. Korzystaj z moich doświadczeń. Nie rób tego.
- Model – tak, wreszcie dochodzimy do ostatniej warstwy aplikacji webowych – czyli do modelu. W najprostszym przypadku MVC to właśnie tutaj powinniśmy umieszczać większość logiki aplikacji.
Logika w modelu? Może jednak niekoniecznie…
Chciałbym, żebyś pomyślał nad rozwojem aplikacji, w której stosujesz logikę w modelu. Zasymulujmy jak może się rozwijać kod (na szczegóły implementacyjne nie zwracaj uwagi póki co).
Krok 1 – Jest dobrze!
Na początku model „z logiką” wygląda fajnie.
Krok 2 – Powiedzmy…
Po niedługim czasie, zaczynasz widzieć jak się rozrasta…
Krok 3 – WTF?
No tak, to jest ten moment, do którego niewątpliwie musisz dojść. A jeśli tak się stanie, wtedy albo przyjdzie Ci zrobić refactor kodu, albo brnąć dalej w takie coś. Obie opcje są niezbyt przyjemne.
Tak więc – jak widzisz – trzymanie logiki całej aplikacji w modelach, to nie jest najlepszy pomysł. Na szczęście z odsieczą przychodzą…
Serwisy! Czyli jak sobie ułatwić życie
Jeśli jesteś początkującym programistą, to musisz wiedzieć, że serwisy to najzwyklejsze w świecie klasy. W Ruby on Rails umieszczamy je w katalogu app/services (za pierwszym razem musisz katalog services stworzyć). To tam wrzucamy większość logiki, odciążając w ten sposób model, oraz kontroler. Ot co, cały sens serwisów streszczony w kilku słowach. Spójrzmy więc i zapamiętajmy…
Serwis tworzymy, jeśli chcemy stworzyć jakąś część logiki, odciążając w ten sposób model i kontroler. Serwis umieszczamy w folderze app/services.
Oczywiście otwartą sprawą zostaje to jak technicznie tworzyć serwisy. Ja zaprezentuję tutaj dwa sposoby pisania ich, nie określam jednak który jest lepszy – wszystko zależy od Ciebie, twoich upodobań i konkretnej sytuacji.
Sposób pierwszy – „serwis jako zbiór metod”
Ta wspaniała nazwa jest oczywiście mojego autorstwa i nie radzę się do niej przywiązywać.
Ten sposób tworzenia serwisów jest bardzo prosty i intuicyjny, powiedziałbym nawet że bardzo naturalny. Swoisty wzór na niego przedstawiam poniżej.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class SomethingService def initialize(param1, param2) @param1 = param1 @param2 = param2 end def method_first # Logic... end def method_second # Logic... end end |
Jak widać sprawa ma się bardzo prosto, dla nieobeznanych wyjaśnienia wymaga jedynie metoda initialize. To ona będzie wywołana w momencie, w którym tworzymy obiekt klasy SomethingService (taki konstruktor w Ruby). Wewnątrz można stworzyć zmienne z „@” – wtedy dostępne będą w całej klasie, we wszystkich metodach.
Poniżej przedstawiam użycie takiego serwisu w dowolnym miejscu w kodzie.
1 2 3 |
service_object = SomethingService.new(1, "nazwa") first_thing = service_object.method_first second_thing = service_object.method_second |
W pierwszej linijce stworzyłem obiekt ServiceName – wtedy automatycznie została wywołana metoda initialize. Następne linijki to wywołanie konkretnych metod.
Zwracam uwagę, że nazwa każdego z serwisów powinna się kończyć słówkiem „Service” (np. SkillService, StatsService itd), zaś nazwa pliku powinna kończyś się „_service” (np. skill_service, stats_service).
Sposób drugi – „serwis z pojedynczą odpowiedzialnością (single responsibility service)”
Nazywany potocznie „ten z call’em”. Różni się od pierwszego tym, że cały serwis pełni jedną funkcję. Jeśli nie jest to jeszcze jasne, spójrz na wzór na serwis z call’em.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class DoSomething def initialize(param1, param2) @param1 = param1 @param2 = param2 end def call # Some logic... end private def method_first # Logic... end def method_second # Logic... end end |
Tutaj sprawa ma się tak, że tylko metoda call jest publiczna i tylko ją wywołujemy. Wszystkie inne metody są prywatne i służą jej, jeśli oczywiście zachodzi taka potrzeba. W przeciwieństwie do poprzedniego serwisu nie daje nam tak różnorakich możliwości, pozostaje za to bardziej klarowny i przejrzysty.
A oto jak wywołujemy serwis jak ten:
1 |
some_thing = DoSomething.new(1, "nazwa").call |
Prawda, że proste? No bardzo proste. Tutaj również zwracam uwagę na nazewnictwo – ponieważ tutaj serwis odpowiada za jedną rzecz, nie musi kończyć się dopiskami „service” – niemniej pozostawiam tą kwestię twojej wygodzie.
Przykład – tworzymy statystyki na stronę
Oczywiście do zrozumienia przyda się przykład. Posłużę się moją aplikacją, która wspiera usystematyzowane rozwijanie umiejętności. Sprawa jest niezwykle prosta – gdy zaczynamy pracować nad jakąś rzeczą (nazwaną w aplikacji Prefabem) – klikamy przycisk start. Kiedy kończymy – stop. W tym momencie tworzy nam się log_time. Chcemy wyciągnąć informacje o tym ile pracowaliśmy nad daną umiejętnością (prefab może realizować kilka umiejętności) w określonym okresie czasu, oraz z konkretnym wykresem dzień-czas. W efekcie będzie to wyglądało mniej więcej tak jak na screenie poniżej.
W tym celu musimy wykonać cała masę operacji (wyciągnąć logtime’y, policzyć z nich czas, przyporządkować do dni, policzyć czas ogólnie, zamienić czas na konkretną datę… i kilka innych). Do tego stworzymy serwis SkillsStatsService – to będzie „serwis jako zbiór metod”. Ponieważ jednak w kontrolerze potrzebujemy dosłownie kilku danych, do zwrócenia ich użyjemy SkillsDataSummaryService – serwis ten będzie typowym serwisem „z call”. Będzie wykorzystywał SkillsStatsService.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
module DirectData class SkillsStatsService def initialize(skill_id) @skill = Skill.find(skill_id) @generals = DirectData::GeneralStatsService.new(@skill.user.id) end def log_times_in_period(from, to) LogTime.where(prefab_id: @skill.prefabs.select(:id)).where('date_start >= ? AND date_start <=? ', from, to) end def log_times_in_general LogTime.where(prefab_id: @skill.prefabs.select(:id)) end # I inne metody... end |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
module DirectData class SkillsDataSummaryService def initialize(skill_id, from, to) @skill = Skill.find(skill_id) @from = from @to = to @skills_stats = DirectData::SkillsStatsService.new(@skill.id) @generals = DirectData::GeneralStatsService.new(@skill.user.id) end def call data = {} data[:time_jointly] = @generals.seconds_to_units(@skills_stats.time_in_period(@from, @to)) data[:time_per_day] = @skills_stats.time_in_period_per_day(@from, @to) data[:skill_name] = @skill.name data end end end |
Dzięki połączeniu tych dwóch serwisów, bardzo ładnie możemy przekazać do kontrolera tylko niezbędne dane.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class SkillStatsController < ApplicationController def index if current_user.log_times.any? @skills = current_user.skills.pluck(:name) @range = params[:from].nil? ? { from: nil, to: nil} : { from: params[:from].to_date, to: params[:to].to_date } @skill = find_skill(params[:skill_name]) @data = DirectData::SkillsDataSummaryService.new(@skill.id, @range[:from], @range[:to]).call end end private def find_skill(name) skill = name.nil? ? current_user.skills.first : Skill.find_by(name: params[:skill_name].strip) skill = current_user.skills.first if skill == nil skill end end |
Stosuj serwisy i buduj dobre aplikacje!
To już wszystko, gdybyś miał (lub miała) jakiekolwiek pytania, to śmiało do mnie pisz ([email protected]). Zachęcam do budowania swoich aplikacji – możliwie kreatywnych, które rozwiązują twoje konkretne problemy.
Powodzenia!
Ja nazywam się Marek Czuma, a to jest IT-Blog Wolnego Człowieka
Piszę do Ciebie Prosto z Łodzi
Jeśli uważasz, że artykuł był pomocny, lub po prostu lubisz być kreatywnym w IT – polub mój Fanpage na Facebook’u. Zapraszam do zostawienia maila – zero spamu, 100% dobrych treści.