#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を利用してロジックをリファクタリングしてビューをきれいに保つことをぜひ検討してください。


