#286 Draper
- Download:
- source codeProject Files in Zip (125 KB)
- mp4Full Size H.264 Video (27.2 MB)
- m4vSmaller H.264 Video (14.6 MB)
- webmFull Size VP8 Video (16.7 MB)
- ogvFull Size Theora Video (32.1 MB)
今回のエピソードではDraperを紹介します。Draperはpresenterパターンに似た形でRailsアプリケーションのビューにdecoratorを追加できるようにするgemです。テンプレートやヘルパーメソッドに多くの複雑なビューロジックを持っているような場合に、Draperを利用してよりオブジェクト指向のアプローチをとることでコードをすっきりと整理できます。今回のエピソードでそのしくみを紹介します。
対象のアプリケーションを下に示します。ユーザプロファイルのページにそのユーザに関する各種の情報が表示されています。その内容は、アバター画像、フルネーム、ユーザ名、Markdownで記述された簡単な略歴、WebサイトとTwitterフィードへのリンクです。ユーザがWebサイトの情報を入力した場合は、アバター画像とフルネームはそのサイトへのリンクになります。
ページは単純な構造のように見えますが、MrMysteryさんのようにあまり情報を入力していないユーザについても正しく処理しなくてはいけません。
このユーザはユーザ名を入力しただけなので、フルネームの代わりにユーザ名、デフォルトのアバター画像、その他のフィールドには代替テキストを表示しています。このように情報量に応じてユーザを正しく扱うために多くのif条件を持つことで、このページのテンプレートはより複雑になっています。このロジックの一部をどこか他に移すことができれば、このテンプレートをずっとすっきりした形に変えることができるでしょう。
<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
というヘルパーを使っています。その中身を見てみましょう。
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
コマンドを実行します。
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
を取得します。
class UsersController < ApplicationController def index @users = User.all end def show @user = User.find(params[:id]) end end
このユーザをdecoratorでラップ(wrap)するために、User.find
をUserDecorator.find
で置き換えます。
def show @user = UserDecorator.find(params[:id]) end
これでこのコードはUserDecorator
インスタンスを返すようになりました。UserDecorator
はUser
レコードをラップして、デフォルトではすべてのメソッドをUser
に委譲します(これについては後ほど詳しく説明します)。User
ではなくUserDecorator
を対象にするように変わったにもかかわらず、アクションは以前と同じように動作します。それではビューの整理を始めますが、まずはユーザのアバターを表示するコードを修正します。
<%= link_to_if @user.url.present?, image_tag( ↵
"avatars/#{avatar_name(@user)}", class: "avatar"), @user.url %>
ビューのこの部分を次のように置き換えます。
<%= @user.avatar %>
このコードはUserDecorator
のavatarメソッドを探すので、次にそのメソッドを書きます。このメソッドを書くときに注意しなくては行けないことがいくつかあります。decoratorからヘルパーメソッド(例えばlink_to_if
メソッド)を呼び出す場合は、h
メソッド(helperの略)を介する必要があります。モデルを参照したいときは、今回の場合でいえば@user
ではなく、model
を呼び出します。
ビューからavatar
にコピーしたコードでは、avatar_name
ヘルパーメソッドを呼び出しています。decoratorからavatar_name
を呼び出しているので、avatar_name
をUsersHelper
クラスからdecoratorに移動します。これで同じクラス内にメソッドがあるので、User
を渡す必要はなく、userの呼び出しをモデルに置き換えることができます。
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
次にユーザ名を表示するコードを整理します。ビューのこのコードを置き換えます。
<h1><%= link_to_if @user.url.present?, (@user.full_name.present? ? @user.full_name : @user.username), @user.url %></h1>
これを次のように書き換えます。
<h1><%= @user.linked_name %></h1>
UserDecorator
にlinked_name
メソッドを書かなくてはいけません。テンプレートから取り出したコードと前に書いたavatar
メソッドには似ている点があります。どちらもリンクを表示させますが、その内容はユーザのURLが存在する場合はそれに依存して変わります。今はひとつのクラスの中にあるので、この重複は簡単にリファクタリングできます。
リンク生成処理を扱うためにsite_link
というプライベートメソッドを新たに作成します。このメソッドにはパラメータとしてcontentを渡します。これで、avatar
とlinked_name
の両方のメソッドからこのメソッドを呼び出すことができ、きれいに整理できます。前と同じように、linked_name
の中の@user
の呼び出しはすべてmodel
に置き換えます。これらの修正がすべて終わると、decorator は以下のようになります。
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サイトへのリンクを表示する部分のコードです。
<dt>Website:</dt> <dd> <% if @user.url.present? %> <%= link_to @user.url, @user.url %> <% else %> <span class="none">None given</span> <% end %> </dd>
これを下の内容で置き換えます。
<dt>Website:</dt> <dd><%= @user.website %></dd>
前と同じようにメソッドをdecoratorクラスの中に作成します。ビューから削除したコードを見ればわかりますが、ユーザがurl
を持たない場合はあるHTMLが表示されます。これを単に文字列として返すこともできますが、生のHTMLをRubyプログラムの中に置きたくありません。別の解決法として、コードを部分テンプレート(partial)に移動して表示させるというやり方がありますが、たかだか一つのHTML要素を出力するだけなので、content_tag
ヘルパーメソッドを使うほうが合理的でしょう。
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の情報とユーザの略歴をそれぞれ表示するテンプレートの部分についても同じ手法をとることができます。ここでは詳細は述べませんが、修正後のビューのコードはずっときれいになります。
<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
の各メソッドは次のようになります。
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
の各メソッドを整理します。
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
メソッドを作成し、そこで渡されたテキストを表示処理します。
class ApplicationDecorator < Draper::Base def markdown(text) Redcarpet.new(text, :hard_wrap, :filter_html, ↵ :autolink).to_html.html_safe end end
これでUserDecorator
内でbio
メソッドからmarkdown
を呼び出すように修正できます。
def bio handle_none model.bio do markdown(model.bio) end end
モデルを修正する
decoratorが正しく機能するように設定できたので、ここでモデル層を一度見渡してみて、もしビュー関連のコードがあったらそれを対応するdecoratorに移動させます。例えば、User
モデルにはユーザが作成された時間をフォーマットするmember_since
メソッドがあります。このコードは、フォーマットされた文字列を返すだけなのでビュー関連だと見なされます。これをdecoratorに移動します。
class User < ActiveRecord::Base def member_since created_at.strftime("%B %e, %Y") end end
作業としてはメソッドをdecoratorに移動して、created_at
の前にmodel
を付けるだけです。
def member_since model.created_at.strftime("%B %e, %Y") end
allows
メソッドを用いてモデルへのアクセスを制限する
UserDecorator
を修正している間に、Draperのもう一つの機能であるallows
を紹介します。デフォルトではUserDecorator
はすべてのメソッドをUser
オブジェクトに委譲しますが、User
モデルに委譲されるメソッドを選択して指定することも可能です。それにはallows
を使って委譲したいメソッドの名前を渡します。
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削減したことになります。見た目もずっときれいになり、ページのレイアウトを変更したい場合の編集作業もずっと簡単にできるようになりました。