Une spoliation totale de Turbogears: Comment faire un wiki en 20 minutes (enfin, je n’ai rien chronométré)

Content

Objectif

Pour un projet (dont vous ne voulez rien savoir) j’ai dû effectuer une comparaison entreTurbogears et Ruby on Rails (que j’appellerai maintenant RoR).

Comme je ne suis pas un Pythoneux (Pythoniste ?), la solution la plus simple pour moi était de prendre le tutoriel de Turbogears et d’essayer de le répéter avec RoR, afin de pouvoir comparer ces deux frameworks de façon plus précise.

Notez que bien que je suive le plan du tutoriel de Turbogears, j’introduirai aussi quelques concepts propres à RoR, là où il sera pertinent. Par exemple, dans le tutoriel Turbogears, la cohérence de design du wiki est assuré par la copie d’une template initiale. Dans ce cas particulier j’introduirai les layouts de RoR, car c’est exactement ce pour quoi ils existent.

Départ Rapide

Créons tout d’abord la hiérarchie de notre wiki:

rails wiki
   create
   create  app/controllers
   create  app/helpers
   create  app/models
   create  app/views/layouts
   ....
   create  doc/README_FOR_APP
   create  log/server.log
   create  log/production.log
   create  log/development.log
   create  log/test.log

et plongeons à pieds joints:

cd wiki

Service rapide

Nous pouvons démarrer notre serveur web:

script/server

Cela démarrera soit Webrick soit Lighttpd (selon votre système d’exploitation et le fait que lighttpd est installé ou non). Notez que sur ma gentoo, j’ai dû ajouter /usr/sbin à mon path pour que RoR trouve lighttpd.

Regardons maintenant l’URL http://localhost:3000:

Si on se faisait un wiki?

RoR adhère au concept MVC (Model-View-Controller). Pour commencer nous allons donc devoir choisir notre modèle puis notre contrôleur.

Notre wiki sera fait de Pages (notre modèle), chacune ayant un nom (name) et un contenu (content).

Notre modèle sera au final sérialisé dans une table de base de données (BDD), et donc nous avons soit le choix d’écrire directement du code SQL pour créer cette table (pages), avec 2 colonnes name et content, soit de travailler avec les schémas Ruby On Rails et les migrations. Cette dernière solution a au moins deux avantages:

Indiquer une base de données

Commençons d’abord par indiquer à RoR quelle sera notre BDD de développement. Pour cela, on édite le fichier config/database.yml, qui sera l’un des seuls fichier de configuration que vous aurez à éditer.

Dans cet exemple, j’utiliserai SQLite en tant que BDD de développement, et je nommerai le fichier de cette BDD wiki.sqlite:

development:
  adapter: sqlite
  database: db/wiki.sqlite

Nous allons aussi créer notre modèle Page:

script/generate model Page
  exists  app/models/
  exists  test/unit/
  exists  test/fixtures/
  create  app/models/page.rb
  create  test/unit/page_test.rb
  create  test/fixtures/pages.yml
  create  db/migrate
  create  db/migrate/001_create_pages.rb

Création de la base de données

En ce qui concerne la gestion de la base de donnée, et plus particulièrement du schéma, le plus simple dans un premier temps va être de laisser Rails le gérer complètement pour nous. Pour cela, nous allons utiliser les migrations, qui sont, en gros, la manière qu’à Rails de garder en mémoire les différentes versions du schéma (on peut voir cela comme les incréments qui vont faire passer le schéma d’une version à une autre).

Lors de la création de notre modèle Page, vous avez peut-être remarqué la ligne:

create  db/migrations/001_create_pages.rb

Rails a créer pour nous une migration qui indique comme passer de l’état “schéma vide”, à l’état “schéma décrivant une table pages”.

Regardons le fichier db/migrations/001_create_pages.rb:

    class CreatePages < ActiveRecord::Migration
      def self.up
        create_table :pages do |t|
          # t.column :name, :string
        end
      end

      def self.down
        drop_table :pages
      end
    end
  

Ce fichier décrit donc le contenu d’une table nommée pages ... en Ruby. L’avantage étant que l’on peut ensuite générer le code SQL correspondant au type de database choisie (mySQL, SQLite, Oracle …).

La méthode self.up décrit ce qui va faire passer l schéma de la version courante à la version suivante … et le self.down l’inverse.

Décrivons donc notre modèle:

   def self.up
     create_table :pages do |t|
       t.column :name, :string, :limit => 30
       t.column :content, :text
     end
   end
 

Nous avons donc défini une table de notre BDD, nommée pages, avec 2 colonnes:

Il ne nous reste plus quà dire à Rails d’appliquer notre migration à notre BDD:

   > rake migrate
   (in /home/sleeper/tmp/wiki)
   == CreatePages: migrating
   =====================================================
   -- create_table(:pages)
      -> 0.0438s
   == CreatePages: migrated (0.0440s)
   ============================================
 

Notre première table est créée. Notez, que Rails a aussi créé le fichier db/schema.rb, qui représente notre schéma dans sa version actuelle. Et c’est tout: notre BDD est créée, avec le bon schéma:

    > sqlite db/wiki.sqlite
    SQLite version 2.8.16
    Enter ".help" for instructions
    sqlite> .dump pages
    BEGIN TRANSACTION;
    CREATE TABLE pages ("id" INTEGER PRIMARY KEY NOT NULL, "name" 
    varchar(30), "content" text);
    COMMIT;
    sqlite>
  

Redémarrez votre serveur web, en appuyant d’abord sur Control-C dans le terminal puis en re-lançant la commande script/server.

Affichons une page de wiki!

Pour afficher une page de wiki, nous devons utiliser un contrôleur et une (ou plusieurs) vue(s). Pour être original, notre contrôleur s’appellera Wiki.

Pour le moment il ne montrera que l’un de ces méthodes: index. Cette méthode sera appelée chaque fois qu’un client demandera la page http://localhost:3000/wiki/index.html.

Cette méthode (index) doit permettre d’afficher soit la première page (nommée PageFront) ou n’importe quelle page dont le nom aura été donné en paramètre.

Ainsi http://localhost:3000/wiki/index.html affichera la page FrontPage, alors que http://localhost:3000/wiki/index.html?name=Foo affichera la page Foo.

Créons donc notre contrôleur Wiki, ainsi que la méthode index et la vue associée:

script/generate controller Wiki index
  exists  app/controllers/
   exists  app/helpers/
   create  app/views/wiki
   exists  test/functional/
   create  app/controllers/wiki_controller.rb
   create  test/functional/wiki_controller_test.rb
   create  app/helpers/wiki_helper.rb
   create  app/views/wiki/index.rhtml

Notez que nous pouvons nous passer du paramètre index dans la commande précédente et ajouter nous même la méthode index au contrôleur, dans le fichier app/controllers/wiki_controller.rb:

class WikiController < ApplicationController
  def index
    @page = Page.find_by_name( params[:name] || 'FrontPage' )
  end
end

Dans notre méthode index nous cherchons donc dans la BDD (grâce au modèle Page@), une page dont le nom correspond au nom donné en paramètre ou FrontPage si aucun nom n’est donné.

Nous devons aussi créer la page index.html, en éditant la template app/views/wiki/index.rhtml:

     <div style="float:right; width: 10em"> 
       <p>Viewing <span><%= @page.name %></span></p>
       <p>You can return to the <a href="/wiki">FrontPage</a>. </p>
     </div> 
     <div><%= textilize(@page.content) %></div> 
   

Nous utilisons la variable @page que la méthode index vient juste d’affecter, et affichons à la fois son nom et son contenu. Notez que le contenu est d’abord passer par la méthode textilize, qui va transformer tout markup au format Textile en HTML (cela marchera si RedCloth est installé et peut-être appelé au moyen de require).

Essayons donc de charger la page http://localhost:3000/wiki et nous obtenons … une erreur: ... an error:

Cette erreur est causée par le fait que notre BDD est vide ... Utilisons donc script/console pour créer la page FrontPage:

    script/console 
    Loading development environment.
    >> Page.new( :name => 'FrontPage', :content => 'Welcome to *my* front page').save
    => true
    >>   
    

Rechargez la page dans votre navigateur et … tada !!

Ca marche !!!

Comment faire une meilleure page?

Bon. Chaque wiki possède un moyen d’éditer la page courante. Nous allons donc ajouter un lien “Edit this page” à notre page courante.

Première chose: quand nous cliquerons notre lien “edit this page” nous serons redirigés vers une page d’un style semblable à celui de notre page courante (et même à toutes nos pages).

Plutôt que de copier index.rhtml et de la modifier, nous allons utiliser un layout.

Les layouts sont un bon moyen avec RoR de suivre le principe du DRY (Don’t Repeat Yourself—Ne Vous Répétez Pas). Pour parler de façon simple, un layout est une coquille qui va recevoir nos nouvelles pages. Cela permet de créer et maintenir le design général de notre site à un seul endroit.

Donc allons-y et vérifions si cela marche tout d’abord avec notre page index.html. La layout à utiliser pour un contrôleur donné a le même nom que ce contrôleur. Nous voulons aussi que le “Viewing [page name]” soit visible sur toutes les pages de notre wiki. Le placer dans le layout est donc une bonne idée… mais nous devons faire attention au fait que parfois nous ne regarderons pas une page mais nous l’éditerons … Nous devons donc paramètriser cette action …

Créons donc notre layout, nommé app/views/layouts/wiki.rhtml:

    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "DTD/xhtml1-transitional.dtd">
    <html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
    <head>
      <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
      <title> Wiki </title>
      <%= stylesheet_link_tag "wiki" %>
    </head>

    <body>

      <div id="sidebar" style="float:right; width: 10em"> 
        <% if @page and @page.name %>
          <p><%= @page_action || "Viewing" %> <span  id="name"><%= @page.name %></span> </p>
        <% end %>
         <p>You can return to the <a href="/wiki">FrontPage</a> </p>
      </div>            

      <div id="main">
        <%= yield %>
      </div>

    </body>
    </html>
  

Nous demandons juste l’inclusion d’une feuille de style nommée wiki.css (et que l’on devra créer dans public/stylesheets), et l’insertion des pages que nous créons en lieu et place de yield. Nous paramètrisons aussi le nom de l’action à afficher: par défaut ce sera “Viewing” mais on peut changer cela en affectant l’action voulue à la variable @page_action.

Créons une feuille de style simple (public/stylesheets/wiki.css):

  body {
    font-family: sans-serif;
  }

  div#sidebar {
     border: 2px solid red;
  }

  span#name {
     color: blue;
  }

et modifions index.rhtml pour ressembler à:

 <div><%= textilize(@page.content) %></div>
 

Nous pouvons maintenant recharger notre page:

On peut maintenant se pencher sur l’ajout de nouvelles pages. La page qui va nous servir à éditer nos pages est simple: elle contient seulement un formulaire, qui réveillera l’action save, avec une zone texte (textarea) et un bouton pour soumettre le formulaire. Créons donc la template, nommée app/views/wiki/edit.rhtml:

  <%= form_tag :action => "save" , :id => @page %>
    <%= text_area 'page', 'content' %>
    <%= submit_tag %>
  <%= end_form_tag %>

et ajoutons à la fin de nos page (via la template index.rhtml), un lien “edit this page”, pointant sur la page edit.html:

<%= link_to "Edit this page", { :action => "edit", :name => @page.name } %>

Nous avons aussi besoin d’une méthode edit dans notre contrôleur:

def edit
  @page_action = "Editing" 
  @page = Page.find_by_name( params[:name] )
end

Pour faire court: nous définissons l’action courante à “Editing” (afin de changer temporairement le layout), et recherchons dans la BDD une page ayant le même nom que celui donnée par l’utilisateur.

Nous pouvons maintenant recharger notre page et cliquer sur le lien “Edit this page” :

Sauver nos modifications

Nous avons vu que notre formulaire était posté à l’action save. Nous devons donc l’ajouter à notre contrôleur, et elle doit sauvegarder nos changements dans la BDD, et nous avertir si un problème survient.

Commençons par l’ajouter à notre contrôleur (app/controllers/wiki_controller.rb):

   def save
     @page = Page.find( params[:id])
     @page.content = params[:page][:content]
     if @page.save
       flash[:notice] = "Page successfully saved" 
     else
       flash[:notice] = "Unable to save edited page" 
     end
     redirect_to( :action => "index", :name => @page.name )
   end
   

Pour résumer: nous récupérons dans la BDD, la version originale de la page que nous venons d’éditer (en utilisant son id: params[:id]), et nous modifions son contenu avec celui que le formulaire nous a envoyé. Nous sauvons alors cette nouvelle version dans la BDD (méthode save du modèle), et updatons la notification pour informer l’utilisateur du succès ou de l’échec de la sauvegarde. Nous le redirigeons alors vers la page qu’il vient juste d’éditer.

Ajoutons à notre layout de quoi afficher la notification:

   <div id="flash"><%= flash[:notice] %></div>
   

ainsi que ceci à la feuille de style wiki.css:

   div#flash {
      background: #E0F8F8;
      border: 2px solid #244;
      width: 80%;
   }      
   

et voilà:

URLs sympas

Afin d’afficher la page Foo, l’utilisateur doit donc demander l’URL /wiki/index.html?name=Foo, ce qui n’est pas très sexy. Le mieux serait d’avoir quelque chose comme /wiki/Foo. RoR utilise les différents constituants d’une URL pour indiquer le contrôleur et la méthode à utiliser, cela reviendra à essayer d’envoyer le message Foo à notre contrôleur, message qui n’existe pas.

La solution la plus simple est de définir une méthode method_missing pour notre contrôleur: elle sera appelée dès qu’un message inconnu est envoyé au contrôleur … et nous pouvons essayer de la rediriger vers notre méthode index avec le bon paramètre:

  def method_missing( *args )
    redirect_to( :action => 'index', :name => args[0] )
  end
  

Et les WikiWords?

Les WikiWords sont des mots assemblés ensembles dans un CamelCase typique. N’importe quel wiki se respectant créera automatiquement un lien pour chacun de ces mots, pointant vers une page du même nom.

Ça semble assez simple: nous devons juste construire l’expression rationnelle adéquate, et éditer le contenu au vol, pour ajouter les liens.

D’abord l’expression rationnelle. CamelCase peut se décrire comme “une lettre majuscule suivie de n’importe quel lettre ou chiffre, ceci répété au moins une fois”, ce qui exprimé en tant qu’expression rationnelle donne:

CamelCase = Regexp.new( '\b((?:[A-Z]\w+){2,})' )

Maintenant, nous devons juste transformer chaque mot correspondant à notre expression rationnelle en un lien, soit dans le contenu de la page, soit dans sa version post-textilisée:

   def index
     @page = Page.find_by_name(  params[:name] || 'FrontPage' )
     @page.content.gsub!( CamelCase, '<a href="/wiki/\1">\1</a>' )
   end
   

Testons:

Eh, où est la page?

Maintenant nous devons aussi tester l’existence ou la non-existence d’une page. Par exemple si vous cliquez sur le lien précédent “WikiWords”, vous obtiendrez une erreur, notre contrôleur cherchant une page nommée WikiWords dans la BDD, et cette page n’existant pas.

Notre approche du problème sera la suivante: si index n’arrive pas à trouver la page demandée dans la BDD, il redirigera la requête vers la méthode edit. La méthode edit en retour, sera modifiée pour créer une nouvelle page si elle ne trouve pas celle demandée dans la BDD, puis l’éditer. Notez que nous utilisons la méthode create de notre modèle au lieu de la méthode new: create crée l’objet en mémoire et le sauve dans la BDD, alors que new ne crée que l’objet en mémoire, déferrant son enregistrement dans la BDD à l’appel de save.

     def index
       @page = Page.find_by_name(  params[:name] || 'FrontPage' )

       if @page.nil?
         redirect_to( :action => 'edit', :name => params[:name] )
       else
         @page.content.gsub!( CamelCase, '<a href="/wiki/\1">\1</a>' )
       end

     end

     def edit
       @page_action = "Editing" 
       @page = Page.find_by_name( params[:name] ) || Page.create( :name => params[:name] )
     end
     

Ajouter une liste des pages

Comme nous développons un gentil wiki, nous voulons être capable de fournir à nos utilisateurs une liste des pages disponibles. Nous allons donc créer une nouvelle action, list, et sa vue associée list.rhtml.

Donc dans app/controllers/wiki_controller.rb ajoutons:

    def list
      @pages = Page.find_all
    end
  

list se contente d’obtenir la liste de toutes les pages disponibles dans la BDD et d’affecter cette liste à la variables @pages. Créons aussi le fichier app/views/wiki/list.rhtml avec le code suivant:

    <h1>All Of Your Pages</h1> 
    <ul> 
      <% for page in @pages %>
      <li><a href="/wiki/<%= page.name %>" ><%= page.name %></a></li> 
      <% end %>
    </ul> 
  

Pour chacun des éléments de la variables pages nous ajoutons une entrée à la liste, cette entrée étant un lien vers la page en question. Nous devons aussi ajouter un lien vers notre nouvelle fonctionnalité dans notre layout wiki.rhtml, par exemple dans la barre droite:

   <p>View the <%= link_to "complete list of pages", :action => 'list' %></p>
 

I.e. nous demandons l’ajout d’un tag lien (a en HTML) qui nous redirigera vers l’action list (i.e. l’url /wiki/list).

Rechargeons la page index.html dans notre browser, et cliquons sur notre nouveau lien:

Vous voulez de l’AJAX? On a de l’AJAX!

OK. le propos de cette section est de modifier notre liste de page, ou plutôt la façon dont elle s’affiche. Plutôt que de voir cette liste s’afficher dans sa propre page, nous voulons qu’elle apparaisse automagiquement sous notre lien “View the complete list of pages”. C’est très simple en utilisant AJAX et son support dans RoR.

Nous allons d’abord modifier notre layout wiki.rhtml pour inclure la librairie javascript “prototype:http://prototype.conio.net/ (qui est fournie avec RoR). Ajoutons donc a notre layout:

<%= javascript_include_tag "prototype" %>

juste sous la ligne <%= stylesheet_link_tag "wiki" %>.

Nous devons maintenant modifier le lien “View this complete list of pages” en quelque chose comme:

    <p>View the <%= link_to_remote "complete list of pages", 
                                   :update => "pagelist",
                                   :url => { :action => 'list'}

                 %></p>
    <ul id="pagelist"></ul>
  

Nous demandons donc la création d’un tag de lien, qui effectuera une requête AJAX au serveur. Ce lien appellera la méthode list de notre contrôleur, et modifiera l’élément DOM nommé pagelist.

Nous devons donc maintenant modifier notre action list pour pouvoir répondre à une requête AJAX. Bien que RoR donne un moyen de savoir, à l’intérieur d’une méthode du contrôleur si elle a été appelée via une requête AJAX ou une requête rationnelle (ceci afin de pouvoir supporter les deux), nous modifierons list pour répondre seulement à des requêtes AJAX.

Plusieurs solutions s’offrent à nous maintenant: soit créer directement l’HTML à afficher dans l’action list (et violer le concept MVC), ou utiliser des partials. Les partials sont des templates qui seront insérées là ou elles sont appelées. Elles sont souvent utilisées pour afficher une information qui doit avoir le même look en plusieurs endroits (par exemple une liste de pages peut apparaître sur plusieurs pages ou sous-pages, et utiliser les partials permet de ne définir la façon dont elles apparaitront dans une seule template).

Utilisons donc une partial. Le nom d’une partial commence toujours avec un souligné, même si ce souligné n’apparait pas quand nous appelons cette partial. Les partials se trouvent dans le même répertoire que les autres templates (app/views/wiki dans notre exemple). la fonction render permet de rendre des partials de façon simple, et prend aussi en compte les collections/listes (ce qui est pratique dans notre cas):

   def list
     render( :partial => "page", 
             :collection => Page.find_all,
             :layout => false            
           )
   end
 

Nous demandons donc le rendu de la partial nommée _page.rhtml, en utilisant la collection Page.find_all, tout ceci sans layout (comme nous allons insérer ce rendu dans une page contenant déjà un layout). Ainsi la partial _page.rhtml sera appelée (et évaluée) pour chaque élément de la collection (i.e. chaque page). Dans la partial on pourra se référer à chacun de ces éléments sous le nom page (i.e. le nom de la partial).

Créons maintenant app/views/wiki/_page.rhtml:

   <li><%= link_to page.name, :action => "index", :name => page.name %></li>
 

Et c’est tout:

Une dernière chose … Supposons que nous voulions que notre liste apparaisse de façon progressive … Il suffit de changer l’élément pagelist en:

    <ul id="pagelist" style="display: none;"></ul>
    

afin qu’il soit tout d’abord invisible, puis d’ajouter la librairie javascript effects:

<%= javascript_include_tag "prototype", "effects" %>

et enfin de changer le link_to_remote en:

    <p>View the <%= link_to_remote "complete list of pages", 
                                   :update => "pagelist",
                                   :url => { :action => 'list'},
                                   :complete => "new Effect.Appear('pagelist')" 

                 %></p>
  

et c’est tout !!! En fait, pagelist sera updaté, mais étant invisible vous ne verrez pas les changements … jusqu’à ce que la requête AJAX soit terminée (completed) et que le morceau de javascript (new Effect.Appear('pagelist')) ne soit appelé. Il affichera alors de façon progressive l’élément pagelist.

Un auteur?

Bon … tout ça c’est bien joli, mais … il faudrait quand même que les pages aient un auteur ….

Ce qui veut dire que notre modèle Page doit maintenant avoir un autre champ: author ... et que donc le schéma de notre BDD doit changer.

Pour ceci, il y a en gros deux solutions:

Comme nous avons choisi de créer notre schéma initial avec RoR, et que les migrations permettent de changer de façon simple entre l’une ou l’autre version de notre BDD, nous allons les utiliser.

Tout d’abord il faut bien noter une chose: nous ne voulons pas seulement modifier notre modèle/schéma, mais aussi garder les éléments présent dans notre BDD, tout en donnant une valeur par défaut au nouveau champ …

Commençons par créer une migration, et donnons lui le nom add_author:

  script/generate migration add_author
        create  db/migrate
        create  db/migrate/001_add_author.rb
  

Vous pouvez remarquer que notre migration a été numérotée (ce qui permet d’appliquer les migrations dans l’ordre quand nous en aurons plusieurs). Regardons le fichier créé, correspondant à notre migration (db/migrate/001_add_author.rb):

    class AddAuthor < ActiveRecord::Migration
      def self.up
      end

      def self.down
      end
    end
  

Nous avons donc deux méthodes:

Dans notre cas, dans up nous devons ajouter une colonne (author) (qui sera une chaine de caractères, avec une valeur par défaut fixée à “AnonymousCoward”) et décider que toutes les pages déjà existantes auront un auteur nommé ‘Fred’.

  def self.up
    add_column :pages, :author, :string, :default => "AnonymousCoward" 
    Page.find( :all ).each {|p| p.update_attribute :author, "Fred" }
  end

En ce qui concerne down, on indiquera juste de détruire la colonne author:

  def self.down
    remove_column :pages, :author
  end

Nous pouvons alors exécuter notre migration:

rake migrate

et vérifier que notre page FrontPage a bien maintenant un auteur nommé ‘Fred’:

    > script/console
    Loading development environment.
    >> Page.find_by_name( 'FrontPage' )
    => #<Page:0xb7473eac @attributes={"name"=>"FrontPage", "author"=>"Fred", "id"=>"2", "content"=>"Welcome to *my* __super__ front page now with WikiWord"}>
    >>
  

Et voilà :)

Si nous regardons notre schéma db/schema.rb, nous nous apercevons qu’il n’a pas été mis à jour. Qu’à cela ne tienne. Nous devons d’abord indiquer à RoR d’utiliser Ruby comme format par défaut pour le schéma. Cela se fait en éditant le fichier config/environment.rb, de façon à faire apparaître/dé-commenter la ligne:

config.active_record.schema_format = :ruby

puis en ré-appliquant notre migration:

rake migrate

Si nous regardons maintenant notre fichier db/schema.rb:

 ActiveRecord::Schema.define(:version => 1) do

   create_table "pages", :force => true do |t|
     t.column "name", :string, :limit => 30
     t.column "content", :text
     t.column "author", :string, :default => "AnonymousCoward" 
   end

 end
 

On peut aussi remarquer qu’un numéro de version pour notre schéma est apparu ….

Une date de création?

TODO.


Generated on Tue Sep 19 23:19:58 UTC 2006