Créer son microframework avec Rack (Partie 1)
Depuis 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.
La 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 2 3 4 5 6 7 | use Rack::ContentLength map '/time' do run lambda { |env| [200, { 'Content-Type' => 'text/html' }, "Il est #{Time::now}"] } end |
Nous utilisons ici trois mots clés de la DSL Rack::Builder :
- use permet de charger un middleware. A la ligne 1 nous chargeons donc Rack::ContentLength qui va se charger de remplir l’information Content-Length dans l’entête de la réponse.
- map permet de mapper un chemin. Ici (ligne 3) nous précisons que pour le chemin /time la construction de la réponse se fera en prenant le contenu du bloc passé en paramètre.
- run enfin permet d’appeler la méthode qui construit la réponse. Dans notre exemple run prend en paramètre un objet de type Proc renvoyant un tableau dont le premier élément est le code HTTP de réponse, le second est un hachage contenant des infirmations pour l’entête de la réponse et le dernier est une chaîne pour le corps de la réponse.
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 2 3 4 5 6 7 8 9 10 11 12 | require 'rack/file' use Rack::ContentLength map '/' do run Rack::File.new( "." ) end map '/time' do run lambda { |env| [200, { 'Content-Type' => 'text/html' }, "Il est #{Time::now}"] } 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | require 'rubygems' require 'rack' app = Rack::Builder.new { use Rack::ContentLength map '/' do run Rack::File.new( "." ) end map '/time' do run lambda { |env| [200, { 'Content-Type' => 'text/html' }, "Il est #{Time::now}"] } end } |
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 :
17 | 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 2 3 4 5 6 7 8 9 10 11 12 | require 'rubygems' require 'rack' module Rack class MyTime def call( env ) response = Rack::Response.new response.write "Il est #{Time::now}" response.finish end end 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 :
13 14 15 16 | routes = { '/time' => Rack::MyTime.new, '/' => Rack::File.new( "." ) } |
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:
17 | 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 :
18 | app = Rack::ContentLength.new(app) |
Enfin, nous terminons par passer l’application au serveur :
19 | 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.
Tags: Capcode, Cappuccino, Rack
