#212 Refactoring & Dynamic Delegator
- Download:
- source codeProject Files in Zip (102 KB)
- mp4Full Size H.264 Video (11.9 MB)
- m4vSmaller H.264 Video (8.19 MB)
- webmFull Size VP8 Video (21.1 MB)
- ogvFull Size Theora Video (17.1 MB)
L’episodio di questa settimana è un po’ diverso dal solito. E’ un esercizio di refactoring che ci consentirà di scoprire una simpatica tecnica Ruby che chiameremo Dynamic Delegator.
Per mostrarvi il tutto, ci avvarremo di una semplice applicazione di negozio on-line che ha un modello Product
con associato un ProductsController
. La action del controller index
permette che la lista di prodotti sia filtrata per nome o per prezzo. Fornendo uno o più parametri fra name
, price_lt
e price_gt
alla stringa di query, possiamo cercare prodotti che corrispondano a tali criteri di ricerca, per trovare, ad esempio, i soli prodotti il cui nome contenga “video” e che costino più di £50:
Prima di iniziare il il refactoring della action index, diamo un’occhiata a ciò che fa attualmente:
class ProductsController < ApplicationController def index @products = Product.scoped @products = @products.where("name like ?", "%" + params[:name] + "%") if params[:name] @products = @products.where("price >= ?", params[:price_gt]) if params[:price_gt] @products = @products.where("price <= ?", params[:price_lt]) if params[:price_lt] end # Altre action end
Si tratta di un’applicazione Rails 3, dunque si usa il metodo where
per aggiungere condizioni alla query se è stato passato il parametro corrispondente. Prima di fare ciò, tuttavia, usiamo Product.scoped
per ottenere l’insieme di tutti i prodotti. Potreste non conoscere ancora questo metodo, ma essenzialmente è un ulteriore modo per dire Product.all
. La differenza rispetto al primo è che il metodo all
determina istantaneamente una query sul database, restituendo un array di prodotti. Siccome non vogliamo intraprendere una chiamata al database fintanto che non abbiamo applicato i nostri filtri, allora usiamo il metodo scoped
al posto di all
, che ci permette di aggiungere condizioni alla query prima che questa sia eseguita.
Ora pensiamo al refactoring dell’action. Il primo passo che faremo sarà di rimuovere parte della logica dal controller, dal momento che quello non è il posto più indicato per quel genere di codice. In qualunque linguaggio object-orientated, se siete nella situazione in cui un oggetto di un certo tipo sta chiamando molti metodi su un altro oggetto di un altro tipo, probabilmente significa che dovreste spostare tutte quelle chiamate e quella logica all’interno di un metodo dell’altro oggetto. In questo caso,nella action index della classe di ProductController
, stiamo chiamando ben quattro metodi della classe di modello Product
per creare la nostra query e ciò ci suggerisce che questo codice debba appartenere al modello stesso piuttosto che al controller.
Sostituiamo dunque il codice all’interno della action index
con una chiamata al nuovo metodo di classe nel modello Product
chiamato search
, a cui passiamo un hash params
in modo che quest’ultimo sappia su cosa deve essere fatta la query.
class ProductsController < ApplicationController def index @products = Product.search(params) end # Altre actions end
Poi definiamo il metodo in questione nella classe Product
. Il metodo deve essere di classe, per cui lo definiamo come self.search
. Il codice nel metodo sarà lo stesso che avevamo prima all’interno del controller, ma con una variabile locale (products) che sostituisce la variabile di istanza che avevamo prima: questa variabile è restituita alla fine del metodo:
class Product < ActiveRecord::Base belongs_to :category def self.search(params) products = scoped products = products.where("name like ?", "%" + params[:name] + "%") if params[:name] products = products.where("price >= ?", params[:price_gt]) if params[:price_gt] products = products.where("price <= ?", params[:price_lt]) if params[:price_lt] products end end
Se ora ricarichiamo la pagina, vedremo che funziona tutto esattamente come prima.
Naturalmente, ricaricando la pagina abbiamo solo constatato che le modifiche al codice funzionano per quegli specifici parametri; è solo in scenari di sviluppo Test-Driven che si può veramente avere una riprova effettiva del corretto funzionamento della pagina post-refactoring. Ricaricare la pagina diventa rapidamente piuttosto tedioso e non controlla comunque ogni ramo del codice dal momento che la query di ricerca è costruita in modo diverso a seconda dei parametri che vengono passati. E’ una buona idea, specialmente quando si fa del refactoring sul codice, quella di mettere su un sistema di test per assicurarsi di non introdurre regressioni con le modifiche.
Spostare questo codice nel modello presenta l’ulteriore beneficio di rendere più semplice anche il test, dal momento che dovremmo solo scrivere un test unitario sulla classe di modello, anzichè un test completo sull’intero stack.
Introduzione al Dynamic Delegator
Abbiamo un po’ rivisto il codice spostando la logica di ricerca nel modello ed ora andremo oltre, togliendo la necessità di reimpostare la variabile products
ogni volta che aggiungiamo una condizione di ricerca. E’ un pattern ricorrente quando si tratta con le opzioni di ricerca e se ne vedete molti nella vostra applicazione, potreste considerare la tecnica che vi stiamo per mostrare, che abbiamo chiamato dynamic delegator.
Piuttosto che spiegare come funziona un dynamic delegator, ve lo mostreremo usandone uno per il refactoring del nostro codice di ricerca. Cominciamo creando la classe del dynamic delegator nella cartella /lib
della nostra applicazione:
class DynamicDelegator def initialize(target) @target = target end def method_missing(*args, &block) @target.send(*args, &block) end end
La classe DynamicDelegator
accetta un argomento nel suo costruttore, un oggetto target, e imposta una variabile di istanza a quell’oggetto. Fa anche l’override del metodo method_missing
, in modo tale che qualsiasi chiamata a questo oggetto che non sia supportata sia catturata e passata all’oggetto insieme allo stesso blocco e argomenti.
Possiamo pensare al nostro DynamicDelegator
come ad un oggetto proxy che passa ogni chiamata al proprio oggetto target: questo significa che possiamo usarlo ovunque vogliamo al posto dell’oggetto target. Se lo creiamo su un certo oggetto target, si comporterà come se fosse quel tipo di oggetto. Ciò significa che possiamo sostituire l’oggetto scoped
nel nostro metodo di ricerca di Product
con un nuovo DynamicDelegator
che prenda quell’oggetto come argomento:
class Product < ActiveRecord::Base belongs_to :category def self.search(params) products = DynamicDelegator(scoped) products = products.where("name like ?", "%" + params[:name] + "%") if params[:name] products = products.where("price >= ?", params[:price_gt]) if params[:price_gt] products = products.where("price <= ?", params[:price_lt]) if params[:price_lt] products end end
Possiamo verificare che tutto ciò funzione, ricaricando nuovamente la pagina: dovremmo vedere lo stesso insieme di risultati.
Ha funzionato, ma a questo punto vi starete probabilmente domandando quale sia il vantaggio nell’usare un DynamicDelegator
al posto dell’oggetto originale scoped
. Il vantaggio del delegator è che possiamo fare qualsiasi cosa vogliamo all’interno del metodo method_missing
. Anzichè delegare sempre passivamente la stessa cosa all’oggetto target, possiamo modificare il nostro target e renderlo più dinamico.
Per esempio, vogliamo catturare il risultato della chiamata al metodo method_missing
e, se restituisce un oggetto della stessa classe del target, impostare il target stesso come il risultato:
class DynamicDelegator def initialize(target) @target = target end def method_missing(*args, &block) result = @target.send(*args, &block) @target = result if result.kind_of? @target.class result end end
Ora possiamo rimuovere il codice che resetta la variabile products
in ogni linea del metodo search
nel modello Product
:
class Product < ActiveRecord::Base belongs_to :category def self.search(params) products = DynamicDelegator.new(scoped) products.where("name like ?", "%" + params[:name] + "%") if params[:name] products.where("price >= ?", params[:price_gt]) if params[:price_gt] products.where("price <= ?", params[:price_lt]) if params[:price_lt] products end end
Possiamo farlo perchè la chiamata alla where restituirà lo stesso tipo di oggetto dello scoped
e in questo modo il target verrà sostituito ogni volta. Ricarichiamo di nuovo la pagina e vediamo se funziona ancora tutto:
Non funziona più, e la ragione è che non stiamo delegando esattamente tutti i metodo dell’oggetto target. In questo caso la fonte dei problemi è il metodo class
: possiamo usare la console per mostrare il perchè. Se chiamiamo Product.search
con un hash vuoto e chiamiamo class
sul risultato, vedremo che questo è di tipo DynamicDelegator
:
ruby-head > Product.search({}).class => DynamicDelegator
Ciò indica che il nostro dynamic delegator non sta delegando tutto all’oggetto target dal momento che ha alcuni metodi già definiti di per sè. Il motivo per cui ce li ha, è dovuto semplicemente al fatto che la classa DynamicDelegator
li eredita da Object
e Object
ha molti metodi definiti al suo interno, incluso class
:
ruby-head > Object.instance_methods.count => 108 ruby-head > Object.instance_methods.grep /class/ => [:subclasses_of, :class_eval, :class, :singleton_class]
Se abbiamo bisogno di una classe più semplice da cui derivare, da Ruby 1.9 esiste un’altra classe che possiamo usare, chiamata BasicObject, che ha definiti molti meno metodi:
ruby-head > BasicObject.instance_methods => [:==, :equal?, :!, :!=, :instance_eval, :instance_exec, :__send__]
Questo tipo di classe serve meglio a fare da base per un oggetto delegator o un proxy che si basano sul method_missing
per ridefinire il comportamento dei metodi. Possiamo cambiare il DynamicDelegator
per estendere da BasicObject
in modo tale che il metodo class
non sia ereditato (perchè non definito nell’ancestor) e di conseguenza la chiamata a tale metodo sia ricondotta alla gestione mediante method_missing
e quindi delegata effettivamente al target object:
class DynamicDelegator < BasicObject def initialize(target) @target = target end def method_missing(*args, &block) result = @target.send(*args, &block) @target = result if result.kind_of? @target.class result end end
Se ora ricarichiamo la pagina, funzionerà nuovamente tutto.
C’è un altro po’ di refactoring che potremmo considerare di fare nella classe di modello Product
. Il DynamicDelegator
non esprime le proprie intenzioni in modo molto chiaro, per cui potremmo scrivere un metodo nella classe Product
, chiamato scope_builder
e creare lì il DynamicDelegator
:
class Product < ActiveRecord::Base belongs_to :category def self.search(params) products = scope_builder products.where("name like ?", "%" + params[:name] + "%") if params[:name] products.where("price >= ?", params[:price_gt]) if params[:price_gt] products.where("price <= ?", params[:price_lt]) if params[:price_lt] products end def self.scope_builder DynamicDelegator.new(scoped) end end
Ora è più chiaro capire che stiamo trattando con uno scope che abbiamo costruito dinamicamente. Se usiamo questa tecnica su più modelli di record, allora potremmo fattorizzare questo metodo scope_builder
in ActiveRecord::Base
, in modo tale da averlo disponibile su tutti i modelli. Quest’ultima cosa la potremmo realizzare propriamente in un initializer.