homeASCIIcasts

231: Las rutas, paso a paso 

(view original Railscast)

Other translations: En It

Other formats:

Written by Juan Lupión

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.

/config/routes.rb

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.

La estructura de directorios del código fuente de Rails.

Dentro de actionpack el código relacionado con las rutas se encuentra en el directorio lib/action_dispatch.

Estructura de directorio de actionpack mostrando los archivos de implementación de las rutas.

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.

/config/routes.rb

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.

/config/application.rb

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.

rails/railties/lib/rails/application.rb

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.

rails/actionpack/lib/action_dispatch/routing/route_set.rb

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:

/config/routes.rb

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.

/config/routes.rb

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.

rails/actionpack/lib/action_dispatch/routing/mapper.rb

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.

rails/actionpack/lib/action_dispatch/routing/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.

rails/actionpack/lib/action_dispatch/routing/mapper.rb

# 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:

/config/routes.rb

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.

rails/actionpack/lib/action_dispatch/routing/mapper.rb

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.

rails/actionpack/lib/action_dispatch/routing/mapper.rb

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.

rails/actionpack/lib/action_dispatch/routing/mapper.rb

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.

rails/actionpack/lib/action_dispatch/routing/mapper.rb

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.

rails/actionpack/lib/action_dispatch/routing/mapper.rb

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.

rails/actionpack/lib/action_dispatch/routing/route_set.rb

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.