#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)
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.
Antes de refactorizar la acción index
, echémosle un vistazo para ver qué es lo que hace.
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.
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.
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.
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.
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.
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.
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.
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
.
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.
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
.
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.
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
.
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.