I'll write code that writes code for food

le 29/03/2011 par Vincent Coste
Tags: Software Engineering

Derrière ce titre un peu provocateur (je code pour le plaisir voyons, pas pour manger!) se cache le fameux mythe de l’AGL et de la génération de code a partir de spécifications.

Nous n’allons pas parler de cela. Non, le but ici est de vous initier aux arcanes des moteurs (engines) et générateurs (generators) en ruby on rails, le framework de dévelopement web dont tous les hype kid coders sont fans.

Un moteur, en quelques mots et grossièrement, est une application rails (rails lui même est un engine dans la version 3) que vous rajoutez a votre application rails. Il peut contenir des modèles, des vues et des contolleurs. En fait, c'est comme si vous aviez deux applications rails qui cohabitaient dans une seule et même application.

Un générateur... génère du code.

Du choix d’un sujet (contexte)

Un article technique à ceci de complexe qu’il faut choisir un sujet. Je suis parti d’autre chose: d’un sujet pour en faire un article. J’ai été récemment confronté à un problème très commun pour ceux qui font du web B2C: il me fallait ce système de badge et réputation pour mon site, depuis le temps que je vois les foursquare et autres stackOverflow. Le souci, c’est que j’ai 50 badges à faire. En creusant un peu de ci de là, j’ai trouvé des façons très élégantes de gérer ça dont celle ci, qui m’a (très) fortement inspirée: http://stackoverflow.com/questions/885277/how-to-implement-an-achievement-system-in-ror .

Pour clarifier, qu'est ce qu'un badge? Le badging est une technique de web marketing, basée sur le jeu. Le but est de récompenser l'utilisateur pour certaines de ses actions sur le site: commentaires, connexions etc. Ex: Lors de son premier commentaire sur un autre utilisateur, je le récompense du badge: First Comment! Les objectifs sont évidemment de pousser les utilisateurs à certaines actions qui servent la qualité de mon site (ici, qualification des autres utilisateurs).

Et là je me suis dit que j’étais face à un cas parfait où j’allais avoir besoin d’un Engine (pour rajouter des comportements à mon application) et d’un Generator (pour générer un maximum de code pour mes badges).

Allez, commençons à l’implémenter ensemble.

Disclaimer et prérequis: Notre objectif ici c’est pas de faire le plus beau code, ni de montrer l’ensemble des tests. Nous partons du principe que vous connaissez RVMBundler et RSpec et bien sur Ruby. Si vous ne voulez pas lire la suite mais voulez voir le code resultant: badgr .

Générons la structure de notre GEM

Installez ces 2 gems dans votre gemset en tapant en console:

$ gem install jeweler --pre
$ gem install enginex

Et générez la structure de votre Engine:

$ enginex badgr -t rspec

La sortie standard devrait vous indiquer quels fichiers ont été crées.

Modifiez votre Gemfile pour qu’il ressemble à celui ci: https://github.com/Pasta/badgr/blob/master/Gemfile

Notez les webrat et autres capybara. Nous les retirerons en temps et en heure car nous n'en aurons pas besoin.

Essayez de lancer les tests comme suit après avoir fait un

$ bundle install
$ rspec spec -cf nested

vous devriez voir ceci:

Nos premiers tests passent. Mais... Comment? Je veux dire, c’est un Engine, il ne peut pas vivre tout seul, il lui faut un VRAIE application rails avec laquelle tourner.

C’est là que vous regardez dans le dossier spec et que vous voyez un dossier dummy. C’est une application rails générée et configurée pour utiliser votre Engine par Enginex. Facile et utile.

Bon, pour finir le packaging de notre Engine, il nous reste deux/trois fichier à rajouter/modifier. Pour cela, utilisons jeweler qui va nous générer un Engine lui aussi, mais avec des paramètres qui nous correpondent mieux:

$ jeweler badgr --rspec

Copiez maintenant le contenu du Rakefile, Licence et .document dans votre Engine. Vous pouvez supprimer tout ce qui à été généré par jeweler à présent. N’hésitez pas à modifier le Rakefile pour rajouter des informations qui vous semblent importantes pour votre Engine.

Pour tester que tout c’est bien passé:

$ rake gemspec

Vous devriez maintenant avoir une arboresence comme suit:

De l’utilisation de notre Engine dans une application rails

Bon, tout est en place, maintenant, nous voulons pouvoir “étendre” une application. Pour ça nous allons commencer par définir notre Engine en créant un fichier dans lib/badgr nommé badger.rb et en rajoutant ceci:

module Badgr
    require 'badgr/engine' if defined?(Rails)
end

Puis créez dans un fichier dans lib/badgr/ nommé engine.rb et contenant:

module Badgr
    class Engine < Rails::Engine
    end
end

Cela dit, nous aurions pu directement mettre le contenu de ce fichier dans le badgr.rb.

Si vous ne voulez pas utiliser capybara (framework de test), retirez les lignes contenant le mot dans le fichier spec/spec_helper.rb.

Bon, nous allons enfin pouvoir commencer à coder notre système de badges. En commençant par les tests.

Oh my god a double rainbow!

Créons un fichier dans spec/models nommé achievement_spec.rb. Nous allons ici tout simplement définir la création d'un achievement et comment elle devrait se dérouler:

require 'spec_helper'
    describe Achievement do

        describe "creation" do

            it { should belong_to(:user) }

            it "should be possible to create an achievement" do
                achievement = Achievement.new
                achievement.should be_valid
                achievement.save!

                assert_equal 'Achievement', achievement.type
            end
         end
     end

Lancez cette commande pour executer les tests :

$ rspec spec -cf nested

et regardez le test fail. Pour le voir fail en couleur, mettez vous à la racine de votre projet et

$ echo “--color” > .rspec

Maintenant, créez un fichier dans app/models nommé achievement.rb et qui contiendra votre modèle. Ici, nous ne voulons pas faire de l'ultra générique. Nous voulons juste que nos achievements appartiennent à notre utilisateur. Faites passer votre test au vert en rajoutant le contenu:

class Achievement < ActiveRecord::Base
    belongs_to :user
end

Profitez en pour créer un fichier dans votre application dummy, dans le dossier app/models nommé user.rb (qui rappellez vous, vous sert à simuler ce que vous ferez dans la véritable application qui utilisera votre engine). Ici, nous voulons juste un utilisateur, rien de plus.

class User < ActiveRecord::Base
end

Et en créant une migration dans votre application dummy (dans le dossier db/migrate) qui ressemble à ça

class CreateAchievement < ActiveRecord::Migration
    def self.up
         create_table :achievements do |t|
              t.integer :user_id
              t.timestamps
         end
     end
     def self.down
         drop_table :achievements
     end
end

Profitez en rajouter une qui contient la création de la table user:

class CreateUser < ActiveRecord::Migration
    def self.up
        create_table :users do |t|
             t.timestamps
        end
     end
     def self.down
        drop_table :users
     end
end

Relancez rspec spec et regardez le tout passer au vert. C’est beau. Mais ça ne fait rien. Ce qu’on aimerait vraiment, c’est qu’en un minimum de lignes, on puisse:

  1. Ajouter une condition pour donner un badge à un utilisateur
  2. Donner un badge a notre utilisateur

Oh, starting to be a TRIPLE RAINBOW! Ecrivons donc le test correspondant au point numéro 2, que nous allons mettre dans spec/models/achievements_spec.rb . Nous allons décrire ici comment nous voulons récompenser un utilisateur avec un badge. Pourquoi pas avec un simple User#award_with ? Profitons en pour décrire une methode qui va nous permettre de vérifier si un utilisateur à déjà un badge.

require 'spec_helper'

describe Achievements do
    before do
      @user = User.new
      @user.save
    end
    it "should be able to be awared" do
      @user.award_with Achievement

      assert @user.has_achievement?(CommentsAchievement, 1)
    end
end

Comme d’hab, rspec spec, and watch it FAIL. Maintenant, let’s the ruby magic play. Nous voulons rajouter la methode d’instance award_with(Achievement) à notre utilisateur. Mais, nous n’avons pas accès à la classe, vraiment, quand quelqu’un utilisera notre engine. Disons, que nous aimerions que l’utilisateur de notre Engine ai le minimum de choses à toucher dans son code. Nous voulons donc pouvoir le rajouter à la volée. C’est là que le concept de mixin intervient. (Si vous n’êtes pas familier, just google it, ou essayez de comprendre les prochaines lignes de code, c’est pas bien compliqué).

Nous allons donc utiliser ActiveSupport::Concern qui va nous permettre de créer un joli mixin. Ce qui sera défini dans included do end sera exécuté au moment de l'inclusion du module et le reste sera considéré comme des methodes d'instances (pour rajouter des méthodes de classe cf ce post:http://goo.gl/sQn7m).

Créez un fichier dans app/models nommé achievements.rb (à coté de achievement.rb) et écrivez ceci:

module Achievements
    extend ActiveSupport::Concern
    # sera executé a l'inclusion
    included do
       has_many :achievements
    end
    # seront ajoutés comme methodes d'instances
    def award_with(achievement, options = {})
       achievement.create!(:user => self, :level => options[:level])
    end

    def has_achievement?(achievement, level = nil)
       conditions = {:type => achievement.to_s, :user_id => self.id}
       conditions[:level] = level if level
       achievements.first(:conditions => conditions).present?
    end
end

La méthode de classe self.included est appellée quand un module est invoqué dans une classe (en faisant un include Achievement par exemple). Et nous rajoutons donc à cette classe... has_many :achievements. Les deux méthodes après sont finalement assez simple à comprendre. Allons maintenant dans notre classe user de notre application dummy, et agissons comme si nous étions dans notre future application, et rajoutons:

class User < ActiveRecord::Base
    include Achievements
end

Relancez vos tests, magie. C’est vert. Ce qui signifie, que grâce à ce minuscule ajout d’un include Achievements, l’utilisateur de votre engine peut maintenant ajouter des badges à ses utilisateurs. Je sais pas vous, mais moi, je trouve ça carrément trop beau. Un peu comme un double rainbow. Même un triple rainbow! Mais tous ça, ne crée pas d’intelligence, ni de condition pour donner un badge.

Un peu d’intelligence (testée bien sur!).

Ici, nous n’allons pas écrire de code, mais spécifier par l’exemple à quoi ressembleront nos futurs badges. Je répète: nous voulons tester ce à quoi vont ressembler nos futurs badges. Mais comme ils seront générés, impossible de savoir ce qu'ils seront. Nous allons donc en écrire un directement dans le test. Créez un fichier dans spec/models nommé achievement_user_spec.rb dans Et mettez ça:

require 'spec_helper'

describe Achievement do
    # le fameux badge qui sera peut etre un jour généré
    class TwoCommentsAchievement < Achievement

       def self.award_achievement_for user
          return unless user
          # pensez à rajouter attr_accessors :comment_number_test  dans votre modèle User
          if user.comment_number_test == 2
             user.award_with(self)
          end
       end
     end

     describe "simple achievement" do

       it "should award user with two comments achievement" do
         user = User.new
         user.comment_number_test = 2
         TwoCommentsAchievement.award_achievement_for user

         user.achievements.size.should == 1
         user.achievements[0].type.should == "TwoCommentsAchievement"
       end
     end
end

Ici, nous créons un badge pour les besoins du test. Ces badges seront générés par notre futur super générateur, mais chut, c’est pour plus tard. Lancez les tests. Ca marche, évidemment, tout est dans le code du test. Mais n’oublions pas, les tests sont aussi des specs, et nous serviront pour créer l’ossature de nos fichiers générés. D’ailleurs, on le fait quand ça? I’ll definitely write code that write code for fame (code me, I’m famous) Bon, que veut on générer pour notre utilisateur?

  1. Les migrations pour le modèle Achievement (à l’installation)
  2. Trois fichiers pour chaque badge crée
    1. Le modèle
    2. Le test du modèle
    3. Un observer

Commençons par l’install. Nous voulons que l’utilisateur, après avoir rajouté gem ‘badgr’ dans son gemfile, fasse un

$ rails g badgr

et que ça génère les migrations. Créez l’arborescence suivante dans lib/ lib/ badgr/ templates/ USAGE badgr_generator.rb achievement/ templates/ USAGE achievement_generator.rb Les dossiers template contiendront les “templates” de fichiers que nous allons “copier” sur demande dans l’application de l’utilisateur. Les fichiers rb contiennent la “logique” de génération. Nous ne montrerons pas tous les fichiers templates ici, par commodité de lecture. Si vous voulez voir tous les fichiers, reférez vous au lien vers mon engine en préambule. Créons donc notre migration: Dans badgr_generator:

require 'rails/generators'
require 'rails/generators/migration'

class BadgrGenerator < Rails::Generators::Base
    include Rails::Generators::Migration
    def self.source_root
       @source_root ||= File.join(File.dirname(__FILE__), 'templates')
    end

    def self.next_migration_number(dirname)
       if ActiveRecord::Base.timestamped_migrations
         Time.new.utc.strftime("%Y%m%d%H%M%S")
       else
         "%.3d" % (current_migration_number(dirname) + 1)
       end
    end

     def create_migration_file
        migration_template 'migration.rb', 'db/migrate/create_achievement_table.rb'
      end

    def show_readme
       readme "README" if behavior == :invoke
    end

end

La première chose importante à savoir, c’est que les classes qui étendent Generators sont exécutés ENTIEREMENT. On n’appelle pas une méthode en particulier en faisant rails g badgr, mais bien le fichier qui sera exécuté dans son ensemble. def self.source_root sert à définir là ou seront les fichier templates à aller chercher def self.next_migration_number(dirname) sert a définir comment seront générés les id de migration def create_migration_file est la méthode qui contient la logique de génération Le fichier template/create_achievement_table.rb contient:

class CreateAchievementTable < ActiveRecord::Migration
  def self.up
    create_table :achievements do |t|
      t.integer :user_id
      t.string  :type
      t.string :name
      t.integer :level
      t.timestamps
    end
  end

  def self.down
    drop_table :achievements
  end
end

Rien de fou. Lancez maintenant votre rails g badgr à la racine de votre dummy applciation,   et regardez le fichier se créer. Même pas un simple rainbow, mais coloré quand même. Passons à quelque chose de plus intéressant, le contenu du fichier achievement_generator.rb:

class AchievementGenerator < Rails::Generators::NamedBase
  source_root File.expand_path('../templates', __FILE__)

  def generate_achievement
    template "achievement.rb", "app/models/achievements/#{name.underscore}_achievement.rb"
    template "achievement_observer.rb", "app/models/achievements/#{name.underscore}_achievement_observer.rb"
    template "achievement_test.rb", "test/unit/achievements/#{name.underscore}_achievement_test.rb"
  end

  def show_readme
    readme "README" if behavior == :invoke
  end
end

Ici, vous remarquerez que nous n’étendons pas Base, mais NamedBase, ce qui signifie que nous attendons un nom après notre générateur:

$ rails g achievement FirstCommentOnSite

On voit bien que template() prend deux arguments, en premier le template, en second, le fichier de destination. Intéressant de voir que le FirstCommentOnSite sera passé à travers la variable name (c’est overridable bien sur, et on peut rajouter plus d’arguments si besoin). Voyons maintenant le contenu d’un de nos templates, l’observer, comme l’achievement, nous en avons eu un aperçu dans nos specs. Ici, nous voulons bien créer un fichier qui sera le modèle de TOUS nos observers:

</pre>
class <%= name.camelize %>AchievementObserver < ActiveRecord::Observer
 observe :some_model
 def after_create(some_model)
 <%= name.camelize %>Achievement.award_achievement_for(some_model.user) if ('your conditions here')
 end
end

Nous retrouvons de nouveau la variable name, qui va nous permettre de personnaliser notre classe.

Lancez un

$ rails g achievement FirstCommentOnSite

Et vous devriez voir se générer les fichiers au bon endroit (app/models).

Voilà, écrivez un joli readme, et poussez ça sur github, prêt à le partager.

J’espère que vous trouverez pleins d’utilisations à ces engines. En effet le système permet d’imaginer un ensemble de “composants”, marchant les une à côté des autres, les uns avec les autres, réutilisables à l’infini, pour, toujours avec les nouveaux frameworks webs, aller de plus en plus vite.

J'aime particulièrement "Devise", qui permet de mettre un système d'authentification en deux temps trois mouvements (pour peu que vous l'ayez déjà utilisé, sinon 4/5). Générez votre système, incluez le module, générez vos vues, et c'est parti, vos utilisateurs peuvent commencer à se connecter.