#262 Trees with Ancestry
- Download:
- source codeProject Files in Zip (206 KB)
- mp4Full Size H.264 Video (16.7 MB)
- m4vSmaller H.264 Video (10.5 MB)
- webmFull Size VP8 Video (23.6 MB)
- ogvFull Size Theora Video (21.8 MB)
たとえばメッセージを登録できるアプリケーションがあるとします。メッセージページにはすべてのメッセージの一覧とその下には新しいメッセージを追加できるテキストフィールドがあります。
新しいメッセージはすべて一覧の一番下に現れます。今回はこのアプリケーションを改良して、メッセージをスレッド表示する機能を追加したいと思います。各メッセージに「Reply(返信)」リンクを追加して、特定のメッセージに返信できるようにします。これによって、新しいメッセージは一覧中の親メッセージのすぐ下に現れるようになります。
エピソード162[動画を見る, 読む]では、acts_as_treeプラグインを使ってツリー構造を作りました。今回もこの方法でうまくいくかもしれませんが、最高の速度を得ることはできません。この方法では、各メッセージの子供を特定するために、メッセージごとに個別にSQLを発行しなくてはいけないからです。あるメッセージのすべての子孫を一度の問い合わせで取得できればずっと効率的でしょう。
ネストされたセットを扱うプラグインはいくつかありますが、ここで紹介するのはAncestryというgemですこのgemのユニークな特徴は、数値フィールドに親レコードのid
を持つ方式ではなく、すべての階層情報をひとつの文字列フィールドに保持する点です。これによって各レコードが関連レコードを取得するのに必要な情報を持っているのに加え、Ancestryはそのためのparent
、 siblings
、children
などのメソッドを提供します。
アプリケーションにAncestryを追加するために、Gemfile
にgemの参照情報を追記し、bundle
コマンドを実行してインストールします。
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フィールドに索引を追加することを推奨しているので、マイグレーションの実行前にそれも追加しておきます。
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
の呼び出しを追加します。
class Message < ActiveRecord::Base has_ancestry end
これで完成です。Ancestryが設定されました。
メッセージのスレッド表示機能を追加する
Ancestryが設定できたので、アプリケーションを修正していきます。まず最初にメッセージのリストを表示するビューコードを修正して、各メッセージの下に「Reply」リンクを追加します。これを新規メッセージのページにリンクし、新規メッセージがどのメッセージへの返信かがわかるように、現在のメッセージのid
をparent_id
パラメータとして渡します。
<% 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" %>
MessageController
のnew
アクションで、parent_id
パラメータを新規メッセージに渡します。Ancestryはこのparent_id
パラメータを使ってメッセージの親を設定します。
def new @message = Message.new(:parent_id => params[:parent_id]) end
最後に新規メッセージフォームでparent_id
をhiddenフィールドとして追加し、新規メッセージが、返信されようとしているメッセージの子供として識別できるようにします。
<%= 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
をクエリ文字列に含んだ新規メッセージページに切り替わります。
このときに返信しようとしている元メッセージがフォーム上に表示されていた方が便利でしょう。理想的には「Reply」リンクがクリックされたときにAJAXベースの機能で動的に入力フォームを表示できればいいのですが、アプリケーションをシンプルにするために、新規メッセージページのフォームの上に親メッセージを表示することにします。これを簡単におこなうために、メッセージを表示する部分のコードをindexビューから切り出してpartialファイルにします。
<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を利用するように書き換えます。
<% title "Messages" %> <%= render @messages %> <%= render "form" %>
新しいテンプレートでは、親メッセージがあればこのpartialを使ってそれらを表示します。
<% 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
に代わって、スレッド化されたメッセージのリストを表示させます。
<% 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
で出力します。
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
を追加したので、スタイルを適用してインデントをかけ各メッセージと返信の関係がわかりやすくなるように修正します。
.nested_messages { margin-left: 30px; }
メッセージへの返信数が多いとマージンが増えすぎてスクリーンから溢れてしまいます。そこで次のようなスタイル規則を追加して、例えば3階層よりも深い場合はインデントしないようにします。
.nested_messages .nested_messages .nested_messages { margin-left: 0; }
メッセージページを再度読み込むと、ネストされたメッセージが正しくインデントされています。元からある返信に対してさらに返信を追加しても、正しくインデントされます。
ひとつのスレッドのみを表示する
今回の締めくくりに、もう一つ小さな機能を追加します。アプリケーションを修正して、メッセージをクリックするとそのメッセージとそれへの返信だけが表示されるようにします。
まず初めにメッセージのテキストをリンクに変更し、そのメッセージのshow
アクションにリンクします。
<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
で作成日順に並び替えます。
<% title "Message" %> <%= nested_messages @message.subtree.arrange(:order => :created_at) %> <p><%= link_to "Back to All Messages", messages_path %></p>
メッセージのページを再度読み込んで、いずれかのメッセージをクリックすると、そのメッセージとその子供を見ることができます。
あるメッセージにどれだけ多くの返信がついていたとしても、データベースへの1回の問い合わせですべてを取得できるので、アプリケーションのパフォーマンスへの影響を心配する必要はありません。
Ancestryについての今回のエピソードは以上です。Railsのモデルをツリー構造で保存する場合の優れた解決方法であり、このような処理が必要な場合は検討する価値があるでしょう。