#231 Routing Walkthrough Part 1
Esta semana tenemos un episodio un poco diferente. Vamos a bucear en la implementación de Rails 3 para echar un vistazo a parte de su código, concretamente el código que se encarga del enrutamiento. A continuación se muestra el fichero de rutas de la aplicación de tienda del último episodio. Veremos el código real de Rails que se ejecuta para comprender su funcionamiento con todo detalle.
Store::Application.routes.draw do resources :products do resources :reviews end root :to => "products#index" end
Cabría preguntarse si tiene sentido esto de leer el código ajeno, pero en nuestra opinión leer código escrito por otros es una magnífica manera de mejorar nuestro nivel de Ruby. Veremos trucos y técnicas usadas por otros que aprenderemos a usar en nuestro propio código. Además, leer el código fuente de Rails también nos servirá para aprender a hacer un mejor uso de Rails, en este caso veremos formas de escribir mejor los archivos de rutas. Nos será también útil si algún día intentamos depurar un problema u optimizar nuestro código o incluso si consideramos colaborar algún día con el desarrollo de Rails.
Empezando
En este episodio se asumirá que el lector conoce el funcionamiento de las rutas en Rails 3. Si no es el caso, o como refresco, se puede visitar primero el episodio 203 [verlo or leerlo] porque la sintaxis de las rutas es distinta a la de Rails 2
El código fuente de Rails está alojado en Github por lo que es muy fácil clonar el repositorio para su estudio. Tan sólo tenemos que ejecutar
$ git clone git://github.com/rails/rails.git
La rama maestra del repositorio que hemos descargado es la de Rails 3.1, que es la versión actualmente en desarrollo. Dado que queremos ver la misma versión de Rails que está ejecutando nuestra aplicación vamos a cambiar a la versión etiquetada como 3.0.0 ejecutando
$ git checkout v3.0.0
Veremos que Rails está compuesto de varios componentes separados. Cualquier cosa que tenga que ver con los controladores, vistas y rutas se encontrará alojada en el directorio actionpack
.
Dentro de actionpack
el código relacionado con las rutas se encuentra en el directorio lib/action_dispatch
.
Los métodos routes
y draw
.
Antes de empezar a leer el código de Rails volvamos a nuestra aplicación y repasemos una vez más el fichero de rutas.
Store::Application.routes.draw do resources :products do resources :reviews end root :to => "products#index" end
La primera línea de código invoca el método routes
sobre Store::Application
, donde Store
es el nombre de la aplicación. En el archivo application.rb
podremos ver que es ahí donde se define el nombre de la aplicación.
require File.expand_path('../boot', __FILE__) require 'rails/all' # If you have a Gemfile, require the gems listed there, including any gems # you've limited to :test, :development, or :production. Bundler.require(:default, Rails.env) if defined?(Bundler) module Store class Application < Rails::Application # Configure sensitive parameters which will be filtered from the log file. config.filter_parameters += [:password] end end
En la clase Store::Application
es donde tiene lugar la mayor parte de la configuración de la aplicación y hereda de Rails::Application
. Cualquier cosa con el prefijo Rails
se encuentra por lo general definida en el directorio railties
del código fuente de Rails; el código de la clase Rails::Application
se encuentra en rails/railties/lib/rails/application.rb
, donde podremos encontra el método routes
.
def routes @routes ||= ActionDispatch::Routing::RouteSet.new end
Este es el método que se ejecuta desde el fichero de rutas de la aplicación, y su única misión es crear un nuevo objeto ActionDispatch::Routing::RouteSet
. El método routes
devuelve este RouteSet
, sobre el que se invocará el método draw
en la primera línea de nuestro archivo de rutas. Este método se puede encontrar en la definición de la clase. Por lo general en el código fuente de Rails las clases se definen dentro de archivos que se llaman como la clase y RouteSet
no es una excepción.
def draw(&block) clear! unless @disable_clear_and_finalize mapper = Mapper.new(self) if block.arity == 1 mapper.instance_exec(DeprecatedMapper.new(self), &block) else mapper.instance_exec(&block) end finalize! unless @disable_clear_and_finalize nil end
El método draw
recibe un bloque como argumento. En primer lugar despeja cualquier ruta previamente existente y luego crea un nuevo objeto Mapper
pasándole self
(el RouteSet
) El método luego comprueba el número de argumentos del bloque, si recibe al menos uno entonces quiere decir que el fichero de rutas está usando la sintaxis de Rails 2. De esta forma la aplicación puede funcionar también definiendo rutas al estilo de Rails 2, donde el bloque tomaba como argumento un parámetro map
:
Store::Application.routes.draw do |map| # Rails 2 routes... end
Independientemente de que nuestra aplicación configure las rutas al estilo de Rails 2 o Rails 3 lo siguiente que se hace es llamar a instance_exec
. Si la aplicación usa las rutas de Rails 2 se genera un objeto DeprecatedMapper
para pasárselo a instance_exec
, mientras que si usa el estilo de rutas de Rails 3 se pasará directamente el bloque. instance_exec
ejecutará el bloque exactamente igual que si estuviera dentro de la instancia lo que significa que dentro de un fichero de rutas cualquier cosa que escribamos se ejecuta dentro de un objeto Mapper
, que es la clase que implementa el cómodo lenguaje de rutas de Rails 3 donde podemos invocar a métodos como resources
sin tener que invocarlos sobre objetos específicos como en Rails 2 (donde usábamos map.resources
).
La último que hace el método draw
es invocar el método finalize!
que se define un poco más adelante en la clase RouteSet
para congelar el conjunto de rutas.
Cómo se mapean las rutas
Ahora que ya sabemos que todo lo que está dentro del archivo de rutas de Rails 3 se ejecuta contra un objeto Mapper
vamos a usar una ruta sencilla como ejemplo para ver cómo es procesada.
Store::Application.routes.draw do match 'products', :to => 'products#index' end
Veamos lo que hace el método match
de la clase Mapper
. Nos encontramos con varios métodos llamados match
en dicha clase; el que nos interesa se encuentra en el módulo Base
.
module Base def initialize(set) #:nodoc: @set = set end def root(options = {}) match '/', options.reverse_merge(:as => :root) end def match(path, options=nil) mapping = Mapping.new(@set, @scope, path, options || {}).to_route @set.add_route(*mapping) self end # other methods end
Para crear un nuevo objeto Mapper
tenemos que pasar un RouteSet
. Cuando se invoca el método match
en el fichero de rutas para crear una nueva ruta dicha ruta se añade al RouteSet
, creando un nuevo objeto Mapping
y luego ejecutándose to_route
sobre él. Nótese también que el método root
es realmente sencillo, tan sólo llama a match
pasando la raíz URL y añadiendo la opción :as => :root
para que sea una ruta con nombre.
Veamos a continuación la clase Mapping
, que está contenido en el mismo fichero mapper.rb
.
class Mapping #:nodoc: IGNORE_OPTIONS = [:to, :as, :via, :on, :constraints, :defaults, :only, :except, :anchor, :shallow, :shallow_path, :shallow_prefix] def initialize(set, scope, path, options) @set, @scope = set, scope @options = (@scope[:options] || {}).merge(options) @path = normalize_path(path) normalize_options! end def to_route [ app, conditions, requirements, defaults, @options[:as], @options[:anchor] ] end private def normalize_options! path_without_format = @path.sub(/\(\.:format\)$/, '') if using_match_shorthand?(path_without_format, @options) to_shorthand = @options[:to].blank? @options[:to] ||= path_without_format[1..-1].sub(%r{/([^/]*)$}, '#\1') @options[:as] ||= Mapper.normalize_name(path_without_format) end @options.merge!(default_controller_and_action(to_shorthand)) end # other private methods omitted. end
Algunas variables de instancia del Mapper
se establecen en la inicialización a partir de los parámetros recibidos, y luego se invoca a normalize_options!
. Este método comprueba si estamos usando la sintaxis abreviada en la ruta y en el método using_match_shorthand?
nos encontramos con un truco interesante. Hay una forma abreviada de definir acciones de controlador, donde basta con separar el nombre y la acción por una barra inclinada.
# match "account/overview" def using_match_shorthand?(path, options) path && options.except(:via, :anchor, :to, :as).empty? && path =~ %r{^/[\w\/]+$} end
Si hemos definido así la ruta las opciones :to
y :as
se establecerán automáticamente dependiendo del nombre de la URL. Veamos esto en el archivo de rutas de nuestra aplicación añadiendo otra ruta que utiliza esta sintaxis abreviada:
Store::Application.routes.draw do match 'products', :to => 'products#index' match 'products/recent', :to => 'products#recent' end
Hemos pasado el parámetro <cdoe>:to</cdoe> a esta nueva ruta pero si lo omitimos será asignado automáticamente. El método además también añadirá el parámetro :as
como si hubiésemos escrito :as => products_recent
.
Cómo se usa Rack en las rutas
En el método match
, una vez que hemos creado un nuevo objeto Mapping
invocamos al método to_route
sobre él. Este método devuelve un array de opciones que son las que se utilizan para crear la nueva ruta.
def to_route [ app, conditions, requirements, defaults, @options[:as], @options[:anchor] ] end
Los primeros cuatro elementos del array son valores recibidos de llamadas a métodos en la clase Mapper
.
def app Constraints.new( to.respond_to?(:call) ? to : Routing::RouteSet::Dispatcher.new(:defaults => defaults), blocks, @set.request_class ) end def conditions { :path_info => @path }.merge(constraints).merge(request_method_condition) end def requirements @requirements ||= (@options[:constraints].is_a?(Hash) ? @options[:constraints] : {}).tap do |requirements| requirements.reverse_merge!(@scope[:constraints]) if @scope[:constraints] @options.each { |k, v| requirements[k] = v if v.is_a?(Regexp) } end end def defaults @defaults ||= (@options[:defaults] || {}).tap do |defaults| defaults.reverse_merge!(@scope[:defaults]) if @scope[:defaults] @options.each { |k, v| defaults[k] = v unless v.is_a?(Regexp) || IGNORE_OPTIONS.include?(k.to_sym) } end end
La primera opción es app
, y cuando veamos en el código fuente de Rails algo llamado app
es muy probable que se refiera a una aplicación Rack. Este método app
devuelve un nuevo objeto Constraints
, así que veamos si se trata de una aplicación Rack. La clase Constraints
está definida en el mismo archivo mapper.rb
. Recibe varios métodos, uno de los cuales se llama call
y recibe como parámetro un entorno.
def call(env) req = @request.new(env) @constraints.each { |constraint| if constraint.respond_to?(:matches?) && !constraint.matches?(req) return [ 404, {'X-Cascade' => 'pass'}, [] ] elsif constraint.respond_to?(:call) && !constraint.call(*constraint_args(constraint, req)) return [ 404, {'X-Cascade' => 'pass'}, [] ] end } @app.call(env) end
La clase Constraints
desde luego tiene toda la pinta de ser una aplicación Rack. Como detalle interesante esta clase redefine el método self.new
y cabe preguntarse por qué lo hace así si ya tiene su propio método initialize
.
def self.new(app, constraints, request = Rack::Request) if constraints.any? super(app, constraints, request) else app end end attr_reader :app def initialize(app, constraints, request) @app, @constraints, @request = app, constraints, request end
El motivo es el rendimiento. Constraints
es un middleware de Rack, o lo que es lo mismo, es un envoltorio de otra aplicación Rack. El primer argumento que recibe self.new
es app
, que es una aplicación Rack y cuando se invoca el método call
si se cumple cualquiera de las restricciones devolverá un 404
y en otro caso invocará a la aplicación Rack que está envolviendo. El método self.new
es un pequeño truco de rendimiento para que cuando se llame no sea necesario reservar espacio para otro objeto en memoria, envolverlo y utilizarlo, en su lugar simplemente devolverá la misma aplicación inicial Rack.
Volvamos al código que invoca esto. Nótese que los dos primeros argumentos son una aplicación Rack y un array de restricciones, y se le llama desde el método app
de la clase Mapping
. Cuando se crea una nueva restricción simplemente tenemos que ver si el método responde a call
(el método to
simplemente devuelve la opción :to
que se definió para la ruta) si lo hace entonces es una aplicación Rack y podemos pasarla directamente, de lo contrario creo un nuevo objeto RouteSet::Dispatcher
con ciertas opciones por defecto.
def app Constraints.new( to.respond_to?(:call) ? to : Routing::RouteSet::Dispatcher.new(:defaults => defaults), blocks, @set.request_class ) end
Este es el código que nos permite poder pasar una aplicación Rack a una ruta de esta manera:
root :to => proc { |env| [200, {}, ["Welcome"]] }
Poder usar Rack en combinación con las rutas de Rails nos da una gran flexibilidad, se trata este tema en más profundidad en el episodio 222 [verlo, leerlo].
Si no le pasamos una aplicación Rack a la opción :to
se creará un nuevo objeto de tipo RouteSet::Dispatcher
. A continuación veremos cómo se gestiona esto.
La clase Dispatcher
gestiona el paso de la petición al controlador adecuado. En el método controller_reference
se puede ver el código donde se determina cuál es dicho controlador.
def controller_reference(controller_param) unless controller = @controllers[controller_param] controller_name = "#{controller_param.camelize}Controller" controller = @controllers[controller_param] = ActiveSupport::Dependencies.ref(controller_name) end controller.get end
La clase también tiene métodos para hacer cosas como establecer la acción index
por defecto y un método dispatch
que llama a la acción propiamente dicha y que devuelve una aplicación Rack. Esto significa que podemos coger cualquiera de los controladores de nuestra aplicación e invocar action
sobre él pasándole el nombre de la acción que queramos y obtendremos de vuelta una aplicación Rack.
ruby-1.9.2-p0 > ProductsController.action("index") => #<Proc:0x00000100ec56c0@/Users/eifion/.rvm/gems/ruby-1.9.2-p0/gems/actionpack-3.0.0/lib/action_controller/metal.rb:172>
Esto es lo que ocurre entre bambalinas cuando pasamos una ruta como esta
match 'products', :to => 'products#index'
La cadena products#index
se transforma en ProductsController.action("index")
, lo que devuelve una aplicación Rack. La sintaxis de la cadena es un simple atajo de hacer lo mismo.
Hay mucha más que tratar acerca de las rutas que podríamos repasar: los métodos resources
que generan varias rutas de una vez, los métodos que nos permiten definiri ámbitos y pasarles bloques... esperamos que eso sea suficiente para servir de acicate para leer el código de las rutas.
Las rutas probablemente sean una de las áreas más complejas de Rails, por lo que si nos intimida la complejidad del código visto aquí no hay que preocuparse: es un código difícil. Es recomendable empezar primero por otras partes del código de Rails antes de enfrentarse a las partes más complejas.