homeASCIIcasts

212: Refactoring e Dynamic Delegator 

(view original Railscast)

Other translations: En Es

Other formats:

Written by Andrea Salicetti

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:

Filtering the list of products.

Prima di iniziare il il refactoring della action index, diamo un’occhiata a ciò che fa attualmente:

/app/controllers/products_controller.rb

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.

/app/controllers/products_controller.rb

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:

/app/models/product.rb

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.

La pagina funziona ancora.

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:

/lib/dynamic_delegator.rb

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:

/app/models/product.rb

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.

La pagina continua a funzionare.

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:

/lib/dynamic_delegator.rb

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:

/app/models/product.rb

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:

Il dynamic delegator restituisce se stesso anzichè il suo oggetto target.

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:

/lib/dynamic_delegator.rb

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.

La pagina funziona nuovamente.

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:

/app/models/product.rb

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.