Ruby on Rails zawiera wszystko, czego potrzebujesz do szybkiego prototypowania aplikacji, ale kiedy twoja baza kodu zaczyna się rozrastać, napotykasz na scenariusze, w których konwencjonalna mantra Fat Model, Skinny Controller przestaje działać. Kiedy twoja logika biznesowa nie mieści się ani w modelu ani w kontrolerze, wtedy właśnie przychodzą z pomocą obiekty usługowe, które pozwalają nam oddzielić każdą akcję biznesową w osobnym obiekcie Ruby.
W tym artykule wyjaśnię kiedy obiekt usługowy jest wymagany; jak pisać czyste obiekty usługowe i grupować je razem dla zachowania porządku; ścisłe zasady jakie narzucam moim obiektom usługowym aby powiązać je bezpośrednio z moją logiką biznesową; oraz jak nie zamienić twoich obiektów usługowych w wysypisko całego kodu, z którym nie wiesz co zrobić.
- Dlaczego potrzebuję obiektów usługowych?
- Czym są obiekty usługowe?
- Tworzenie obiektu usługi
- Adding Syntactic Sugar to Make Rails Service Objects Suck Less
- Grouping Similar Service Objects for Sanity
- Obiekty usług do obsługi operacji na bazie danych
- What Do I Return from My Service Object?
- Return true lub false
- Return a Value
- Respond withem
- Sh Shouldn’t I Put Service Objects in lib/servicesniedawno zamiast app/services?
- Are Service Objects a Good Idea?
- When Should I Not Use a Service Object?
- Rules for Writing Good Service Objects
- Reguła 1: Tylko jedna metoda publiczna na obiekt usługi
- Reguła 2: Nazywaj obiekty usługowe jak głupie role w firmie
- Reguła 3: Nie twórz obiektów generycznych do wykonywania wielu czynności
- Reguła 4: Obsługuj wyjątki wewnątrz obiektu usługowego
Dlaczego potrzebuję obiektów usługowych?
Spróbuj tego: Co zrobisz, gdy twoja aplikacja potrzebuje tweetować tekst z params
?
Jeśli do tej pory używałeś waniliowych Railsów, to prawdopodobnie zrobiłeś coś takiego:
class TweetController < ApplicationController def create send_tweet(params) end private def send_tweet(tweet) client = Twitter::REST::Client.new do |config| config.consumer_key = ENV config.consumer_secret = ENV config.access_token = ENV config.access_token_secret = ENV end client.update(tweet) endend
Problem polega na tym, że dodałeś co najmniej dziesięć linii do swojego kontrolera, ale tak naprawdę one tam nie należą. Ponadto, co by się stało, gdybyś chciał użyć tej samej funkcjonalności w innym kontrolerze? Czy przenieść to do troski? Czekaj, ale ten kod tak naprawdę nie należy w ogóle do kontrolerów. Dlaczego API Twittera nie może po prostu przyjść z pojedynczym przygotowanym obiektem do wywołania?
Pierwszy raz, gdy to zrobiłem, poczułem, że zrobiłem coś brudnego. Moje, wcześniej pięknie chude kontrolery Rails zaczęły robić się grube i nie wiedziałem co robić. Ostatecznie naprawiłem mój kontroler za pomocą obiektu usługi.
Zanim zaczniesz czytać ten artykuł, udawajmy:
- Ta aplikacja obsługuje konto na Twitterze.
- The Rails Way oznacza “konwencjonalny sposób robienia rzeczy w Ruby on Rails”, a ta książka nie istnieje.
- Jestem ekspertem od Railsów… codziennie mi mówią, że nim jestem, ale trudno mi w to uwierzyć, więc udawajmy, że naprawdę nim jestem.
Czym są obiekty usługowe?
Obiekty usługowe są obiektami Plain Old Ruby Objects (PORO), które są zaprojektowane do wykonywania pojedynczej akcji w logice domeny i robią to dobrze. Weźmy pod uwagę powyższy przykład: Nasza metoda posiada już logikę do wykonania jednej rzeczy, a jest nią utworzenie tweeta. Co jeśli ta logika byłaby zamknięta w pojedynczej klasie Rubiego, którą możemy zainicjować i wywołać metodę? Coś w stylu:
tweet_creator = TweetCreator.new(params)tweet_creator.send_tweet# Later on in the article, we'll add syntactic sugar and shorten the above to:TweetCreator.call(params)
To jest całkiem sporo; nasz TweetCreator
obiekt usługi, raz utworzony, może być wywołany z dowolnego miejsca, i będzie robił tę jedną rzecz bardzo dobrze.
Tworzenie obiektu usługi
Najpierw stwó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ę wewnątrz nowej klasy Ruby:
# app/services/tweet_creator.rbclass TweetCreator def initialize(message) @message = message end def send_tweet client = Twitter::REST::Client.new do |config| config.consumer_key = ENV config.consumer_secret = ENV config.access_token = ENV config.access_token_secret = ENV end client.update(@message) endend
Wtedy możesz wywołać TweetCreator.new(params).send_tweet
gdziekolwiek w swojej aplikacji, i to będzie działać. Railsy załadują ten obiekt w magiczny sposób, ponieważ autoloadują wszystko pod app/
. Sprawdź to, uruchamiając:
$ rails cRunning via Spring preloader in process 12417Loading development environment (Rails 5.1.5) > puts ActiveSupport::Dependencies.autoload_paths.../Users/gilani/Sandbox/nazdeeq/app/services
Chcesz wiedzieć więcej o tym, jak działa autoload
? Przeczytaj Przewodnik po Autoloading and Reloading Constants Guide.
Adding Syntactic Sugar to Make Rails Service Objects Suck Less
Wygląda na to, że w teorii jest to świetne rozwiązanie, ale TweetCreator.new(params).send_tweet
to po prostu mordęga. Jest o wiele za bardzo rozbudowany z nadmiarowymi słowami… podobnie jak HTML (ba-dum tiss!). Z całą powagą jednak, dlaczego ludzie używają HTML, gdy HAML jest w pobliżu? Albo nawet Slim. Domyślam się, że to kolejny artykuł na inny czas. Wracając do zadania, które mamy przed sobą:
TweetCreator
to ładna, krótka nazwa klasy, ale dodatkowe elementy związane z instancją obiektu i wywołaniem metody są po prostu za długie! Gdyby tylko w Rubim istniało pierwszeństwo dla wywołania czegoś i natychmiastowego wykonania tego z podanymi parametrami… oh wait, istnieje! To Proc#call
.
Proccall
wywołuje blok, ustawiając jego parametry na wartości w params używając czegoś zbliżonego do semantyki wywołania metody. Zwraca wartość ostatniego wyrażenia obliczonego w bloku.aproc = Proc.new {|scalar, values| values.map {|value| valuescalar } }aproc.call(9, 1, 2, 3) #=> aproc #=> aproc.(9, 1, 2, 3) #=> aproc.yield(9, 1, 2, 3) #=>
Dokumentacja
Jeśli to Cię dezorientuje, pozwól mi wyjaśnić. A proc
może być call
-ed, aby wykonać siebie z podanymi parametrami. Oznacza to, że gdyby TweetCreator
był proc
, moglibyśmy go wywołać za pomocą TweetCreator.call(message)
, a wynik byłby równoważny TweetCreator.new(params).call
, co wygląda dość podobnie do naszego starego, nieporęcznego TweetCreator.new(params).send_tweet
.
Sprawmy więc, aby nasz obiekt usługi zachowywał się bardziej jak proc
!
Początkowo, ponieważ prawdopodobnie chcemy ponownie wykorzystać to zachowanie we wszystkich naszych obiektach usługowych, pożyczmy od Rails Way i stwórzmy klasę o nazwie ApplicationService
:
# app/services/application_service.rbclass ApplicationService def self.call(*args, &block) new(*args, &block).call endend
Widzisz, co tam zrobiłem? Dodałem metodę klasy o nazwie call
, która tworzy nową instancję klasy z argumentami lub blokiem, który do niej przekazujesz, i wywołuje call
na tej instancji. Dokładnie to, co chcieliśmy! Ostatnią rzeczą, jaką należy zrobić, jest zmiana nazwy metody z naszej klasy TweetCreator
na call
, a klasa powinna dziedziczyć po ApplicationService
:
# app/services/tweet_creator.rbclass TweetCreator < ApplicationService attr_reader :message def initialize(message) @message = message end def call client = Twitter::REST::Client.new do |config| config.consumer_key = ENV config.consumer_secret = ENV config.access_token = ENV config.access_token_secret = ENV end client.update(@message) endend
I na koniec podsumujmy to, wywołując nasz obiekt usługi w kontrolerze:
class TweetController < ApplicationController def create TweetCreator.call(params) endend
Grouping Similar Service Objects for Sanity
Powyższy przykład ma tylko jeden obiekt usługi, ale w prawdziwym świecie sprawy mogą być bardziej skomplikowane. 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, oszalałbym, gdyby folder zawierał 200 unikalnie wyglądających plików, więc dobrze, że jest jeszcze jeden wzorzec z Rails Way, który możemy skopiować – to znaczy, użyć jako inspiracji: rozstawianie nazw.
Upozorujmy, że otrzymaliśmy zadanie stworzenia obiektu usługi, który śledzi inne profile na Twitterze.
Spójrzmy na nazwę naszego poprzedniego obiektu usługi: TweetCreator
. Brzmi ona jak osoba, a przynajmniej rola w organizacji. Ktoś, kto tworzy Tweety. Lubię nazywać moje obiekty usługowe tak, jakby były one właśnie tym: rolami w organizacji. Zgodnie z tą konwencją, będę nazywał mój nowy obiekt: ProfileFollower
.
Teraz, ponieważ jestem najwyższym władcą tej aplikacji, utworzę pozycję menedżera w mojej hierarchii usług i przekażę odpowiedzialność za obie te usługi do tej pozycji. Nazwę tę nową pozycję menedżera TwitterManager
.
Ponieważ menedżer ten nie robi nic poza zarządzaniem, uczyńmy go modułem i zagnieżdżmy nasze obiekty usług pod tym modułem. Nasza struktura folderów będzie teraz wyglądać następująco:
services├── application_service.rb└── twitter_manager ├── profile_follower.rb └── tweet_creator.rb
A nasze obiekty usług:
# services/twitter_manager/tweet_creator.rbmodule TwitterManager class TweetCreator < ApplicationService ... endend
# services/twitter_manager/profile_follower.rbmodule TwitterManager class ProfileFollower < ApplicationService ... endend
A nasze wywołania staną się teraz TwitterManager::TweetCreator.call(arg)
, i TwitterManager::ProfileManager.call(arg)
.
Obiekty usług do obsługi operacji na bazie danych
Powyższy przykład zawierał wywołania API, ale obiekty usług mogą być również używane, gdy wszystkie wywołania są kierowane do bazy danych zamiast do API. Jest to szczególnie pomocne, gdy niektóre działania biznesowe wymagają wielu aktualizacji bazy danych w ramach jednej transakcji. Na przykład, ten przykładowy kod użyłby usług do zarejestrowania wymiany walut, która ma miejsce.
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 ... endend
What Do I Return from My Service Object?
Przedyskutowaliśmy, jak call
nasz obiekt usługi, ale co ten obiekt powinien zwrócić? Istnieją trzy sposoby podejścia do tego zagadnienia:
- Return
true
lubfalse
- Return a value
- Return an Enum
Return true
lub false
Ten jest prosty: Jeśli działanie działa zgodnie z przeznaczeniem, zwróć true
; w przeciwnym razie zwróć false
:
def call ... return true if client.update(@message) false end
Return a Value
Jeśli twój obiekt usługi pobiera skądś dane, prawdopodobnie chcesz zwrócić tę wartość:
def call ... return false unless exchange_rate exchange_rate end
Respond withem
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ć enumy, aby kontrolować przepływ twoich usług:
class ExchangeRecorder < ApplicationService RETURNS = def call foo = do_something return SUCCESS if foo.success? return FAILURE if foo.failure? PARTIAL_SUCCESS end private def do_something endend
w swojejszym aplikacji możesz użyć:
case ExchangeRecorder.call when ExchangeRecorder::SUCCESS foo when ExchangeRecorder::FAILURE bar when ExchangeRecorder::PARTIAL_SUCCESS baz end
Sh Shouldn’t I Put Service Objects in lib/servicesniedawno zamiast app/services?
To jest subiektywne. Opinie ludzi różnią się co do tego, gdzie umieścić swoje obiekty usług. Niektórzy ludzie umieszczają je w lib/services
, podczas gdy niektórzy tworzą app/services
. Ja należę do tego drugiego obozu. Rails’ Getting Started Guide opisuje folder lib/
jako miejsce do umieszczenia “rozszerzonych modułów dla twojej aplikacji.”
W mojej skromnej opinii, “rozszerzone moduły” oznaczają moduły, które nie hermetyzują głównej logiki domeny i mogą być generalnie 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ę własnym klejnotem.”
Are Service Objects a Good Idea?
Zależy od przypadku użycia. Fakt, że czytasz teraz ten artykuł sugeruje, że próbujesz napisać kod, który nie do końca pasuje do modelu lub kontrolera. Ostatnio przeczytałem artykuł o tym, że obiekty usługowe są anty-wzorcem. Autor ma swoje zdanie, ale z całym szacunkiem się z nim nie zgadzam.
Tylko dlatego, że jakaś inna osoba nadużyła obiektów usługowych, nie oznacza to, że są one z natury złe. W moim startupie, Nazdeeq, używamy obiektów usługowych, a także modeli nie-ActiveRecord. Ale różnica pomiędzy tym, co gdzie pasuje, zawsze była dla mnie oczywista: Trzymam wszystkie działania biznesowe w obiektach usług, podczas gdy zasoby, które tak naprawdę nie potrzebują trwałości, trzymam w modelach non-ActiveRecord. Na koniec dnia to ty decydujesz, jaki wzorzec jest dla ciebie dobry.
Czy jednak uważam, że obiekty usługowe w ogóle są dobrym pomysłem? Absolutnie! Dzięki nim mój kod jest schludnie zorganizowany, a to co sprawia, że jestem pewny siebie w używaniu PORO to fakt, że Ruby kocha obiekty. Nie, poważnie, Ruby kocha obiekty. Jest to szalone, całkowicie szalone, ale kocham to! Przykład w punkcie:
> 5.is_a? Object # => true > 5.class # => Integer > class Integer?> def woot?> 'woot woot'?> end?> end # => :woot > 5.woot # => "woot woot"
See? 5
jest dosłownie obiektem.
W wielu językach, liczby i inne prymitywne typy nie są obiektami. Ruby podąża za wpływem języka Smalltalk, dając metody i zmienne instancji wszystkim swoim typom. Ułatwia to korzystanie z Rubiego, ponieważ zasady odnoszące się do obiektów odnoszą się do całego Rubiego.Ruby-lang.org
When Should I Not Use a Service Object?
To jest proste. Mam następujące 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 współdzielić swój kod w różnych kontrolerach?
W tym przypadku nie używaj obiektu usługi – użyj koncernu. - Czy twój kod jest jak model, który nie potrzebuje trwałości?
Jeśli tak, nie używaj obiektu usługi. Zamiast tego użyj modelu nie-ActiveRecord. - Czy twój kod jest konkretną akcją biznesową? (np. “Wynieś śmieci”, “Wygeneruj PDF używając tego tekstu” lub “Oblicz cło używając tych skomplikowanych zasad”)
W tym 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 zapraszamy do dostosowania ich do własnych przypadków użycia. Działały one bardzo dobrze dla mnie, ale twój przebieg może się różnić.
Rules for Writing Good Service Objects
Mam cztery zasady tworzenia obiektów usługowych. Nie są one zapisane w kamieniu, i jeśli naprawdę chcesz je złamać, możesz to zrobić, ale prawdopodobnie poproszę cię o ich zmianę podczas przeglądu kodu, chyba że twoje rozumowanie jest rozsądne.
Reguła 1: Tylko jedna metoda publiczna na obiekt usługi
Obiekty usługi są pojedynczymi działaniami biznesowymi. Możesz zmienić nazwę swojej metody publicznej, jeśli chcesz. Ja wolę używać call
, ale baza kodów Gitlab CE nazywa ją execute
, a inni ludzie mogą używać perform
. Użyj tego, co chcesz – możesz nazwać to nermin
dla wszystkich, na których mi zależy. Po prostu nie twórz dwóch metod publicznych dla pojedynczego obiektu usługi. Rozbij go na dwa obiekty, jeśli musisz.
Reguła 2: Nazywaj obiekty usługowe jak głupie role w firmie
Obiekty usługowe to pojedyncze działania biznesowe. Wyobraź sobie, że zatrudniłeś jedną osobę w firmie do wykonania tej jednej pracy, jak byś ją nazwał? Jeśli jej zadaniem jest tworzenie tweetów, nazwij ją TweetCreator
. Jeśli ich zadaniem jest czytanie konkretnych tweetów, nazwij ich TweetReader
.
Reguła 3: Nie twórz obiektów generycznych do wykonywania wielu czynności
Obiekty usługowe są pojedynczymi działaniami biznesowymi. Rozbiłem funkcjonalność na dwie części: TweetReader
, oraz ProfileFollower
. To, czego nie zrobiłem, to stworzenie pojedynczego obiektu generycznego o nazwie TwitterHandler
i zrzucenie tam całej funkcjonalności API. Proszę, nie rób tego. Jest to sprzeczne z mentalnością “działania biznesowego” i sprawia, że obiekt usługi wygląda jak Twitter Fairy. Jeśli chcesz dzielić kod między obiektami biznesowymi, po prostu stwórz obiekt lub moduł BaseTwitterManager
i wmieszaj go w swoje obiekty usługowe.
Reguła 4: Obsługuj wyjątki wewnątrz obiektu usługowego
Po raz enty: Obiekty usługowe to pojedyncze działania biznesowe. Nie mogę powiedzieć tego wystarczająco dużo. Jeśli masz osobę, która czyta tweety, to albo poda ci tweeta, albo powie: “Ten tweet nie istnieje”. Podobnie, nie pozwól, aby twój obiekt usługi wpadł w panikę, wskoczył na biurko kontrolera i kazał mu wstrzymać całą pracę, ponieważ “Błąd!”. Po prostu zwróć false
i pozwól kontrolerowi przejść stamtąd.
Ten artykuł nie byłby możliwy bez niesamowitej społeczności programistów Ruby w Toptal. Jeśli kiedykolwiek napotkam problem, społeczność ta jest najbardziej pomocną grupą utalentowanych inżynierów, jakich kiedykolwiek spotkałem.
Jeśli używasz obiektów usługowych, możesz się zastanawiać, jak wymusić pewne odpowiedzi podczas testowania. Polecam przeczytanie tego artykułu o tym jak tworzyć mock service objects w Rspec, które zawsze zwrócą wynik, który chcesz, bez faktycznego uderzania w obiekt usługi!
Jeśli chcesz dowiedzieć się więcej o sztuczkach Rubiego, polecam Creating a Ruby DSL: A Guide to Advanced Metaprogramming autorstwa kolegi z Toptaler Máté Solymosi. Opisuje on jak plik routes.rb
nie wygląda jak Ruby i pomaga zbudować własny DSL.