homeASCIIcasts

262: Ancestryでツリー構造 

(view original Railscast)

Other translations: En Es

Other formats:

Written by Naomi Fujimoto

たとえばメッセージを登録できるアプリケーションがあるとします。メッセージページにはすべてのメッセージの一覧とその下には新しいメッセージを追加できるテキストフィールドがあります。

メッセージ登録用アプリケーション

新しいメッセージはすべて一覧の一番下に現れます。今回はこのアプリケーションを改良して、メッセージをスレッド表示する機能を追加したいと思います。各メッセージに「Reply(返信)」リンクを追加して、特定のメッセージに返信できるようにします。これによって、新しいメッセージは一覧中の親メッセージのすぐ下に現れるようになります。

エピソード162[動画を見る, 読む]では、acts_as_treeプラグインを使ってツリー構造を作りました。今回もこの方法でうまくいくかもしれませんが、最高の速度を得ることはできません。この方法では、各メッセージの子供を特定するために、メッセージごとに個別にSQLを発行しなくてはいけないからです。あるメッセージのすべての子孫を一度の問い合わせで取得できればずっと効率的でしょう。

ネストされたセットを扱うプラグインはいくつかありますが、ここで紹介するのはAncestryというgemですこのgemのユニークな特徴は、数値フィールドに親レコードのidを持つ方式ではなく、すべての階層情報をひとつの文字列フィールドに保持する点です。これによって各レコードが関連レコードを取得するのに必要な情報を持っているのに加え、Ancestryはそのためのparentsiblingschildrenなどのメソッドを提供します。

アプリケーションにAncestryを追加するために、Gemfileにgemの参照情報を追記し、bundleコマンドを実行してインストールします。

/Gemfile

source 'http://rubygems.org'

gem 'rails', '3.0.6'
gem 'sqlite3'
gem 'nifty-generators'
gem 'ancestry'

次にマイグレーションを実行してmessagesテーブルにAncestryの機能を追加します。

$ rails g migration add_ancestry_to_message ancestry:string

AncestryのREADMEファイルではancestryフィールドに索引を追加することを推奨しているので、マイグレーションの実行前にそれも追加しておきます。

/db/migrate/20110418204049_add_ancestry_to_message.rb

class AddAncestryToMessage < ActiveRecord::Migration
  def self.up
    add_column :messages, :ancestry, :string
    add_index :messages, :ancestry
  end

  def self.down
    remove_index :messages, :ancestry
    remove_column :messages, :ancestry
  end
end

通常のとおり、rake db:migrateでマイグレーションを実行します。

最後にMessageモデルのファイルにhas_ancestryの呼び出しを追加します。

/app/models/message.rb

class Message < ActiveRecord::Base
  has_ancestry
end

これで完成です。Ancestryが設定されました。

メッセージのスレッド表示機能を追加する

Ancestryが設定できたので、アプリケーションを修正していきます。まず最初にメッセージのリストを表示するビューコードを修正して、各メッセージの下に「Reply」リンクを追加します。これを新規メッセージのページにリンクし、新規メッセージがどのメッセージへの返信かがわかるように、現在のメッセージのidparent_idパラメータとして渡します。

/app/views/messages/index.html.erb

<% title "Messages" %>

<% for message in @messages %>
  <div class="message">
    <div class="created_at"><%= message.created_at.strftime("%B %d, %Y") %></div>   
    <div class="content">
      <%= message.content %>
    </div>
    <div class="actions">
      <%= link_to "Reply", new_message_path(:parent_id => message) %> |
      <%= link_to "Destroy", message, :confirm => "Are you sure?", :method => :delete %>
    </div>
  </div>
<% end %>

<%= render "form" %>

MessageControllernewアクションで、parent_idパラメータを新規メッセージに渡します。Ancestryはこのparent_idパラメータを使ってメッセージの親を設定します。

/app/controllers/messages_controller.rb

def new
  @message = Message.new(:parent_id => params[:parent_id])
end

最後に新規メッセージフォームでparent_idをhiddenフィールドとして追加し、新規メッセージが、返信されようとしているメッセージの子供として識別できるようにします。

/app/views/messages/_form.html.erb

<%= form_for @message do |f| %>
  <%= f.error_messages %>
  <%= f.hidden_field :parent_id %>
  <p>
    <%= f.label :content, "New Message" %><br />
    <%= f.text_area :content, :rows => 8 %>
  </p>
  <p><%= f.submit "Post Message" %></p>
<% end %>

では試してみましょう。メッセージ一覧のページを再度読み込むと、各メッセージに「Reply」リンクが付いています。リンクをクリックすると、返信しようとするメッセージのidをクエリ文字列に含んだ新規メッセージページに切り替わります。

親メッセージのidをクエリ文字列に持つ新規メッセージフォーム

このときに返信しようとしている元メッセージがフォーム上に表示されていた方が便利でしょう。理想的には「Reply」リンクがクリックされたときにAJAXベースの機能で動的に入力フォームを表示できればいいのですが、アプリケーションをシンプルにするために、新規メッセージページのフォームの上に親メッセージを表示することにします。これを簡単におこなうために、メッセージを表示する部分のコードをindexビューから切り出してpartialファイルにします。

/app/views/messages/_message.html.erb

<div class="message">
  <div class="created_at"><%= message.created_at.strftime("%B %d, %Y") %></div>
  <div class="content">
    <%= message.content %>
  </div>
  <div class="actions">
    <%= link_to "Reply", new_message_path(:parent_id => message) %> |
    <%= link_to "Destroy", message, :confirm => "Are you sure?", :method => :delete %>
  </div>
</div>

indexビューのコードも修正し、新しく作ったpartialを利用するように書き換えます。

/app/views/messages/index.html.erb

<% title "Messages" %>

<%= render @messages %>

<%= render "form" %>

新しいテンプレートでは、親メッセージがあればこのpartialを使ってそれらを表示します。

/app/views/messages/new.html.erb

<% title "Reply" %>

<%= render @message.parent if @message.parent %>

<%= render "form" %>

<p><%= link_to "Back to Messages", messages_path %></p>

既存のメッセージで「Reply」をクリックすると、入力フォームの上にそのメッセージが表示されます。返信を入力し、どうなるか見てみましょう。

返信されているメッセージがフォームの上に表示される

「Post Message(メッセージを送信)」ボタンをクリックすると、indexアクションにリダイレクトされ、新規メッセージがリストに追加されます。新規メッセージの親はAncestryによって記録されますが、先に定義したリストの表示方法に従い、新規メッセージは親の下ではなくリストの最後に表示されています。

新規メッセージがフォームの上に表示される

メッセージを並び替える

Ancestryにはarrangeというメソッドがあり、複数のレコードがネストされたハッシュの集合として返されるので、メッセージをスレッド表示させるに非常に適しています。このメソッドに:order句を渡して、レコードの並び順を指定できます。

indexテンプレートで、arrangeを用いて希望する順番でメッセージを返すように指定します。arrangeはハッシュを返すので、出力を直接renderに渡すことはできないため、各メッセージをループ処理する必要があります。これをnested_messagesというヘルパーメソッド内で行い、renderに代わって、スレッド化されたメッセージのリストを表示させます。

/app/views/messages/index.html.erb

<% title "Messages" %>

<%= nested_messages @messages.arrange(:order => :created_at) %>

<%= render "form" %>

MessagesHelper内にnested_messagesを作成し、これがネストされたメッセージのハッシュの集合を受け取ります。このメソッドは、各ハッシュをループ処理するためにmapを利用し、最後にまたすべてをひとつに結合します。mapがとるブロックは、キーとして一つのメッセージを、値としてその子メッセージを持ちます。

ブロック内では、まず現在のメッセージを表示し、その後再帰的にnested_messagesを呼び出して現在のメッセージの子を渡します。子メッセージの外観は後ほど修正するとして、content_tagを用いて子メッセージをnested_messagesというクラスを定義したdivに入れます。すべてのメッセージの表示が定義できたらすべてを結合して、html_safeで出力します。

/app/helpers/messages_helper.rb

module MessagesHelper
  def nested_messages(messages)
    messages.map do |message, sub_messages|
      render(message) + content_tag(:div, nested_messages(sub_messages), :class => "nested_messages")
    end.join.html_safe
  end
end

メッセージのページを再度読み込むと、最初のメッセージへの返信が、親の下の正しい場所に表示されています。

メッセージが正しい順番で表示される

子メッセージにclassを追加したので、スタイルを適用してインデントをかけ各メッセージと返信の関係がわかりやすくなるように修正します。

/public/stylesheets/application.css

.nested_messages {
  margin-left: 30px;
}

メッセージへの返信数が多いとマージンが増えすぎてスクリーンから溢れてしまいます。そこで次のようなスタイル規則を追加して、例えば3階層よりも深い場合はインデントしないようにします。

/public/stylesheets/application.css

.nested_messages .nested_messages .nested_messages {
  margin-left: 0;
}

メッセージページを再度読み込むと、ネストされたメッセージが正しくインデントされています。元からある返信に対してさらに返信を追加しても、正しくインデントされます。

返信がインデントされている

ひとつのスレッドのみを表示する

今回の締めくくりに、もう一つ小さな機能を追加します。アプリケーションを修正して、メッセージをクリックするとそのメッセージとそれへの返信だけが表示されるようにします。

まず初めにメッセージのテキストをリンクに変更し、そのメッセージのshowアクションにリンクします。

/app/views/messages/_message.html.erb

<div class="message">
  <div class="created_at"><%= message.created_at.strftime("%B %d, %Y") %></div>
  <div class="content">
    <%= link_to message.content, message %>
  </div>
  <div class="actions">
    <%= link_to "Reply", new_message_path(:parent_id => message) %> |
    <%= link_to "Destroy", message, :confirm => "Are you sure?", :method => :delete %>
  </div>
</div>

次にshowテンプレートを修正して、メッセージを表示させます。メッセージでsubtreeを呼び出して、そのメッセージとそのすべての子供を取得して、arrangeで作成日順に並び替えます。

/app/views/messages/show.html.erb

<% title "Message" %>

<%= nested_messages @message.subtree.arrange(:order => :created_at) %>

<p><%= link_to "Back to All Messages", messages_path %></p>

メッセージのページを再度読み込んで、いずれかのメッセージをクリックすると、そのメッセージとその子供を見ることができます。

個別のスレッドのメッセージ

あるメッセージにどれだけ多くの返信がついていたとしても、データベースへの1回の問い合わせですべてを取得できるので、アプリケーションのパフォーマンスへの影響を心配する必要はありません。

Ancestryについての今回のエピソードは以上です。Railsのモデルをツリー構造で保存する場合の優れた解決方法であり、このような処理が必要な場合は検討する価値があるでしょう。