Twórz eleganckie komponenty Rails ze zwykłymi starymi obiektami Ruby
Opublikowany: 2022-03-11Twoja strona internetowa zyskuje na popularności, a Ty szybko się rozwijasz. Ruby/Rails to wybrany przez Ciebie język programowania. Twój zespół jest większy i zrezygnowałeś z „grubych modeli, chudych kontrolerów” jako stylu projektowania aplikacji Railsowych. Jednak nadal nie chcesz rezygnować z używania Railsów.
Nie ma problemu. Dzisiaj omówimy, jak korzystać z najlepszych praktyk OOP, aby Twój kod był czystszy, bardziej odizolowany i bardziej oddzielony.
Czy Twoja aplikacja jest warta refaktoryzacji?
Zacznijmy od przyjrzenia się, jak zdecydować, czy Twoja aplikacja jest dobrym kandydatem do refaktoryzacji.
Oto lista wskaźników i pytań, które zwykle zadaję sobie, aby określić, czy mój kod wymaga refaktoryzacji.
- Powolne testy jednostkowe. Testy jednostkowe PORO zwykle działają szybko z dobrze wyizolowanym kodem, więc wolno działające testy mogą często wskazywać na zły projekt i nadmiernie powiązane obowiązki.
- Modele lub kontrolery FAT. Model lub kontroler z ponad 200 wierszami kodu (LOC) jest zazwyczaj dobrym kandydatem do refaktoryzacji.
- Zbyt duża baza kodu. Jeśli masz ERB/HTML/HAML z ponad 30 000 LOC lub kodem źródłowym Ruby (bez GEM) z ponad 50 000 LOC, istnieje duża szansa, że powinieneś dokonać refaktoryzacji.
Spróbuj użyć czegoś takiego, aby dowiedzieć się, ile masz wierszy kodu źródłowego Rubiego:
find app -iname "*.rb" -type f -exec cat {} \;| wc -l
To polecenie przeszuka wszystkie pliki z rozszerzeniem .rb (pliki ruby) w folderze /app i wydrukuje liczbę wierszy. Należy pamiętać, że ta liczba jest jedynie przybliżona, ponieważ w tych sumach zostaną uwzględnione wiersze komentarzy.
Inną bardziej precyzyjną i bardziej pouczającą opcją jest użycie stats
zadania Rails rake, które wyświetlają szybkie podsumowanie linii kodu, liczbę klas, liczbę metod, stosunek metod do klas oraz stosunek linii kodu na metodę:
bundle exec rake stats +----------------------+-------+-----+-------+---------+-----+-------+ | Name | Lines | LOC | Class | Methods | M/C | LOC/M | +----------------------+-------+-----+-------+---------+-----+-------+ | Controllers | 195 | 153 | 6 | 18 | 3 | 6 | | Helpers | 14 | 13 | 0 | 2 | 0 | 4 | | Models | 120 | 84 | 5 | 12 | 2 | 5 | | Mailers | 0 | 0 | 0 | 0 | 0 | 0 | | Javascripts | 45 | 12 | 0 | 3 | 0 | 2 | | Libraries | 0 | 0 | 0 | 0 | 0 | 0 | | Controller specs | 106 | 75 | 0 | 0 | 0 | 0 | | Helper specs | 15 | 4 | 0 | 0 | 0 | 0 | | Model specs | 238 | 182 | 0 | 0 | 0 | 0 | | Request specs | 699 | 489 | 0 | 14 | 0 | 32 | | Routing specs | 35 | 26 | 0 | 0 | 0 | 0 | | View specs | 5 | 4 | 0 | 0 | 0 | 0 | +----------------------+-------+-----+-------+---------+-----+-------+ | Total | 1472 |1042 | 11 | 49 | 4 | 19 | +----------------------+-------+-----+-------+---------+-----+-------+ Code LOC: 262 Test LOC: 780 Code to Test Ratio: 1:3.0
- Czy mogę wyodrębnić powtarzające się wzorce w mojej bazie kodu?
Oddzielenie w działaniu
Zacznijmy od przykładu ze świata rzeczywistego.
Udawaj, że chcemy napisać aplikację, która śledzi czas biegaczy. Na stronie głównej użytkownik może zobaczyć godziny, w których wszedł.
Za każdym razem wpis zawiera datę, odległość, czas trwania i dodatkowe istotne informacje o „statusie” (np. pogoda, rodzaj terenu itp.) oraz średnią prędkość, którą można obliczyć w razie potrzeby.
Potrzebujemy strony raportu, która wyświetla średnią prędkość i dystans na tydzień.
Jeśli średnia prędkość dla wpisu jest wyższa niż ogólna średnia prędkość, powiadomimy użytkownika SMS-em (w tym przykładzie do wysłania SMS-a użyjemy Nexmo RESTful API).
Strona główna pozwoli Ci wybrać dystans, datę i czas spędzony na bieganiu, aby utworzyć wpis podobny do tego:
Mamy również stronę ze statistics
, która jest w zasadzie cotygodniowym raportem, który zawiera średnią prędkość i dystans pokonywany w tygodniu.
- Możesz sprawdzić próbkę online tutaj.
Kod
Struktura katalogu app
wygląda mniej więcej tak:
⇒ tree . ├── assets │ └── ... ├── controllers │ ├── application_controller.rb │ ├── entries_controller.rb │ └── statistics_controller.rb ├── helpers │ ├── application_helper.rb │ ├── entries_helper.rb │ └── statistics_helper.rb ├── mailers ├── models │ ├── entry.rb │ └── user.rb └── views ├── devise │ └── ... ├── entries │ ├── _entry.html.erb │ ├── _form.html.erb │ └── index.html.erb ├── layouts │ └── application.html.erb └── statistics └── index.html.erb
Nie będę omawiał modelu User
, ponieważ nie jest on niczym specjalnym, ponieważ używamy go z Devise do implementacji uwierzytelniania.
Model Entry
zawiera logikę biznesową naszej aplikacji.
Każde Entry
należy do User
.
Sprawdzamy obecność atrybutów distance
, time_period
, date_time
i status
dla każdego wpisu.
Za każdym razem, gdy tworzymy wpis, porównujemy średnią prędkość użytkownika ze średnią wszystkich innych użytkowników w systemie i powiadamiamy użytkownika SMS-em za pomocą Nexmo (nie będziemy dyskutować o tym, jak korzysta się z biblioteki Nexmo, chociaż chciałem aby zademonstrować przypadek, w którym korzystamy z zewnętrznej biblioteki).
- Próbka zbiorcza
Zauważ, że model Entry
zawiera więcej niż samą logikę biznesową. Obsługuje również niektóre walidacje i wywołania zwrotne.
Plik entries_controller.rb
zawiera główne akcje CRUD (jednak bez aktualizacji). EntriesController#index
pobiera wpisy dla bieżącego użytkownika i porządkuje rekordy według daty utworzenia, podczas gdy EntriesController#create
tworzy nowy wpis. Nie ma potrzeby omawiania oczywistych i obowiązków EntriesController#destroy
:
- Próbka zbiorcza
Podczas gdy statistics_controller.rb
odpowiada za obliczanie raportu tygodniowego, StatisticsController#index
pobiera wpisy dla zalogowanego użytkownika i grupuje je według tygodnia, używając metody #group_by
zawartej w klasie Enumerable w Railsach. Następnie próbuje ozdobić wyniki za pomocą prywatnych metod.
- Próbka zbiorcza
Nie omawiamy tutaj zbyt wiele poglądów, ponieważ kod źródłowy nie wymaga wyjaśnień.
Poniżej znajduje się widok listy wpisów dla zalogowanego użytkownika ( index.html.erb
). To jest szablon, który będzie używany do wyświetlania wyników akcji indeksowania (metody) w kontrolerze wpisów:
- Próbka zbiorcza
Zwróć uwagę, że używamy render @entries
, aby wyciągnąć udostępniony kod do częściowego szablonu _entry.html.erb
, abyśmy mogli zachować nasz kod w stanie suchym i nadający się do ponownego użycia:
- Próbka zbiorcza
To samo dotyczy części _form
. Zamiast używać tego samego kodu z akcjami (nowe i edycyjne), tworzymy formularz częściowy wielokrotnego użytku:
- Próbka zbiorcza
Jeśli chodzi o widok strony raportu tygodniowego, statistics/index.html.erb
pokazuje niektóre statystyki i raportuje tygodniową wydajność użytkownika, grupując niektóre wpisy :
- Próbka zbiorcza
I wreszcie, helper dla wpisów, entries_helper.rb
, zawiera dwa helpery readable_time_period
i readable_speed
, które powinny sprawić, że atrybuty będą bardziej czytelne dla człowieka:
- Próbka zbiorcza
Jak dotąd nic wymyślnego.
Większość z was będzie argumentować, że refaktoryzacja jest sprzeczna z zasadą KISS i sprawi, że system będzie bardziej skomplikowany.
Czy ta aplikacja naprawdę wymaga refaktoryzacji?
Absolutnie nie , ale rozważymy to tylko w celach demonstracyjnych.
W końcu, jeśli zapoznasz się z poprzednią sekcją i cechami, które wskazują, że aplikacja wymaga refaktoryzacji, staje się oczywiste, że aplikacja w naszym przykładzie nie jest prawidłowym kandydatem do refaktoryzacji.
Koło życia
Zacznijmy więc od wyjaśnienia struktury wzorca Rails MVC.
Zwykle zaczyna się od wysłania żądania przez przeglądarkę, na przykład https://www.toptal.com/jogging/show/1
.
Serwer sieciowy odbiera żądanie i wykorzystuje routes
aby dowiedzieć się, którego controller
użyć.
Kontrolerzy wykonują pracę polegającą na parsowaniu żądań użytkowników, przesyłaniu danych, plikach cookie, sesjach itp., a następnie proszą model
o pobranie danych.
models
to klasy Ruby, które komunikują się z bazą danych, przechowują i weryfikują dane, wykonują logikę biznesową i w inny sposób wykonują ciężkie zadania. Widoki to to, co widzi użytkownik: HTML, CSS, XML, Javascript, JSON.
Jeśli chcemy pokazać sekwencję cyklu życia żądania Rails, wyglądałoby to mniej więcej tak:
To, co chcę osiągnąć, to dodać więcej abstrakcji za pomocą zwykłych starych obiektów ruby (PORO) i sprawić, by wzorzec był podobny do następującego dla akcji create/update
:
I coś takiego jak poniżej dla akcji list/show
:
Dodając abstrakcje POROs zapewnimy pełne rozdzielenie obowiązków SRP, w czym Railsy nie są zbyt dobre.
Wytyczne
Aby uzyskać nowy projekt, skorzystam z poniższych wskazówek, ale pamiętaj, że nie są to zasady, których musisz przestrzegać, aby uzyskać T. Pomyśl o nich jako o elastycznych wytycznych, które ułatwiają refaktoryzację.
- Modele ActiveRecord mogą zawierać asocjacje i stałe, ale nic więcej. Oznacza to brak wywołań zwrotnych (użyj obiektów usług i dodaj tam wywołania zwrotne) i brak walidacji (użyj obiektów Form, aby uwzględnić nazewnictwo i walidacje dla modelu).
- Trzymaj kontrolery jako cienkie warstwy i zawsze wywołuj obiekty usługi. Niektórzy z was zapytają, po co w ogóle używać kontrolerów, skoro chcemy nadal wywoływać obiekty usług, aby zawierały logikę? Cóż, kontrolery są dobrym miejscem na routing HTTP, parsowanie parametrów, uwierzytelnianie, negocjowanie treści, wywoływanie właściwej usługi lub obiektu edytora, przechwytywanie wyjątków, formatowanie odpowiedzi i zwracanie właściwego kodu statusu HTTP.
- Usługi powinny wywoływać obiekty Query i nie powinny przechowywać stanu. Używaj metod instancji, a nie metod klas. Powinno być bardzo mało publicznych metod zgodnych z SRP.
- Zapytania powinny być wykonywane w obiektach zapytań. Metody obiektu zapytania powinny zwracać obiekt, skrót lub tablicę, a nie powiązanie ActiveRecord.
- Unikaj używania pomocników i zamiast tego używaj dekoratorów. Czemu? Częstą pułapką związaną z helperami Rails jest to, że mogą one przekształcić się w duży stos funkcji innych niż OO, z których wszystkie dzielą przestrzeń nazw i nachodzą na siebie. Ale o wiele gorsze jest to, że nie ma świetnego sposobu na użycie jakiegokolwiek rodzaju polimorfizmu z helperami Rails — dostarczanie różnych implementacji dla różnych kontekstów lub typów, nadpisywanie lub subklasowanie helperów. Myślę, że klasy pomocnicze Rails powinny być ogólnie używane do metod narzędziowych, a nie do konkretnych przypadków użycia, takich jak formatowanie atrybutów modelu dla dowolnego rodzaju logiki prezentacji. Utrzymuj je lekkie i przewiewne.
- Unikaj używania obaw i zamiast tego używaj dekoratorów/delegatorów. Czemu? W końcu obawy wydają się być podstawową częścią Railsów i mogą wysuszyć kod, gdy są współdzielone przez wiele modeli. Jednak głównym problemem jest to, że obawy nie sprawiają, że obiekt modelu jest bardziej spójny. Kod jest po prostu lepiej zorganizowany. Innymi słowy, nie ma rzeczywistych zmian w interfejsie API modelu.
- Spróbuj wyodrębnić obiekty wartości z modeli, aby zachować czystszy kod i pogrupować powiązane atrybuty.
- Zawsze przekazuj jedną zmienną wystąpienia na widok.
Refaktoryzacja
Zanim zaczniemy, chcę omówić jeszcze jedną rzecz. Kiedy zaczynasz refaktoryzację, zwykle kończysz się pytaniem: „Czy to naprawdę dobry refaktoring?”
Jeśli czujesz, że robisz więcej separacji lub izolacji między obowiązkami (nawet jeśli oznacza to dodanie większej ilości kodu i nowych plików), to zwykle jest to dobra rzecz. W końcu oddzielenie aplikacji jest bardzo dobrą praktyką i ułatwia nam wykonanie właściwych testów jednostkowych.
Nie będę omawiał takich rzeczy, jak przenoszenie logiki z kontrolerów do modeli, ponieważ zakładam, że już to robisz i czujesz się komfortowo używając Rails (zwykle Skinny Controller i model FAT).
W celu zachowania ścisłości tego artykułu nie będę omawiał tutaj testowania, ale to nie znaczy, że nie powinieneś testować.
Wręcz przeciwnie, zawsze powinieneś zacząć od testu , aby upewnić się, że wszystko jest w porządku, zanim przejdziesz dalej. Jest to konieczność, zwłaszcza przy refaktoryzacji.
Następnie możemy wprowadzić zmiany i upewnić się, że wszystkie testy pomyślnie przejdą odpowiednie części kodu.
Wyodrębnianie obiektów wartości
Po pierwsze, czym jest obiekt wartości?
Martin Fowler wyjaśnia:
Obiekt wartości to mały obiekt, taki jak obiekt pieniędzy lub zakresu dat. Ich kluczową właściwością jest to, że kierują się semantyką wartości, a nie semantyką odniesienia.
Czasami możesz spotkać się z sytuacją, w której koncepcja zasługuje na własną abstrakcję i której równość nie opiera się na wartości, ale na tożsamości. Przykładami mogą być Data Ruby's, URI i Pathname. Ekstrakcja do obiektu wartości (lub modelu domeny) jest wielką wygodą.
Po co się męczyć?
Jedną z największych zalet obiektu Value jest ekspresja, którą pomagają osiągnąć w kodzie. Twój kod będzie znacznie jaśniejszy, a przynajmniej może być, jeśli masz dobre praktyki nazewnictwa. Ponieważ obiekt wartości jest abstrakcją, prowadzi do czystszego kodu i mniejszej liczby błędów.
Kolejną wielką wygraną jest niezmienność. Niezmienność obiektów jest bardzo ważna. Kiedy przechowujemy pewne zestawy danych, które można wykorzystać w obiekcie wartości, zwykle nie chcę, aby tymi danymi manipulowano.
Kiedy jest to przydatne?
Nie ma jednej, uniwersalnej odpowiedzi. Rób to, co jest dla Ciebie najlepsze i ma sens w danej sytuacji.
Wychodząc jednak poza to, istnieją pewne wskazówki, których używam, aby pomóc mi podjąć tę decyzję.
Jeśli myślisz, że grupa metod jest powiązana, z obiektami Value są one bardziej wyraziste. Ta ekspresja oznacza, że obiekt Value powinien reprezentować odrębny zestaw danych, który przeciętny programista może wywnioskować po prostu patrząc na nazwę obiektu.
Jak to się robi?
Obiekty wartości powinny przestrzegać kilku podstawowych zasad:
- Obiekty wartości powinny mieć wiele atrybutów.
- Atrybuty powinny być niezmienne przez cały cykl życia obiektu.
- Równość jest określana przez atrybuty obiektu.
W naszym przykładzie utworzę obiekt wartości EntryStatus
, aby abstrahować Entry#status_weather
i Entry#status_landform
do ich własnej klasy, co wygląda mniej więcej tak:
- Próbka zbiorcza
Uwaga: To jest po prostu zwykły stary obiekt Ruby (PORO), który nie dziedziczy z ActiveRecord::Base
. Zdefiniowaliśmy metody czytnika dla naszych atrybutów i przypisujemy je podczas inicjalizacji. Użyliśmy również porównywalnego domieszki do porównania obiektów przy użyciu metody (<=>).
Możemy zmodyfikować model Entry
, aby używał utworzonego przez nas obiektu wartości:
- Próbka zbiorcza
Możemy również zmodyfikować EntryController#create
, aby odpowiednio używała nowego obiektu wartości:
- Próbka zbiorcza
Wyodrębnij obiekty usługowe
Czym więc jest obiekt usługi?
Zadaniem obiektu usługi jest przechowywanie kodu dla określonego fragmentu logiki biznesowej. W przeciwieństwie do stylu „grubego modelu” , w którym niewielka liczba obiektów zawiera wiele, wiele metod dla całej niezbędnej logiki, użycie obiektów Service daje w wyniku wiele klas, z których każda służy jednemu celowi.

Czemu? Jakie są korzyści?
- Oddzielenie. Obiekty usług pomagają osiągnąć większą izolację między obiektami.
- Widoczność. Obiekty usług (jeśli są dobrze nazwane) pokazują, co robi aplikacja. Wystarczy spojrzeć na katalog usług, aby zobaczyć, jakie możliwości zapewnia aplikacja.
- Modele porządkowe i kontrolery. Kontrolery zamieniają żądanie (params, session, cookies) na argumenty, przekazują je do usługi i przekierowują lub renderują zgodnie z odpowiedzią usługi. Podczas gdy modele zajmują się tylko skojarzeniami i wytrwałością. Wyodrębnianie kodu z kontrolerów/modeli do obiektów usług wspierałoby SRP i czyniło kod bardziej oddzielonym. Obowiązkiem modelu byłoby wtedy tylko zajmowanie się skojarzeniami i zapisywaniem/usuwaniem rekordów, podczas gdy obiekt usługi miałby jedną odpowiedzialność (SRP). Prowadzi to do lepszego projektowania i lepszych testów jednostkowych.
- SUCHE i przytulaj się do zmian. Utrzymuję obiekty usługowe tak proste i małe, jak tylko potrafię. Komponuję obiekty usługowe z innymi obiektami usługowymi i używam ich ponownie.
- Oczyść i przyspiesz swój zestaw testów. Usługi są łatwe i szybkie w testowaniu, ponieważ są to małe obiekty Ruby z jednym punktem wejścia (metoda call). Kompleksowe usługi są skomponowane z innymi usługami, dzięki czemu możesz łatwo podzielić swoje testy. Ponadto użycie obiektów usługowych ułatwia mock/stub powiązanych obiektów bez konieczności ładowania całego środowiska rails.
- Możliwość dzwonienia z dowolnego miejsca. Obiekty usługowe mogą być wywoływane z kontrolerów, a także innych obiektów usługowych, DelayedJob / Rescue / Sidekiq Jobs, zadania rake, konsola itp.
Z drugiej strony nic nigdy nie jest doskonałe. Wadą obiektów Service jest to, że mogą być przesadą dla bardzo prostej akcji. W takich przypadkach możesz bardzo dobrze skomplikować, a nie uprościć swój kod.
Kiedy należy wyodrębnić obiekty usługowe?
Tutaj też nie ma sztywnych i szybkich reguł.
Zwykle obiekty usług są lepsze dla średnich i dużych systemów; te z przyzwoitą ilością logiki wykraczającą poza standardowe operacje CRUD.
Dlatego zawsze, gdy myślisz, że fragment kodu może nie należeć do katalogu, w którym miał zostać dodany, prawdopodobnie dobrym pomysłem jest ponowne rozważenie i sprawdzenie, czy zamiast tego powinien trafić do obiektu usługi.
Oto kilka wskaźników, kiedy należy używać obiektów usługi:
- Akcja jest złożona.
- Akcja dociera do wielu modeli.
- Akcja współdziała z usługą zewnętrzną.
- Akcja nie jest głównym problemem modelu bazowego.
- Akcję można wykonać na wiele sposobów.
Jak zaprojektować obiekty usługowe?
Projektowanie klasy dla obiektu usługi jest stosunkowo proste, ponieważ nie potrzebujesz żadnych specjalnych klejnotów, nie musisz uczyć się nowego DSL i możesz w mniejszym lub większym stopniu polegać na umiejętnościach projektowania oprogramowania, które już posiadasz.
Zazwyczaj do projektowania obiektu usługi posługuję się następującymi wytycznymi i konwencjami:
- Nie przechowuj stanu obiektu.
- Używaj metod instancji, a nie metod klas.
- Powinno być bardzo mało publicznych metod (najlepiej jedna do obsługi SRP.
- Metody powinny zwracać obiekty wyników rozszerzonych, a nie wartości logiczne.
- Usługi znajdują się w katalogu
app/services
. Zachęcam do korzystania z podkatalogów dla domen z dużą logiką biznesową. Na przykład plikapp/services/report/generate_weekly.rb
zdefiniujeReport::GenerateWeekly
, podczas gdyapp/services/report/publish_monthly.rb
zdefiniujeReport::PublishMonthly
. - Usługi zaczynają się od czasownika (i nie kończą się na Service):
ApproveTransaction
,SendTestNewsletter
,ImportUsersFromCsv
. - Usługi odpowiadają na metodę wywołania. Zauważyłem, że użycie innego czasownika sprawia, że jest to trochę zbędne: ApproveTransaction.approve() nie czyta się dobrze. Ponadto metoda call jest de facto metodą dla obiektów lambda, procs i metod.
Jeśli spojrzysz na StatisticsController#index
, zauważysz grupę metod ( avg_distance
weeks_to_date_to
weeks_to_date_from
.) połączonych z kontrolerem. To nie jest dobre. Rozważ konsekwencje, jeśli chcesz generować raport tygodniowy poza statistics_controller
.
W naszym przypadku utwórzmy Report::GenerateWeekly
i wyodrębnijmy logikę raportu z StatisticsController
:
- Próbka zbiorcza
Tak więc StatisticsController#index
wygląda teraz czyściej:
- Próbka zbiorcza
Stosując wzorzec obiektu Service łączymy kod wokół określonej, złożonej akcji i promujemy tworzenie mniejszych, bardziej przejrzystych metod.
Praca domowa: rozważ użycie obiektu Value dla WeeklyReport
zamiast Struct
.
Wyodrębnij obiekty zapytania z kontrolerów
Co to jest obiekt zapytania?
Obiekt Query to PORO reprezentujące zapytanie do bazy danych. Można go ponownie używać w różnych miejscach aplikacji, jednocześnie ukrywając logikę zapytań. Zapewnia również dobrą izolowaną jednostkę do testowania.
Należy wyodrębnić złożone zapytania SQL/NoSQL do ich własnej klasy.
Każdy obiekt zapytania odpowiada za zwrócenie zestawu wyników na podstawie kryteriów/reguł biznesowych.
W tym przykładzie nie mamy żadnych złożonych zapytań, więc użycie obiektu Query nie będzie efektywne. Jednak w celach demonstracyjnych wyodrębnijmy zapytanie w Report::GenerateWeekly#call
i utwórz generate_entries_query.rb
:
- Próbka zbiorcza
A w Report::GenerateWeekly#call
zastąpmy:
def call @user.entries.group_by(&:week).map do |week, entries| WeeklyReport.new( ... ) end end
z:
def call weekly_grouped_entries = GroupEntriesQuery.new(@user).call weekly_grouped_entries.map do |week, entries| WeeklyReport.new( ... ) end end
Wzorzec obiektu zapytania pomaga utrzymać logikę modelu ściśle powiązaną z zachowaniem klasy, jednocześnie utrzymując wąskie kontrolery. Ponieważ nie są niczym więcej niż zwykłymi starymi klasami Rubiego, obiekty zapytań nie muszą dziedziczyć po ActiveRecord::Base
i powinny być odpowiedzialne jedynie za wykonywanie zapytań.
Wyodrębnij Utwórz wpis do obiektu usługi
Teraz wydzielmy logikę tworzenia nowego wpisu do nowego obiektu usługi. Użyjmy konwencji i stwórzmy CreateEntry
:
- Próbka zbiorcza
A teraz nasz EntriesController#create
wygląda następująco:
def create begin CreateEntry.new(current_user, entry_params).call flash[:notice] = 'Entry was successfully created.' rescue Exception => e flash[:error] = e.message end redirect_to root_path end
Przenieś walidacje do obiektu formularza
Teraz zaczyna się robić ciekawiej.
Pamiętaj, że w naszych wytycznych uzgodniliśmy, że chcemy, aby modele zawierały asocjacje i stałe, ale nic więcej (bez walidacji i wywołań zwrotnych). Zacznijmy więc od usunięcia wywołań zwrotnych i zamiast tego użyjmy obiektu Form.
Obiekt formularza to zwykły stary obiekt rubinowy (PORO). Przejmuje od kontrolera/obiektu usługi wszędzie tam, gdzie musi komunikować się z bazą danych.
Dlaczego warto korzystać z obiektów formularza?
Podczas refaktoryzacji aplikacji zawsze warto pamiętać o zasadzie pojedynczej odpowiedzialności (SRP).
SRP pomaga podejmować lepsze decyzje projektowe dotyczące tego, za co klasa powinna być odpowiedzialna.
Twój model tabeli bazy danych (model ActiveRecord w kontekście Rails), na przykład, reprezentuje pojedynczy rekord bazy danych w kodzie, więc nie ma powodu, aby martwić się czymkolwiek, co robi twój użytkownik.
Tutaj wkraczają obiekty Form.
Obiekt Form odpowiada za reprezentowanie formularza w aplikacji. Tak więc każde pole wejściowe może być traktowane jako atrybut w klasie. Może sprawdzić, czy te atrybuty spełniają pewne reguły walidacji, i może przekazać „czyste” dane tam, gdzie muszą trafić (np. do modeli baz danych lub być może do kreatora zapytań wyszukiwania).
Kiedy należy używać obiektu Form?
- Kiedy chcesz wydobyć walidacje z modeli Rails.
- Gdy wiele modeli może zostać zaktualizowanych przez jedno przesłanie formularza, warto utworzyć obiekt Form.
Dzięki temu można umieścić całą logikę formularza (konwencje nazewnictwa, walidacje itd.) w jednym miejscu.
Jak stworzyć obiekt formularza?
- Utwórz zwykłą klasę Ruby.
- Dołącz
ActiveModel::Model
(w Rails 3 musisz zamiast tego uwzględnić Nazewnictwo, Konwersję i Walidacje) - Zacznij używać nowej klasy formularza tak, jakby był zwykłym modelem ActiveRecord, największą różnicą jest to, że nie możesz zachować danych przechowywanych w tym obiekcie.
Pamiętaj, że możesz użyć klejnotu reformy, ale pozostając przy PORO, utworzymy entry_form.rb
, który wygląda tak:
- Próbka zbiorcza
I zmodyfikujemy CreateEntry
, aby zacząć używać obiektu Form EntryForm
:
class CreateEntry ...... ...... def call @entry_form = ::EntryForm.new(@params) if @entry_form.valid? .... else .... end end end
Uwaga: Niektórzy z was powiedzieliby, że nie ma potrzeby uzyskiwania dostępu do obiektu Form z obiektu Service i że możemy po prostu wywołać obiekt Form bezpośrednio z kontrolera, co jest prawidłowym argumentem. Wolałbym jednak mieć przejrzysty przepływ i dlatego zawsze wywołuję obiekt Form z obiektu Service.
Przenieś wywołania zwrotne do obiektu usługi
Jak uzgodniliśmy wcześniej, nie chcemy, aby nasze modele zawierały walidacje i wywołania zwrotne. Wyodrębniliśmy walidacje za pomocą obiektów Form. Ale nadal używamy niektórych wywołań zwrotnych ( after_create
w modelu Entry
compare_speed_and_notify_user
).
Dlaczego chcemy usunąć callbacki z modeli?
Deweloperzy Railsów zwykle zaczynają zauważać ból wywołania zwrotnego podczas testowania. Jeśli nie testujesz swoich modeli ActiveRecord, zaczniesz zauważać problemy później, gdy aplikacja się rozrośnie i gdy potrzeba więcej logiki do wywołania lub uniknięcia wywołania zwrotnego.
Wywołania zwrotne after_*
są używane głównie w odniesieniu do zapisywania lub utrwalania obiektu.
Po uratowaniu obiektu cel (tzn. odpowiedzialność) obiektu został spełniony. Jeśli więc nadal widzimy wywołania zwrotne wywoływane po zapisaniu obiektu, prawdopodobnie widzimy wywołania zwrotne wykraczające poza obszar odpowiedzialności obiektu i wtedy napotykamy problemy.
W naszym przypadku wysyłamy SMS-a do użytkownika po zapisaniu wpisu, który tak naprawdę nie jest związany z domeną Wejście.
Prostym sposobem rozwiązania problemu jest przeniesienie wywołania zwrotnego do powiązanego obiektu usługi. W końcu wysłanie wiadomości SMS do użytkownika końcowego jest związane z obiektem usługi CreateEntry
, a nie z samym modelem wejścia.
Czyniąc to, nie musimy już usuwać w naszych testach metody compare_speed_and_notify_user
. Uprościliśmy tworzenie wpisu bez konieczności wysłania SMS-a i podążamy za dobrym projektem zorientowanym obiektowo, upewniając się, że nasze klasy mają jedną odpowiedzialność (SRP).
Więc teraz nasze CreateEntry
wygląda mniej więcej tak:
- Próbka zbiorcza
Używaj dekoratorów zamiast pomocników
Chociaż możemy z łatwością korzystać z kolekcji modeli widoków i dekoratorów Draper, pozostanę przy PORO na potrzeby tego artykułu, tak jak robiłem to do tej pory.
To, czego potrzebuję, to klasa, która będzie wywoływać metody na dekorowanym obiekcie.
Aby to zaimplementować, mogę użyć method_missing
, ale użyję standardowej biblioteki Rubiego SimpleDelegator
.
Poniższy kod pokazuje, jak użyć SimpleDelegator
do zaimplementowania naszego podstawowego dekoratora:
% app/decorators/base_decorator.rb require 'delegate' class BaseDecorator < SimpleDelegator def initialize(base, view_context) super(base) @object = base @view_context = view_context end private def self.decorates(name) define_method(name) do @object end end def _h @view_context end end
Dlaczego więc metoda _h
?
Ta metoda działa jako proxy dla kontekstu widoku. Domyślnie kontekst widoku jest instancją klasy widoku, domyślną klasą widoku jest ActionView::Base
. Dostęp do pomocników widoku można uzyskać w następujący sposób:
_h.content_tag :div, 'my-div', class: 'my-class'
Aby było to wygodniejsze, dodajemy metodę decorate
do ApplicationHelper
:
module ApplicationHelper # ..... def decorate(object, klass = nil) klass ||= "#{object.class}Decorator".constantize decorator = klass.new(object, self) yield decorator if block_given? decorator end # ..... end
Teraz możemy przenieść pomocników EntriesHelper
do dekoratorów:
# app/decorators/entry_decorator.rb class EntryDecorator < BaseDecorator decorates :entry def readable_time_period mins = entry.time_period return Time.at(60 * mins).utc.strftime('%M <small>Mins</small>').html_safe if mins < 60 Time.at(60 * mins).utc.strftime('%H <small>Hour</small> %M <small>Mins</small>').html_safe end def readable_speed "#{sprintf('%0.2f', entry.speed)} <small>Km/H</small>".html_safe end end
I możemy użyć readable_time_period
i readable_speed
tak:
# app/views/entries/_entry.html.erb - <td><%= readable_speed(entry) %> </td> + <td><%= decorate(entry).readable_speed %> </td>
- <td><%= readable_time_period(entry) %></td> + <td><%= decorate(entry).readable_time_period %></td>
Struktura po refaktoryzacji
Skończyło się na większej liczbie plików, ale niekoniecznie jest to zła rzecz (i pamiętajmy, że od samego początku uznaliśmy, że ten przykład służy wyłącznie do celów demonstracyjnych i niekoniecznie jest dobrym przypadkiem użycia do refaktoryzacji):
app ├── assets │ └── ... ├── controllers │ ├── application_controller.rb │ ├── entries_controller.rb │ └── statistics_controller.rb ├── decorators │ ├── base_decorator.rb │ └── entry_decorator.rb ├── forms │ └── entry_form.rb ├── helpers │ └── application_helper.rb ├── mailers ├── models │ ├── entry.rb │ ├── entry_status.rb │ └── user.rb ├── queries │ └── group_entries_query.rb ├── services │ ├── create_entry.rb │ └── report │ └── generate_weekly.rb └── views ├── devise │ └── .. ├── entries │ ├── _entry.html.erb │ ├── _form.html.erb │ └── index.html.erb ├── layouts │ └── application.html.erb └── statistics └── index.html.erb
Wniosek
Mimo że w tym poście skupiliśmy się na Railsach, RoR nie jest zależnością od opisanych obiektów usługowych i innych PORO. Możesz użyć tego podejścia z dowolną platformą internetową, aplikacją mobilną lub konsolową.
Używając MVC jako architektury aplikacji internetowych, wszystko pozostaje połączone i sprawia, że działasz wolniej, ponieważ większość zmian ma wpływ na inne części aplikacji. Ponadto zmusza do zastanowienia się, gdzie umieścić jakąś logikę biznesową – czy powinna ona trafić do modelu, kontrolera czy widoku?
Stosując proste PORO przenieśliśmy logikę biznesową do modeli lub usług, które nie dziedziczą po ActiveRecord
, co już jest wielką wygraną, nie wspominając o tym, że mamy czystszy kod, który obsługuje SRP i szybsze testy jednostkowe.
Czysta architektura ma na celu umieszczenie przypadków użycia w centrum/na górze struktury, dzięki czemu można łatwo zobaczyć, co robi Twoja aplikacja. Ułatwia również przyjmowanie zmian, ponieważ jest znacznie bardziej modułowy i izolowany.
Mam nadzieję, że pokazałem, jak używanie Plain Old Ruby Objects i większej liczby abstrakcji rozprzęga obawy, upraszcza testowanie i pomaga w tworzeniu czystego, łatwego w utrzymaniu kodu.