Follow me
RSS feed
My sources
My Viadeo

Créer son microframework avec Rack (Partie 1)

Greg | 04 Mar 2009

projetsDepuis que je développe exclusivement avec Cappuccino pour créer des applications Web, je me suis trouvé face à un dilemme de taille. En effet, si Cappuccino gère remarquablement la partie interface, il n'en reste pas moins qu'il faut faire un choix pour la partie serveur.

Pour cela n'importe quel langage fera l'affaire. Mais ma préférence allant vers Ruby, je ne vais parler que de ce dernier...

Bien entendu il y Rails ou Merb (permettez-moi de les mettre dans le même panier...). Mais c'est peut-être un peu overkill. En effet, avec Cappuccino, nous voulons simplement gérer des requêtes JSON, et à la rigueur pouvoir accéder à une base de données... Bref des choses élémentaires que Rails et Merb savent parfaitement gérer, et même très bien, mais c'est un peu réducteur comme travail pour de si gros frameworks. Ayant abandonné Bivouac je ne vous le proposerai pas, mais Camping peut-être une solution. En effet, ce framework est petit, simple à mettre en place, bref c'est un peu comme le candidat idéal. Un peu car lui aussi ne me satisfait pas. Premièrement parce que depuis qu'il a été quelque peu délaissé par _why, il avance lentement. Nous sommes toujours avec la version 1.5 qui date d'octobre 2006 et on nous promet une v2 depuis pas mal de temps maintenant. Ensuite parce qu'il s'agit d'un framework qui a été fait pour créer des applications web classiques et qui lui aussi propose des solutions qui vont au-delà de notre besoin.

Après de très courtes recherches, j'ai trouvé mon salut avec Rack. Rack n'est pas un framework, mais il est une base idéale pour en fabriquer un (Camping par exemple l'utilise pour sa future version 2.0).

Dans ma quête de simplicité, je me suis donc amusé avec Rack. Je vous propose donc de développer notre propre micro framework, nom de code Capcode.

cape_codLa première chose à faire est bien entendu d'installer Rack. Le gem existant, il n'y a aucune surprise : sudo gem install rack

Commençons par un premier exemple histoire de comprendre comment Rack fonctionne. Pour cela, rappelons les principes de base. Le navigateur envoie une requête de type HTTP vers une URL donnée, cette URL comprenant un nom de serveur, un port et un chemin. Par exemple dans http://www.monsite.com:3000/hello, www.monsite.com est le nom du serveur, 3000 le port et /hello le chemin. Il faut donc que nous soyons capables d'intercepter cette requête et de renvoyer un résultat.

Rack arrive avec l'exécutable rackup qui va, pour le moment, gérer pour nous la partie serveur et port. En effet, si vous regarder l'aide de rackup vous verrez que nous pouvons, lors du lancement, lui spécifier le host (l'interface) sur laquelle il doit écouter (option -o), le port (option -p) mais également le serveur à utiliser (option -s), ce dernier choix se faisons entre WEBrick ou Mongrel. Vous remarquez enfin que rackup attend un fichier de configuration (avec l'extension .ru). C'est sur ce fichier que nous allons nous concentrer pour le moment.

Le fichier de configuration de rackup est en fait un fichier Ruby dont l'ensemble du code sera passé en paramètre à la méthode d'initialisation de la DSL Rack::Builder. En effet, la création d'une application avec Rack commence par la création d'un objet Rack::Builder prenant en paramètre un bloc dans lequel nous décrivons le comportement de l'application. Oublions cela pour le moment.

Notre premier exemple sera très simple1. Nous allons faire en sorte de pouvoir renvoyer l'heure quand l'utilisateur interroge notre application sur le chemin /time. Voici le contenu de notre fichier (exemple.ru)

1 use Rack::ContentLength
2 
3 map '/time' do
4   run lambda { |env| 
5     [200, { 'Content-Type' => 'text/html' }, "Il est #{Time::now}"] 
6   }
7 end

Nous utilisons ici trois mots clés de la DSL Rack::Builder :

Nous pouvons maintenant tester que tout cela fonctionne. Pour cela il suffit simplement d'exécuter la commande suivante :

rackup exemple.ru

Par défaut, rackup utilise le port 9292 et l'interface 0.0.0.0. Depuis notre navigateur préféré, nous pouvons nous connecter à l'adresse http://localhost:9292/time

Ô joie, nous savons maintenant quelle heure il est ;)

Si vous essayez de vous connecter à l'adresse http://localhost:9292 vous aurez le droit à un insolent message du type Not Found: /. C'est tout à fait normal, car nous n'avons pas géré le cas du chemin /.

Maintenant que nous savons mapper un chemin, nous pouvons envisager d'aller un peu plus loin. Cependant, même si Rack n'est pas un framwork, il offre tout de même quelques facilités. Prenons le cas ou nous voulons servir des fichiers. Nous voulons pas exemple que si l'on fait un appel du type http://localhost:9292/index.html, le contenu du fichier index.html soit renvoyé. Pour cela nous pouvons éviter de tout écrire à la main en utilisant le helper Rack::File. Voici comment nous modifions l'exemple précédent :

 1 require 'rack/file'
 2 use Rack::ContentLength
 3 
 4 map '/' do
 5   run Rack::File.new( "." )
 6 end
 7 
 8 map '/time' do
 9   run lambda { |env| 
10     [200, { 'Content-Type' => 'text/html' }, "Il est #{Time::now}"] 
11   }
12 end

Vous le voyez, nous avons mappé le chemin / en passant à la méthode run un objet Rack::File. Lors de l'initialisation de ce dernier, nous avons passé en paramètre le chemin d'accès, sur le disque, au répertoire contenant les fichiers -- dans le cas présent, le répertoire courant. Ajoutez un fichier index.html à côté de notre fichier de configuration, relancer rackup et testez en vous rendant à l'adresse http://localhost:9292/index.html. Vous noterez, et c'est heureux, qu'il n'y a aucun changement si vous allez à l'adresse http://localhost:9292/time

Jusqu'à maintenant, nous avons utilisé rackup. C'est une solution très facile à mettre en place et nous pourrions nous arrêter là. Finalement, au JSON prêt, nous voyons facilement comment rendre le service dont nous avons besoin (à savoir créer un backend pour une application Cappuccino). Cependant, nous pouvons aller plus loin. Tout d'abord, j'aimerai ne pas être obligé d'utiliser rackup. C'est vrai, si nous voulons rester proche de Ruby (non pas que nous nous en sommes éloignés) autant créer un vrai script Ruby. Nous allons donc voir comment refaire ce que nous venons de développer, mais en pur Ruby.

Pour cela nous allons devoir aller un peu plus loin dans la compréhension de Rack.

En choisissant de ne pas passer par un fichier de configuration rackup c'est à nous de mettre en place l'environnement via Rack::Builder. C'est relativement simple :

 1 require 'rubygems'
 2 require 'rack'
 3 
 4 app = Rack::Builder.new {
 5   use Rack::ContentLength
 6   
 7   map '/' do
 8     run Rack::File.new( "." )
 9   end
10   
11   map '/time' do
12     run lambda { |env| 
13       [200, { 'Content-Type' => 'text/html' }, "Il est #{Time::now}"] 
14     }
15   end
16 }

Comme vous pouvez le voir, ce code est presque identique à celui du fichier de configuration de rackup, la seule différence vient du fait que nous avons passé les différents mapping dans un bloc en paramètre du constructeur de Rack::Builder. En fait, ce n'est pas tout. En effet, pour le moment nous avons déclaré une application (app) mais il faut la faire porter par un serveur. Pour cela nous allons utiliser un handler. Rack propose plusieurs handler dont un pour Mongrel, WEBrick, Thin, FastCGI, ... Faisons simple et utilisons ce qu'il y a de plus standard. Pour cela, il suffit d'ajouter la ligne suivante dans notre script :

1 Rack::Handler::WEBrick.run( app, {:Port => 9292} )

Voilà, vous pouvez maintenant exécuter cet exemple :

ruby exemple.rb

Vous constaterez que son comportement est exactement le même qu'avec rackup. Mais nous pouvons faire encore mieux. En effet, écrire toute l'intelligence de notre application dans un bloc n'est pas une solution très élégante. Qu'à cela ne tienne, nous allons faire autrement. Le principe est assez simple en fait. Tout comme Rack propose des helpers nous allons créer les nôtres.

Un helper Rack est une classe du module Rack dans laquelle nous avons besoin de créer une simple méthode call. Cette méthode prend un paramètre qui correspond à un hachage contenant l'ensemble des informations de la requête. Voici donc comment écrire le helper qui nous donne l'heure :

 1 require 'rubygems'
 2 require 'rack'
 3 
 4 module Rack
 5   class MyTime
 6     def call( env )
 7       response = Rack::Response.new
 8       response.write "Il est #{Time::now}"
 9       response.finish
10     end
11   end
12 end

Comme vous pouvez le voir, dans notre méthode call nous utilisons un objet Rack::Response pour formater le corps de la réponse. Bien entendu, nous pouvons également utiliser ce même objet pour ajouter des informations dans l'entête, ...

Pour utiliser ce helper, et le helper Rack::File, nous allons créer un hachage dont les clés correspondent aux chemins d'accès et les valeurs sont les objets helper correspondant :

1 routes = {
2   '/time' => Rack::MyTime.new,
3   '/' => Rack::File.new( "." )
4 }

Pour terminer, il faut créer une application à partie de ce hachage. Pour cela nous utilisons la classe Rack::URLMap en lui passant en paramètre le hachage:

1 app = Rack::URLMap.new(routes)

Dans les exemples précédents nous avons utilisé Rack::ContentLength pour faciliter la mise en place de la valeur du Content-Length dans les entêtes de réponses. Nous pouvons faire de même :

1 app = Rack::ContentLength.new(app)

Enfin, nous terminons par passer l'application au serveur :

1 Rack::Handler::WEBrick.run( app, {:Port => 9292} )

Sauvegardez, tester... Aucune surprise !

Maintenant que nous connaissons les bases de Rack -- et nous n'en utiliserons pas beaucoup plus -- voyons comment créer notre micoframework...

Pour cela je vais partir de la fin... A savoir vous montrer un exemple de ce à quoi nous souhaitons2 aboutir3 :

require 'rubygems'
require 'capcode'

module Capcode
  class Hello < Route '/hello/(.*)'
    def get( r )
      "Hello #{r} it's #{Time.now} !"
    end
  end

  class Js < Route '/hello/json/(.*)'
    def get( r )
      json( { :who => r, :time => Time.now } )
    end
  end
end

Capcode.map( "/files" ) do
  Rack::File.new( "." )
end

Capcode.run( :port => 3001, :host => "localhost" )

La partie la plus intéressante ici est très certainement la réalisation de méthode qui va permettre la création des routes. Avant de nous y attaquer, voyons de quoi nous avons besoin. Si vous comptez bien, vous verrez que nous avons seulement besoin de 4 méthodes : Capcode.map, Capcode.run, json et Route. Mais prenons les choses dans l'ordre.

Comme vous pouvez le voir, une application Capcode est décrite dans le module Capcode. En fait, nous ne faisons là que reproduire ce que nous avons fait avec le dernier exemple avec Rack. En effet, pour réécrire le même exemple en utilisant un module MonModule (à la place de du module Rack), il suffit simplement de créer le module MonModule et d'un inclure le module Rack. Donc nous savons déjà que nous allons devoir lamentablement plagier Rack pour créer Capcode...

Chaque contrôleur4 est décrit dans une classe héritant de Route à qui nous passons le chemin d'accès. Vous vous en doutiez déjà, mais si Route prend un paramètre, c'est donc une méthode. En fait, pour être exacte c'est une méthode de classe de Capcode.

module Capcode
  include Rack

  class << self
    def Route *u
      # ...
    end
  end
end

Dans route, il faut non seulement faire quelque chose avec u mais également renvoyer une classe. Cette dernière étape est relativement simple. En effet, il suffit de créer cette classe et de la renvoyer :

module Capcode
  include Rack

  class << self
    def Route *u
      Class.new {
        # ...
      }
    end
  end
end

Nous pouvons y ajouter toutes les méthodes nécessaires. Nous avons vu que les classes applicatives de Rack ont toutes une méthode call. Or dans Capcode nous avons une méthode get (et post mais je n'en parlerai pas pour le moment). Vous l'avez certainement compris, mais en fait c'est la classe héritée de Route qui possède la méthode call et c'est cette dernière qui appelle la méthode get

module Capcode
  include Rack

  class << self
    def Route *u
      Class.new {
        # ...

        def call(e) 
          @env = e
          @response = Rack::Response.new
          @request = Rack::Request.new(@env)
          
          @case @env["REQUEST_METHOD"]
            when "GET"
              @response.write get
            when "POST"
              @response.write post
          end
          @response.finish
        end

        # ...
      }
    end
  end
end

Histoire de nous faciliter une peu la tache, j'ai ajouté la ligne @request = Rack::Request.new(@env) dans la méthode call afin de nous permettre de retrouver les données de la requête. Pour plus de détails je vous engage à regarder la documentation de Rack::Request.

J'ai également ajouté quelques petites méthodes qui, si elles n'ont pas une grande utilité, permettent de rendre les choses plus belles :

module Capcode
  include Rack

  class << self
    def Route *u
      Class.new {
        # ...

        def params
          @request.params
        end
        
        def env
          @env
        end
        
        def request
          @request
        end
        
        def response
          @response
        end

        def call(e) 
          @env = e
          @response = Rack::Response.new
          @request = Rack::Request.new(@env)
          
          @case @env["REQUEST_METHOD"]
            when "GET"
              @response.write get
            when "POST"
              @response.write post
          end
          @response.finish
        end

        # ...
      }
    end
  end
end

Et si nous faisions quelque chose avec ce paramètre u.

Nous avons besoin de stocker les routes passées en paramètre à Route. Pour cela nous allons faire un peu de meta-programmation en utilisant la méthode Object.meta_def de _why. En fait, nous allons créer dans la classe renvoyée par Route une méthode de classe : __urls__. Quand cette méthode est appelée, elle renvoie un tableau contenant les informations relatives aux routes de la classe. De telle sorte que si nous déclarons une méthode de la façon suivante :

module Capcode
  class Glop < Route '/glop/(.*)', '/glop/code/(.*)'
    def get
      # ...
    end
  end
end

Quand nous appelons Glog.__urls__ nous obtenons le résultat suivant :

[{"/glop/code"=>"(.*)", "/glop"=>"(.*)"}, Capcode::Glop]

Et nous en finissons donc, pour le moment, avec la méthode Route :

module Capcode
  include Rack

  class << self
    def Route *u
      Class.new {
        meta_def(:__urls__){
          h = {}
          u.each do |_u|
            m = /\/([^\/]*\(.*)/.match( _u )
            if m.nil?
              h[_u] = ''
            else
              h[m.pre_match] = m.captures[0]
            end
          end
          [h, self]
        }

        def params
          @request.params
        end
        
        def env
          @env
        end
        
        def request
          @request
        end
        
        def response
          @response
        end

        def call(e) 
          @env = e
          @response = Rack::Response.new
          @request = Rack::Request.new(@env)
          
          @case @env["REQUEST_METHOD"]
            when "GET"
              @response.write get
            when "POST"
              @response.write post
          end
          @response.finish
        end

        # ...
      }
    end
  end
end

La méthode json est en fait un helper que nous aurions tout aussi bien pu mettre dans la classe renvoyée par Route. Oui mais cela ne serait pas drôle. De plus, il est intéressant de lui garder son côté helper, car, en effet, nous pourrions très facilement faire autrement. Anyway5 ! Nous allons donc créer un module Capcode::Helpers dans lequel nous mettrons cette méthode :

module Capcode
  module Helpers
    def json( d )
      @response['Content-Type'] = 'application/json'
      d.to_json
    end
  end

  include Rack

  class << self
    def Route *u
      # ...
    end
  end
end

Comme vous pouvez le voir, cette méthode se contente de positionner la valeur du Content-Type dans l'entête de la réponse et de transformer ce qui est passé en paramètre au format JSON puis de renvoyer le tout.

Il faut maintenant prendre en compte le module Capcode::Helpers. Rien de plus simple. Il suffit de rajouter une include Capcode::Helpers à la fin de la création de la classe renvoyée par Route :

module Capcode
  module Helpers
    def json( d )
      @response['Content-Type'] = 'application/json'
      d.to_json
    end
  end

  include Rack

  class << self
    def Route *u
      Class.new {
        meta_def(:__urls__){
          h = {}
          u.each do |_u|
            m = /\/([^\/]*\(.*)/.match( _u )
            if m.nil?
              h[_u] = ''
            else
              h[m.pre_match] = m.captures[0]
            end
          end
          [h, self]
        }

        def params
          @request.params
        end

        def env
          @env
        end

        def request
          @request
        end

        def response
          @response
        end

        def call(e) 
          @env = e
          @response = Rack::Response.new
          @request = Rack::Request.new(@env)

          @case @env["REQUEST_METHOD"]
            when "GET"
              @response.write get
            when "POST"
              @response.write post
          end
          @response.finish
        end

        include Capcode::Helpers
      }
    end
  end
end

La méthode map est beaucoup plus simple. En fait, ce que nous voulons faire, c'est compléter un hachage comme nous l'avons fait plus haut. Souvenez-vous de ceci :

routes = {
  '/time' => Rack::MyTime.new,
  '/' => Rack::File.new( "." )
}

Et bien dans Capcode, nous allons faire la même chose, à la différence que notre hachage sera ici une variable de classe du module Capcode :

module Capcode
  module Helpers
    def json( d )
      ...
    end
  end

  include Rack
  @@__ROUTES = {}

  class << self
    def Route *u
      ...
    end

    def map( r, &b )
      @@__ROUTES[r] = yield
    end
  end
end

Terminons avec la méthode run. Dans cette méthode nous avons besoin de mettre en place l'application. Pour cela nous avons besoins de retrouver toutes les routes, les classes qui s'y rattachent, puis de mettre à jour le hachage @@__ROUTES qu'il suffira ensuite de passer au constructeur de Rack::URLMap. Pour retrouver l'ensemble les routes d'une classe, nous avons la méthode __urls__. Mais il faut au préalable retrouver les classes. Sachant qu'en Ruby une classe est avant tout une constante, il suffit d'utiliser la méthode Module::constants :

module Capcode
  module Helpers
    def json( d )
      ...
    end
  end

  include Rack
  @@__ROUTES = {}

  class << self
    def Route *u
      ...
    end

    def map( r, &b )
      ...
    end

    def run( args )
      # ...

      Capcode.constants.each do |k|
        begin
          if eval "Capcode::#{k}.public_methods(true).include?( '__urls__' )"
            u, c = eval "Capcode::#{k}.__urls__"
            u.keys.each do |_u|
              @@__ROUTES[_u] = c.new
            end
          end
        rescue => e
          raise e.message
        end
      end

      # ...
    end
  end
end

Dans ce code, nous parcourons toutes les constantes du module Capcode. Pour chacune nous vérifions si elle possède une méthode __urls__. Si c'est le cas, nous appelons cette méthode et nous mettons à jour le hachage @@__ROUTES.

Il ne reste plus qu'à créer l'application avec Rack::URLMap comme nous l'avons fait précédemment. Voici donc le code complet de Capcode (capcode.rb). Je n'entre pas plus dans les détails de la méthode run c'est sensiblement la même chose que ce que nous avons fait plus haut.

require 'rubygems'
require 'rack'
require 'json'
require 'logger'

class Object #:nodoc:
  def meta_def(m,&b) #:nodoc:
    (class<<self
self end).send(:define_method,m,&b)
  end
end

module Capcode
  module Helpers
    def json( d )
      @response['Content-Type'] = 'application/json'
      d.to_json
    end
  end
  
  include Rack
  
  CAPCOD_VERION="0.1.0"
  @@__ROUTES = {}
  
  class << self
    def map( r, &b )
      @@__ROUTES[r] = yield
    end
    
    def Route *u
      Class.new {
        meta_def(:__urls__){
          h = {}
          u.each do |_u|
            m = /\/([^\/]*\(.*)/.match( _u )
            if m.nil?
              h[_u] = ''
            else
              h[m.pre_match] = m.captures[0]
            end
          end
          [h, self]
        }

        def params
          @request.params
        end
        
        def env
          @env
        end
        
        def request
          @request
        end
        
        def response
          @response
        end
        
        def call( e ) #:nodoc:
          @env = e
          @response = Rack::Response.new
          @request = Rack::Request.new(@env)

          case @env["REQUEST_METHOD"]
            when "GET"
              @response.write get
            when "POST"
              @response.write post
          end
          @response.finish
        end
                
        include Capcode::Helpers
      }      
    end
  
    def run( args = {} )
      conf = {
        :port => args[:port]||3000, 
        :host => args[:host]||"localhost",
        :server => args[:server]||nil,
        :log => args[:log]||$stdout,
        :session => args[:session]||{}
      }
      
      # Check that mongrel exists 
      if conf[:server].nil? || conf[:server] == "mongrel"
        begin
          require 'mongrel'
          conf[:server] = "mongrel"
        rescue LoadError 
          puts "!! could not load mongrel. Falling back to webrick."
          conf[:server] = "webrick"
        end
      end
          
      Capcode.constants.each do |k|
        begin
          if eval "Capcode::#{k}.public_methods(true).include?( '__urls__' )"
            u, c = eval "Capcode::#{k}.__urls__"
            u.keys.each do |_u|
              @@__ROUTES[_u] = c.new
            end
          end
        rescue => e
          raise e.message
        end
      end
      
      app = Rack::URLMap.new(@@__ROUTES)
      app = Rack::Session::Cookie.new( app, conf[:session] )
      app = Rack::ContentLength.new(app)
      app = Rack::Lint.new(app)
      app = Rack::ShowExceptions.new(app)
      app = Rack::Reloader.new(app) ## -- NE RELOAD QUE capcode.rb -- So !!!
      app = Rack::CommonLogger.new( app, Logger.new(conf[:log]) )
      
      
      case conf[:server]
      when "mongrel"
        puts "** Starting Mongrel on #{conf[:host]}:#{conf[:port]}"
        Rack::Handler::Mongrel.run( app, {:Port => conf[:port], :Host => conf[:host]} ) { |server|
          trap "SIGINT", proc { server.stop }
        }
      when "webrick"
        puts "** Starting WEBrick on #{conf[:host]}:#{conf[:port]}"
        Rack::Handler::WEBrick.run( app, {:Port => conf[:port], :BindAddress => conf[:host]} ) { |server|
          trap "SIGINT", proc { server.shutdown }
        }
      end
    end
  end
end

Si vous y regardez de près, nous ne sommes pas exactement conformes avec ce que nous avons énoncé dans l'exemple de départ. Disons simplement que c'est une v1 et que vous allez attendre patiemment le prochain épisode...

1 les premiers exemples sont toujours simple...
2 Si, si, vous souhaitez...
3 comment ça vachement inspiré de Camping ?
4 ça y est, j'ai fini par le dire !
5 en Québécois dans le texte.

Copyright © 2009 - 2011 Grégoire Lejeune.
All documents licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 2.5 License, except ones with specified licence.
Powered by Jekyll.