Obiekty usług Rails: kompleksowy przewodnik

Opublikowany: 2022-03-11

Ruby 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.

Przykładowy cykl żądań z obiektami usług Rails

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 lub false
  • 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:

  1. 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.
  2. Czy próbujesz udostępnić swój kod w różnych kontrolerach?
    W takim przypadku nie używaj obiektu usługi — użyj problemu.
  3. 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.
  4. 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.