Costruisci componenti di binari eleganti con semplici vecchi oggetti color rubino
Pubblicato: 2022-03-11Il tuo sito web sta guadagnando terreno e stai crescendo rapidamente. Ruby/Rails è il tuo linguaggio di programmazione preferito. Il tuo team è più grande e hai rinunciato a "modelli grassi, controller magri" come stile di progettazione per le tue app Rails. Tuttavia, non vuoi ancora abbandonare l'uso di Rails.
Nessun problema. Oggi parleremo di come utilizzare le best practice di OOP per rendere il codice più pulito, più isolato e più disaccoppiato.
La tua app vale il refactoring?
Iniziamo osservando come dovresti decidere se la tua app è un buon candidato per il refactoring.
Di seguito è riportato un elenco di metriche e domande che di solito mi pongo per determinare se il mio codice necessita o meno di refactoring.
- Test unitari lenti. Gli unit test PORO di solito vengono eseguiti velocemente con codice ben isolato, quindi i test a esecuzione lenta possono spesso essere un indicatore di una cattiva progettazione e responsabilità eccessivamente accoppiate.
- Modelli o controller FAT. Un modello o un controller con più di 200 righe di codice (LOC) è generalmente un buon candidato per il refactoring.
- Base di codice eccessivamente grande. Se hai ERB/HTML/HAML con più di 30.000 LOC o codice sorgente Ruby (senza GEM) con più di 50.000 LOC, ci sono buone probabilità che dovresti rifattorizzare.
Prova a usare qualcosa del genere per scoprire quante righe di codice sorgente di Ruby hai:
find app -iname "*.rb" -type f -exec cat {} \;| wc -l
Questo comando cercherà tutti i file con estensione .rb (file ruby) nella cartella /app e stamperà il numero di righe. Si prega di notare che questo numero è solo approssimativo poiché le righe di commento saranno incluse in questi totali.
Un'altra opzione più precisa e più informativa è utilizzare le stats
dell'attività di rake di Rails che generano un rapido riepilogo di righe di codice, numero di classi, numero di metodi, rapporto tra metodi e classi e rapporto tra righe di codice per metodo:
bundle exec rake stats +----------------------+-------+-----+-------+---------+-----+-------+ | Name | Lines | LOC | Class | Methods | M/C | LOC/M | +----------------------+-------+-----+-------+---------+-----+-------+ | Controllers | 195 | 153 | 6 | 18 | 3 | 6 | | Helpers | 14 | 13 | 0 | 2 | 0 | 4 | | Models | 120 | 84 | 5 | 12 | 2 | 5 | | Mailers | 0 | 0 | 0 | 0 | 0 | 0 | | Javascripts | 45 | 12 | 0 | 3 | 0 | 2 | | Libraries | 0 | 0 | 0 | 0 | 0 | 0 | | Controller specs | 106 | 75 | 0 | 0 | 0 | 0 | | Helper specs | 15 | 4 | 0 | 0 | 0 | 0 | | Model specs | 238 | 182 | 0 | 0 | 0 | 0 | | Request specs | 699 | 489 | 0 | 14 | 0 | 32 | | Routing specs | 35 | 26 | 0 | 0 | 0 | 0 | | View specs | 5 | 4 | 0 | 0 | 0 | 0 | +----------------------+-------+-----+-------+---------+-----+-------+ | Total | 1472 |1042 | 11 | 49 | 4 | 19 | +----------------------+-------+-----+-------+---------+-----+-------+ Code LOC: 262 Test LOC: 780 Code to Test Ratio: 1:3.0
- Posso estrarre pattern ricorrenti nella mia codebase?
Disaccoppiamento in azione
Iniziamo con un esempio del mondo reale.
Facciamo finta di voler scrivere un'applicazione che tenga traccia del tempo per i corridori. Nella pagina principale, l'utente può vedere gli orari inseriti.
Ogni voce di tempo ha una data, distanza, durata e informazioni aggiuntive relative allo "stato" (es. meteo, tipo di terreno, ecc.) e una velocità media che può essere calcolata quando necessario.
Abbiamo bisogno di una pagina di rapporto che mostri la velocità e la distanza media a settimana.
Se la velocità media per la voce è superiore alla velocità media complessiva, avviseremo l'utente con un SMS (per questo esempio utilizzeremo l'API RESTful di Nexmo per inviare l'SMS).
La homepage ti consentirà di selezionare la distanza, la data e il tempo trascorso a fare jogging per creare una voce simile a questa:
Abbiamo anche una pagina statistics
che è fondamentalmente un rapporto settimanale che include la velocità media e la distanza percorsa a settimana.
- Puoi controllare il campione online qui.
Il codice
La struttura della directory app
è simile a:
⇒ tree . ├── assets │ └── ... ├── controllers │ ├── application_controller.rb │ ├── entries_controller.rb │ └── statistics_controller.rb ├── helpers │ ├── application_helper.rb │ ├── entries_helper.rb │ └── statistics_helper.rb ├── mailers ├── models │ ├── entry.rb │ └── user.rb └── views ├── devise │ └── ... ├── entries │ ├── _entry.html.erb │ ├── _form.html.erb │ └── index.html.erb ├── layouts │ └── application.html.erb └── statistics └── index.html.erb
Non discuterò del modello User
in quanto non è niente di speciale poiché lo stiamo usando con Devise per implementare l'autenticazione.
Come per il modello Entry
, contiene la logica di business per la nostra applicazione.
Ogni Entry
appartiene ad un User
.
Convalidiamo la presenza degli attributi distance
, time_period
, date_time
e status
per ogni voce.
Ogni volta che creiamo una voce, confrontiamo la velocità media dell'utente con la media di tutti gli altri utenti nel sistema e informiamo l'utente tramite SMS utilizzando Nexmo (non discuteremo di come viene utilizzata la libreria Nexmo, anche se volevo per dimostrare un caso in cui utilizziamo una libreria esterna).
- Esempio di sostanza
Si noti che il modello Entry
contiene più della sola logica aziendale. Gestisce anche alcune convalide e callback.
entries_controller.rb
ha le principali azioni CRUD (nessun aggiornamento però). EntriesController#index
ottiene le voci per l'utente corrente e ordina i record in base alla data di creazione, mentre EntriesController#create
crea una nuova voce. Non c'è bisogno di discutere l'ovvio e le responsabilità di EntriesController#destroy
:
- Esempio di sostanza
Mentre statistics_controller.rb
è responsabile del calcolo del report settimanale, StatisticsController#index
ottiene le voci per l'utente connesso e le raggruppa per settimana, utilizzando il metodo #group_by
contenuto nella classe Enumerable in Rails. Quindi prova a decorare i risultati usando alcuni metodi privati.
- Esempio di sostanza
Non discutiamo molto delle opinioni qui, poiché il codice sorgente è autoesplicativo.
Di seguito è riportata la visualizzazione dell'elenco delle voci per l'utente che ha effettuato l'accesso ( index.html.erb
). Questo è il modello che verrà utilizzato per visualizzare i risultati dell'azione di indicizzazione (metodo) nel controller di accesso:
- Esempio di sostanza
Nota che stiamo usando render @entries
, per estrarre il codice condiviso in un modello parziale _entry.html.erb
in modo da poter mantenere il nostro codice ASCIUTTO e riutilizzabile:
- Esempio di sostanza
Lo stesso vale per il _form
parziale. Invece di utilizzare lo stesso codice con le azioni (nuovo e modifica), creiamo un modulo parziale riutilizzabile:
- Esempio di sostanza
Per quanto riguarda la visualizzazione della pagina del report settimanale, statistics/index.html.erb
mostra alcune statistiche e riporta le prestazioni settimanali dell'utente raggruppando alcune voci:
- Esempio di sostanza
E infine, l'helper per le voci, entries_helper.rb
, include due helper readable_time_period
e readable_speed
che dovrebbero rendere gli attributi più umanamente leggibili:
- Esempio di sostanza
Niente di speciale finora.
La maggior parte di voi sosterrà che il refactoring di questo è contrario al principio KISS e renderà il sistema più complicato.
Quindi questa applicazione ha davvero bisogno di refactoring?
Assolutamente no , ma lo considereremo solo a scopo dimostrativo.
Dopotutto, se controlli la sezione precedente e le caratteristiche che indicano che un'app necessita di refactoring, diventa ovvio che l'app nel nostro esempio non è un candidato valido per il refactoring.
Ciclo vitale
Quindi iniziamo spiegando la struttura del pattern MVC di Rails.
Di solito, inizia dal browser che effettua una richiesta, ad esempio https://www.toptal.com/jogging/show/1
.
Il server web riceve la richiesta e utilizza routes
per scoprire quale controller
utilizzare.
I responsabili del trattamento svolgono il lavoro di analisi delle richieste degli utenti, degli invii di dati, dei cookie, delle sessioni, ecc., quindi chiedono al model
di ottenere i dati.
I models
sono classi Ruby che parlano al database, archiviano e convalidano i dati, eseguono la logica di business e altrimenti fanno il lavoro pesante. Le visualizzazioni sono ciò che l'utente vede: HTML, CSS, XML, Javascript, JSON.
Se vogliamo mostrare la sequenza di un ciclo di vita di una richiesta Rails, assomiglierebbe a questo:
Quello che voglio ottenere è aggiungere più astrazione usando semplici vecchi oggetti rubino (PORO) e rendere il modello qualcosa di simile al seguente per le azioni di create/update
:
E qualcosa come il seguente per le azioni list/show
:
Aggiungendo le astrazioni PORO assicureremo la piena separazione tra le responsabilità SRP, qualcosa in cui Rails non è molto bravo.
Linee guida
Per ottenere il nuovo design, utilizzerò le linee guida elencate di seguito, ma tieni presente che queste non sono regole da seguire fino alla T. Considerale come linee guida flessibili che semplificano il refactoring.
- I modelli ActiveRecord possono contenere associazioni e costanti, ma nient'altro. Ciò significa che nessun callback (usa gli oggetti di servizio e aggiungi i callback lì) e nessuna convalida (usa gli oggetti Form per includere la denominazione e le convalide per il modello).
- Mantieni i controller come livelli sottili e chiama sempre gli oggetti di servizio. Alcuni di voi chiederebbero perché usare i controller poiché vogliamo continuare a chiamare oggetti di servizio per contenere la logica? Bene, i controller sono un buon posto per avere il routing HTTP, l'analisi dei parametri, l'autenticazione, la negoziazione del contenuto, la chiamata al servizio o l'oggetto editor corretto, la cattura delle eccezioni, la formattazione della risposta e la restituzione del codice di stato HTTP corretto.
- I servizi devono chiamare oggetti Query e non devono archiviare lo stato. Usa metodi di istanza, non metodi di classe. Ci dovrebbero essere pochissimi metodi pubblici in armonia con SRP.
- Le query devono essere eseguite negli oggetti query. I metodi dell'oggetto query devono restituire un oggetto, un hash o una matrice, non un'associazione ActiveRecord.
- Evita di usare gli aiutanti e usa invece i decoratori. Come mai? Una trappola comune con gli helper di Rails è che possono trasformarsi in una grande pila di funzioni non OO, che condividono tutti uno spazio dei nomi e si calpestano l'un l'altro. Ma molto peggio è che non esiste un ottimo modo per utilizzare alcun tipo di polimorfismo con gli helper Rails, fornendo diverse implementazioni per diversi contesti o tipi, sovrascrivendo o sottoclassi. Penso che le classi helper di Rails dovrebbero essere generalmente utilizzate per metodi di utilità, non per casi d'uso specifici, come la formattazione degli attributi del modello per qualsiasi tipo di logica di presentazione. Mantienili leggeri e ventilati.
- Evita di usare preoccupazioni e usa invece Decoratori/Delegatori. Come mai? Dopotutto, le preoccupazioni sembrano essere una parte fondamentale di Rails e possono PROSCIUGARE il codice se condiviso tra più modelli. Tuttavia, il problema principale è che le preoccupazioni non rendono l'oggetto del modello più coeso. Il codice è solo meglio organizzato. In altre parole, non c'è alcuna modifica reale all'API del modello.
- Prova a estrarre oggetti valore dai modelli per mantenere il tuo codice più pulito e per raggruppare gli attributi correlati.
- Passa sempre una variabile di istanza per vista.
Refactoring
Prima di iniziare, voglio discutere un'altra cosa. Quando inizi il refactoring, di solito finisci per chiederti: "È davvero un buon refactoring?"
Se ritieni di fare più separazione o isolamento tra le responsabilità (anche se ciò significa aggiungere più codice e nuovi file), di solito questa è una buona cosa. Dopotutto, il disaccoppiamento di un'applicazione è un'ottima pratica e ci semplifica l'esecuzione di unit test adeguati.
Non discuterò di cose, come spostare la logica dai controller ai modelli, poiché presumo che tu lo stia già facendo e che tu sia a tuo agio nell'usare Rails (di solito Skinny Controller e modello FAT).
Per il bene di mantenere stretto questo articolo, non discuterò di test qui, ma ciò non significa che non dovresti testare.
Al contrario, dovresti sempre iniziare con un test per assicurarti che le cose siano a posto prima di andare avanti. Questo è un must, soprattutto durante il refactoring.
Quindi possiamo implementare le modifiche e assicurarci che tutti i test passino per le parti rilevanti del codice.
Estrazione di oggetti di valore
Innanzitutto, cos'è un oggetto valore?
Martin Fowler spiega:
L'oggetto valore è un piccolo oggetto, ad esempio denaro o intervallo di date. La loro proprietà chiave è che seguono la semantica dei valori piuttosto che la semantica di riferimento.
A volte si può incontrare una situazione in cui un concetto merita una propria astrazione e la cui uguaglianza non si basa sul valore, ma sull'identità. Gli esempi includono la data, l'URI e il nome del percorso di Ruby. L'estrazione in un oggetto valore (o modello di dominio) è una grande comodità.
Perché preoccuparsi?
Uno dei maggiori vantaggi di un oggetto Value è l'espressività che aiutano a raggiungere nel tuo codice. Il tuo codice tenderà ad essere molto più chiaro, o almeno può esserlo se hai buone pratiche di denominazione. Poiché l'oggetto Value è un'astrazione, porta a un codice più pulito e a meno errori.
Un'altra grande vittoria è l'immutabilità. L'immutabilità degli oggetti è molto importante. Quando memorizziamo determinati insiemi di dati, che potrebbero essere utilizzati in un oggetto valore, di solito non voglio che i dati vengano manipolati.
Quando è utile?
Non esiste una risposta unica e valida per tutti. Fai ciò che è meglio per te e ciò che ha senso in una determinata situazione.
Andando oltre, tuttavia, ci sono alcune linee guida che utilizzo per aiutarmi a prendere questa decisione.
Se pensi che un gruppo di metodi sia correlato, con gli oggetti Value sono più espressivi. Questa espressività significa che un oggetto Value dovrebbe rappresentare un insieme distinto di dati, che il tuo sviluppatore medio può dedurre semplicemente guardando il nome dell'oggetto.
Come si fa?
Gli oggetti valore dovrebbero seguire alcune regole di base:
- Gli oggetti valore devono avere più attributi.
- Gli attributi dovrebbero essere immutabili durante tutto il ciclo di vita dell'oggetto.
- L'uguaglianza è determinata dagli attributi dell'oggetto.
Nel nostro esempio, creerò un oggetto valore EntryStatus
per astrarre gli Entry#status_weather
e Entry#status_landform
alla propria classe, che assomiglia a questo:
- Esempio di sostanza
Nota: questo è solo un Plain Old Ruby Object (PORO) che non eredita da ActiveRecord::Base
. Abbiamo definito i metodi di lettura per i nostri attributi e li stiamo assegnando all'inizializzazione. Abbiamo anche usato un mixin comparabile per confrontare gli oggetti usando il metodo (<=>).
Possiamo modificare il modello Entry
per utilizzare l'oggetto valore che abbiamo creato:
- Esempio di sostanza
Possiamo anche modificare il metodo EntryController#create
per utilizzare il nuovo oggetto valore di conseguenza:
- Esempio di sostanza
Estrai oggetti di servizio
Quindi cos'è un oggetto Service?
Il compito di un oggetto Service consiste nel mantenere il codice per un particolare bit di logica aziendale. A differenza dello stile del "modello grasso" , in cui un piccolo numero di oggetti contiene molti, molti metodi per tutta la logica necessaria, l'utilizzo di oggetti di servizio produce molte classi, ognuna delle quali ha un unico scopo.

Come mai? Quali sono i vantaggi?
- Disaccoppiamento. Gli oggetti di servizio consentono di ottenere un maggiore isolamento tra gli oggetti.
- Visibilità. Gli oggetti di servizio (se denominati correttamente) mostrano cosa fa un'applicazione. Posso semplicemente dare un'occhiata alla directory dei servizi per vedere quali funzionalità offre un'applicazione.
- Modelli e controller di pulizia. I titolari trasformano la richiesta (param, sessione, cookie) in argomenti, la trasmettono al servizio e la reindirizzano o elaborano in base alla risposta del servizio. Mentre i modelli si occupano solo di associazioni e persistenza. L'estrazione del codice da controller/modelli per gli oggetti di servizio supporterebbe SRP e renderebbe il codice più disaccoppiato. La responsabilità del modello sarebbe quindi solo quella di gestire le associazioni e il salvataggio/cancellazione di record, mentre l'oggetto di servizio avrebbe una responsabilità unica (SRP). Questo porta a una migliore progettazione e migliori unit test.
- ASCIUTTO e abbraccia il cambiamento. Mantengo gli oggetti di servizio il più semplice e piccolo possibile. Compongo oggetti di servizio con altri oggetti di servizio e li riutilizzo.
- Pulisci e velocizza la tua suite di test. I servizi sono facili e veloci da testare poiché sono piccoli oggetti Ruby con un punto di ingresso (il metodo di chiamata). I servizi complessi sono composti con altri servizi, quindi puoi dividere facilmente i tuoi test. Inoltre, l'utilizzo di oggetti di servizio rende più semplice simulare/stubare oggetti correlati senza dover caricare l'intero ambiente rails.
- Chiamabile da qualsiasi luogo. È probabile che gli oggetti di servizio vengano chiamati dai controller e da altri oggetti di servizio, DelayedJob / Rescue / Sidekiq Job, Rake task, console, ecc.
D'altra parte, niente è mai perfetto. Uno svantaggio degli oggetti Service è che possono essere eccessivi per un'azione molto semplice. In questi casi, potresti benissimo finire per complicare, piuttosto che semplificare, il tuo codice.
Quando dovresti estrarre gli oggetti di servizio?
Anche qui non esiste una regola rigida.
Normalmente, gli oggetti Service sono migliori per sistemi di dimensioni medio-grandi; quelli con una discreta quantità di logica oltre le operazioni CRUD standard.
Quindi, ogni volta che pensi che un frammento di codice potrebbe non appartenere alla directory in cui lo avresti aggiunto, è probabilmente una buona idea riconsiderare e vedere se dovrebbe invece andare a un oggetto di servizio.
Di seguito sono riportati alcuni indicatori di quando utilizzare gli oggetti Servizio:
- L'azione è complessa.
- L'azione si estende su più modelli.
- L'azione interagisce con un servizio esterno.
- L'azione non è una preoccupazione centrale del modello sottostante.
- Esistono diversi modi per eseguire l'azione.
Come dovresti progettare gli oggetti di servizio?
Progettare la classe per un oggetto di servizio è relativamente semplice, dal momento che non hai bisogno di gemme speciali, non devi imparare un nuovo DSL e puoi più o meno fare affidamento sulle capacità di progettazione del software che già possiedi.
Di solito utilizzo le seguenti linee guida e convenzioni per progettare l'oggetto del servizio:
- Non memorizzare lo stato dell'oggetto.
- Usa metodi di istanza, non metodi di classe.
- Dovrebbero esserci pochissimi metodi pubblici (preferibilmente uno per supportare SRP.
- I metodi dovrebbero restituire oggetti con risultati multimediali e non booleani.
- I servizi vanno nella directory
app/services
. Ti incoraggio a utilizzare le sottodirectory per i domini pesanti per la logica aziendale. Ad esempio, il fileapp/services/report/generate_weekly.rb
definiràReport::GenerateWeekly
mentreapp/services/report/publish_monthly.rb
definiràReport::PublishMonthly
. - I servizi iniziano con un verbo (e non terminano con Service):
ApproveTransaction
,SendTestNewsletter
,ImportUsersFromCsv
. - I servizi rispondono al metodo di chiamata. Ho scoperto che l'uso di un altro verbo lo rende un po' ridondante: ApproveTransaction.approve() non si legge bene. Inoltre, il metodo call è il metodo de facto per oggetti lambda, procs e metodo.
Se guardi StatisticsController#index
, noterai un gruppo di metodi ( weeks_to_date_from
, weeks_to_date_to
, avg_distance
, ecc.) accoppiati al controller. Non va bene. Considera le ramificazioni se desideri generare il rapporto settimanale al di fuori di statistics_controller
.
Nel nostro caso, creiamo Report::GenerateWeekly
ed estraiamo la logica del report da StatisticsController
:
- Esempio di sostanza
Quindi StatisticsController#index
ora sembra più pulito:
- Esempio di sostanza
Applicando il modello di oggetti Service, uniamo il codice attorno a un'azione specifica e complessa e promuoviamo la creazione di metodi più piccoli e più chiari.
Compiti a casa: considera l'utilizzo dell'oggetto Value per WeeklyReport
invece di Struct
.
Estrai oggetti query dai controller
Che cos'è un oggetto Query?
Un oggetto Query è un PORO che rappresenta una query di database. Può essere riutilizzato in diversi punti dell'applicazione nascondendo allo stesso tempo la logica della query. Fornisce anche una buona unità isolata da testare.
Dovresti estrarre query SQL/NoSQL complesse nella loro classe.
Ogni oggetto Query è responsabile della restituzione di un set di risultati in base ai criteri/regole aziendali.
In questo esempio, non abbiamo query complesse, quindi l'utilizzo dell'oggetto Query non sarà efficiente. Tuttavia, a scopo dimostrativo, estraiamo la query in Report::GenerateWeekly#call
e creiamo generate_entries_query.rb
:
- Esempio di sostanza
E in Report::GenerateWeekly#call
, sostituiamo:
def call @user.entries.group_by(&:week).map do |week, entries| WeeklyReport.new( ... ) end end
insieme a:
def call weekly_grouped_entries = GroupEntriesQuery.new(@user).call weekly_grouped_entries.map do |week, entries| WeeklyReport.new( ... ) end end
Il modello dell'oggetto query aiuta a mantenere la logica del modello strettamente correlata al comportamento di una classe, mantenendo allo stesso tempo scarsi i controller. Poiché non sono altro che semplici vecchie classi Ruby, gli oggetti query non devono ereditare da ActiveRecord::Base
e non dovrebbero essere responsabili nient'altro che l'esecuzione di query.
Estrai Crea voce in un oggetto di servizio
Ora, estraiamo la logica di creazione di una nuova voce in un nuovo oggetto di servizio. Usiamo la convenzione e creiamo CreateEntry
:
- Esempio di sostanza
E ora il nostro EntriesController#create
è il seguente:
def create begin CreateEntry.new(current_user, entry_params).call flash[:notice] = 'Entry was successfully created.' rescue Exception => e flash[:error] = e.message end redirect_to root_path end
Sposta le convalide in un oggetto modulo
Ora, qui le cose iniziano a farsi più interessanti.
Ricorda che nelle nostre linee guida abbiamo convenuto che volevamo che i modelli contenessero associazioni e costanti, ma nient'altro (nessuna validazione e nessuna richiamata). Quindi iniziamo rimuovendo i callback e utilizziamo invece un oggetto Form.
Un oggetto Form è un Plain Old Ruby Object (PORO). Prende il posto dell'oggetto controller/servizio ovunque sia necessario per comunicare con il database.
Perché usare gli oggetti Form?
Quando si cerca di refactoring dell'app, è sempre una buona idea tenere presente il principio di responsabilità singola (SRP).
SRP ti aiuta a prendere decisioni di progettazione migliori su ciò di cui una classe dovrebbe essere responsabile.
Il tuo modello di tabella del database (un modello ActiveRecord nel contesto di Rails), ad esempio, rappresenta un singolo record di database nel codice, quindi non c'è motivo di preoccuparsi di qualsiasi cosa l'utente stia facendo.
È qui che entrano in gioco gli oggetti Form.
Un oggetto Modulo è responsabile della rappresentazione di un modulo nell'applicazione. Quindi ogni campo di input può essere trattato come un attributo nella classe. Può convalidare che quegli attributi soddisfino alcune regole di convalida e può passare i dati "puliti" dove devono andare (ad esempio, i tuoi modelli di database o forse il tuo generatore di query di ricerca).
Quando dovresti usare un oggetto Form?
- Quando vuoi estrarre le convalide dai modelli Rails.
- Quando più modelli possono essere aggiornati con un unico invio di moduli, potresti voler creare un oggetto Modulo.
Ciò consente di mettere tutta la logica del modulo (convenzioni di denominazione, convalide e così via) in un'unica posizione.
Come si crea un oggetto Form?
- Crea una semplice classe Ruby.
- Includi
ActiveModel::Model
(in Rails 3, devi includere invece Naming, Conversion e Validations) - Inizia a usare la tua nuova classe modulo come se fosse un normale modello ActiveRecord, la differenza più grande è che non puoi mantenere i dati archiviati in questo oggetto.
Tieni presente che puoi usare la gemma di riforma, ma attenendoti ai PORO creeremo entry_form.rb
che assomiglia a questo:
- Esempio di sostanza
E modificheremo CreateEntry
per iniziare a utilizzare l'oggetto Form EntryForm
:
class CreateEntry ...... ...... def call @entry_form = ::EntryForm.new(@params) if @entry_form.valid? .... else .... end end end
Nota: alcuni di voi direbbero che non è necessario accedere all'oggetto Form dall'oggetto Service e che possiamo semplicemente chiamare l'oggetto Form direttamente dal controller, il che è un argomento valido. Tuttavia, preferirei avere un flusso chiaro, ed è per questo che chiamo sempre l'oggetto Form dall'oggetto Service.
Sposta i callback all'oggetto di servizio
Come concordato in precedenza, non vogliamo che i nostri modelli contengano convalide e callback. Abbiamo estratto le convalide utilizzando gli oggetti Form. Ma stiamo ancora usando alcuni callback ( after_create
nel modello Entry
compare_speed_and_notify_user
).
Perché vogliamo rimuovere i callback dai modelli?
Gli sviluppatori di Rails di solito iniziano a notare dolore di callback durante i test. Se non stai testando i tuoi modelli ActiveRecord, inizierai a notare problemi in seguito man mano che l'applicazione cresce e poiché è necessaria più logica per chiamare o evitare il callback.
after_*
callback vengono utilizzati principalmente in relazione al salvataggio o alla persistenza dell'oggetto.
Una volta che l'oggetto è stato salvato, lo scopo (cioè la responsabilità) dell'oggetto è stato raggiunto. Quindi, se vediamo ancora i callback invocati dopo che l'oggetto è stato salvato, quello che probabilmente vediamo sono i callback che arrivano al di fuori dell'area di responsabilità dell'oggetto, ed è allora che ci imbattiamo in problemi.
Nel nostro caso, stiamo inviando un SMS all'utente dopo aver salvato una voce, che non è realmente correlata al dominio di Entry.
Un modo semplice per risolvere il problema consiste nello spostare la richiamata sull'oggetto di servizio correlato. Dopotutto, l'invio di un SMS per l'utente finale è correlato all'oggetto del servizio CreateEntry
e non al modello Entry stesso.
In tal modo, non dobbiamo più escludere il metodo compare_speed_and_notify_user
nei nostri test. Abbiamo semplificato la creazione di una voce senza richiedere l'invio di un SMS e stiamo seguendo una buona progettazione orientata agli oggetti assicurandoci che le nostre classi abbiano un'unica responsabilità (SRP).
Quindi ora il nostro CreateEntry
assomiglia a qualcosa di simile a:
- Esempio di sostanza
Usa i decoratori invece degli aiutanti
Sebbene possiamo facilmente utilizzare la raccolta Draper di modelli di visualizzazione e decoratori, mi atterrò ai PORO per il bene di questo articolo, come ho fatto finora.
Quello di cui ho bisogno è una classe che chiamerà i metodi sull'oggetto decorato.
Posso usare method_missing
per implementarlo, ma userò la libreria standard di Ruby SimpleDelegator
.
Il codice seguente mostra come utilizzare SimpleDelegator
per implementare il nostro decoratore di base:
% app/decorators/base_decorator.rb require 'delegate' class BaseDecorator < SimpleDelegator def initialize(base, view_context) super(base) @object = base @view_context = view_context end private def self.decorates(name) define_method(name) do @object end end def _h @view_context end end
Allora perché il metodo _h
?
Questo metodo funge da proxy per il contesto di visualizzazione. Per impostazione predefinita, il contesto di visualizzazione è un'istanza di una classe di visualizzazione, la classe di visualizzazione predefinita è ActionView::Base
. È possibile accedere agli helper di visualizzazione come segue:
_h.content_tag :div, 'my-div', class: 'my-class'
Per renderlo più conveniente, aggiungiamo un metodo di decorate
ad ApplicationHelper
:
module ApplicationHelper # ..... def decorate(object, klass = nil) klass ||= "#{object.class}Decorator".constantize decorator = klass.new(object, self) yield decorator if block_given? decorator end # ..... end
Ora possiamo spostare gli aiutanti di EntriesHelper
ai decoratori:
# app/decorators/entry_decorator.rb class EntryDecorator < BaseDecorator decorates :entry def readable_time_period mins = entry.time_period return Time.at(60 * mins).utc.strftime('%M <small>Mins</small>').html_safe if mins < 60 Time.at(60 * mins).utc.strftime('%H <small>Hour</small> %M <small>Mins</small>').html_safe end def readable_speed "#{sprintf('%0.2f', entry.speed)} <small>Km/H</small>".html_safe end end
E possiamo usare readable_time_period
e readable_speed
in questo modo:
# app/views/entries/_entry.html.erb - <td><%= readable_speed(entry) %> </td> + <td><%= decorate(entry).readable_speed %> </td>
- <td><%= readable_time_period(entry) %></td> + <td><%= decorate(entry).readable_time_period %></td>
Struttura dopo il refactoring
Abbiamo finito con più file, ma non è necessariamente una cosa negativa (e ricorda che, fin dall'inizio, abbiamo riconosciuto che questo esempio era solo a scopo dimostrativo e non era necessariamente un buon caso d'uso per il refactoring):
app ├── assets │ └── ... ├── controllers │ ├── application_controller.rb │ ├── entries_controller.rb │ └── statistics_controller.rb ├── decorators │ ├── base_decorator.rb │ └── entry_decorator.rb ├── forms │ └── entry_form.rb ├── helpers │ └── application_helper.rb ├── mailers ├── models │ ├── entry.rb │ ├── entry_status.rb │ └── user.rb ├── queries │ └── group_entries_query.rb ├── services │ ├── create_entry.rb │ └── report │ └── generate_weekly.rb └── views ├── devise │ └── .. ├── entries │ ├── _entry.html.erb │ ├── _form.html.erb │ └── index.html.erb ├── layouts │ └── application.html.erb └── statistics └── index.html.erb
Conclusione
Anche se in questo post del blog ci siamo concentrati su Rails, RoR non è una dipendenza degli oggetti di servizio descritti e di altri PORO. Puoi utilizzare questo approccio con qualsiasi framework Web, app per dispositivi mobili o console.
Utilizzando MVC come architettura delle app Web, tutto rimane agganciato e ti fa andare più lentamente perché la maggior parte delle modifiche ha un impatto su altre parti dell'app. Inoltre, ti costringe a pensare a dove inserire una logica di business: dovrebbe entrare nel modello, nel controller o nella vista?
Utilizzando semplici PORO, abbiamo spostato la logica aziendale su modelli o servizi che non ereditano da ActiveRecord
, che è già una grande vittoria, per non parlare del fatto che abbiamo un codice più pulito, che supporta SRP e unit test più veloci.
L'architettura pulita mira a mettere i casi d'uso al centro/in alto della struttura, in modo da poter vedere facilmente cosa fa la tua app. Inoltre, rende più facile l'adozione delle modifiche poiché è molto più modulare e isolato.
Spero di aver dimostrato come l'uso di Plain Old Ruby Objects e più astrazioni disunifichi le preoccupazioni, semplifichi i test e aiuti a produrre codice pulito e manutenibile.