homeASCIIcasts

212: Refactorizando con el Delegador Dinámico 

(view original Railscast)

Other translations: En It

Other formats:

Written by Juan Lupión

Esta semana tenemos un episodio un poco diferente. Se trata de un ejercicio de refactorización que mostrará una divertida técnica de Ruby que hemos bautizado como el Delegador Dinámico.

Como demostración de esta técnica utilizaremos una aplicación que es una tienda sencilla, con un modelo Product que tiene su controlador asociado, ProductsController. La acción index de este controlador permite filtra la lista de productos por nombre y precio. Si pasamos en la URL uno o varios parámetros llamados name, price_lt y price_gt se pueden hacer búsquedas de productos que coincidan con ese nombre y precios, por ejemplo, todos los productos cuyo nombre contiene “video” y cuestan más de £50.

Filtro de la lista de productos.

Antes de refactorizar la acción index, echémosle un vistazo para ver qué es lo que hace.

/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

  # Other actions
end

Se trata de una aplicación Rails 3, así que estamos usando el método where para agregar condiciones a la consulta si recibimos los parámetros correspondientes pero antes de hacerlo utilizamos Product.scoped para recuperar todos los productos. Puede que no estemos familiarizados con este método, pero en esencia no es más que otra forma de decir Product.all, la diferencia estriba en que el método all realizará una consulta a la base de datos tan pronto como sea ejecutado y devolverá un array de productos. No queremos que esto ocurra hasta que no hayamos aplicado nuestros filtros por lo que usando scoped podremos añadir condiciones a la consulta antes de ejecutarla.

Veamos cómo refactorizar esta acción. El primer paso que vamos a dar será sacar la lógica del controlador, ya que no pertenece ahí. En cualquier lenguaje orientado a objetos, si descubrimos que desde un objeto llamamos a muchos métodos de otro por lo general esto quiere decir que deberíamos mover esa lógica al otro objeto. En este caso en la acción index de la clase ProductController estamos invocando cuatro métodos del modelo Product lo que sugiere que este código debería estar en el modelo.

Quitaremos el código de la acción index y en su lugar pondremos una llamada a un nuevo método de clase en el modelo Product, llamada search, pasándole el hash de parámetros para que sepa qué es lo que tiene que buscar.

/app/controllers/products_controller.rb

class ProductsController < ApplicationController
  def index
    @products = Product.search(params)
  end

  # Other actions
end

A continuación escribiremos este método en el modelo Product. Queremos que este método sea de clase, asi que lo definiremos como self.search. Este código será el mismo que teníamos en el controlador pero en lugar de la variable de instancia que teníamos en el controlador aquí tendremos una variable local que devolveremos al salir del método.

/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

Si recargamos la página veremos que parece que no hemos roto nada.

La página sigue funcionando.

Está claro que al recargar la página sólo hemos comprobado que los cambios del código funcionan para estos parámetros específícos. Es en este tipo de escenarios en los que es mejor aplicar el Desarrollo Basado en Tests: cada vez se nos hará más tedioso ir recargando la página y no nos será fácil ir comprobando todos y cada uno de los caminos que puede seguir el código según los parámetros recibidos. Es buena idea, sobre todo cuando estamos rfactorizando nuestro código, tener una batería de tests lo más completa posible para poder estar seguros de que no hemos introducido ningún efecto lateral.

Además al haber llevado el código al modelo tenemos la ventaja adicional de que éste será más fácil de probar porque sólo nos va a hacer falta escribir un test unitario contra el código del modelo en lugar de hacer un escenario completo de integración.

Introducción al Delegador Dinámico

Ya hemos refactorizado un poco el código moviendo la lógica de búsqueda al modelo, y vamos a ir un poco más lejos eliminando la necesidad de reasignar la variable products cada vez que añadimos una condición. Se trata de un patrón común cuando trabajamos con parámetros de búsqueda y si se da mucho en nuestras aplicaciones deberíamos considerar la técnica que vamos a presentar, que nosotros hemos llamado el Delegador Dinámico.

En vez de explicar cómo funciona el delegador dinámico, veremos cómo podemos utilizar uno para refactorizar el código de nuestra búsqueda. Empezaremos creando una clase de delegador dinámico en el directorio /lib de nuestra aplicación.

/lib/dynamic_delegator.rb

class DynamicDelegator
  def initialize(target)
    @target = target
  end  
  
  def method_missing(*args, &block)
    @target.send(*args, &block)
  end
end

La clase DynamicDelegator recibe un argumento en su inicializador, un objeto destino, y pone una variable local apuntando a dicho objeto. También sobrecarga method_missing, por lo que cualquier llamada a este objeto que no esté soportada será enviada al objeto destino con los mismos argumentos y el mismo bloque (si lo hubiera).

Podemos ver nuestro DynamicDelegator como un objeto proxy que pasa cualquier método que sea invocado sobre él al objeto de destino, y esto significa que podemos usarlo donde queramos, así que podemos cambiar el objeto scoped en el método search del modelo Product por un DynamicDelegator que recibe dicho objeto como argumento.

/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

Se puede comprobar que esto funciona recargando la página y veremos los mismos resultados.

La página sigue funcionando.

En este punto tal vez nos preguntemos el sentido de utilizar un DynamicDelegator en lugar del objeto scoped, y la ventaja del delegador es que podemos hacer lo que queramos dentro de method_missing. En lugar de delegar siempre al mismo objeto de destino podemos modificar dicho destino y hacerlo más dinámico.

Por ejemplo podríamos querer capturar el resultado de la llamado al método en method_missing y si devuelve un objeto de la misma clase que el objeto destino, hacer que el nuevo destino pase a ser el resultado.

/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

Con esto ya podemos quitar el código que reinicia la variable products en cada línea del método search en el modelo 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

Esto se puede hacer porque siempre que invoquemos el método where recibiremos un objeto del mismo tipo que scoped y por tanto el destino cambiará cada vez. Recarguemos la página y veamos que sigue funcionando.

El delegador dinámico se devuelve a sí mismo en lugar de su objeto destino.

No lo hace, y el motivo es que no estamos delegando todos los métodos a nuestro objeto de destino. En este caso el problema es el método class, y veremos por qué en la consola. Si invocamos Product.search con un hash vacío e invocamos class sobre el resultado, veremos que recibimos DynamicDelegator.

ruby-head > Product.search({}).class
 => DynamicDelegator

Así que nuestro delegador dinámico no lo está delegando todo al objeto de destino, ya que tiene algunos métodos definidos sobre sí mismo. Esto es debido a que la clase DynamicDelegator hereda de Object y Object tiene muchos métodos definidos, uno de los cuales es class.

ruby-head > Object.instance_methods.count
 => 108 
ruby-head > Object.instance_methods.grep /class/
 => [:subclasses_of, :class_eval, :class, :singleton_class]

Necesitamos algo más despejado sobre lo que basarnos, y en Ruby 1.9 existe otra clase que podemos usar, llamada BasicObject que tiene muchos menos métodos.

ruby-head > BasicObject.instance_methods
 => [:==, :equal?, :!, :!=, :instance_eval, :instance_exec, :__send__]

Esta clase es un mejor punto de partida para hacer objetos proxy o delegados que usen method_missing. Si cambiamos DynamicDelegator para que herede de BasicObject , el método class no estará definido y su invocación acabará pasando a través de method_missing.

/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

Si recargamos la página, veremos que ahora sí que funciona.

La página vuelve a funcionar.

Aún podemos llevar un poco más lejos la refactorización en el modelo Product. El delegador dinámico no expresa su intención con mucha claridad por lo que podríamos escribir un método en la clase Product llamado scope_builder en el que crear el 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

Ahora queda mucho más claro que estamos trabajando con un ámbito que estamos construyendo dinámicamente. Si usamos esta técnica en varios modelos podríamos mover el método scope_builder a ActiveRecord::Base para que esté disponible en todos los modelos. Esto lo podríamos hacer en un archivo de inicialización.

Esto es todo por este episodio. Esta técnica pueda parecer muy simple pero si estamos construyendo muchas consultas puede hacer que nuestro código quede mucho más limpio.