homeASCIIcasts

286: Draper 

(view original Railscast)

Other translations: En Ru

Other formats:

Written by Naomi Fujimoto

今回のエピソードではDraperを紹介します。Draperはpresenterパターンに似た形でRailsアプリケーションのビューにdecoratorを追加できるようにするgemです。テンプレートやヘルパーメソッドに多くの複雑なビューロジックを持っているような場合に、Draperを利用してよりオブジェクト指向のアプローチをとることでコードをすっきりと整理できます。今回のエピソードでそのしくみを紹介します。

対象のアプリケーションを下に示します。ユーザプロファイルのページにそのユーザに関する各種の情報が表示されています。その内容は、アバター画像、フルネーム、ユーザ名、Markdownで記述された簡単な略歴、WebサイトとTwitterフィードへのリンクです。ユーザがWebサイトの情報を入力した場合は、アバター画像とフルネームはそのサイトへのリンクになります。

すべての詳細情報を入力したユーザのプロファイルページ

ページは単純な構造のように見えますが、MrMysteryさんのようにあまり情報を入力していないユーザについても正しく処理しなくてはいけません。

ほとんど詳細情報を入力していないユーザのプロファイルページ

このユーザはユーザ名を入力しただけなので、フルネームの代わりにユーザ名、デフォルトのアバター画像、その他のフィールドには代替テキストを表示しています。このように情報量に応じてユーザを正しく扱うために多くのif条件を持つことで、このページのテンプレートはより複雑になっています。このロジックの一部をどこか他に移すことができれば、このテンプレートをずっとすっきりした形に変えることができるでしょう。

/app/views/users/show.html.erb

<div id="profile">
  <%= link_to_if @user.url.present?, ↵ 
  image_tag("avatars/#{avatar_name(@user)}", class: "avatar"), ↵
  @user.url %>
  <h1><%= link_to_if @user.url.present?, ↵
    (@user.full_name.present? ? @user.full_name : ↵
    @user.username), @user.url %></h1>
  <dl>
    <dt>Username:</dt>
    <dd><%= @user.username %></dd>
    <dt>Member Since:</dt>
    <dd><%= @user.member_since %></dd>
    <dt>Website:</dt>
    <dd>
    <% if @user.url.present? %>
      <%= link_to @user.url, @user.url %>
    <% else %>
      <span class="none">None given</span>
    <% end %>
    </dd>
    <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>
    <dt>Bio:</dt>
    <dd>
    <% if @user.bio.present? %>
      <%=raw Redcarpet.new(@user.bio, :hard_wrap, :filter_html, ↵
        :autolink).to_html %>
    <% else %>
      <span class="none">None given</span>
    <% end %>
    </dd>
  </dl>
</div>

このロジックはビュー関連のため、モデル側に抽出する訳にはいきません。解決策としては、ヘルパーメソッドを使うことが考えられます。このテンプレートではすでに、アバターを表示するためにimage_tagというヘルパーを使っています。その中身を見てみましょう。

/app/helpers/users_helper.rb

module UsersHelper
  def avatar_name(user)
    if user.avatar_image_name.present?
      user.avatar_image_name
    else
      "default.png"
    end
  end
end

このヘルパーメソッドは現在のユーザがアバターを持っているかどうかを判断して、持っていない場合にデフォルト画像のファイル名を返します。ビューからさらにロジックをヘルパーメソッドとして抽出することもできますが、この方法の問題はそれらがグローバルな名前空間の単なるメソッドでありまったくオブジェクト指向ではないという点です。

Draperのインストール

このケースはpresenter(Draperの用語ではdecorator)を使う例として適しているので、このアプリケーションにDraperを追加してみましょう。Draper gemは通常の方法でインストールします。Gemfileに参照情報を追加してbundleコマンドを実行します。

/Gemfile

source 'http://rubygems.org'

gem 'rails', '3.1.0'
gem 'sqlite3'


# Gems used only for assets and not required
# in production environments by default.
group :assets do
  gem 'sass-rails', "  ~> 3.1.0"
  gem 'coffee-rails', "~> 3.1.0"
  gem 'uglifier'
end

gem 'jquery-rails'
gem 'redcarpet'

gem 'draper'

Draperがインストールされたら、Userモデルに対してdecoratorを作成するためにdraper:decoratorジェネレータを実行します。

$ rails g draper:decorator user
      create  app/decorators
      create  app/decorators/application_decorator.rb
      create  app/decorators/user_decorator.rb

これが初めてのdecoratorなので、application_decoratorも同時に作成されます。作成されるdecoratorはすべてApplicationDecoratorを継承するので、すべてのdecoratorで共通の機能はすべてそこで定義します。

UserDecoratorクラスは見た通り単純で、ほとんどがそのしくみを説明するコメントになっています。それではこれを使って、テンプレートの整理を始めましょう。

プロファイルページを整理する

プロファイルページでDraperを使うためにはまずUsersController内のshowアクションを修正する必要があります。このアクションは、現状では通常の方法でUserを取得します。

/app/controllers/users_controller.rb

class UsersController < ApplicationController
  def index
    @users = User.all
  end

  def show
    @user = User.find(params[:id])
  end
end

このユーザをdecoratorでラップ(wrap)するために、User.findUserDecorator.findで置き換えます。

/app/controllers/users_controller.rb

def show
  @user = UserDecorator.find(params[:id])
end

これでこのコードはUserDecoratorインスタンスを返すようになりました。UserDecoratorUserレコードをラップして、デフォルトではすべてのメソッドをUserに委譲します(これについては後ほど詳しく説明します)。UserではなくUserDecoratorを対象にするように変わったにもかかわらず、アクションは以前と同じように動作します。それではビューの整理を始めますが、まずはユーザのアバターを表示するコードを修正します。

/app/views/users/show.html.erb

<%= link_to_if @user.url.present?, image_tag( ↵ 
  "avatars/#{avatar_name(@user)}", class: "avatar"), @user.url %>

ビューのこの部分を次のように置き換えます。

/app/views/users/show.html.erb

<%= @user.avatar %>

このコードはUserDecoratorのavatarメソッドを探すので、次にそのメソッドを書きます。このメソッドを書くときに注意しなくては行けないことがいくつかあります。decoratorからヘルパーメソッド(例えばlink_to_ifメソッド)を呼び出す場合は、hメソッド(helperの略)を介する必要があります。モデルを参照したいときは、今回の場合でいえば@userではなく、modelを呼び出します。

ビューからavatarにコピーしたコードでは、avatar_nameヘルパーメソッドを呼び出しています。decoratorからavatar_nameを呼び出しているので、avatar_nameUsersHelperクラスからdecoratorに移動します。これで同じクラス内にメソッドがあるので、Userを渡す必要はなく、userの呼び出しをモデルに置き換えることができます。

/app/decorators/user_decorator.rb

class UserDecorator < ApplicationDecorator
  decorates :user
  
  def avatar
    h.link_to_if model.url.present?, h.image_tag("avatars/#{avatar_name}", class: "avatar"), model.url
  end
  
  private
  def avatar_name
    if model.avatar_image_name.present?
      model.avatar_image_name
    else
      "default.png"
    end
  end
end

次にユーザ名を表示するコードを整理します。ビューのこのコードを置き換えます。

/app/views/users/show.html.erb

<h1><%= link_to_if @user.url.present?, (@user.full_name.present? ? @user.full_name : @user.username), @user.url %></h1>

これを次のように書き換えます。

/app/views/users/show.html.erb

<h1><%= @user.linked_name %></h1>

UserDecoratorlinked_nameメソッドを書かなくてはいけません。テンプレートから取り出したコードと前に書いたavatarメソッドには似ている点があります。どちらもリンクを表示させますが、その内容はユーザのURLが存在する場合はそれに依存して変わります。今はひとつのクラスの中にあるので、この重複は簡単にリファクタリングできます。

リンク生成処理を扱うためにsite_linkというプライベートメソッドを新たに作成します。このメソッドにはパラメータとしてcontentを渡します。これで、avatarlinked_nameの両方のメソッドからこのメソッドを呼び出すことができ、きれいに整理できます。前と同じように、linked_nameの中の@userの呼び出しはすべてmodelに置き換えます。これらの修正がすべて終わると、decorator は以下のようになります。

app/decorators/user_decorator.rb

class UserDecorator < ApplicationDecorator
  decorates :user
  
  def avatar
    site_link h.image_tag("avatars/#{avatar_name}", ↵
      class: "avatar")
  end
  
  def linked_name
    site_link(model.full_name.present? ? model.full_name : ↵
      model.username)
  end
  
  private
  def site_link(content)
    h.link_to_if model.url.present?, content, model.url
  end
  
  def avatar_name
    if model.avatar_image_name.present?
      model.avatar_image_name
    else
      "default.png"
    end
  end
end

ここでユーザのプロファイルページを読み込み直すと、前とまったく同じように表示されるでしょう。

テンプレートは十分きれいになったようですが、まだ改善の余地があります。次はビューコードの中のより大きな部分をリファクタリングします。ユーザのWebサイトへのリンクを表示する部分のコードです。

/app/views/user/show.html.erb

<dt>Website:</dt>
<dd>
  <% if @user.url.present? %>
    <%= link_to @user.url, @user.url %>
  <% else %>
    <span class="none">None given</span>
  <% end %>
</dd>

これを下の内容で置き換えます。

/app/views/user/show.html.erb

<dt>Website:</dt>
<dd><%= @user.website %></dd>

前と同じようにメソッドをdecoratorクラスの中に作成します。ビューから削除したコードを見ればわかりますが、ユーザがurlを持たない場合はあるHTMLが表示されます。これを単に文字列として返すこともできますが、生のHTMLをRubyプログラムの中に置きたくありません。別の解決法として、コードを部分テンプレート(partial)に移動して表示させるというやり方がありますが、たかだか一つのHTML要素を出力するだけなので、content_tagヘルパーメソッドを使うほうが合理的でしょう。

/app/decorators/user_decorator.rb

def website
  if model.url.present?
    h.link_to model.url, model.url
  else
    h.content_tag :span, "None given", class: "none"
  end  
end

Twitterの情報とユーザの略歴をそれぞれ表示するテンプレートの部分についても同じ手法をとることができます。ここでは詳細は述べませんが、修正後のビューのコードはずっときれいになります。

/app/views/users/show.html.erb

<div id="profile">
  <%= @user.avatar %>
  <h1><%= @user.linked_name %></h1>
  <dl>
    <dt>Username:</dt>
    <dd><%= @user.username %></dd>
    <dt>Member Since:</dt>
    <dd><%= @user.member_since %></dd>
    <dt>Website:</dt>
    <dd><%= @user.website %></dd>
    <dt>Twitter:</dt>
    <dd><%= @user.twitter %></dd>
    <dt>Bio:</dt>
    <dd><%= @user.bio %></dd>
  </dl>
</div>

decorator内の新しいtwitterbioの各メソッドは次のようになります。

/app/decorators/user_decorator.rb

def website
  if model.url.present?
    h.link_to model.url, model.url
  else
    h.content_tag :span, "None given", class: "none"
  end  
end

def twitter
  if model.twitter_name.present?
    h.link_to model.twitter_name, ↵  
      "http://twitter.com/#{model.twitter_name}"
  else
    h.content_tag :span, "None given", class: "none"
  end
end
  
def bio
  if model.bio.present?
    Redcarpet.new(model.bio, :hard_wrap, :filter_html, ↵
 		:autolink).to_html.html_safe
  else
    h.content_tag :span, "None given", class: "none"
  end
end

2つの新しいメソッドはとても似ていて、前に書いたwebsiteにも似ています。3つのメソッドの多くの部分、特にelse節が重複しています。そこでこの部分を独立したメソッドとして抽出するのがいいでしょう。

そのためにはブロックが役に立つでしょう。else節を独立したメソッドとして抽出し、handle_noneという名前にします。存在を確認したい値とブロックをこのメソッドに渡します。値が存在したらブロック内のコードが実行され、なければspanタグを表示します。このhandle_noneを使って、websitetwitterbioの各メソッドを整理します。

/app/decorators/user_decorator.rb

def website
  handle_none model.url do
    h.link_to model.url, model.url
  end  
end
  
def twitter
  handle_none model.twitter_name do
    h.link_to model.twitter_name, ↵ 
      "http://twitter.com/#{model.twitter_name}"
  end
end
  
def bio
  handle_none model.bio do
    Redcarpet.new(model.bio, :hard_wrap, :filter_html, ↵
      :autolink).to_html.html_safe
  end
end
  
private
def handle_none(value)
  if value.present?
    yield
  else
    h.content_tag :span, "None given", class: "none"
  end
end

もう一つ修正できる点として、Markdownの表示処理をApplicationDecoratorに抽出して、今後作るかもしれない他のdecoratorから呼び出せるようにします。新たにmarkdownメソッドを作成し、そこで渡されたテキストを表示処理します。

/app/decorators/application_decorator.rb

class ApplicationDecorator < Draper::Base
  def markdown(text)
    Redcarpet.new(text, :hard_wrap, :filter_html, ↵ 
      :autolink).to_html.html_safe
  end
end

これでUserDecorator内でbioメソッドからmarkdownを呼び出すように修正できます。

/app/decorators/user_decorator.rb

def bio
  handle_none model.bio do
    markdown(model.bio)
  end
end

モデルを修正する

decoratorが正しく機能するように設定できたので、ここでモデル層を一度見渡してみて、もしビュー関連のコードがあったらそれを対応するdecoratorに移動させます。例えば、Userモデルにはユーザが作成された時間をフォーマットするmember_sinceメソッドがあります。このコードは、フォーマットされた文字列を返すだけなのでビュー関連だと見なされます。これをdecoratorに移動します。

/app/models/user.rb

class User < ActiveRecord::Base
  def member_since
    created_at.strftime("%B %e, %Y")
  end
end

作業としてはメソッドをdecoratorに移動して、created_atの前にmodelを付けるだけです。

/app/decorators/user_decorator.rb

def member_since
  model.created_at.strftime("%B %e, %Y")
end

allowsメソッドを用いてモデルへのアクセスを制限する

UserDecoratorを修正している間に、Draperのもう一つの機能であるallowsを紹介します。デフォルトではUserDecoratorはすべてのメソッドをUserオブジェクトに委譲しますが、Userモデルに委譲されるメソッドを選択して指定することも可能です。それにはallowsを使って委譲したいメソッドの名前を渡します。

/app/decorators/user_decorator.rb

class UserDecorator < ApplicationDecorator
  decorates :user
  allows :username

  # Other methods omitted
end

ここではusernameのみの委譲を許可し、これによってusernameメソッドだけがUserに委譲されます。これが、decoratorになくてビューから呼び出される唯一のメソッドなので、委譲しなくてはいけないメソッドはこれだけです。これによって、decoratorのインタフェースをより細かく制御できるようになります。

decoratorに必要なものを抽出するリファクタリング作業がすべて終了したので、ユーザのプロファイルページを再度読み込んですべてが変わらず表示されることを確認します。

修正作業後、ユーザプロファイルページがすべて変わりなく表示される

正しく表示されています。念のため他のユーザを見てもすべて同じように表示されていますが、ビューコードは以前よりずっときれいに整理されました。

MrMysteryさんのプロファイルページも変わりなく表示される

decoratorを使用することで、1050バイト、34行だったshowテンプレートは382バイト、16行になったので、サイズを2/3削減したことになります。見た目もずっときれいになり、ページのレイアウトを変更したい場合の編集作業もずっと簡単にできるようになりました。