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を導入するというのもアリだと思う。