#287 Presenters from Scratch pro
- Download:
- source codeProject Files in Zip (131 KB)
- mp4Full Size H.264 Video (30.2 MB)
- m4vSmaller H.264 Video (16.7 MB)
- webmFull Size VP8 Video (19.1 MB)
- ogvFull Size Theora Video (34.7 MB)
今回のエピソードでは、presenterをゼロから作ります。そのために使用するサンプルのアプリケーションは、Draperに関する前回のエピソード[動画を見る, 読む]で扱ったものです。このアプリケーションにはユーザプロファイルのページがあり、アバター画像と一緒にユーザが入力した情報が表示されています。
ユーザはすべてのフィールドに情報を入力しなくてもいいので、このページには代わりにデフォルト値を表示する機能が必要です。MrMysteryさんという、ユーザ名しか入力してないユーザの例を見ることができます。
デフォルト値を扱わなくてはいけないということは、ページのビューコードにたくさんの複雑なロジックが含まれるという意味です。
<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>
このページはいくつかのif文が含まれています。それぞれがあるフィールドに対して入力された値かデフォルト値のいずれを表示させるかのロジックを処理しています。このロジックはすべてビュー関係なので、presenterクラスを利用するのがいいでしょう。それによってビューのコードを見違えるほどきれいに整理することができます。presenterはビューとモデルの両方を理解しているクラスで、複雑なビューロジックを処理する優れたオブジェクト指向的な方法です。今回のアプリケーションでこれを使用して、ユーザプロファイルのページをきれいに整理します。
最初のpresenterを書く
presenterはシンプルなRubyクラスです。アプリケーション内のpresenterを一緒にまとめておくために、アプリケーションのapp
ディレクトリの下に新たにpresenters
ディレクトリを作成し、そこにuser_presenter.rb
ファイルを置きます。古いバージョンのRailsでは/config/application.rb
ファイルの中でアプリケーションのconfig.autoload_paths
に新しいディレクトリを追加する必要がありました。Rails 3.1ではサーバを再起動しさえすれば、この作業は必要なくなりました。新しいディレクトリはサーバ起動時に自動的に認識されます。
presenterは処理対象のモデルとビューを知っている必要があるので、それらをinitialize
メソッドに渡してインスタンス変数に割り当てます。
class UserPresenter def initialize(user, template) @user = user @template = template end end
これでビューロジックをこのクラスに抽出する準備ができましたが、一体どのようにすればいいのでしょうか? 最初にどこかで新しいUserPresenter
クラスをインスタンス化する必要があります。Draperやその他のpresenterライブラリでは、これをコントローラアクションでおこなうのが一般的です。しかし、コントローラはpresenterを意識するべきではないという議論もあり、ここではそのアプローチはとりません。
コントローラを修正するのではなく、presenterインスタンスを生成するヘルパーメソッドを新規に作成します。このメソッドはpresent
という名前で、presenterを作成したい対象のモデルと、presenterオブジェクトを返すブロックをとります。ビューテンプレートのコードをすべてブロックの中に置いて、presenterがビューからアクセス可能になるようにします。
<% present @user do |user_presenter| %> <div id="profile"> <!-- Rest of view code omitted --> </div> <% end %>
presentメソッドをApplicationHelper
内に書きます。モデルオブジェクトに加えて、オプションでクラス変数を指定して、使われるpresenterクラスをカスタマイズします。クラスが指定されない場合、オブジェクトのクラス名の後ろにPresenter
をつけたものをベースにクラスを見つけます。今回の場合、名前はUserPresenter
になります。その文字列に対してconstantize
を呼び出して、クラス定数を取得します。
presenterクラスができたので、klass.new
を呼び出してそれをインスタンス化し、モデルobject
とself
を渡します。self
は我々がアクセスしたいヘルパーメソッドを持つテンプレートメソッドです。もしブロックがメソッドに渡されたらpresenterをyield
して、最後にそれを返します。
module ApplicationHelper def present(object, klass = nil) klass ||= "{object.class}Presenter".constantize presenter = klass.new(object, self) yield presenter if block_given? presenter end end
present
メソッドによって、いずれのテンプレートからでもすべてのオブジェクトのpresenterに簡単にアクセスできるようになります。これで、テンプレートからpresenterにコードを移動し始めることができます。まずアバターを表示するコードから始めます。
<%= link_to_if @user.url.present?, image_tag("avatars/#{avatar_name(@user)}", class: "avatar"), @user.url %>
これをUserPresenter
に新たに作るavatar
の呼び出しと置き換えます。
<%= user_presenter.avatar %>
ビューから取ったロジックをこの新しいメソッドに貼り付けます。
def avatar link_to_if @user.url.present?, image_tag("avatars/#{avatar_name(@user)}", class: "avatar"), @user.url end
presenterから呼び出すヘルパーメソッドはすべてテンプレートを介して呼び出される必要があります。しかしどこでも@template
を使うのではなく、Draperの手法にならってh
メソッドを用いて、テンプレートを返すhメソッドをpresenterに追加します。その上でそれをlink_to_if
とimage_tag
の各メソッドと合わせて使用して、テンプレートを介して呼ばれるようにします。
コピーしたコードはまた、新規に作成したヘルパーメソッドのavatar_name
を呼び出します。これはUsersHelper
で定義されています。
module UsersHelper def avatar_name(user) if user.avatar_image_name.present? user.avatar_image_name else "default.png" end end end
presenterにある同じモデルオブジェクトを引数にとるヘルパーメソッドがある場合は、それをpresenterに移動するのが理にかなっているので、avatar_name
をUsersHelper
からUsersPresenter
に移動してプライベートに変更します。presenterにはすでに現在のユーザがあるので、user
引数は削除してその代わりに@user
インスタンス変数を呼び出します。
class UserPresenter def initialize(user, template) @user = user @template = template end def avatar h.link_to_if @user.url.present?, h.image_tag("avatars/#{avatar_name}", class: "avatar"), @user.url end private def h @template end def avatar_name if @user.avatar_image_name.present? @user.avatar_image_name else "default.png" end end end
ユーザプロファイルのページをリロードすると前とまったく同じように表示されるので、presenterは正しく動作しているようです。
基本のpresenterを作成する
presenterを使用するアプリケーションはおそらくそれを複数持つので、それらの振る舞いを一般化するのがいいでしょう。作成するpresenterはすべて同じinitalize
とh
の各メソッドを持つので、これらを基本クラスに移してpresenterから継承させることにします。
新たに作成する基本クラスでinitialize
に渡されるモデル名をより汎用的な名称、たとえばobject
、に変更します。これによってUserPresenter
で直接userオブジェクトを参照することができなくなります。@object
を呼び出すこともできるのですが、その代わりにBasePresenter
内にnameを引数にとるpresents
という名前のクラスメソッドを作成します。このメソッドは、@object
に保持されたモデルを返す、そのモデルと同じ名前のメソッドを定義します。
class BasePresenter def initialize(object, template) @object = object @template = template end def self.presents(name) define_method(name) do @object end end def h @template end end
UserPresenter
とその他の作成するpresenterはすべてこの新しいクラスから継承できるようになりました。UserPresenter
のpresents :user
を呼び出すと、current userを返すuser
メソッドを利用できるようになるので、@user
の呼び出しをすべてuser
に置き換えます。
class UserPresenter < BasePresenter presents :user def avatar h.link_to_if user.url.present?, h.image_tag("avatars/#{avatar_name}", class: "avatar"), user.url end private def avatar_name if user.avatar_image_name.present? user.avatar_image_name else "default.png" end end end
ビューの残りの部分を整理する
テンプレートは多少きれいになりましたが、まだ整理できる部分はたくさんあります。これをおこなうためにとるステップは、Draperのエピソードで採用した方法にとても似ています。具体的な方法を見たい場合は、動画を見るか記事を読むことができます。これらの変更の実際の作業は省略して、結果のみを示します。重いロジック部分をpresenterに移動して、テンプレートはずっときれいに整理されました。
<% present @user do |user_presenter| %> <div id="profile"> <%= user_presenter.avatar %> <h1><%= user_presenter.linked_name %></h1> <dl> <dt>Username:</dt> <dd><%= user_presenter.username %></dd> <dt>Member Since:</dt> <dd><%= user_presenter.member_since %></dd> <dt>Website:</dt> <dd><%= user_presenter.website %></dd> <dt>Twitter:</dt> <dd><%= user_presenter.twitter %></dd> <dt>Bio:</dt> <dd><%= user_presenter.bio %></dd> </dl> </div> <% end %>
presenterのコードを下に示します。ページのロジックをすべて処理してくれるいくつかの短いメソッドだけになりました。
class UserPresenter < BasePresenter presents :user delegate :username, to: :user def avatar site_link image_tag("avatars/#{avatar_name}", class: "avatar") end def linked_name site_link(user.full_name.present? ? user.full_name : user.username) end def member_since user.created_at.strftime("%B %e, %Y") end def website handle_none user.url do h.link_to(user.url, user.url) end end def twitter handle_none user.twitter_name do h.link_to user.twitter_name, "http://twitter.com/#{user.twitter_name}" end end def bio handle_none user.bio do markdown(user.bio) end end private def handle_none(value) if value.present? yield else h.content_tag :span, "None given", class: "none" end end def site_link(content) h.link_to_if(user.url.present?, content, user.url) end def avatar_name if user.avatar_image_name.present? user.avatar_image_name else "default.png" end end end
presenterに対しておこなった修正について、何点か補足します。ファイルの最初あたりでdelegate
でusername
メソッドをUser
クラスに委譲しています。User
モデルのusername
は変更する必要がないので、そこから直接取得できます。
テンプレートはRedcloth
を用いてユーザの履歴を返します。これは他のpresenterで使うと便利なので、BasePresenter
にmarkdown
メソッドを作成し、どのpresenterのMarkdownコードも簡単に返せるようにしました。そこでUserPresenter
のこのメソッドを呼び出すことができます。
def markdown(text) Recarpet.new(text, :hard_wrap, :filter_html, :autolink).to_html.html_safe end
handle_none
メソッドがtwitter
などのフィールドを処理し、ユーザが何も情報を入力してくれなかった場合に「None given(情報なし)」を表示させます。このメソッドではcontent_tag
を使ってHTML spanにデフォルトの値を返します。このアプローチは、このように単純なHTMLではうまくいきますが、より複雑なマークアップを表示したい場合は、部分テンプレート(partial)を作成してそれを表示させるか、Markabyのようなマークアップ言語を利用するなどの方法がいいでしょう。
hメソッドの代替
先にh
メソッドを定義して、ヘルパーメソッドにアクセスできるようにしました。これを望まない場合、代替案としてmethod_missing
を設定してすべての不明なメソッドをテンプレートに委譲するという方法があります。これはBasePresenter
内に簡単に設定して、すべてのpresenterで機能させることができます。
def method_missing(*args, &block) @template.send(*args, &block) end
これによって、presenterが理解できないものはすべてテンプレートに送られ、image_tag
などのヘルパーメソッドを呼び出すときはh
メソッドを介するのではなく直接呼び出します。
コントローラからpresenterにアクセスする
ある時点では、コントローラ層からpresenterにアクセスしなくてはいけないという状況が発生すると思います。例えばUserController
のshow
アクションで使用して、次のようなコードでJSONを返したいという場合があるかもしれません。
def show @user = User.find(params[:id]) present(@user).to_json end
present
メソッドはヘルパーメソッドではないのでこれはうまくいきません。しかし、アプリケーションのApplicationController
にコントローラ用のpresent
メソッドを作成することができます。このメソッドのトリックは、presenterをインスタンス化するときにself
の代わりにview_content
を呼び出すという点で、これはそれがビューを返すテンプレートオブジェクトだからです。
class ApplicationController < ActionController::Base protect_from_forgery private def present(object, klass = nil) klass ||= "#{object.class}Presenter".constantize klass.new(view_content, object) end end
テスト
これでアプリケーションが新たなきれいなビューで一新されましたが、テストはどうなるでしょうか? presenterはどうテストすればいいでしょう? Capybara (エピソード275 [動画を見る, 読む]で取り上げました)を使用してハイレベルな統合テストを行うべきですが、presenterを利用することの利点のひとつは、より低いレベルでビューロジックをテストできるという点です。これをこのあと実際にデモしていきますが、最初はTest::Unitで、次にRSpecでおこないます。
Test::Unitによるpresenterのテストを書くために、/test/unit
ディレクトリの下に新規にpresenters
ディレクトリを作成し、そこにuser_presenter_test.rb
ファイルを追加します。このファイル内に作成するUserPresenterTest
クラスは、通常のActiveSupport::TestCase
を継承するのではなく、ActionView::TestCase
を継承します。これによって、ビューにアクセスできるようになりそれをpresenterに渡すことができます。
これを簡単なテストでデモします。ユーザが詳細情報を入力しなかった場合にUserPresenter
のwebsite
メソッドから「None given」というテキストが返されると仮定(assert)します。まず新規にUserPresenter
を作成し、新しいUser
とビューテンプレートを渡します。テストはActionView::TestCase
を継承するので、現在のビューテンプレートを保持するview
変数にアクセスできます。次にpresenterのwebsite
メソッドを呼び出し、そこに「None Given」というテキストが含まれていると仮定します。
require 'test_helper' class UserPresenterTest < ActionView::TestCase test "says when none given" do presenter = UserPresenter.new(User.new, view) assert_match "None given", presenter.website end end
rake testでテストを実行すると成功します。
$ rake test Loaded suite /Users/eifion/.rvm/gems/ruby-1.9.2-p180@global/gems/rake-0.9.2/lib/rake/rake_test_loader Started . Finished in 0.155069 seconds. 1 tests, 1 assertions, 0 failures, 0 errors, 0 skips
テストからpresenterにアクセスできるということは、テスト駆動開発の手法を用いてpresenterを書けることを意味します。まず失敗するpresenterのテストから始めて、テストが成功するようにpresenterを修正していきます。
presenterをRSpecを用いてテストするには、まず/spec
の下にpresenters
ディレクトリを作成し、そこにuser_presenter_spec.rb
ファイルを追加します。先ほどのテストと同じことをテストするspecを書きます。presenterのspecを書くときの鍵は、ActionView::TestCase::Behavior
をインクルードするという点です。これによって、テンプレートを含むview変数を使用できます。これはTest::Unitを利用しているときにActionView::TestCase
を継承する場合と同じです。
spec自体は、先ほどのテストととても似ています。再度新しいpresenterを作成し、website
メソッドが正しいテキストを返すかどうかチェックします。
require 'spec_helper' describe UserPresenter do include ActionView::TestCase::Behavior it "says when none given" do presenter = UserPresenter.new(User.new, view) presenter.website.should include("None given") end end
rake spec
を実行すると1つのspecが成功するはずで、実際その通りの結果が表示されます。
$ rake spec /Users/eifion/.rvm/rubies/ruby-1.9.2-p180/bin/ruby -S bundle exec rspec ./spec/presenters/user_presenter_spec.rb . Finished in 0.06957 seconds 1 example, 0 failures
すべてのpresenter specにBehavior
モジュールをインクルードする代わりに、自動的にすべてのspecにインクルードされるように、SpecHelperファイルのconfig ブロックを修正します。これによってspec/helpersディレクトリ以下のすべてのファイルにBehaviorモジュールがインクルードされるので、presenter specにそれを追加する必要がなくなります。
RSpec.configure do |config| config.include ActionView::TestCase::Behavior, example_group: {file_path: %r{spec/presenters}} # Rest of block omitted. end
presenterをテストするときに気をつけなくてはいけないことがひとつあります。presenterに渡すview
オブジェクトはすべてのヘルパーメソッドにアクセスできますが、例外としてコントローラで定義されたものにはアクセスできません。例えばコントローラ内にhelper_method
を用いてヘルパーメソッドとして指定したcurrent_user
メソッドを持っていても、テストでそれをview
オブジェクトから呼び出すことはできません。代わりにこのメソッドをスタブ化して、求める値を返すようにする必要があります。通常current_user
メソッドはセッションやクッキーの値に依存するため、いずれにしろここはスタブ化するのがいいアイデアでしょう。
presenterをゼロから作る今回のエピソードは以上で終わりです。必要な作業の内容を見ることで十分な知識を得られたと思いますので、自分でカスタムのソリューションを書くべきか、Draperのようなgemを利用するべきか、判断することができるでしょう。どちらを選ぶにしても、アプリケーションのビューが複雑なロジックを持っている場合は、presenterを利用してロジックをリファクタリングしてビューをきれいに保つことをぜひ検討してください。