読者です 読者をやめる 読者になる 読者になる

Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

RailsでDraperを使ってプレゼンテーション層(デコレーター)を実装する

設計 リファクタリング Rails View Rails中級

f:id:nipe880324:20150415020650j:plain
Photo by Flickr: HerryLawford's Photostream

DraperはRailsのプレゼンテーション層の役割を担うgemです。
この記事では、Draperを通し、プレゼンテーション層の必要性や使い方を説明します。


動作確認

  • Ruby 2.2.1
  • Rails 4.2.0
  • Draper 1.4.0

目次


0. プレゼンテーション層の必要性

Draperはデコレーター(他にはプレゼンター、ビューモデルなどと呼ばれます)であり、ビューとモデルの中間に位置し、モデルやビューに実装されやすい表示ロジック/フォーマットといったプレゼンテーション層の責務を引き受けることで可読性、保守性を向上させることができます。

ビューにif文が多くて見ずらい、モデルに表示用のロジックが多くなってしまった、ビューのロジックをヘルパーに書いているがヘルパーの名前空間の衝突が怖い(※1)などの場合にDraperを導入するとよいと思います。

※1 Railsのヘルパーはグローバルな名前空間にメソッドが定義されるので、異なるヘルパーモジュールに同じメソッド名のメソッドを定義すると名前が衝突します


ビューにif文が多くて見ずらい

ビューからロジックをなくしたい。HTML構造が複雑になり、ビューファイルが見ずらくなる。

# Draper導入前
<dt>Twitter:</dt>
<dd>
<% if @user.twitter_name.present? %>
  <%= link_to @user.twitter_name, "http://twitter.com/#{@user.twitter_name}" %>
<% else %>
  <span class="none">None given</span>
<% end %>
</dd>

# Draper導入後
<dt>Twitter:</dt>
<dd><%= @user.twitter %></dd>
<%
ヘルパーの名前空間の衝突

別々のヘルパーでfooメソッドという同じメソッド名のメソッドを定義したので衝突してしまって、思うようにメソッドが動かない。

module ApplicationHelper
  def foo
    'ApplicationHelper#foo'
  end
end

module BooksHelper
  def foo
    'BooksHelper#foo'
  end
end

# ApplicationHelper#fooが上書きされてしまう??
foo #=> 'BooksHelper#foo' 
モデルに表示用のロジックが多い

モデルはドメインロジックを記載するべきであり、メソッドが多くやすいので、UIの細かなフォーマットなどはあまり書きたくない。

# Draper導入前
class SomeModel < ActiveRecord::Base
  def posted_at
    created_at.strftime("%Y/%m/%d")
  end
end

# Draper導入後
class SomeModel < ActiveRecord::Base
  # posted_atメソッドはデコレータークラスに移動
end


1. Draperのインストール方法

GemfileにDraperを追加します。

# Gemfile
gem 'draper', '~> 1.3'


バンドラーを実行して、Draperをインストールします。

bundle install


2. Draperの簡単な使い方

まず、Scaffoldで作成します。
Draperを入れたので、デコレーターも作成されます。

bin/rails g scaffold Article title body:text
      ...
      invoke    decorator
      create      app/decorators/article_decorator.rb

rails g resourcerails g decoratorというジェネレーターを実行した場合もデコレーターが作成されます。


デコレーターにプレゼンテーション層のメソッドを定義します。
イメージとしては、ビューやモデルに書くべきではなく、ヘルパーに書くようなメソッドです。例えば、日付のフォーマット、条件分岐でビューの表示が少し変わるなどです。
今回は、日付をフォーマットして、spanタグ付きで表示するposted_atメソッドを追加しました。

# app/decorators/article_decorator.rb
class ArticleDecorator < Draper::Decorator
  delegate_all

  def posted_at
    h.content_tag :span, class: 'time' do
      model.created_at.strftime("%Y/%m/%d %H:%m")
    end
  end
end

デコレーターはモデルオブジェクトを保持しています。
そして、delegate_allと記載することで、デコレーター内に定義されていないメソッドが呼び出されたい場合、デコレーターが保持しているモデルオブジェクトにメソッド呼び出しを委譲します。
そのため、基本的にデコレーターを導入しても、モデルの処理が行われるので従来通り動きます。


次に、コントローラー内で作成したデコレーターを使いようにします。

  • コレクションの個々のオブジェクトをデコレートするにはdecorate_collection
  • 単独のオブジェクトをデコレートするにはdecorate

を使います。

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  before_action :set_article, only: [:show, :edit, :update, :destroy]

  def index
    @articles = ArticleDecorator.decorate_collection(Article.all)
  end

  ...

  private

    def set_article
      @article = ArticleDecorator.decorate(Article.find(params[:id]))
    end

これで、Articleオブジェクトを保持した、Articleデコレーターインスタンスが作成されました。


最後に、ビューからデコレーターのメソッドを呼び出します。

<!-- app/views/articles/index.html.erb -->

   <tbody>
    <% @articles.each do |article| %>
      <tr>
        <td><%= article.title %></td>
        <td><%= article.body %></td>
        <!-- デコレーターのメソッド呼び出し -->
        <td><%= article.posted_at %></td>
        <td><%= link_to 'Show', article %></td>
        <td><%= link_to 'Edit', edit_article_path(article) %></td>
        <td><%= link_to 'Destroy', article, method: :delete, data: { confirm: 'Are you sure?' } %></td>
      </tr>
    <% end %>
  </tbody>

titlebodyは、デコレーターのdelegate_allでモデルに委譲させるのでモデルのメソッドが呼び出されます。
posted_atは、デコレーターで定義したメソッドが呼び出されます。

画面は次のようになります。(Posted atの値のスタイリングをしています)
f:id:nipe880324:20150415021453j:plain:w480

このようにして、Draperを使うことができます。
モデルとビューの間にデコレーターを導入することで、モデル内のビジネスロジックと関係ないメソッドを減らせ、ビューから条件分岐を減らせ、ヘルパーメソッドのメソッド名の衝突問題を防ぐことができるようになります。
アプリの規模が大きくなってきてこのようなことに困った場合は、導入をお勧めします。




3. デコレーターインスタンスの作成

3.1. 単独のオブジェクトのデコレーター

単独のオブジェクトの場合、次のようにdecorateメソッドやデコレーターのnewメソッドを使います。

# decorateメソッドを使うと、モデルからデコレーターを推測して作成されます
# Articleの場合、ArticleDecoratorが使われる。
@article = Article.find(params[:id]).decorate


# デコレーターを指定したい場合は、デコーレータを作成します
@widget = ProductDecorator.new(Widget.find(params[:id]))
# 以下も同じ
@widget = ProductDecorator.decorate(Widget.find(params[:id]))


また、次のようにdecorates_findersメソッドをデコレーターに定義すれば、ActiveRecordのようにfindメソッドがつかえるようになります。

# app/decorators/articl_decorator.rb
class ArticleDecorator < Draper::Decorator
  decorates_finders
end

# コントローラーなど
@article = ArticleDecorator.find(params[:id])


3.2. コレクションの個々のオブジェクトのデコレーター

コレクションの場合、次のようにdecorate_collectionメソッドを使います。

@articles = ArticleDecorator.decorate_collection(Article.all)


3.3. コレクション自身のデコレーター

コレクション自身をデコレートしたい場合は、Draper::CollectionDecoratorのサブクラスのデコレータークラスを定義します。

# app/decorators/articles_decorator.rb
class ArticlesDecorator < Draper::CollectionDecorator
  def page_number
    42
  end
end

# コントローラーなど
@articles = ArticlesDecorator.new(Article.all)
# もしくは、
@articles = ArticlesDecorator.decorate(Article.all)


decorates_associationを使うことで、関連するオブジェクトのデコレーターを使うように宣言できます。
次の場合、authorモデルに対応するAuthorDecoratorを使うことができます。

# app/decorators/article_decorator.rb
class ArticleDecorator < Draper::Decorator
  decorates_association :author
end


# app/decorators/author_decorator.rb
class AuthorDecorator < Draper::Decorator
  def hoge
    model.name + "hogehoge"
  end
end


# ビューでAuthorDecoratorのメソッドを呼び出す
article.author.hoge


4. デコレータークラスの作成

4.1. デコレーター内でヘルパーメソッドへのアクセス

デコレーターからヘルパーメソッドを使うには、hメソッドを使います。

class ArticleDecorator < Draper::Decorator
  def emphatic
    h.content_tag(:strong, "Awesome")
    #=> <strong>Awesome</strong>
  end
end


4.2. デコレーター内でモデルオブジェクトへアクセス

デコレーター内でデコレーターが保持しているモデルオブジェクトにアクセスするにはモデルには、object(もしくは、エイリアスのmodel)を使います。

class ArticleDecorator < Draper::Decorator
  def published_at
    object.published_at.strftime("%A, %B %e")
  end
end


4.3. デコレーターでHTMLをレンダリングする

条件分岐をビューからデコレーターに移す場合、HTMLの断片が入ってしまうことがあります。
デコレーター内に直でHTMLコードを記載するとHTMLコードの断片が散らばり可読性や保守性が下がるので、次のようにするといいかもしれません。

HTMLのレンダリングが

  • 1行程度ならcontent_tagヘルパーメソッドを使う
  • 複数行の場合はrenderメソッドを使って部分テンプレートを表示する
class ArticleDecorator < Draper::Decorator
  delegate_all

  # 1行程度なので、content_tagメソッドを使う
  def emphatic
    h.content_tag(:strong, "Awesome")
    #=> <strong>Awesome</strong>
  end

  # 複数行のため、部分テンプレートを呼び出す
  def sub_view
    h.render 'sub_view', title: model.title
    # => articles/sub_view.html.erb を表示する
  end
end


4.4. デコレーターのデリゲート(委託)

デコレーターオブジェクトへのメソッド呼び出しを、モデルにデリゲート(委託)することができます。
delegate_allで全てのメソッドを委託、delegateで指定したメソッドを委託するように宣言できます。

# デコレーター
class ArticleDecorator < Draper::Decorator
  # 全てのメソッド呼び出しにおいて、デコレーターで定義していないメソッドは、モデルオブジェクトへ委譲される
  delegate_all

  # 特定のメソッドのみ委譲する(toオプションを指定しないとデフォルトでobjectに委譲する)
  delegate :title, :body

  # 特定のメソッドを指定したオブジェクトに委譲する
  delegate :name, to: :author, prefix: true

  ...
end


# 使い方(ビューなど)
@article = ArticleDecorator.decorate(Article.find(params[:id]))
@article.title  # => articleのtitle
@article.body   # => articleのbody
@article.author_name   # => articleの author.name


以上です。