Gli oggetti di servizio Rails: A Comprehensive Guide

Ruby on Rails viene fornito con tutto il necessario per prototipare rapidamente la vostra applicazione, ma quando il vostro codice inizia a crescere, vi imbatterete in scenari in cui il mantra convenzionale Fat Model, Skinny Controller si rompe. Quando la vostra logica di business non può stare né in un modello né in un controller, ecco che entrano in gioco gli oggetti di servizio che ci permettono di separare ogni azione di business in un proprio oggetto Ruby.

Un esempio di ciclo di richiesta con gli oggetti di servizio Rails

In questo articolo, spiegherò quando è necessario un oggetto di servizio; come procedere per scrivere oggetti di servizio puliti e raggrupparli per la sanità dei contributori; le regole severe che impongo ai miei oggetti di servizio per legarli direttamente alla mia logica di business; e come non trasformare i vostri oggetti di servizio in una discarica per tutto il codice di cui non sapete cosa fare.

Perché ho bisogno degli oggetti di servizio?

Prova questo: Cosa fate quando la vostra applicazione ha bisogno di twittare il testo da params?

Se avete usato Rails vanilla finora, probabilmente avete fatto qualcosa del genere:

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

Il problema qui è che avete aggiunto almeno dieci linee al vostro controller, ma non è il loro posto. Inoltre, cosa succede se vuoi usare 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ò venire con un singolo oggetto preparato da chiamare?

La prima volta che l’ho fatto, mi sono sentito come se avessi fatto qualcosa di sporco. I miei controller Rails, precedentemente molto snelli, avevano iniziato a diventare grassi e non sapevo cosa fare. Alla fine, ho sistemato il mio controller con un oggetto servizio.

Prima di iniziare a leggere questo articolo, facciamo finta:

  • Questa applicazione gestisce un account Twitter.
  • The Rails Way significa “il modo convenzionale di Ruby on Rails di fare le cose” e il libro non esiste.
  • Sono un esperto di Rails… che mi dicono ogni giorno che lo sono, ma faccio fatica a crederci, quindi facciamo finta che lo sia davvero.

Che cosa sono gli oggetti di servizio?

Gli oggetti di servizio sono Plain Old Ruby Objects (PORO) che sono progettati per eseguire una singola azione nella vostra logica di dominio e farla bene. Considerate l’esempio precedente: Il nostro metodo ha già la logica per fare una sola cosa, cioè creare un tweet. E se questa logica fosse incapsulata in una singola classe Ruby che possiamo istanziare e chiamare un metodo? Qualcosa come:

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)

Questo è più o meno tutto; il nostro oggetto di servizio TweetCreator, una volta creato, può essere chiamato da qualsiasi luogo, e farebbe quest’unica cosa molto bene.

Creazione di un oggetto di servizio

Prima 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 in una nuova classe 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

Poi potete chiamare TweetCreator.new(params).send_tweet ovunque nella vostra applicazione, e funzionerà. Rails caricherà magicamente questo oggetto perché autocarica tutto sotto app/. Verificatelo eseguendo:

$ 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

Vuoi saperne di più su come funziona autoload? Leggete la Guida all’autoloading e al ricaricamento delle costanti.

Aggiungimento di zucchero sintattico per far sì che gli oggetti di servizio di Rails facciano meno schifo

Sentite, questo è fantastico in teoria, ma TweetCreator.new(params).send_tweet è proprio una boccaccia. È troppo prolisso con parole ridondanti… molto simile all’HTML (ba-dum tiss!). In tutta serietà, però, perché la gente usa l’HTML quando c’è HAML? O anche Slim. Credo che questo sia un altro articolo per un’altra volta. Tornando al compito a portata di mano:

TweetCreator è un bel nome di classe breve, ma la parte extra di istanziare l’oggetto e chiamare il metodo è troppo lunga! Se solo ci fosse la precedenza in Ruby per chiamare qualcosa e farlo eseguire immediatamente con i parametri dati… oh aspetta, c’è! È Proc#call.

Proccall invoca il blocco, impostando i parametri del blocco ai valori in params usando qualcosa di simile alla semantica della chiamata al 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) #=> aproc #=> aproc.(9, 1, 2, 3) #=> aproc.yield(9, 1, 2, 3) #=> 

Documentazione

Se questo vi confonde, lasciatemi spiegare. Un procpuò essere called eseguito da solo con i parametri dati. Il che significa che se TweetCreator fosse un proc, potremmo chiamarlo con TweetCreator.call(message) e il risultato sarebbe equivalente a TweetCreator.new(params).call, che assomiglia abbastanza al nostro ingombrante vecchio TweetCreator.new(params).send_tweet.

Facciamo quindi in modo che il nostro oggetto servizio si comporti più come un proc!

Prima di tutto, perché probabilmente vogliamo riutilizzare questo comportamento in tutti i nostri oggetti di servizio, prendiamo in prestito dal Rails Way e creiamo una classe chiamata ApplicationService:

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

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 passate, e chiama call sull’istanza. Esattamente quello che volevamo! L’ultima cosa da fare è rinominare il metodo dalla nostra classe TweetCreator in call, e fare in modo che la classe erediti da 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

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

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

Grouping Similar Service Objects for Sanity

L’esempio precedente ha un solo oggetto servizio, ma nel mondo reale, le cose possono diventare più complicate. Per esempio, cosa succederebbe se aveste centinaia di servizi, e la metà di essi fossero azioni commerciali correlate, per esempio, avere un servizio Follower che segue un altro account Twitter? Onestamente, impazzirei se una cartella contenesse 200 file dall’aspetto unico, quindi è un bene che ci sia un altro pattern della Rails Way che possiamo copiare, cioè usare come ispirazione: il namespacing.

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

Guardiamo il nome del nostro precedente oggetto servizio: TweetCreator. Suona come una persona, o almeno un ruolo in un’organizzazione. Qualcuno che crea Tweets. 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, dato che sono il signore supremo di questa applicazione, creerò una posizione manageriale nella mia gerarchia di servizi e delegherò la responsabilità di entrambi questi servizi a questa posizione. Chiamerò questa nuova posizione manageriale TwitterManager.

Siccome questo manager non fa altro che gestire, rendiamolo un modulo e annidiamo i nostri oggetti di servizio sotto questo modulo. La nostra struttura di cartelle sarà ora 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.rbmodule TwitterManager class TweetCreator < ApplicationService ... endend
# services/twitter_manager/profile_follower.rbmodule TwitterManager class ProfileFollower < ApplicationService ... endend

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

Oggetti di servizio per gestire operazioni di database

L’esempio precedente ha fatto chiamate API, ma gli oggetti di servizio possono anche essere usati quando tutte le chiamate sono al vostro database invece che a un API. Questo è particolarmente utile se alcune azioni di business richiedono più aggiornamenti del database avvolti in una transazione. Per esempio, questo codice di esempio userebbe i servizi per registrare un cambio di valuta che ha luogo.

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

Cosa restituisco dal mio oggetto di servizio?

Abbiamo discusso come callil nostro oggetto di servizio, ma cosa dovrebbe restituire l’oggetto? Ci sono tre modi per affrontare questo problema:

  • Ritorna true o false
  • Ritorna un valore
  • Ritorna un Enum

Ritorna true o false

Questo è semplice: Se un’azione funziona come previsto, ritorna true; altrimenti, ritorna false:

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

Ritorna un valore

Se il vostro oggetto di servizio recupera dati da qualche parte, probabilmente volete restituire quel valore:

 def call ... return false unless exchange_rate exchange_rate end

Risponde con un Enum

Se il tuo oggetto di servizio è un po’ più complesso, e vuoi gestire diversi scenari, potresti semplicemente aggiungere degli enum per controllare il flusso dei tuoi servizi:

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

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 mettere gli 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 altre creano app/services. Io rientro in quest’ultimo campo. La Guida introduttiva di Rails descrive la cartella lib/ come il posto dove mettere “moduli estesi per la vostra applicazione”

A mio modesto parere, “moduli estesi” significa moduli che non incapsulano la logica di dominio principale e che possono generalmente essere usati in più progetti. Nelle sagge parole di una risposta casuale di Stack Overflow, metteteci del codice che “può potenzialmente diventare una gemma a sé stante”.

Gli oggetti di servizio sono una buona idea?

Dipende dal vostro caso d’uso. Sentite, il fatto che stiate leggendo questo articolo in questo momento suggerisce che state cercando di scrivere codice che non appartiene esattamente a un modello o a un controller. Recentemente ho letto questo articolo su come gli oggetti di servizio sono un anti-pattern. L’autore ha le sue opinioni, ma io rispettosamente non sono d’accordo.

Solo perché qualche altra persona ha usato troppo gli oggetti di servizio non significa che siano intrinsecamente cattivi. Nella mia startup, Nazdeeq, usiamo gli oggetti di servizio così come i modelli non ActiveRecord. Ma la differenza tra ciò che va dove è sempre stata evidente per me: Tengo tutte le azioni di business negli oggetti di servizio mentre tengo le risorse che non hanno davvero bisogno di persistenza nei modelli non ActiveRecord. Alla fine della giornata, sta a voi decidere quale modello è buono per voi.

Tuttavia, penso che gli oggetti di servizio in generale siano una buona idea? Assolutamente sì! Mantengono il mio codice ben organizzato, e ciò che mi rende sicuro nel mio uso dei PORO è che Ruby ama gli oggetti. No, seriamente, Ruby ama gli oggetti. È pazzesco, totalmente folle, ma lo adoro! Esempio:

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

Vedi? 5 è letteralmente un oggetto.

In molte lingue, i numeri e altri tipi primitivi non sono oggetti. Ruby segue l’influenza del linguaggio Smalltalk dando 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 vostro codice gestisce il routing, i parametri o fa altre cose da controller?
    Se è così, non usate un oggetto di servizio – il vostro codice appartiene al controller.
  2. Stai cercando di condividere il tuo codice in diversi controller?
    In questo caso, non usare un oggetto di servizio-usa una preoccupazione.
  3. Il tuo codice è come un modello che non ha bisogno di persistenza?
    In tal caso, non usare un oggetto di servizio. Usate invece un modello non ActiveRecord.
  4. Il vostro codice è una specifica azione di business? (ad esempio, “Porta fuori la spazzatura”, “Genera un PDF usando questo testo”, o “Calcola il dazio doganale usando queste regole complicate”)
    In questo caso, usa un oggetto di servizio. Quel codice probabilmente non si adatta logicamente né al tuo controller né al tuo modello.

Ovviamente, queste sono le mie regole, quindi sei il benvenuto ad adattarle ai tuoi casi d’uso. Queste hanno funzionato molto bene per me, ma il vostro chilometraggio può variare.

Regole per scrivere buoni oggetti di servizio

Ho quattro regole per creare oggetti di servizio. Queste non sono scritte nella pietra, e se volete davvero infrangerle, potete farlo, ma probabilmente vi chiederò di cambiarle nelle revisioni del codice a meno che il vostro ragionamento non sia valido.

Regola 1: Solo un metodo pubblico per oggetto di servizio

Gli oggetti di servizio sono azioni commerciali singole. Puoi cambiare il nome del tuo metodo pubblico se vuoi. Io preferisco usare call, ma il codebase di Gitlab CE lo chiama execute e altre persone potrebbero usare perform. Usate quello che volete – potete chiamarlo nermin per quello che mi interessa. Solo non create due metodi pubblici per un singolo oggetto di servizio. Dividetelo in due oggetti, se necessario.

Regola 2: Nominare gli oggetti di servizio come i ruoli stupidi di un’azienda

Gli oggetti di servizio sono singole azioni di business. Immaginate se assumete una persona nell’azienda per fare quell’unico lavoro, come la chiamereste? Se il loro lavoro è quello di creare tweet, chiamatelo TweetCreator. Se il loro lavoro è quello di leggere specifici tweet, chiamateli TweetReader.

Regola 3: Non creare oggetti generici per eseguire azioni multiple

Gli oggetti servizio sono singole azioni di business. Ho rotto la funzionalità in due pezzi: TweetReader e ProfileFollower. Quello che non ho fatto è stato creare un singolo oggetto generico chiamato TwitterHandler e scaricare tutte le funzionalità API lì dentro. Per favore non fatelo. Questo va contro la mentalità di “azione di business” e rende l’oggetto servizio simile alla fata di Twitter. Se vuoi condividere il codice tra gli oggetti di business, crea semplicemente un oggetto o un modulo BaseTwitterManager e mescolalo ai 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 lo dirò mai abbastanza. Se avete una persona che legge i tweet, vi darà il tweet o vi dirà: “Questo tweet non esiste”. Allo stesso modo, non lasciate che il vostro oggetto di servizio vada in panico, salti sulla scrivania del vostro controller e gli dica di fermare tutto il lavoro perché “Error!” Basta restituire false e lasciare che il controller vada avanti da lì.

Questo articolo non sarebbe stato possibile senza l’incredibile comunità di sviluppatori Ruby di Toptal. Se mai mi imbatto in un problema, la comunità è il gruppo più utile di ingegneri di talento che abbia mai incontrato.

Se state usando oggetti di servizio, potreste trovarvi a chiedervi come forzare certe risposte durante i test. Vi consiglio di leggere questo articolo su come creare mock di oggetti di servizio in Rspec che restituiranno sempre il risultato che volete, senza effettivamente colpire l’oggetto di servizio!

Se volete imparare di più sui trucchi di Ruby, vi consiglio Creating a Ruby DSL: A Guide to Advanced Metaprogramming del collega Toptaler Máté Solymosi. Egli spiega come il file routes.rb non si senta come Ruby e ti aiuta a costruire il tuo DSL.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.