Dans notre précédent billet, nous nous avions constaté que les gems Ruby permettant de décorer des objets ActiveRecord ne nous conviennent pas à 100%. Chez Level UP, on apprécie les solutions simples, efficaces et facile à maintenir. Voyant que réaliser une solution maison ne serait pas compliqué, nous l'avons développée.

Helpers et décorateurs

Les décorateurs allègent les modèles de tout ce qui est lié à la présentation de données, autrement dit les vues, avec Rails. Ils limitent l'utilisation des helpers, parfois difficiles à organiser et tester. Essentiellement dans les vues, l'appel à une méthode “décorant” un modèle permet également d'alléger les vues de code liées à la construction de structures ou d'itération permettant d'obtenir un résultat consolidé.

Historiquement Rails propose pour cela d'utiliser des helpers. Ces derniers sont effectivement très pratiques pour extraire de la complexité inutile dans les vues, mais ayant une portée globale ne sont pas toujours pratiques, faciles à tester ou efficaces lorsque l'on travaille sur des objets ActiveRecord.

SimpleDelegator : un début de solution, natif à Ruby

Ruby propose depuis très longtemps1 la classe SimpleDelegator qui permet de déléguer l'appel de méthodes à un objet passé en paramètre :

require "date"
require "delegate"

# app/models/user.rb
class User
  def born_on
    Date.new(1989, 9, 10)
  end
end

# app/decorators/user_decorator.rb
class UserDecorator < SimpleDelegator
  def birth_year
    born_on.year
  end
end

decorated_user = UserDecorator.new(User.new)
decorated_user.born_on    #=> 1989-09-10
decorated_user.birth_year #=> 1989

C'est ni plus ni moins ce qu'il nous fallait. Notez que ce patron de conception n'est pas réservé aux modèles ActiveRecord et peut s'appliquer décorer n'importe quel objet Ruby. Nous l'utilisons par exemple sur d'autres projets basés sur Hanami et Sinatra.

Dans une application Rails où le framework apporte beaucoup de “magie”, ce n'est pas suffisant. Pour une utilisation dans les vues de l'application, il manque l'accès aux helpers de Rails les plus courants : link_to, content_tag, url_for, etc.

Ces helpers sont disponibles via ActionView::Base. Une solution serait de préfixer tous les appels aux helpers, mais nous perdrions une notion essentielle dans Rails, le contexte. Nous perdrions aussi l'accès aux helpers que nous avons définis dans app/helpers.

Le contrôleur fournit ce contexte aux vues via la méthode view_context. Par défaut renvoie une instance de ActionView::Base. Le monde Rails est bien fait !

ApplicationDecorator : notre décorateur adapté à Rails

Notre première implémentation de ressemblait donc à ceci :

# app/decorators/application_decorator.rb
class ApplicationDecorator < SimpleDelegator
  DEFAULT_VIEW_CONTEXT = ApplicationController.new.view_context

  def initialize(model, view_context = nil)
    super(model)
    @view_context = view_context || DEFAULT_VIEW_CONTEXT
  end
end

# app/decorators/user_decorator.rb
class UserDecorator < ApplicationDecorator
  def register_link
    @view_context.link_to "Inscrire l'utilisateur", @view_context.register_user_path(user_id: id)
  end
end

Un surcharge de SimpleDelegator qui ajoute l'accès au contexte de vue permettant d'appeler les helpers de Rails avec le préfixe : view_context.link_to par exemple. Un fallback sur le contexte par défaut fournit par ApplicationController permet de résoudre d'éventuels cas ou le contexte ne serait pas passé ou pas disponible.

L'appel à ce décorateur dans nos vues se déroule donc de la sorte :

<!-- app/views/users/_user_with_test.html.erb -->

<% decorated_user = UserDecorator.new(@user) %>
<p>
  <%= decorated_user.fullname %>
  <% unless decorated_user.registered? %><%= decorated_user.register_link %>
  <% end %>
</p>

La structure de contrôle de l'affichage ou pas du lien d'inscription peut même être gérée au sein de la méthode décorée :

# app/decorators/user_decorator.rb

class UserDecorator < ApplicationDecorator
  def register_link
    return if registered?

    view_context.link_to "Inscrire l'utilisateur", view_context.register_user_path(user_id: id)
  end
end

Ce qui permet une réelle simplification de la vue qui n'a pas à se soucier de l'état de l'utilisateur.

<!-- app/views/users/_user_with_test.html.erb -->
<% decorated_user = UserDecorator.new(@user) %>

<p><%= decorated_user.fullname %> <%= decorated_user.register_link %></p>

Tester les décorateurs

Pour tester unitairement les méthodes de nos décorateurs, il suffit d'hériter le test de ActionView::TestCase, et de passer au décorateur le view_context pour le test qui est disponible via la variable view :

require "test_helper"

class UserDecoratorTest < ActionView::TestCase
  def setup
    @user           = User.new(name: "Alice")
    @decorated_user = UserDecorator.new(@user, view)
  end

  def test_register_link
    assert_includes @decorated_user.register_link, @user.name
    assert_includes @decorated_user.register_link, "Inscrire"
  end
end

Fin de la deuxième partie

Grâce à ce petit PORO2, nous avons pu remplacer la gem Draper et réussi la montée en version d'une grosse application Rails vers la 5.0 dans un temps raisonnable. Dans une dernière partie, nous verrons les petites surprises que nous avons toutefois rencontrées et comment nous les avons contournées, ainsi que les dernières améliorations que nous avons apportées à notre ApplicationDecorator afin qu'il répondent parfaitement à l'utilisation que nous avions des décorateurs issus de Draper.


  1. au moins depuis la version 1.8, les documentations des versions précédentes n'étant plus disponibles en ligne. Son utilisation a été grandement simplifiée à partir de Ruby 2.1. 

  2. Plain Old Ruby Object. On a souvent tendance à vouloir utiliser des bibliothèques ou des fonctions avancées des frameworks, alors que la plupart du temps, Ruby se suffit amplement à lui-même !