286: Draper
Other formats:
今回のエピソードでは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.findをUserDecorator.findで置き換えます。
/app/controllers/users_controller.rb
def show @user = UserDecorator.find(params[:id]) end
これでこのコードはUserDecoratorインスタンスを返すようになりました。UserDecoratorはUserレコードをラップして、デフォルトではすべてのメソッドを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_nameをUsersHelperクラスから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>
UserDecoratorにlinked_nameメソッドを書かなくてはいけません。テンプレートから取り出したコードと前に書いたavatarメソッドには似ている点があります。どちらもリンクを表示させますが、その内容はユーザのURLが存在する場合はそれに依存して変わります。今はひとつのクラスの中にあるので、この重複は簡単にリファクタリングできます。
リンク生成処理を扱うためにsite_linkというプライベートメソッドを新たに作成します。このメソッドにはパラメータとしてcontentを渡します。これで、avatarとlinked_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内の新しいtwitterとbioの各メソッドは次のようになります。
/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を使って、website、twitter、bioの各メソッドを整理します。
/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に必要なものを抽出するリファクタリング作業がすべて終了したので、ユーザのプロファイルページを再度読み込んですべてが変わらず表示されることを確認します。
正しく表示されています。念のため他のユーザを見てもすべて同じように表示されていますが、ビューコードは以前よりずっときれいに整理されました。
decoratorを使用することで、1050バイト、34行だったshowテンプレートは382バイト、16行になったので、サイズを2/3削減したことになります。見た目もずっときれいになり、ページのレイアウトを変更したい場合の編集作業もずっと簡単にできるようになりました。


