È molto probabile che al lavoro vi siate imbattuti in modelli sovraccarichi e in un numero enorme di chiamate nei controllori per un bel po' di volte. Basandomi sulla conoscenza dell'ambiente Rails, in questo articolo proporrò una semplice soluzione a questo problema.
Un aspetto molto importante dell'applicazione rails è quello di ridurre al minimo il numero di dipendenze ridondanti, motivo per cui l'intero ambiente Rails ha recentemente promosso l'approccio a oggetti di servizio e l'uso del metodo PORO (Pure Old Ruby Object). Una descrizione di come utilizzare una soluzione di questo tipo si trova in qui. In questo articolo, risolveremo il concetto passo dopo passo e lo adatteremo al problema.
Problema
In un'ipotetica applicazione, abbiamo a che fare con un complicato sistema di transazioni. Il nostro modello, che rappresenta ogni transazione, ha un insieme di scopi che aiutano a ottenere i dati. È un'ottima facilitazione per il lavoro, in quanto si possono trovare in un unico posto. Tuttavia, questo non dura a lungo. Con lo sviluppo dell'applicazione, il progetto sta diventando sempre più complicato. Gli scope non hanno più semplici riferimenti 'dove', mancano i dati e si iniziano a caricare le relazioni. Dopo un po', si trasforma in un complicato sistema di specchi. E, quel che è peggio, non sappiamo come fare una lambda multilinea!
Di seguito è riportato un modello di applicazione già ampliato. Le transazioni del sistema di pagamento sono memorizzate in. Come si può vedere nell'esempio seguente:
classe Transazione { where(visible: true) }
ambito(:active, lambda do
si unisce(<<-SQL
LEFT OUTER JOIN source ON transactions.source_id = source.id
E source.accepted_at NON È NULL
SQL
fine)
fine
Il modello è una cosa, ma quando la scala del nostro progetto aumenta, anche i controllori iniziano a gonfiarsi. Osserviamo l'esempio seguente:
classe TransactionsController < ApplicationController
def indice
@transazioni = Transaction.for_publishers
.attivo
.visibile
.joins("LEFT JOIN withdrawal_items ON withdrawal_items.transaction_id = transactions.id")
.joins("LEFT JOIN withdrawals ON withdrawals.id = withdrawal_items.withdrawal_id OR
(prelievi.id = sorgente.resource_id AND sorgente.resource_type = 'Prelievo')")
.order(:created_at)
.page(params[:page])
.per(params[:page])
@transazioni = apply_filters(@transazioni)
fine
fine
Qui possiamo vedere molte righe di metodi concatenati insieme a join aggiuntivi che non vogliamo eseguire in molti punti, ma solo in questo particolare. I dati allegati vengono poi utilizzati dal metodo apply_filters, che aggiunge i filtri appropriati, basati sui parametri GET. Naturalmente, possiamo trasferire alcuni di questi riferimenti all'ambito, ma non è questo il problema che stiamo cercando di risolvere?
Soluzione
Poiché siamo già a conoscenza di un problema, dobbiamo risolverlo. In base al riferimento dell'introduzione, utilizzeremo qui l'approccio PORO. In questo caso specifico, l'approccio si chiama oggetto query, che è uno sviluppo del concetto di oggetti di servizio.
Creiamo una nuova cartella denominata "services", situata nella cartella apps del nostro progetto. Lì creeremo una classe chiamata TransactionsQuery
.
classe TransactionsQuery
fine
Come passo successivo, dobbiamo creare un inizializzatore in cui verrà creato un percorso di chiamata predefinito per il nostro oggetto
classe TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
fine
fine
Grazie a ciò, saremo in grado di trasferire la relazione dal record attivo alla nostra struttura. Ora possiamo trasferire tutti gli ambiti alla classe, che sono necessari solo nel controllore presentato.
classe TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
fine
privato
def active(scope)
scope.joins(<<-SQL
LEFT OUTER JOIN source ON transactions.source_id = source.id
E source.accepted_at NON È NULLO
SQL
fine
def visible(scope)
scope.where(visible: true)
fine
def for_publishers(scope)
scope.select("transazioni.*")
.join(:account)
.where("account.owner_type = 'Publisher'")
.joins("JOIN publishers ON owner_id = publishers.id")
fine
fine
Manca ancora la parte più importante, ovvero raccogliere i dati in una stringa e rendere pubblica l'interfaccia. Il metodo con il quale metteremo tutto insieme si chiamerà "chiamata".
Ciò che è veramente importante è che useremo la variabile di istanza @scope, dove si trova l'ambito della nostra chiamata.
classe TransactionsQuery
...
def call
visible(@scope)
.then(&metodo(:attivo))
.then(&metodo(:for_publishers))
.order(:created_at)
fine
privato
...
fine
L'intera classe si presenta come segue:
classe TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
fine
chiamata
visibile(@scopio)
.then(&metodo(:attivo))
.then(&metodo(:for_publishers))
.order(:created_at)
fine
privato
def active(scope)
scope.joins(<<-SQL
LEFT OUTER JOIN source ON transactions.source_id = source.id
E source.accepted_at NON È NULLO
SQL
fine
def visible(scope)
scope.where(visible: true)
fine
def for_publishers(scope)
scope.select("transazioni.*")
.join(:account)
.where("account.owner_type = 'Publisher'")
.joins("JOIN publishers ON owner_id = publishers.id")
fine
fine
Dopo la pulizia, il modello appare decisamente più leggero. Ci concentriamo solo sulla convalida dei dati e sulle relazioni tra gli altri modelli.
classe Transazione < ActiveRecord::Base
appartiene a :conto
ha_una :voce_prelievo
fine
Il controllore ha già implementato la nostra soluzione; abbiamo spostato tutte le query aggiuntive in una classe separata. Tuttavia, le chiamate che non avevamo nel modello rimangono un problema non risolto. Dopo alcune modifiche, la nostra azione indice si presenta così:
classe TransactionsController < ApplicationController
def indice
@transazioni = TransactionsQuery.new
.call
.joins("LEFT JOIN withdrawal_items ON withdrawal_items.accounting_event_id = transactions.id")
.joins("LEFT JOIN withdrawals ON withdrawals.id = withdrawal_items.withdrawal_id OR
(prelievi.id = sorgente.resource_id AND sorgente.resource_type = 'Prelievo')")
.order(:created_at)
.page(params[:page])
.per(params[:page])
@transazioni = apply_filters(@transazioni)
fine
fine
Soluzione
Nel caso dell'implementazione di buone pratiche e convenzioni, una buona idea può essere quella di sostituire tutte le occorrenze simili di un determinato problema. Pertanto, sposteremo la query SQL dall'azione indice all'oggetto query separato. Chiameremo questo oggetto TransactionsFilterableQuery
classe. Lo stile con cui prepareremo la lezione sarà simile a quello presentato in TransactionsQuery
. Come parte del codice modifiche, verrà contrabbandata una registrazione più intuitiva di query SQL di grandi dimensioni, utilizzando stringhe di caratteri multilinea chiamate heredoc. La soluzione disponibile è riportata di seguito:
classe TransactionsFilterableQuery
def initialize(scope = Transaction.all)
@scope = scope
fine
chiamata
withdrawal(@scope).then(&method(:withdrawal_items))
fine
privato
def prelievo(ambito)
scope.joins(<<-SQL
LEFT JOIN prelievi ON prelievi.id = voci_prelievo.prelievo_id OR
(withdrawals.id = source.resource_id AND source.resource_type = 'Withdrawal')
SQL
fine
def voci_prelievo(ambito)
scope.joins(<<-SQL
LEFT JOIN voci_prelievo ON voci_prelievo.accounting_event_id = transazioni.id
SQL
fine
fine
In caso di modifiche al controllore, riduciamo la massa di righe aggiungendo l'oggetto query. È importante separare tutto, tranne la parte responsabile della paginazione.
classe TransactionsController < ApplicationController
def indice
@transazioni = TransactionsQuery.new.call.then do |scope|
TransactionsFilterableQuery.new(scope).call
end.page(params[:page]).per(params[:page])
@transazioni = apply_filters(@transazioni)
fine
fine
Sintesi
L'oggetto Query cambia molto l'approccio alla scrittura delle query SQL. In ActiveRecord, è molto facile collocare tutta la logica del business e del database nel modello, poiché tutto si trova in un unico posto. Questo funziona bene per le applicazioni più piccole. Quando la complessità del progetto aumenta, la logica viene collocata in altri luoghi. Lo stesso oggetto query consente di raggruppare le query dei membri in un problema specifico.
Grazie a ciò, abbiamo una facile possibilità di ereditarietà successiva del codice e, grazie alla tipizzazione dell'anatra, è possibile utilizzare queste soluzioni anche in altri modelli. Lo svantaggio di questa soluzione è una maggiore quantità di codice e la frammentazione delle responsabilità. Tuttavia, se vogliamo accettare o meno questa sfida, dipende da noi e da quanto siamo disturbati dai modelli grassi.