Obiecte de serviciu șine: un ghid cuprinzător
Publicat: 2022-03-11Ruby on Rails este livrat cu tot ceea ce aveți nevoie pentru a vă prototipa rapid aplicația, dar când baza de cod începe să crească, veți întâlni scenarii în care mantra convențională Fat Model, Skinny Controller se sparge. Când logica dvs. de afaceri nu se poate încadra într-un model sau într-un controler, atunci intră obiectele de serviciu și ne permit să separăm fiecare acțiune de afaceri în propriul obiect Ruby.
În acest articol, voi explica când este necesar un obiect de serviciu; cum să scrieți obiecte de serviciu curate și să le grupați împreună pentru sănătatea contribuitorului; regulile stricte pe care le impun obiectelor mele de serviciu pentru a le lega direct de logica mea de afaceri; și cum să nu transformi obiectele tale de serviciu într-un gunoi pentru tot codul cu care nu știi ce să faci.
De ce am nevoie de obiecte de serviciu?
Încercați acest lucru: Ce faceți când aplicația dvs. trebuie să tweeteze textul din params[:message]
?
Dacă ați folosit până acum Vanilla Rails, atunci probabil că ați făcut ceva de genul acesta:
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
Problema aici este că ați adăugat cel puțin zece linii la controler, dar ele nu aparțin cu adevărat acolo. De asemenea, ce se întâmplă dacă doriți să utilizați aceeași funcționalitate într-un alt controler? Mutați asta la o preocupare? Stai, dar acest cod nu aparține deloc controlerelor. De ce API-ul Twitter nu poate veni cu un singur obiect pregătit pe care să îl sun?
Prima dată când am făcut asta, am simțit că am făcut ceva murdar. Controlerele mele, anterior, frumos de slabe Rails începuseră să se îngrașă și nu știam ce să fac. În cele din urmă, mi-am reparat controlerul cu un obiect de serviciu.
Înainte de a începe să citiți acest articol, să ne prefacem:
- Această aplicație gestionează un cont Twitter.
- The Rails Way înseamnă „modul convențional Ruby on Rails de a face lucrurile” și cartea nu există.
- Sunt un expert în Rails... ceea ce mi se spune în fiecare zi că sunt, dar îmi este greu să cred asta, așa că hai să ne prefacem că sunt cu adevărat unul.
Ce sunt obiectele de serviciu?
Obiectele de serviciu sunt Plain Old Ruby Objects (PORO) care sunt proiectate să execute o singură acțiune în logica domeniului dvs. și să o facă bine. Luați în considerare exemplul de mai sus: metoda noastră are deja logica de a face un singur lucru, și anume de a crea un tweet. Ce se întâmplă dacă această logică ar fi încapsulată într-o singură clasă Ruby la care putem instanția și la care putem apela o metodă? Ceva asemănător cu:
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])
Cam asta este; obiectul nostru de serviciu TweetCreator
, odată creat, poate fi apelat de oriunde și ar face acest lucru foarte bine.
Crearea unui obiect de serviciu
Mai întâi, să creăm un nou TweetCreator
într-un dosar nou numit app/services
:
$ mkdir app/services && touch app/services/tweet_creator.rb
Și să aruncăm toată logica noastră într-o nouă clasă 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
Apoi puteți apela TweetCreator.new(params[:message]).send_tweet
oriunde în aplicația dvs. și va funcționa. Rails va încărca acest obiect în mod magic, deoarece încarcă automat totul sub app/
. Verificați acest lucru rulând:
$ 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
Doriți să aflați mai multe despre cum funcționează autoload
? Citiți Ghidul de încărcare automată și reîncărcare constante.
Adăugarea de zahăr sintactic pentru a face ca obiectele de serviciu șinelor să fie mai puțin naibii
Uite, se pare grozav în teorie, dar TweetCreator.new(params[:message]).send_tweet
este doar o gură. Este mult prea verbos cu cuvinte redundante... la fel ca HTML (ba-dum tiss! ). Cu toată seriozitatea, totuși, de ce folosesc oamenii HTML când există HAML? Sau chiar Slim. Bănuiesc că este un alt articol pentru altă dată. Înapoi la sarcina în cauză:
TweetCreator
este un nume de clasă scurt și frumos, dar cruft suplimentar în ceea ce privește instanțiarea obiectului și apelarea metodei este prea lungă! Dacă ar fi avut prioritate în Ruby pentru a apela ceva și a-l face să se execute imediat cu parametrii dați... oh, stai, există! Este Proc#call
.
Proccall
invocă blocul, setând parametrii blocului la valorile din parametri folosind ceva apropiat de semantica de apelare a metodei. Returnează valoarea ultimei expresii evaluate în bloc.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]
Documentație
Dacă asta te încurcă, lasă-mă să explic. Un proc
poate fi call
-ed pentru a se executa singur cu parametrii dați. Ceea ce înseamnă că, dacă TweetCreator
ar fi un proc
, l-am putea apela cu TweetCreator.call(message)
și rezultatul ar fi echivalent cu TweetCreator.new(params[:message]).call
, care arată destul de asemănător cu vechiul nostru TweetCreator.new(params[:message]).send_tweet
greu de manevrat. TweetCreator.new(params[:message]).send_tweet
.
Deci, să facem ca obiectul nostru de serviciu să se comporte mai mult ca un proc
!
În primul rând, pentru că probabil dorim să reutilizam acest comportament în toate obiectele noastre de serviciu, să împrumutăm de la Rails Way și să creăm o clasă numită ApplicationService
:
# app/services/application_service.rb class ApplicationService def self.call(*args, &block) new(*args, &block).call end end
Ai văzut ce am făcut acolo? Am adăugat o metodă de clasă numită call
care creează o nouă instanță a clasei cu argumentele sau blocul pe care îi treceți și apelează call
pe instanță. Exact ce ne-am dorit! Ultimul lucru de făcut este să redenumim metoda din clasa noastră TweetCreator
pe care să o call
și ca clasa să moștenească de la 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, în sfârșit, să încheiem acest lucru apelând obiectul nostru de serviciu în controler:
class TweetController < ApplicationController def create TweetCreator.call(params[:message]) end end
Gruparea obiectelor de serviciu similare pentru Sanity
Exemplul de mai sus are un singur obiect de serviciu, dar în lumea reală, lucrurile se pot complica. De exemplu, ce se întâmplă dacă ai avea sute de servicii, iar jumătate dintre ele ar fi acțiuni legate de afaceri, de exemplu, dacă ai un serviciu de Follower
care urmărea un alt cont Twitter? Sincer, aș înnebuni dacă un folder ar conține 200 de fișiere cu aspect unic, atât de bine că există un alt model de la Rails Way pe care îl putem copia - adică, folosim ca inspirație: spațiarea numelor.
Să presupunem că am fost însărcinați să creăm un obiect de serviciu care urmează alte profiluri Twitter.
Să ne uităm la numele obiectului nostru de serviciu anterior: TweetCreator
. Sună ca o persoană sau, cel puțin, un rol într-o organizație. Cineva care creează tweet-uri. Îmi place să numesc obiectele mele de serviciu ca și cum ar fi doar atât: roluri într-o organizație. Urmând această convenție, voi numi noul meu obiect: ProfileFollower
.
Acum, din moment ce sunt stăpânul suprem al acestei aplicații, voi crea o poziție de conducere în ierarhia mea de servicii și voi delega responsabilitatea pentru ambele servicii în acea poziție. Voi numi această nouă poziție managerială TwitterManager
.
Deoarece acest manager nu face altceva decât să gestioneze, haideți să-l transformăm într-un modul și să imbricați obiectele noastre de serviciu sub acest modul. Structura noastră de foldere va arăta acum astfel:
services ├── application_service.rb └── twitter_manager ├── profile_follower.rb └── tweet_creator.rb
Și obiectele noastre de serviciu:
# 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
Iar apelurile noastre vor deveni acum TwitterManager::TweetCreator.call(arg)
și TwitterManager::ProfileManager.call(arg)
.

Obiecte de serviciu pentru a gestiona operațiunile bazei de date
Exemplul de mai sus a făcut apeluri API, dar obiectele de serviciu pot fi utilizate și atunci când toate apelurile sunt către baza de date în loc de un API. Acest lucru este util în special dacă unele acțiuni de afaceri necesită mai multe actualizări ale bazei de date incluse într-o tranzacție. De exemplu, acest exemplu de cod ar folosi servicii pentru a înregistra un schimb valutar care are loc.
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
Ce returnez de la obiectul meu de serviciu?
Am discutat cum să call
obiectul nostru de serviciu, dar ce ar trebui să returneze obiectul? Există trei moduri de a aborda acest lucru:
- Returnează
true
saufalse
- Returnează o valoare
- Returnează o Enum
Returnează true
sau false
Acesta este simplu: dacă o acțiune funcționează conform intenției, returnează true
; în caz contrar, returnează false
:
def call ... return true if client.update(@message) false end
Returnează o valoare
Dacă obiectul serviciului preia date de undeva, probabil că doriți să returnați acea valoare:
def call ... return false unless exchange_rate exchange_rate end
Răspundeți cu o Enum
Dacă obiectul dvs. de serviciu este puțin mai complex și doriți să gestionați diferite scenarii, puteți adăuga doar enumerari pentru a controla fluxul serviciilor dvs.:
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
Și apoi, în aplicația dvs., puteți utiliza:
case ExchangeRecorder.call when ExchangeRecorder::SUCCESS foo when ExchangeRecorder::FAILURE bar when ExchangeRecorder::PARTIAL_SUCCESS baz end
Nu ar trebui să pun obiecte de serviciu în lib/services
în loc de app/services
?
Acest lucru este subiectiv. Părerile oamenilor diferă cu privire la locul în care să-și pună obiectele de serviciu. Unii oameni le pun în lib/services
, în timp ce unii creează app/services
. Cad în tabăra din urmă. Ghidul de pornire al lui Rails descrie folderul lib/
ca fiind locul în care să plasați „module extinse pentru aplicația dumneavoastră”.
În umila mea părere, „module extinse” înseamnă module care nu încapsulează logica domeniului de bază și pot fi utilizate în general în cadrul proiectelor. În cuvintele înțelepte ale unui răspuns aleatoriu Stack Overflow, introduceți acolo cod care „poate deveni propria sa bijuterie”.
Sunt obiectele de serviciu o idee bună?
Depinde de cazul dvs. de utilizare. Uite, faptul că citești acest articol chiar acum sugerează că încerci să scrii cod care nu aparține exact unui model sau controler. Am citit recent acest articol despre modul în care obiectele de serviciu sunt un anti-model. Autorul are părerile lui, dar eu nu sunt de acord.
Doar pentru că o altă persoană a suprautilizat obiectele de serviciu nu înseamnă că acestea sunt în mod inerent rele. La startup-ul meu, Nazdeeq, folosim obiecte de serviciu, precum și modele non-ActiveRecord. Dar diferența dintre ceea ce merge unde a fost întotdeauna evidentă pentru mine: păstrez toate acțiunile de afaceri în obiecte de serviciu, păstrând în același timp resurse care nu au nevoie cu adevărat de persistență în modelele non-ActiveRecord. La sfârșitul zilei, este pentru tine să decizi ce model este bun pentru tine.
Cu toate acestea, cred că obiectele de serviciu în general sunt o idee bună? Absolut! Îmi păstrează codul ordonat, iar ceea ce mă face încrezător în utilizarea PORO-urilor este că Ruby iubește obiectele. Nu, serios, Ruby iubește obiectele. Este o nebunie, total nebunească, dar îmi place! Caz elocvent:
> 5.is_a? Object # => true > 5.class # => Integer > class Integer ?> def woot ?> 'woot woot' ?> end ?> end # => :woot > 5.woot # => "woot woot"
Vedea? 5
este literalmente un obiect.
În multe limbi, numerele și alte tipuri primitive nu sunt obiecte. Ruby urmărește influența limbajului Smalltalk oferind metode și variabile de instanță tuturor tipurilor sale. Acest lucru ușurează utilizarea Ruby, deoarece regulile care se aplică obiectelor se aplică întregului Ruby. Ruby-lang.org
Când nu ar trebui să folosesc un obiect de serviciu?
Acesta este ușor. Am aceste reguli:
- Codul tău gestionează rutarea, parametrii sau face alte lucruri legate de controler?
Dacă da, nu utilizați un obiect de serviciu - codul dvs. aparține controlerului. - Încercați să vă partajați codul în controlere diferite?
În acest caz, nu utilizați un obiect de serviciu - folosiți o preocupare. - Codul tău este ca un model care nu are nevoie de persistență?
Dacă da, nu utilizați un obiect de serviciu. Utilizați în schimb un model non-ActiveRecord. - Codul dvs. este o acțiune comercială specifică? (de exemplu, „Scoate gunoiul”, „Generează un PDF folosind acest text” sau „Calculează taxa vamală folosind aceste reguli complicate”)
În acest caz, utilizați un obiect de serviciu. Acest cod probabil nu se potrivește în mod logic nici în controler, nici în model.
Desigur, acestea sunt regulile mele , așa că sunteți binevenit să le adaptați la propriile cazuri de utilizare. Acestea au funcționat foarte bine pentru mine, dar kilometrajul dvs. poate varia.
Reguli pentru scrierea obiectelor de serviciu bune
Am patru reguli pentru crearea obiectelor de serviciu. Acestea nu sunt scrise în piatră, iar dacă chiar doriți să le spargeți, puteți, dar probabil vă voi cere să o schimbați în recenziile de cod, dacă raționamentul dvs. nu este solid.
Regula 1: O singură metodă publică per obiect de serviciu
Obiectele de serviciu sunt acțiuni comerciale individuale . Dacă doriți, puteți schimba numele metodei publice. Prefer să folosesc call
, dar baza de cod a Gitlab CE îl numește execute
și alți oameni pot folosi perform
. Folosește orice vrei - ai putea să-l numești nermin
pentru tot ce îmi pasă. Doar nu creați două metode publice pentru un singur obiect de serviciu. Împărțiți-l în două obiecte dacă aveți nevoie.
Regula 2: Numele obiectelor de serviciu, cum ar fi roluri stupide la o companie
Obiectele de serviciu sunt acțiuni comerciale individuale. Imaginați-vă dacă ați angaja o persoană la companie pentru a face acel loc de muncă, cum i-ați numi? Dacă sarcina lor este să creeze tweet-uri, numiți-le TweetCreator
. Dacă sarcina lor este să citească anumite tweet-uri, numiți-le TweetReader
.
Regula 3: Nu creați obiecte generice pentru a efectua acțiuni multiple
Obiectele de serviciu sunt acțiuni comerciale individuale. Am împărțit funcționalitatea în două părți: TweetReader
și ProfileFollower
. Ceea ce nu am făcut a fost să creez un singur obiect generic numit TwitterHandler
și să arunc acolo toate funcționalitățile API. Te rog nu face asta. Acest lucru contravine mentalității „acțiunii de afaceri” și face ca obiectul serviciului să arate ca Zâna Twitter. Dacă doriți să partajați codul între obiectele de afaceri, trebuie doar să creați un obiect sau un modul BaseTwitterManager
și să amestecați-l în obiectele dvs. de serviciu.
Regula 4: Gestionați excepțiile din interiorul obiectului de serviciu
Pentru a entimea oară: obiectele de serviciu sunt acțiuni comerciale individuale. Nu pot spune asta destul. Dacă aveți o persoană care citește tweet-uri, fie vă va da tweet-ul, fie vă va spune „Acest tweet nu există”. În mod similar, nu lăsați obiectul dvs. de serviciu să intre în panică, săriți pe biroul controlerului și spuneți-i să oprească orice lucru pentru că „Eroare!” Doar întoarceți false
și lăsați controlerul să meargă mai departe de acolo.
Credite și pașii următori
Acest articol nu ar fi fost posibil fără comunitatea uimitoare de dezvoltatori Ruby de la Toptal. Dacă întâmpin vreodată o problemă, comunitatea este cel mai util grup de ingineri talentați pe care i-am întâlnit vreodată.
Dacă utilizați obiecte de serviciu, este posibil să vă întrebați cum să forțați anumite răspunsuri în timpul testării. Vă recomand să citiți acest articol despre cum să creați obiecte de serviciu simulate în Rspec, care vor returna întotdeauna rezultatul dorit, fără a lovi de fapt obiectul de serviciu!
Dacă doriți să aflați mai multe despre trucurile Ruby, vă recomand Crearea unui Ruby DSL: A Guide to Advanced Metaprogramming de către coleg Toptaler Mate Solymosi. El dezvăluie modul în care fișierul routes.rb
nu se simte ca Ruby și vă ajută să vă construiți propriul DSL.