Obiekty usług Rails: kompleksowy przewodnik
Opublikowany: 2022-03-11Ruby on Rails zawiera wszystko, czego potrzebujesz do szybkiego prototypowania aplikacji, ale kiedy baza kodu zacznie rosnąć, natkniesz się na scenariusze, w których konwencjonalna mantra Fat Model, Skinny Controller się zepsuje. Kiedy logika biznesowa nie pasuje ani do modelu, ani do kontrolera, wtedy przychodzą obiekty usług i pozwól nam oddzielić każdą akcję biznesową do własnego obiektu Ruby.
W tym artykule wyjaśnię, kiedy wymagany jest obiekt usługi; jak pisać czyste obiekty usług i grupować je w celu uzyskania zdrowego rozsądku; surowe zasady, które nakładam na moje obiekty usług, aby powiązać je bezpośrednio z moją logiką biznesową; i jak nie zamienić obiektów usługi w wysypisko całego kodu, z którym nie wiesz, co zrobić.
Dlaczego potrzebuję obiektów serwisowych?
Spróbuj tego: Co robisz, gdy Twoja aplikacja musi tweetować tekst z params[:message]
?
Jeśli do tej pory używałeś vanilla Rails, prawdopodobnie zrobiłeś coś takiego:
class TweetController < ApplicationController def create send_tweet(params[:message]) end private def send_tweet(tweet) client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(tweet) end end
Problem polega na tym, że dodałeś co najmniej dziesięć linii do kontrolera, ale tak naprawdę tam nie pasują. A co, jeśli chcesz użyć tej samej funkcjonalności w innym kontrolerze? Czy przenosisz to do problemu? Czekaj, ale ten kod tak naprawdę w ogóle nie należy do kontrolerów. Dlaczego API Twittera nie może po prostu dostarczyć jednego przygotowanego obiektu, który mam wywołać?
Kiedy zrobiłem to po raz pierwszy, poczułem się, jakbym zrobił coś brudnego. Moje poprzednio pięknie szczupłe kontrolery Rails zaczęły przybierać na wadze i nie wiedziałem, co robić. Ostatecznie naprawiłem swój kontroler z obiektem usługi.
Zanim zaczniesz czytać ten artykuł, załóżmy, że:
- Ta aplikacja obsługuje konto na Twitterze.
- The Rails Way oznacza „konwencjonalny sposób robienia rzeczy w Ruby on Rails”, a książka nie istnieje.
- Jestem ekspertem od Railsów… o którym codziennie mi mówi się, że jestem, ale trudno mi w to uwierzyć, więc po prostu udawajmy, że naprawdę nim jestem.
Czym są obiekty usługowe?
Obiekty usług to Plain Old Ruby Objects (PORO), które są zaprojektowane do wykonywania jednej akcji w logice domeny i robią to dobrze. Rozważ powyższy przykład: nasza metoda ma już logikę, aby zrobić jedną rzecz, a mianowicie utworzyć tweet. Co by było, gdyby ta logika była zamknięta w pojedynczej klasie Ruby, którą możemy utworzyć i wywołać metodę? Coś jak:
tweet_creator = TweetCreator.new(params[:message]) tweet_creator.send_tweet # Later on in the article, we'll add syntactic sugar and shorten the above to: TweetCreator.call(params[:message])
To prawie wszystko; nasz obiekt usługi TweetCreator
, raz utworzony, może być wywołany z dowolnego miejsca i wykonałby tę jedną rzecz bardzo dobrze.
Tworzenie obiektu usługi
Najpierw utwórzmy nowy TweetCreator
w nowym folderze o nazwie app/services
:
$ mkdir app/services && touch app/services/tweet_creator.rb
I po prostu zrzućmy całą naszą logikę do nowej klasy Ruby:
# app/services/tweet_creator.rb class TweetCreator def initialize(message) @message = message end def send_tweet client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(@message) end end
Następnie możesz wywołać TweetCreator.new(params[:message]).send_tweet
w dowolnym miejscu swojej aplikacji i zadziała. Railsy załadują ten obiekt magicznie, ponieważ automatycznie ładują wszystko pod app/
. Sprawdź to, uruchamiając:
$ rails c Running via Spring preloader in process 12417 Loading development environment (Rails 5.1.5) > puts ActiveSupport::Dependencies.autoload_paths ... /Users/gilani/Sandbox/nazdeeq/app/services
Chcesz dowiedzieć się więcej o tym, jak działa autoload
? Przeczytaj przewodnik dotyczący automatycznego ładowania i ponownego ładowania stałych.
Dodanie cukru składniowego, aby obiekty usług Rails były mniej bezużyteczne
Słuchaj, teoretycznie jest to świetne, ale TweetCreator.new(params[:message]).send_tweet
to tylko kęs. Jest zbyt gadatliwy ze zbędnymi słowami… podobnie jak HTML (ba-dum tiss! ). Jednak mówiąc poważnie, dlaczego ludzie używają HTML, gdy w pobliżu jest HAML? Albo nawet Slim. Myślę, że to kolejny artykuł na inny czas. Wracając do aktualnego zadania:
TweetCreator
to fajna, krótka nazwa klasy, ale dodatkowy problem związany z tworzeniem instancji obiektu i wywoływaniem metody jest po prostu zbyt długi! Gdyby tylko Ruby miał pierwszeństwo dla wywołania czegoś i natychmiastowego wykonania się z podanymi parametrami… och czekaj, jest! To jest Proc#call
.
Proccall
wywołuje blok, ustawiając parametry bloku na wartości w params za pomocą czegoś zbliżonego do metody wywołującej semantykę. Zwraca wartość ostatniego wyrażenia ocenionego w bloku.aproc = Proc.new {|scalar, values| values.map {|value| valuescalar } } aproc.call(9, 1, 2, 3) #=> [9, 18, 27] aproc[9, 1, 2, 3] #=> [9, 18, 27] aproc.(9, 1, 2, 3) #=> [9, 18, 27] aproc.yield(9, 1, 2, 3) #=> [9, 18, 27]
Dokumentacja
Jeśli to cię zdezorientuje, wyjaśnię. proc
można call
-ed, aby wykonać się z podanymi parametrami. Co oznacza, że gdyby TweetCreator
był proc
, moglibyśmy wywołać go za pomocą TweetCreator.call(message)
, a wynik byłby odpowiednikiem TweetCreator.new(params[:message]).call
, który wygląda całkiem podobnie do naszego starego, nieporęcznego TweetCreator.new(params[:message]).send_tweet
.
Sprawmy więc, aby nasz obiekt usługi zachowywał się bardziej jak proc
!
Po pierwsze, ponieważ prawdopodobnie chcemy ponownie wykorzystać to zachowanie we wszystkich naszych obiektach usług, zapożyczmy z Rails Way i stwórzmy klasę o nazwie ApplicationService
:
# app/services/application_service.rb class ApplicationService def self.call(*args, &block) new(*args, &block).call end end
Widziałeś, co tam zrobiłem? Dodałem metodę klasy o nazwie call
, która tworzy nową instancję klasy z przekazanymi do niej argumentami lub blokiem i wywołuje call
tej instancji. Dokładnie to, czego chcieliśmy! Ostatnią rzeczą do zrobienia jest zmiana nazwy metody z naszej klasy TweetCreator
na call
i dziedziczenie klasy po ApplicationService
:
# app/services/tweet_creator.rb class TweetCreator < ApplicationService attr_reader :message def initialize(message) @message = message end def call client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(@message) end end
I na koniec zakończmy to, wywołując nasz obiekt usługi w kontrolerze:
class TweetController < ApplicationController def create TweetCreator.call(params[:message]) end end
Grupowanie podobnych obiektów usług dla Sanity
Powyższy przykład ma tylko jeden obiekt usługi, ale w świecie rzeczywistym sprawy mogą się bardziej skomplikować. Na przykład, co by było, gdybyś miał setki usług, a połowa z nich była powiązanymi działaniami biznesowymi, np. posiadanie usługi Follower
, która śledziła inne konto na Twitterze? Szczerze mówiąc, zwariowałbym, gdyby folder zawierał 200 unikatowo wyglądających plików, więc dobrze, że istnieje inny wzorzec z Rails Way, który możemy skopiować – to znaczy użyć jako inspiracji: przestrzeni nazw.
Załóżmy, że otrzymaliśmy zadanie stworzenia obiektu usługi, który śledzi inne profile na Twitterze.
Przyjrzyjmy się nazwie naszego poprzedniego obiektu usługi: TweetCreator
. Brzmi jak osoba, a przynajmniej jakaś rola w organizacji. Ktoś, kto tworzy tweety. Lubię nazywać moje obiekty usług tak, jakby były po prostu: role w organizacji. Zgodnie z tą konwencją wywołam mój nowy obiekt: ProfileFollower
.
Teraz, ponieważ jestem najwyższym zwierzchnikiem tej aplikacji, zamierzam utworzyć stanowisko kierownicze w mojej hierarchii usług i delegować odpowiedzialność za obie te usługi na to stanowisko. Nazwę to nowe stanowisko kierownicze TwitterManager
.
Ponieważ ten menedżer nie zajmuje się wyłącznie zarządzaniem, zróbmy z niego moduł i zagnieżdżmy nasze obiekty usług w tym module. Nasza struktura folderów będzie teraz wyglądać tak:
services ├── application_service.rb └── twitter_manager ├── profile_follower.rb └── tweet_creator.rb
A nasze obiekty usługowe:
# services/twitter_manager/tweet_creator.rb module TwitterManager class TweetCreator < ApplicationService ... end end
# services/twitter_manager/profile_follower.rb module TwitterManager class ProfileFollower < ApplicationService ... end end
A nasze wywołania zmienią się teraz TwitterManager::TweetCreator.call(arg)
i TwitterManager::ProfileManager.call(arg)
.

Obiekty usługowe do obsługi operacji bazy danych
Powyższy przykład wykonał wywołania API, ale obiekty usług mogą być również używane, gdy wszystkie wywołania są skierowane do Twojej bazy danych zamiast do API. Jest to szczególnie przydatne, gdy niektóre działania biznesowe wymagają wielu aktualizacji bazy danych zawartych w transakcji. Na przykład ten przykładowy kod wykorzysta usługi do zarejestrowania wymiany waluty.
module MoneyManager # exchange currency from one amount to another class CurrencyExchanger < ApplicationService ... def call ActiveRecord::Base.transaction do # transfer the original currency to the exchange's account outgoing_tx = CurrencyTransferrer.call( from: the_user_account, to: the_exchange_account, amount: the_amount, currency: original_currency ) # get the exchange rate rate = ExchangeRateGetter.call( from: original_currency, to: new_currency ) # transfer the new currency back to the user's account incoming_tx = CurrencyTransferrer.call( from: the_exchange_account, to: the_user_account, amount: the_amount * rate, currency: new_currency ) # record the exchange happening ExchangeRecorder.call( outgoing_tx: outgoing_tx, incoming_tx: incoming_tx ) end end end # record the transfer of money from one account to another in money_accounts class CurrencyTransferrer < ApplicationService ... end # record an exchange event in the money_exchanges table class ExchangeRecorder < ApplicationService ... end # get the exchange rate from an API class ExchangeRateGetter < ApplicationService ... end end
Co mam zwrócić z obiektu usługi?
Omówiliśmy, jak call
nasz obiekt usługi, ale co powinien zwrócić obiekt? Można do tego podejść na trzy sposoby:
- Zwróć
true
lubfalse
- Zwróć wartość
- Zwróć Enum
Zwróć true
lub false
To jest proste: jeśli akcja działa zgodnie z przeznaczeniem, zwróć true
; w przeciwnym razie zwróć false
:
def call ... return true if client.update(@message) false end
Zwróć wartość
Jeśli obiekt usługi pobiera skądś dane, prawdopodobnie chcesz zwrócić tę wartość:
def call ... return false unless exchange_rate exchange_rate end
Odpowiedz Enum
Jeśli Twój obiekt usługi jest nieco bardziej złożony i chcesz obsługiwać różne scenariusze, możesz po prostu dodać wyliczenia, aby kontrolować przepływ swoich usług:
class ExchangeRecorder < ApplicationService RETURNS = [ SUCCESS = :success, FAILURE = :failure, PARTIAL_SUCCESS = :partial_success ] def call foo = do_something return SUCCESS if foo.success? return FAILURE if foo.failure? PARTIAL_SUCCESS end private def do_something end end
A następnie w swojej aplikacji możesz użyć:
case ExchangeRecorder.call when ExchangeRecorder::SUCCESS foo when ExchangeRecorder::FAILURE bar when ExchangeRecorder::PARTIAL_SUCCESS baz end
Czy nie powinienem umieszczać obiektów usługi w lib/services
zamiast app/services
?
To jest subiektywne. Ludzie mają różne opinie na temat tego, gdzie umieścić swoje obiekty usługowe. Niektórzy umieszczają je w lib/services
, a niektórzy tworzą app/services
. Wpadam do drugiego obozu. Przewodnik dla początkujących opisuje folder lib/
jako miejsce, w którym można umieścić „rozszerzone moduły dla twojej aplikacji”.
Moim skromnym zdaniem „moduły rozszerzone” oznaczają moduły, które nie zawierają podstawowej logiki domeny i generalnie mogą być używane w różnych projektach. W mądrych słowach losowej odpowiedzi Stack Overflow umieść tam kod, który „może potencjalnie stać się swoim własnym klejnotem”.
Czy obiekty usługowe to dobry pomysł?
To zależy od twojego przypadku użycia. Spójrz — fakt, że czytasz ten artykuł w tej chwili sugeruje, że próbujesz napisać kod, który nie pasuje dokładnie do modelu lub kontrolera. Niedawno przeczytałem ten artykuł o tym, jak obiekty usług są antywzorcami. Autor ma swoje opinie, ale z całym szacunkiem się nie zgadzam.
Tylko dlatego, że jakaś inna osoba nadużywała obiektów usługowych, nie oznacza to, że są one z natury złe. W moim startupie, Nazdeeq, używamy zarówno obiektów usługowych, jak i modeli innych niż ActiveRecord. Ale różnica między tym, co dzieje się tam, gdzie zawsze była dla mnie oczywista: wszystkie działania biznesowe przechowuję w obiektach usług, jednocześnie zachowując zasoby, które tak naprawdę nie wymagają trwałości w modelach innych niż ActiveRecord. Ostatecznie to Ty decydujesz, jaki wzór jest dla Ciebie dobry.
Czy jednak ogólnie uważam, że obiekty usługowe są dobrym pomysłem? Absolutnie! Utrzymują mój kod w porządku, a to, co upewnia mnie, że używam PORO, to fakt, że Ruby uwielbia obiekty. Nie, poważnie, Ruby kocha przedmioty. To szalone, totalnie szalone, ale uwielbiam to! Przykładem:
> 5.is_a? Object # => true > 5.class # => Integer > class Integer ?> def woot ?> 'woot woot' ?> end ?> end # => :woot > 5.woot # => "woot woot"
Widzieć? 5
jest dosłownie przedmiotem.
W wielu językach liczby i inne typy pierwotne nie są obiektami. Ruby śledzi wpływ języka Smalltalk, nadając metody i zmienne instancji wszystkim jego typom. Ułatwia to korzystanie z Rubiego, ponieważ reguły odnoszące się do obiektów dotyczą całego Rubiego. Ruby-lang.org
Kiedy nie powinienem używać obiektu usługowego?
Ten jest łatwy. Mam te zasady:
- Czy twój kod obsługuje routing, parametry lub robi inne rzeczy związane z kontrolerem?
Jeśli tak, nie używaj obiektu usługi — Twój kod należy do kontrolera. - Czy próbujesz udostępnić swój kod w różnych kontrolerach?
W takim przypadku nie używaj obiektu usługi — użyj problemu. - Czy Twój kod przypomina model, który nie wymaga trwałości?
Jeśli tak, nie używaj obiektu usługi. Zamiast tego użyj modelu innego niż ActiveRecord. - Czy Twój kod to konkretne działanie biznesowe? (np. „Wynieś śmieci”, „Wygeneruj plik PDF, korzystając z tego tekstu” lub „Oblicz cło, korzystając z tych skomplikowanych reguł”)
W takim przypadku użyj obiektu usługi. Ten kod prawdopodobnie nie pasuje logicznie ani do twojego kontrolera, ani do twojego modelu.
Oczywiście są to moje zasady, więc możesz je dostosować do własnych przypadków użycia. U mnie sprawdziły się bardzo dobrze, ale Twój przebieg może się różnić.
Zasady pisania dobrych obiektów usługowych
Mam cztery zasady tworzenia obiektów serwisowych. Nie są one napisane w kamieniu, a jeśli naprawdę chcesz je złamać, możesz, ale prawdopodobnie poproszę cię o zmianę tego w przeglądach kodu, chyba że twoje rozumowanie jest solidne.
Zasada 1: Tylko jedna metoda publiczna na obiekt usługi
Obiekty usług to pojedyncze akcje biznesowe. Jeśli chcesz, możesz zmienić nazwę swojej metody publicznej. Wolę używać call
, ale baza kodu execute
CE wywołuje go, a inne osoby mogą używać perform
. Używaj tego, co chcesz — możesz to nazwać nermin
, jeśli tylko o mnie to obchodzi. Po prostu nie twórz dwóch metod publicznych dla jednego obiektu usługi. Rozbij go na dwa przedmioty, jeśli musisz.
Zasada 2: Nazwij obiekty usług jak głupie role w firmie
Obiekty usług to pojedyncze akcje biznesowe . Wyobraź sobie, że gdybyś zatrudnił jedną osobę w firmie do wykonania tej jednej pracy, jak byś ją nazwał? Jeśli ich zadaniem jest tworzenie tweetów, nazwij je TweetCreator
. Jeśli ich zadaniem jest czytanie określonych tweetów, nazwij je TweetReader
.
Zasada 3: Nie twórz ogólnych obiektów do wykonywania wielu czynności
Obiekty usług to pojedyncze akcje biznesowe. Podzieliłem tę funkcjonalność na dwie części: TweetReader
i ProfileFollower
. Czego nie zrobiłem, to stworzyłem jeden ogólny obiekt o nazwie TwitterHandler
i zrzuciłem tam całą funkcjonalność API. Proszę, nie rób tego. Jest to sprzeczne z nastawieniem na „działanie biznesowe” i sprawia, że obiekt usługi wygląda jak wróżka na Twitterze. Jeśli chcesz udostępnić kod między obiektami biznesowymi, po prostu utwórz obiekt lub moduł BaseTwitterManager
i połącz go z obiektami usług.
Zasada 4: Obsługuj wyjątki wewnątrz obiektu usługi
Po raz enty: Obiekty usług to pojedyncze akcje biznesowe. Nie mogę tego wystarczająco powiedzieć. Jeśli masz osobę, która czyta tweety, albo poda ci tweeta, albo powie: „Ten tweet nie istnieje”. Podobnie, nie pozwól, aby obiekt usługi wpadł w panikę, wskocz na biurko kontrolera i powiedz mu, aby przestał działać, ponieważ „Błąd!” Po prostu zwróć false
i pozwól kontrolerowi przejść od tego miejsca.
Kredyty i kolejne kroki
Ten artykuł nie byłby możliwy bez niesamowitej społeczności programistów Ruby w Toptal. Jeśli kiedykolwiek napotkam problem, społeczność jest najbardziej pomocną grupą utalentowanych inżynierów, jaką kiedykolwiek spotkałem.
Jeśli używasz obiektów usług, możesz się zastanawiać, jak wymusić określone odpowiedzi podczas testowania. Polecam przeczytanie tego artykułu na temat tworzenia pozorowanych obiektów usług w Rspec, które zawsze zwrócą żądany wynik, bez faktycznego uderzania w obiekt usługi!
Jeśli chcesz dowiedzieć się więcej o trikach Ruby, polecam Tworzenie Ruby DSL: Przewodnik po zaawansowanym metaprogramowaniu autorstwa innego Toptaler Mate Solymosi. Wyjaśnia, w jaki sposób plik routes.rb
nie przypomina Rubiego i pomaga zbudować własne łącze DSL.