Rails Service Objects: una guida completa

Pubblicato: 2022-03-11

Ruby on Rails viene fornito con tutto il necessario per prototipare rapidamente la tua applicazione, ma quando la tua base di codice inizia a crescere, ti imbatterai in scenari in cui il tradizionale mantra Fat Model, Skinny Controller si rompe. Quando la tua logica di business non può adattarsi né a un modello né a un controller, è allora che entrano in gioco gli oggetti di servizio e separiamo ogni azione aziendale nel proprio oggetto Ruby.

Un esempio di ciclo di richiesta con oggetti di servizio Rails

In questo articolo spiegherò quando è richiesto un oggetto servizio; come fare per scrivere oggetti di servizio puliti e raggrupparli insieme per la sanità mentale del contributore; le rigide regole che impongo ai miei oggetti di servizio per legarli direttamente alla mia logica aziendale; e come non trasformare i tuoi oggetti di servizio in una discarica per tutto il codice con cui non sai cosa fare.

Perché ho bisogno di oggetti di servizio?

Prova questo: cosa fai quando la tua applicazione ha bisogno di twittare il testo da params[:message] ?

Se finora hai utilizzato vanilla Rails, probabilmente hai fatto qualcosa del genere:

 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

Il problema qui è che hai aggiunto almeno dieci righe al tuo controller, ma in realtà non appartengono a lì. Inoltre, cosa succede se si desidera utilizzare la stessa funzionalità in un altro controller? Lo sposti in una preoccupazione? Aspetta, ma questo codice non appartiene affatto ai controller. Perché l'API di Twitter non può semplicemente venire con un singolo oggetto preparato da chiamare?

La prima volta che l'ho fatto, mi sentivo come se avessi fatto qualcosa di sporco. I miei controller Rails, in precedenza meravigliosamente snelli, avevano iniziato a ingrassare e non sapevo cosa fare. Alla fine, ho riparato il mio controller con un oggetto di servizio.

Prima di iniziare a leggere questo articolo, facciamo finta:

  • Questa applicazione gestisce un account Twitter.
  • The Rails Way significa "il modo convenzionale di fare le cose di Ruby on Rails" e il libro non esiste.
  • Sono un esperto di Rails... e ogni giorno mi viene detto che lo sono, ma ho difficoltà a crederci, quindi facciamo finta che lo sia davvero.

Cosa sono gli oggetti di servizio?

Gli oggetti di servizio sono Plain Old Ruby Objects (PORO) progettati per eseguire una singola azione nella logica di dominio e farlo bene. Considera l'esempio sopra: il nostro metodo ha già la logica per fare una sola cosa, ovvero creare un tweet. E se questa logica fosse incapsulata all'interno di una singola classe Ruby di cui possiamo istanziare e chiamare un metodo? Qualcosa di simile a:

 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])

Questo è praticamente tutto; il nostro oggetto di servizio TweetCreator , una volta creato, può essere chiamato da qualsiasi luogo e farebbe questa cosa molto bene.

Creazione di un oggetto di servizio

Per prima cosa creiamo un nuovo TweetCreator in una nuova cartella chiamata app/services :

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

E scarichiamo tutta la nostra logica all'interno di una nuova classe 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

Quindi puoi chiamare TweetCreator.new(params[:message]).send_tweet ovunque nella tua app e funzionerà. Rails caricherà questo oggetto magicamente perché carica automaticamente tutto sotto app/ . Verificalo eseguendo:

 $ 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

Vuoi saperne di più su come funziona il autoload ? Leggi la Guida alle costanti di caricamento e ricaricamento automatico.

Aggiunta di zucchero sintattico per fare in modo che gli oggetti di servizio Rails succhino di meno

Guarda, in teoria è fantastico, ma TweetCreator.new(params[:message]).send_tweet è solo un boccone. È troppo prolisso con parole ridondanti... proprio come HTML (ba-dum tiss! ). In tutta serietà, però, perché le persone usano HTML quando HAML è in giro? O anche Slim. Immagino che sia un altro articolo per un'altra volta. Torna al compito a portata di mano:

TweetCreator è un bel nome di classe breve, ma il problema extra per creare un'istanza dell'oggetto e chiamare il metodo è troppo lungo! Se solo ci fosse la precedenza in Ruby per chiamare qualcosa e farlo eseguire immediatamente con i parametri indicati... oh aspetta, c'è! È Proc#call .

Proccall richiama il blocco, impostando i parametri del blocco sui valori in parametri usando qualcosa di simile alla semantica di chiamata del metodo. Restituisce il valore dell'ultima espressione valutata nel blocco.

 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]

Documentazione

Se questo ti confonde, lascia che ti spieghi. Un proc può essere call -ed per eseguirsi con i parametri indicati. Ciò significa che se TweetCreator fosse un proc , potremmo chiamarlo con TweetCreator.call(message) e il risultato sarebbe equivalente a TweetCreator.new(params[:message]).call , che sembra abbastanza simile al nostro vecchio ingombrante TweetCreator.new(params[:message]).send_tweet .

Quindi facciamo in modo che il nostro oggetto di servizio si comporti più come un proc !

Innanzitutto, poiché probabilmente vogliamo riutilizzare questo comportamento in tutti i nostri oggetti di servizio, prendiamo in prestito da Rails Way e creiamo una classe chiamata ApplicationService :

 # app/services/application_service.rb class ApplicationService def self.call(*args, &block) new(*args, &block).call end end

Hai visto cosa ho fatto lì? Ho aggiunto un metodo di classe chiamato call che crea una nuova istanza della classe con gli argomenti o il blocco che gli passi e chiama call sull'istanza. Esattamente quello che volevamo! L'ultima cosa da fare è rinominare il metodo dalla nostra classe TweetCreator per call e far ereditare la classe da 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

E infine, concludiamo chiamando il nostro oggetto di servizio nel controller:

 class TweetController < ApplicationController def create TweetCreator.call(params[:message]) end end

Raggruppamento di oggetti di servizio simili per Sanity

L'esempio sopra ha un solo oggetto di servizio, ma nel mondo reale le cose possono diventare più complicate. Ad esempio, cosa succederebbe se avessi centinaia di servizi e metà di essi fossero azioni commerciali correlate, ad esempio avere un servizio Follower che seguiva un altro account Twitter? Onestamente, diventerei pazzo se una cartella contenesse 200 file dall'aspetto unico, quindi per fortuna c'è un altro modello di Rails Way che possiamo copiare, voglio dire, usare come ispirazione: lo spazio dei nomi.

Facciamo finta di essere stati incaricati di creare un oggetto servizio che segua altri profili Twitter.

Diamo un'occhiata al nome del nostro precedente oggetto di servizio: TweetCreator . Suona come una persona, o almeno, un ruolo in un'organizzazione. Qualcuno che crea Tweet. Mi piace nominare i miei oggetti di servizio come se fossero proprio questo: ruoli in un'organizzazione. Seguendo questa convenzione, chiamerò il mio nuovo oggetto: ProfileFollower .

Ora, poiché sono il signore supremo di questa app, creerò una posizione manageriale nella mia gerarchia dei servizi e delegherò la responsabilità di entrambi questi servizi a quella posizione. Chiamerò questa nuova posizione manageriale TwitterManager .

Poiché questo manager non fa altro che gestire, rendiamolo un modulo e annidiamo i nostri oggetti di servizio in questo modulo. La nostra struttura di cartelle ora sarà simile a:

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

E i nostri oggetti di servizio:

 # 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

E le nostre chiamate diventeranno ora TwitterManager::TweetCreator.call(arg) e TwitterManager::ProfileManager.call(arg) .

Oggetti di servizio per gestire le operazioni di database

L'esempio precedente ha effettuato chiamate API, ma gli oggetti servizio possono essere utilizzati anche quando tutte le chiamate sono al database anziché a un'API. Ciò è particolarmente utile se alcune azioni aziendali richiedono più aggiornamenti del database racchiusi in una transazione. Ad esempio, questo codice di esempio utilizzerà i servizi per registrare un cambio di valuta in corso.

 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

Cosa restituisco dal mio oggetto di servizio?

Abbiamo discusso di come call il nostro oggetto di servizio, ma cosa dovrebbe restituire l'oggetto? Ci sono tre modi per avvicinarsi a questo:

  • Restituisce true o false
  • Restituire un valore
  • Restituire un Enum

Restituisce true o false

Questo è semplice: se un'azione funziona come previsto, restituisce true ; altrimenti restituisci false :

 def call ... return true if client.update(@message) false end

Restituire un valore

Se il tuo oggetto di servizio recupera i dati da qualche parte, probabilmente vorrai restituire quel valore:

 def call ... return false unless exchange_rate exchange_rate end

Rispondi con un Enum

Se il tuo oggetto servizio è un po' più complesso e vuoi gestire diversi scenari, puoi semplicemente aggiungere enumerazioni per controllare il flusso dei tuoi servizi:

 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

E poi nella tua app, puoi usare:

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

Non dovrei inserire oggetti di servizio in lib/services invece di app/services ?

Questo è soggettivo. Le opinioni delle persone differiscono su dove mettere i loro oggetti di servizio. Alcune persone li mettono in lib/services , mentre altri creano app/services . Cado in quest'ultimo campo. La Guida introduttiva di Rails descrive la cartella lib/ come il luogo in cui inserire "moduli estesi per la tua applicazione".

A mio modesto parere, "moduli estesi" significa moduli che non incapsulano la logica del dominio principale e possono essere generalmente utilizzati in più progetti. Nelle sagge parole di una risposta casuale di Stack Overflow, inserisci il codice che "può potenzialmente diventare la sua gemma".

Gli oggetti di servizio sono una buona idea?

Dipende dal tuo caso d'uso. Guarda: il fatto che stai leggendo questo articolo in questo momento suggerisce che stai cercando di scrivere codice che non appartiene esattamente a un modello o controller. Di recente ho letto questo articolo su come gli oggetti di servizio sono un anti-pattern. L'autore ha le sue opinioni, ma rispettosamente non sono d'accordo.

Solo perché un'altra persona ha abusato degli oggetti di servizio non significa che siano intrinsecamente cattivi. Al mio avvio, Nazdeeq, utilizziamo oggetti di servizio e modelli non ActiveRecord. Ma la differenza tra ciò che va dove è sempre stata evidente per me: conservo tutte le azioni di business negli oggetti di servizio mantenendo le risorse che non hanno davvero bisogno di persistenza nei modelli non ActiveRecord. Alla fine della giornata, sta a te decidere quale modello è buono per te.

Tuttavia, penso che gli oggetti di servizio in generale siano una buona idea? Assolutamente! Mantengono il mio codice ben organizzato e ciò che mi rende fiducioso nel mio uso di PORO è che Ruby ama gli oggetti. No, sul serio, Ruby ama gli oggetti. È pazzesco, totalmente matto, ma lo adoro! Caso in questione:

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

Vedere? 5 è letteralmente un oggetto.

In molte lingue, i numeri e altri tipi primitivi non sono oggetti. Ruby segue l'influenza del linguaggio Smalltalk fornendo metodi e variabili di istanza a tutti i suoi tipi. Questo facilita l'uso di Ruby, poiché le regole che si applicano agli oggetti si applicano a tutto Ruby. Ruby-lang.org

Quando non dovrei usare un oggetto di servizio?

Questo è facile. Ho queste regole:

  1. Il tuo codice gestisce il routing, i parametri o fa altre cose del controller?
    In tal caso, non utilizzare un oggetto di servizio: il codice appartiene al controller.
  2. Stai cercando di condividere il tuo codice in controller diversi?
    In questo caso, non utilizzare un oggetto di servizio, utilizzare un problema.
  3. Il tuo codice è come un modello che non ha bisogno di persistenza?
    In tal caso, non utilizzare un oggetto di servizio. Utilizzare invece un modello non ActiveRecord.
  4. Il tuo codice è un'azione aziendale specifica? (ad es. "Porta fuori la spazzatura", "Genera un PDF utilizzando questo testo" o "Calcola il dazio doganale utilizzando queste complicate regole")
    In questo caso, utilizzare un oggetto di servizio. Quel codice probabilmente non si adatta logicamente né al tuo controller né al tuo modello.

Naturalmente, queste sono le mie regole, quindi puoi adattarle ai tuoi casi d'uso. Questi hanno funzionato molto bene per me, ma il tuo chilometraggio può variare.

Regole per la scrittura di oggetti di buon servizio

Ho quattro regole per la creazione di oggetti di servizio. Questi non sono scritti nella pietra e se vuoi davvero romperli, puoi, ma probabilmente ti chiederò di cambiarlo nelle revisioni del codice a meno che il tuo ragionamento non sia valido.

Regola 1: un solo metodo pubblico per oggetto di servizio

Gli oggetti di servizio sono singole azioni aziendali. Puoi cambiare il nome del tuo metodo pubblico, se lo desideri. Preferisco usare call , ma la base di codice di Gitlab CE lo chiama execute e altre persone potrebbero usare perform . Usa quello che vuoi, potresti chiamarlo nermin per quanto mi interessa. Basta non creare due metodi pubblici per un singolo oggetto di servizio. Dividilo in due oggetti, se necessario.

Regola 2: nominare oggetti di servizio come ruoli stupidi in un'azienda

Gli oggetti di servizio sono singole azioni aziendali . Immagina se assumessi una persona in azienda per fare quel lavoro, come la chiamereste? Se il loro lavoro è creare tweet, chiamali TweetCreator . Se il loro compito è leggere tweet specifici, chiamali TweetReader .

Regola 3: non creare oggetti generici per eseguire più azioni

Gli oggetti di servizio sono singole azioni aziendali. Ho suddiviso la funzionalità in due parti: TweetReader e ProfileFollower . Quello che non ho fatto è creare un singolo oggetto generico chiamato TwitterHandler e scaricare tutte le funzionalità dell'API lì. Per favore, non farlo. Questo va contro la mentalità "business action" e fa sembrare l'oggetto del servizio la fata di Twitter. Se desideri condividere il codice tra gli oggetti business, crea semplicemente un oggetto o un modulo BaseTwitterManager e mescolalo nei tuoi oggetti di servizio.

Regola 4: gestire le eccezioni all'interno dell'oggetto di servizio

Per l'ennesima volta: gli oggetti di servizio sono singole azioni di business. Non posso dirlo abbastanza. Se hai una persona che legge i tweet, ti darà il tweet o ti dirà: "Questo tweet non esiste". Allo stesso modo, non lasciare che il tuo oggetto di servizio vada nel panico, salti sulla scrivania del controller e digli di interrompere tutto il lavoro perché "Errore!" Basta restituire false e lasciare che il controller si sposti da lì.

Crediti e passaggi successivi

Questo articolo non sarebbe stato possibile senza la straordinaria comunità di sviluppatori Ruby di Toptal. Se dovessi mai incontrare un problema, la community è il gruppo di ingegneri di talento più disponibile che abbia mai incontrato.

Se stai utilizzando oggetti di servizio, potresti trovarti a chiederti come forzare determinate risposte durante il test. Consiglio di leggere questo articolo su come creare oggetti di servizio fittizi in Rspec che restituiranno sempre il risultato desiderato, senza colpire effettivamente l'oggetto di servizio!

Se vuoi saperne di più sui trucchi di Ruby, ti consiglio di creare un Ruby DSL: una guida alla metaprogrammazione avanzata del collega Toptaler Mate Solymosi. Spiega come il file routes.rb non sembra Ruby e ti aiuta a costruire la tua DSL.