Viewの為に簡単なDecoratorをつくる
最近久しぶりに1からRailsアプリをつくる機会があって、 手製で簡単なDecoratorを導入してみたら結構良かったという話です。
サンプル
ブログ記事を描画するViewをつくる例で説明する。
ブログ記事を表すEntryというModel用クラスがあり(ActiveRecord::Baseは使っていない)、このインスタンスがMVCにおけるcontrollerからviewに受け渡される。Entryクラスは、自身の持つデータの読み書きに関する責務だけ負ったクラスである。データは実際にはGithub上にファイルとして保存されており、content(ファイルの中身)、filename(ファイル名)、time(最終編集時刻)を持つ。Viewでは以下のようなものを描画したい。
- Markdownで書かれたファイルの中身を変換したHTML
- 拡張子を取り除いたファイル名(=タイトル)
- 変更した日付
- 記事ページへのリンク
- 記事編集ページへのリンク
- Github上のファイルへのリンク
実装
Decorator層をつくり、Modelに依存した描画を行う責務を負わせる。DecoratorはEntryのプロキシで、描画に特化したメソッドを持ち、自身の知らないメソッドはEntryに委譲する。標準ライブラリのSimpleDelegatorを継承して使うと簡単にこの機能を実現できる。
class EntryDecorator < SimpleDelegator
include Rails.application.routes.url_helpers
def self.renderer
@renderer ||= Redcarpet::Markdown.new(Redcarpet::Render::HTML, :fenced_code_blocks => true)
end
def title
File.basename(filename, ".*")
end
def date
time.to_date
end
def show_path
entry_path(filename)
end
def edit_path
edit_entry_path(filename)
end
def fullpath
"#{Settings.github.entries_path}/#{filename}"
end
def github_path
"#{Settings.repository_files_url}/#{fullpath}"
end
def rendered_content
self.class.renderer.render(content).html_safe
end
end
View
結果的にViewがこういう風に書ける。 helperだけで実現するよりOOPっぽいコードになると思う。
-# app/views/entries/show.html.slim
- entry = decorate(@entry)
article
h1.title= entry.title
.content= entry.rendered_content
- if logged_in?
.buttons
= link_to_with_icon "pencil", entry.edit_path, :class => "btn"
= link_to_with_icon "trash", entry, :method => :delete, :class => "btn"
= link_to_with_icon "github", entry.github_path, :class => "btn"
-# app/views/entries/index.html.slim
ul.entries
- decorate(@entries).each do |entry|
li.entry
= link_to entry.show_path do
.title
i.icon-file-alt
= entry.title
.date= entry.date
# app/helpers/entries_helper.rb
module EntriesHelper
def decorate(args)
case args
when Enumerable
args.map {|e| EntryDecorator.new(e) }
else
EntryDecorator.new(args)
end
end
end
Testing
テストも簡潔に書けて良い。
require "spec_helper"
describe EntryDecorator do
let(:instance) do
described_class.new(entry)
end
let(:entry) do
mock(
:name => "title.md",
:path => "title.md",
:content => "# title",
:time => Time.utc(2000, 1, 1)
)
end
describe "#title" do
it "returns path without its extension part" do
instance.title.should == "title"
end
end
describe "#date" do
it "returns a date of its time" do
instance.date.should == Date.new(2000, 1, 1)
end
end
describe "#show_path" do
it "returns a path to show entry" do
instance.show_path.should == "/entries/title.md"
end
end
describe "#edit_path" do
it "returns a path to edit entry" do
instance.edit_path.should == "/entries/title.md/edit"
end
end
describe "#github_path" do
it "returns a path to show entry on github" do
instance.github_path.should == "https://github.com/owner/repo/tree/master/entries/title.md"
end
end
describe "#fullpath" do
it "returns a relative path of entry from repository root" do
instance.fullpath.should == "entries/title.md"
end
end
describe "#rendered_content" do
it "returns a content rendered as Markdown format" do
instance.rendered_content.should == "<h1>title</h1>\n"
end
end
end
Decoratorの話は、Objects on RailsやClean Ruby、The Rails View等にもっと詳しく載っていると思う。ActiveDecoratorを導入するというのもアリだと思う。