I'll write code that writes code for food
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 RVM, Bundler 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:
- Ajouter une condition pour donner un badge à un utilisateur
- 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?
- Les migrations pour le modèle Achievement (à l’installation)
- Trois fichiers pour chaque badge crée
- Le modèle
- Le test du modèle
- 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.