Rails Service Objects: A Comprehensive Guide

Ruby on Rails este livrat cu tot ceea ce aveți nevoie pentru a realiza rapid prototipul aplicației dumneavoastră, dar atunci când baza de coduri începe să crească, veți întâlni scenarii în care mantra convențională Fat Model, Skinny Controller se rupe. Atunci când logica dvs. de afaceri nu se poate încadra nici într-un model, nici într-un controler, atunci intervin obiectele de serviciu, care ne permit să separăm fiecare acțiune de afaceri în propriul obiect Ruby.

Un exemplu de ciclu de solicitare cu obiecte de serviciu Rails

În acest articol, voi explica când este necesar un obiect de serviciu; cum să procedez pentru a scrie obiecte de serviciu curate și să le grupez împreună pentru a asigura sănătatea mentală a contributorilor; regulile stricte pe care le impun obiectelor mele de serviciu pentru a le lega direct de logica mea de afaceri; și cum să nu vă transformați obiectele de serviciu într-o groapă de gunoi pentru tot codul cu care nu știți ce să faceți.

De ce am nevoie de obiecte de serviciu?

Încercați acest lucru: Ce faceți atunci când aplicația dvs. trebuie să trimită pe Twitter textul din params?

Dacă ați folosit până acum Rails vanilie, atunci probabil că ați făcut ceva de genul acesta:

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

Problema aici este că ați adăugat cel puțin zece linii la controlerul dvs., dar acestea nu își au locul acolo. De asemenea, ce s-ar întâmpla dacă ați dori să folosiți aceeași funcționalitate într-un alt controler? Mutați acest lucru într-o preocupare? Așteptați, dar acest cod nu aparține deloc controlorilor. De ce nu poate API-ul Twitter să vină cu un singur obiect pregătit pe care să îl pot apela?

Prima dată când am făcut acest lucru, m-am simțit ca și cum aș fi făcut ceva murdar. Controlorii mei, anterior, minunat de suplu Rails începuseră să se îngrașe ș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 că:

  • Această aplicație gestionează un cont Twitter.
  • The Rails Way înseamnă “modul convențional de a face lucrurile în Ruby on Rails”, iar cartea nu există.
  • Sunt un expert Rails… ceea ce mi se spune în fiecare zi că sunt, dar mi-e 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 concepute pentru a executa o singură acțiune în logica domeniului dumneavoastră și să o facă bine. Luați în considerare exemplul de mai sus: Metoda noastră are deja logica pentru a face un singur lucru, și anume să creeze un tweet. Cum ar fi dacă această logică ar fi încapsulată într-o singură clasă Ruby pe care o putem instanția și la care putem apela o metodă? Ceva de genul:

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)

Acesta este destul de mult; obiectul nostru de serviciu TweetCreator, odată creat, poate fi apelat de oriunde și ar face foarte bine acest singur lucru.

Crearea unui obiect de serviciu

În primul rând să creăm un nou TweetCreator într-un nou folder numit app/services:

$ mkdir app/services && touch app/services/tweet_creator.rb

Și să aruncăm toată logica noastră în interiorul unei noi clase 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

Apoi puteți apela TweetCreator.new(params).send_tweet oriunde în aplicația dvs. și va funcționa. Rails va încărca acest obiect în mod magic, deoarece autoloadează totul sub app/. Verificați acest lucru rulând:

$ 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

Vreți să aflați mai multe despre cum funcționează autoload? Citiți Ghidul Constantelor de încărcare automată și reîncărcare.

Adăugarea de zahăr sintactic pentru a face ca obiectele de servicii Rails să fie mai puțin nașpa

Ascultați, acest lucru pare grozav în teorie, dar TweetCreator.new(params).send_tweet este doar o gură de aer. Este mult prea verbos, cu cuvinte redundante… foarte asemănător cu HTML (ba-dum tiss!). Cu toată seriozitatea, totuși, de ce folosesc oamenii HTML când există HAML? Sau chiar Slim. Cred că ăsta este un alt articol pentru altă dată. Să revenim la sarcina de față:

TweetCreator este un nume de clasă scurt și frumos, dar partea suplimentară de instanțiere a obiectului și de apelare a metodei este pur și simplu prea lungă! Dacă ar exista o precedență în Ruby pentru a apela ceva și pentru ca acesta să se execute imediat cu parametrii dați… oh, stai, există! Este Proc#call.

Proccall invocă blocul, setând parametrii blocului la valorile din params folosind ceva apropiat de semantica de apelare a metodei. El returnează valoarea ultimei expresii evaluate în bloc.

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) #=> 

Documentație

Dacă acest lucru vă derutează, permiteți-mi să vă explic. Un proc poate fi calleditat pentru a se executa singur cu parametrii dați. Ceea ce înseamnă că, dacă TweetCreator ar fi un proc, am putea să-l apelăm cu TweetCreator.call(message) și rezultatul ar fi echivalent cu TweetCreator.new(params).call, care seamănă destul de mult cu vechiul și greoiul nostru TweetCreator.new(params).send_tweet.

Așa că haideți să facem ca obiectul nostru de serviciu să se comporte mai mult ca un proc!

În primul rând, pentru că probabil vrem să reutilizăm acest comportament în toate obiectele noastre de serviciu, să împrumutăm din calea Rails și să creăm o clasă numită ApplicationService:

# app/services/application_service.rbclass ApplicationService def self.call(*args, &block) new(*args, &block).call endend

Ați 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 le treceți, și apelează call pe instanță. Exact ceea ce am vrut noi! Ultimul lucru de făcut este să redenumim metoda din clasa noastră TweetCreator în call și să facem ca clasa să moștenească din 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, în final, să încheiem acest lucru prin apelarea obiectului nostru de serviciu în controler:

class TweetController < ApplicationController def create TweetCreator.call(params) endend

Gruparea obiectelor de serviciu similare pentru mai multă coerență

Exemplul de mai sus are doar un singur obiect de serviciu, dar în lumea reală, lucrurile pot deveni mai complicate. De exemplu, ce s-ar întâmpla dacă ați avea sute de servicii, iar jumătate dintre ele ar fi acțiuni de afaceri conexe, de exemplu, dacă ați avea un serviciu Follower care urmărește un alt cont Twitter? Sincer, aș înnebuni dacă un dosar ar conține 200 de fișiere cu aspect unic, așa că e bine că există un alt model din Calea Rails pe care îl putem copia – adică, folosi ca sursă de inspirație: namespacing.

Să ne imaginăm că am primit sarcina de a crea un obiect de serviciu care urmărește alte profiluri Twitter.

Să ne uităm la numele obiectului nostru de serviciu anterior: TweetCreator. Sună ca o persoană sau, cel puțin, ca un rol într-o organizație. Cineva care creează Tweeturi. Îmi place să-mi numesc obiectele 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: .

Acum, din moment ce eu sunt stăpânul suprem al acestei aplicații, voi crea o poziție managerială în ierarhia mea de servicii și voi delega responsabilitatea pentru ambele servicii către această poziție. Voi numi această nouă poziție managerială TwitterManager.

Din moment ce acest manager nu face nimic altceva decât să administreze, hai să îl facem un modul și să aninăm obiectele noastre de serviciu sub acest modul. Structura folderului nostru va arăta acum ca:

services├── application_service.rb└── twitter_manager ├── profile_follower.rb └── tweet_creator.rb

Și obiectele noastre de serviciu:

# services/twitter_manager/tweet_creator.rbmodule TwitterManager class TweetCreator < ApplicationService ... endend
# services/twitter_manager/profile_follower.rbmodule TwitterManager class ProfileFollower < ApplicationService ... endend

Și apelurile noastre vor deveni acum TwitterManager::TweetCreator.call(arg), și TwitterManager::ProfileManager.call(arg).

Obiecte de serviciu pentru a gestiona operațiile bazei de date

Exemplul de mai sus a făcut apeluri API, dar obiectele de serviciu pot fi, de asemenea, utilizate atunci când toate apelurile sunt către baza de date în loc de un API. Acest lucru este util mai ales dacă unele acțiuni de afaceri necesită mai multe actualizări ale bazei de date înglobate într-o tranzacție. De exemplu, acest exemplu de cod ar folosi serviciile 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 ... endend

Ce returnez de la obiectul meu de serviciu?

Am discutat despre cum să call obiectul nostru de serviciu, dar ce ar trebui să returneze obiectul? Există trei moduri de a aborda acest lucru:

  • Întoarce true sau false
  • Întoarce o valoare
  • Întoarce un Enum

Întoarce true sau false

Acest lucru 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 dvs. de serviciu extrage date de undeva, probabil doriți să returnați acea valoare:

 def call ... return false unless exchange_rate exchange_rate end

Răspundeți cu o enumerație

Dacă obiectul dvs. de serviciu este puțin mai complex și doriți să gestionați diferite scenarii, puteți adăuga enumerații pentru a controla fluxul serviciilor dvs.:

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

Și apoi în aplicația dvs. puteți folosi:

 case ExchangeRecorder.call when ExchangeRecorder::SUCCESS foo when ExchangeRecorder::FAILURE bar when ExchangeRecorder::PARTIAL_SUCCESS baz end

Nu ar trebui să pun obiectele de serviciu în lib/services în loc de app/services?

Acest lucru este subiectiv. Părerile oamenilor diferă în ceea ce privește locul în care să pună obiectele de serviciu. Unii oameni le pun în lib/services, în timp ce alții creează app/services. Eu mă încadrez în ultima tabără. Ghidul de inițiere Rails descrie folderul lib/ ca fiind locul unde se pun “modulele extinse pentru aplicația dvs.”

În umila mea părere, “modulele extinse” înseamnă module care nu încapsulează logica de bază a domeniului și care, în general, pot fi utilizate în mai multe proiecte. În cuvintele înțelepte ale unui răspuns aleatoriu de pe Stack Overflow, puneți acolo codul care “poate deveni potențial propria sa bijuterie.”

Sunt obiectele de serviciu o idee bună?

Depinde de cazul dumneavoastră de utilizare. Uite – faptul că citești acest articol chiar acum sugerează că încerci să scrii cod care nu aparține tocmai unui model sau controler. Am citit recent acest articol despre cum obiectele de serviciu sunt un anti-pattern. Autorul are părerile sale, dar eu cu respect nu sunt de acord.

Doar pentru că o altă persoană a folosit în exces obiectele de serviciu nu înseamnă că acestea sunt inerent rele. La start-up-ul meu, Nazdeeq, folosim obiecte de servicii, precum și modele non-ActiveRecord. Dar diferența dintre ceea ce merge unde a fost întotdeauna evidentă pentru mine: Eu păstrez toate acțiunile de afaceri în obiecte de servicii, în timp ce resursele care nu au nevoie de persistență în modele non-ActiveRecord. La sfârșitul zilei, depinde de dumneavoastră să decideți ce model este bun pentru dumneavoastră.

Cu toate acestea, cred eu că obiectele de serviciu în general sunt o idee bună? Absolut! Ele îmi mențin codul organizat în mod ordonat, iar ceea ce mă face să am încredere în utilizarea PORO-urilor este faptul că Ruby iubește obiectele. Nu, serios, Ruby iubește obiectele. Este o nebunie, o nebunie totală, dar îmi place! Caz concret:

 > 5.is_a? Object # => true > 5.class # => Integer > class Integer?> def woot?> 'woot woot'?> end?> end # => :woot > 5.woot # => "woot woot"

Vezi? 5 este literalmente un obiect.

În multe limbaje, numerele și alte tipuri primitive nu sunt obiecte. Ruby urmează influența limbajului Smalltalk, oferind metode și variabile de instanță tuturor tipurilor sale. Acest lucru ușurează utilizarea lui Ruby, deoarece regulile care se aplică obiectelor se aplică tuturor tipurilor din Ruby.Ruby-lang.org

Când nu ar trebui să folosesc un obiect de serviciu?

Aceasta este ușoară. Eu am aceste reguli:

  1. Codul dumneavoastră gestionează rutarea, parametrii sau face alte lucruri de tip controler?
    Dacă da, nu folosiți un obiect de serviciu – codul dumneavoastră trebuie să fie în controler.
  2. Încercați să vă partajați codul în diferiți controlori?
    În acest caz, nu folosiți un obiect de serviciu – folosiți o preocupare.
  3. Codul dvs. este ca un model care nu are nevoie de persistență?
    Dacă da, nu folosiți un obiect de serviciu. Folosiți în schimb un model non-ActiveRecord.
  4. Codul dumneavoastră este o acțiune specifică de afaceri? (de exemplu, “Scoateți gunoiul”, “Generați un PDF folosind acest text” sau “Calculați taxa vamală folosind aceste reguli complicate”)
    În acest caz, utilizați un obiect de serviciu. Probabil că acel cod nu se potrivește logic nici în controlerul, nici în model.

Desigur, acestea sunt regulile mele, așa că sunteți bineveniți să le adaptați la propriile dvs. cazuri de utilizare. Acestea au funcționat foarte bine pentru mine, dar kilometrajul dumneavoastră poate varia.

Reguli pentru scrierea unor obiecte de serviciu bune

Am patru reguli pentru crearea obiectelor de serviciu. Acestea nu sunt scrise în piatră și, dacă vreți cu adevărat să le încălcați, puteți să o faceți, dar probabil că vă voi cere să le schimbați în cadrul revizuirilor de cod, cu excepția cazului în care raționamentul dvs. este solid.

Regula 1: O singură metodă publică per obiect de serviciu

Obiectele de serviciu sunt acțiuni de afaceri unice. Puteți schimba numele metodei dvs. publice dacă doriți. Eu prefer să folosesc call, dar baza de cod a Gitlab CE o numește execute, iar alte persoane pot folosi perform. Folosiți ce doriți – ați putea să o numiți nermin din partea mea. Doar nu creați două metode publice pentru un singur obiect de serviciu. Împărțiți-l în două obiecte dacă este nevoie.

Regula 2: Numiți obiectele de serviciu ca pe niște roluri stupide într-o companie

Obiectele de serviciu sunt acțiuni de afaceri unice. Imaginați-vă că ați angajat o singură persoană în cadrul companiei pentru a face acea singură treabă, cum ați numi-o? Dacă sarcina lor este de a crea tweet-uri, numiți-o TweetCreator. Dacă sarcina lor este să citească anumite tweet-uri, numiți-i TweetReader.

Regula 3: Nu creați obiecte generice pentru a efectua acțiuni multiple

Obiectele de serviciu sunt acțiuni de afaceri unice. Am împărțit funcționalitatea în două bucăți: , și ProfileFollower. Ceea ce nu am făcut este să creez un singur obiect generic numit TwitterHandler și să arunc acolo toată funcționalitatea API. Vă rog să nu faceți acest lucru. Acest lucru contravine mentalității de “acțiune de afaceri” și face ca obiectul de serviciu să arate ca Zâna Twitter. Dacă doriți să partajați codul între obiectele de afaceri, creați doar un obiect sau un modul BaseTwitterManager și amestecați-l în obiectele de serviciu.

Regula 4: Gestionați excepțiile în interiorul obiectului de serviciu

Pentru a mia oară: Obiectele de serviciu sunt acțiuni de afaceri unice. Nu pot spune acest lucru suficient de mult. Dacă aveți o persoană care citește tweet-uri, fie vă va da tweet-ul, fie va spune: “Acest tweet nu există”. În mod similar, nu lăsați obiectul dvs. de serviciu să intre în panică, să sară pe biroul controlorului dvs. și să îi spuneți să oprească toată activitatea pentru că “Eroare!”. Doar returnați false și lăsați controlerul să meargă mai departe de acolo.

Acest articol nu ar fi fost posibil fără comunitatea uimitoare de dezvoltatori Ruby de la Toptal. Dacă mă confrunt vreodată cu o problemă, comunitatea este cel mai util grup de ingineri talentați pe care l-am întâlnit vreodată.

Dacă folosiți obiecte de serviciu, s-ar putea 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ă învățați mai multe despre trucuri Ruby, vă recomand Creating a Ruby DSL: A Guide to Advanced Metaprogramming de colegul Toptaler Máté Solymosi. El descompune modul în care fișierul routes.rb nu pare a fi Ruby și vă ajută să vă construiți propriul DSL.

.

Lasă un răspuns

Adresa ta de email nu va fi publicată.