Usando presenters no Rails


Leia em 4 minutos

Um problema muito recorrente de aplicativos de médio-grande porte é que as views são uma bagunça.

É muito comum termos condições em nossas views. Objetos possuem diferentes estados e muitas vezes precisamos mostrar esses estados visualmente. E normalmente começamos pelo caminho mais fácil, que é adicionar ifs na view.

Acontece que isso não precisa ser sempre assim. Neste artigo vou mostrar como funciona um pattern chamado Presenter, que permite diminuir/remover a complexidade de views e controllers.

Começando com o seu próprio presenter

A primeira coisa que você precisa detectar é que tipo de lógica é possível extrair de sua view. Algumas coisas mais genéricas fazem mais sentido serem extraídas como helpers. Outras, como ifs para determinar qual partial deve ser renderizada provavelmente devem ser movidas para seu presenter.

Imagine que você tenha uma view como esta:

<h1><%= @product.name %></h1>

<% if @product.description %>
  <p class="description"><%= @product.more %></p>
<% end %>

Não se deixe enganar por esse tipo de lógica. Embora pareça inofensiva, coisas como esta podem sair do controle rapidamente.

Esta view precisa de uma variável @product que deve ser definida em nosso controller:

class ProductsController < ApplicationController
  def show
    @product = Product.find(params[:id])
  end
end

Agora, precisamos de uma classe que irá “envelopar” nossa instância da classe Product. Crie o arquivo app/presenters/product_presenter.rb. Como este diretório não está no load path do Rails, vamos ter que fazer uma configuração no arquivo config/application.rb.

module HOWTO
  class Application < Rails::Application
    # ...
    config.autoload_paths << config.root.join("app/presenters").to_s
  end
end

Volte ao arquivo product_presenter.rb e crie a classe ProductPresenter.

class ProductPresenter
  def initialize(product)
    @product = product
  end
end

Esta classe será responsável por expor todos os atributos que nossa view irá acessar. Em vez de definir cada um dos métodos manualmente, podemos apenas delegar as chamadas para o objeto. Para fazer isso, vamos usar o método Module#delegate, adicionado pelo ActiveSupport.

class ProductPresenter
  delegate :name, :description, to: :"@product"

  def initialize(product)
    @product = product
  end
end

Qualquer chamada aos métodos ProductPresenter#name e ProductPresenter#description serão delegadas para o objeto que foi armazenado em @product. Você também poderia utilizar o módulo Forwardable, mas a versão adicionada pelo ActiveSupport é mais elegante.

Agora, podemos remover aquele if. Se você não se lembra mais dele, dê uma última olhadela, pois logo ele não mais existirá! Adicione o método description. Faça com que este método retorne o parágrafo com a descrição, caso ela tenha sido definida. Como é necessário retornar uma tag HTML, vamos usar o helper content_tag.

class ProductPresenter
  delegate :name, to: :"@product"

  def initialize(product)
    @product = product
  end

  def description
    if @product.description.present?
      helpers.content_tag(:p, @product.description, class: "description")
    end
  end

  private
  def helpers
    ApplicationController.helpers
  end
end

Altere o controller para que ele passe a instância da classe Product para o presenter.

class ProductsController < ApplicationController
  def show
    @product = ProductPresenter.new(Product.find(params[:id]))
  end
end

Para finalizar, basta modificar nossa view.

<h1><%= @product.name %></h1>
<%= @product.description %>

Para o caso de partials, o funcionamento é basicamente o mesmo. No entanto, em vez de fazer a renderização no próprio presenter, é mais fácil retornar o nome da partial que deve ser renderizada.

Imagine que nossa view tenha mais um if que irá renderizar uma partial diferente para produtos gratuitos.

<% if @product.paid? %>
  <%= render "order", product: @product %>
<% else %>
  <%= render "download", product: @product %>
<% end %>

Podemos implementar um método chamado ProductPresenter#checkout_partial que irá fazer aquele if, retornando apenas o nome da partial.

class ProductPresenter
  delegate :name, to: :"@product"

  def initialize(product)
    @product = product
  end

  def description
    if @product.description.present?
      helpers.content_tag(:p, @product.description, class: "description")
    end
  end

  def checkout_partial
    @product.paid? ? "order" : "download"
  end

  private
  def helpers
    ApplicationController.helpers
  end
end

E na nossa view, basta renderizar o retorno do método ProductPresenter#checkout_partial.

<%= render @product.checkout_partial, product: @product %>

A esta altura, você já deve ter percebido como presenters podem remover completamente a lógica das views. Embora seja muito fácil fazer isso sem a necessidade de bibliotecas, algumas coisas precisam ser implementadas toda vez. É o caso de helpers, rotas e métodos de internacionalização.

Pensando nisso, decidi extrair aquela organização de código que eu estava utilizando em uma gem chamada simple_presenter.

Usando o simple_presenter

Para instalar, basta executar o comando abaixo:

$ gem install simple_presenter

Lembre-se de adicionar a gem ao arquivo Gemfile.

source :rubygems
gem "rails", "3.1.3"
gem "simple_presenter", "~> 0.1"

Aquele mesmo presenter que definimos pode ser trocado por algo como isto:

class ProductPresenter < Presenter
  expose :name, :description

  def description
    if @subject.description.present?
      h.content_tag(:p, @subject.description, class: "description")
    end
  end

  def checkout_partial
    @subject.paid? ? "order" : "download"
  end
end

Note que não precisamos mais definir o método ProductPresenter#initialize, nem o método ProductPresenter#helpers. Também tivemos que mudar todas as referências a @product para @subject; isso é necessário porque, por padrão, o nome do objeto que receberá os métodos delegados é @subject.

O simple_presenter adiciona os métodos helpers e h que permite acessar os helpers do Rails. Os helpers de rotas podem ser acessados com os métodos routes e r. E, finalmente, os helpers de internacionalização podem ser acessados por translate e t, e localize e l.

Escrevendo testes

Escrever testes para presenters é muito simples. No caso do RSpec, basta criar o diretório spec/presenters. Aquele nosso presenter pode ter testes como este:

require "spec_helper"

describe ProductPresenter do
  let(:product) { mock(Product, name: "Some product") }
  subject { described_class.new(product) }

  its(:name) { should eql("Some product") }

  describe "#description" do
    it "returns content" do
      product.stub description: "Some description"
      expected = %[<p class="description">Some description</p>]

      subject.description.should eql(expected)
    end

    it "returns no message" do
        subject.description.should be_blank
    end
  end

  describe "#checkout_partial" do
    it "returns partial for paid products" do
      product.stub paid?: true
      subject.checkout_partial.should eql("order")
    end

    it "returns partial for free products" do
      product.stub paid?: false
      subject.checkout_partial.should eql("download")
    end
  end
end

Finalizando

Você deve ter percebido que presenters permitem tornar suas views muito mais simples. Além disso, eles tem a vantagem de serem fáceis de testar.

A coisa mais difícil dos presenters é se acostumar com eles. Mas depois que você se acostuma, dificilmente terá uma view complicada e também nem vai querer deixar de usá-los!

UPDATE 1: O Valim me lembrou que à partir do Rails 3 não é mais preciso adicionar diretórios app/*, pois o Rails faz isso automaticamente. Eu sabia disso, mas o costume ainda permanece.