#364 Active Record Reputation System
- Download:
- source codeProject Files in Zip (112 KB)
- mp4Full Size H.264 Video (24.5 MB)
- m4vSmaller H.264 Video (12 MB)
- webmFull Size VP8 Video (14.3 MB)
- ogvFull Size Theora Video (27.4 MB)
下の図は、ユーザが俳句を作って投稿することができる“You Haiku”というアプリケーションのスクリーンショットです。
ここにはすでにいくつかの俳句が登録されていますが、ユーザがそれらに対してupかdownで投票できるようにしたいと思います。このアプリケーションには投票システムがまだありませんが、この機能をどう追加すればいいでしょうか? これをゼロから作る方法もありますが、ここではActive Record Reputation Systemというgemを使用することにします。このgemを使って、簡単にユーザ評価の平均を計算したり、投票数を集計することなどが可能になります。今回はこれを自分のアプリケーションで利用できるようにする方法を説明します。
作業にとりかかる
まず最初にgemfileにgemを追加して、bundleコマンドを実行してインストールを行ないます。ファイルを別途reputation_system
としてrequireする必要がありますので注意してください。
gem 'activerecord-reputation-system', require: 'reputation_system'
ジェネレータを実行して、必要なデータベーステーブルを準備するためのmigrationファイルを作成します。これらのファイルの中身については後ほど詳しく見てみることにして、とりあえずここでは必要なテーブルとフィールドをデータベースに追加するためにマイグレーションを実行します。
$ rails g reputation_system $ rake db:migrate
次にユーザに投票させたい対象のモデルを修正して、has_reputation
の呼び出しを追加します。これに対して、reputationに設定したい名前(今回はvotes)と2つのオプションを渡します。1つはsource
で投票を行なうモデルの名称を指定し、もう1つはaggregated_by
で、これをどう集計したいかによってsum
, average
, product
のいずれかを指定します。これらのオプションは、渡すことができるその他のオプションと合わせて、READMEに説明があります。
class Haiku < ActiveRecord::Base attr_accessible :content belongs_to :user has_reputation :votes, source: :user, aggregated_by: :sum end
このコードを設定したら、投票システムにとりかかることができます。それぞれの俳句の横に2つのリンクを設定したいと思います。ユーザがupかdownに投票できるようにするために、これらのリンクはどこにrouteを設定すればいいのでしょうか?いくつか選択肢があります。独立したhaiku_votes
リソースを作るか、あるいはこれをHaiku
リソースのメンバーアクションにするという方法もあります。ここでは後者の方法をとることにして、POSTリクエストをとるvotes
アクションを追加します。
Youhaiku::Application.routes.draw do get 'signup', to: 'users#new', as: 'signup' get 'login', to: 'sessions#new', as: 'login' get 'logout', to: 'sessions#destroy', as: 'logout' resources :users resources :sessions resources :haikus do member { post :vote } end root to: 'haikus#index' end
次にHaikusController
にvoteアクションを追加します。voteの値は、upかdownのいずれかに投票されることで1
か-1
のいずれかになります。typeパラメータを使用して、値が“up”の場合は投票をプラスの評価としてカウントします。次にid
で俳句を取得してadd_evaluation
というメソッドを呼び出します。このメソッドは3つの引数をとります。reputationの名前、追加する値、ソースオブジェクト(今回の場合は現在のユーザ)です。最後に参照元にリダイレクトし、フラッシュメッセージを表示します。
def vote value = params[:type] == "up" ? 1 : -1 @haiku = Haiku.find(params[:id]) @haiku.add_evaluation(:votes, value, current_user) redirect_to :back, notice: "Thank you for voting!" end
次に俳句を表示する部分テンプレートに投票用のリンクを追加します。このリンク先をvote_haiku_path
にして、voteタイプを反映したタイプ属性をとります。
<div class="haiku"> <%= simple_format haiku.content %> <em> -- <%= haiku.user.name %> | <%= link_to "up", vote_haiku_path(haiku, type: "up"), method: "post" %> | <%= link_to "down", vote_haiku_path(haiku, type: "down"), method: "post" %> </em> </div>
これらの変更を有効化するためにサーバを再起動して、ページをリロードすると投票用リンクが表示されます。
投票を更新する
俳句にupを投票してから気が変わって改めてdownの投票を行なうとActiveRecordエラーが表示されます。これは同じユーザが同じ俳句に2度投票を行なったためで、reputationシステムは自動的に重複投票を禁じます。HaikusController
でこの例外をrescue
する方法もありますが、ここではadd_or_update_evaluation
という別のメソッドを使って投票を記録することにします。
def vote value = params[:type] == "up" ? 1 : -1 @haiku = Haiku.find(params[:id]) @haiku.add_or_update_evaluation(:votes, value, current_user) redirect_to :back, notice: "Thank you for voting!" end
このコードは、既存の投票が見つかった場合にそれを更新します。これによって、あるユーザが2度目に投票した場合は、現在の投票が変更されます。それぞれの俳句に対する投票数がわかれば便利なので、次にその機能を付加します。そのためには俳句に対してreputation_value_for
を呼び出して、値を取得したいreputationを渡します。これはfloat値を返すので、to_i
を呼び出して位を丸めます。
<div class="haiku"> <%= simple_format haiku.content %> <em> -- <%= haiku.user.name %> | <%= pluralize haiku.reputation_value_for(:votes).to_i, "vote" %> | <%= link_to "up", vote_haiku_path(haiku, type: "up"), method: "post" %> | <%= link_to "down", vote_haiku_path(haiku, type: "down"), method: "post" %> </em> </div>
俳句の一覧をupの投票数に基づいてソートしておくといいでしょう。これをHaikusController
内の、今はすべての俳句を取得しているだけの、index
アクションで行なうことにします。新たにfind_with_reputation
を使用して、俳句を正しい順番で取得します。
def index @haikus = Haiku.find_with_reputation(:votes, :all, order: 'votes desc') end
2つ目の引数には、適用したいスコープを指定します。reputation scopeについてまだ説明していませんでした。これはActiveRecordの名前付きスコープとは別の、Reputation Systemに固有のものです。ここでは:all
スコープを使用してすべてを検索します。ページをリロードすると、俳句が正しい順番で表示されます。
次にユーザが自分の俳句に対して受けた投票数を表示したいのですが、Reputation Systemがreputationを間接的に定義できるので以下のようにします。
class User < ActiveRecord::Base has_secure_password attr_accessible :name, :password, :password_confirmation validates_uniqueness_of :name has_many :haikus has_reputation :votes, source: {reputation: :votes, of: :haikus}, aggregated_by: :sum end
ここでhas_reputationを元データのハッシュとともに使用します。これがReputation Systemに対して、Haikuモデルの中のvotesというreputationに委譲するよう指示します。この結果を集計し、ユーザに対する全体スコアを算出します。これを、アプリケーションのレイアウトファイルでユーザ名を表示する部分で使用します。
Logged in as <strong><%= current_user.name %></strong> (<%= current_user.reputation_value_for(:votes).to_i %>).
ページをリロードすると、ユーザ名の横に現在のユーザのスコアが表示されています。
ユーザ画面に自分が投票した俳句を表示する
自分が投票した俳句をユーザが簡単に見られるように、俳句の横に何かを表示してリンクを隠します。このgemにはこれを実現する方法はないようですが、できないというわけではありません。先ほど生成したmigrationファイルの一つを見てみると、gemがrs_evaluations
というデータベーステーブルを作成しているのがわかります。ユーザが投票するとここにレコードが追加されます。
def self.up create_table :rs_evaluations do |t| t.string :reputation_name t.references :source, :polymorphic => true t.references :target, :polymorphic => true t.float :value, :default => 0 t.timestamps end # Rest of migration omitted end
このテーブルには以下の情報が記録されています。reputationの名前(今回の例ではvotes
)、source
(今回はUser
モデル)、target(Haiku
モデル)、value
は投票がupかdownかによって1
あるいは-1
になります。source
とtarget
が共にポリモーフィック関連であることに注目してください。このテーブルに対応したRSEvaluation
というモデルがあり、これは以下のようにUser
レコードをそのモデルに関連づけられることを意味しています。
has_many :evaluations, class_name: "RSEvaluation", as: :source
ポリモーフィック関連なので、ここではas:
オプションが必要です。これによって、あるユーザが特定の俳句に投票したかどうかがわかるようになっています。
def voted_for?(haiku) evaluations.where(target_type: haiku.class, target_id: haiku.id).present? end
ここですべての評価を取得し、正しいtype
とid
の評価が存在するかを判定します。一つのページでこの処理を何回も行なうのであればもっと効率的な方法もありますが、ここではこのアプローチで十分です。このメソッドを使って、ユーザがすでに投票している場合はリンクを隠します。
<div class="haiku"> <%= simple_format haiku.content %> <em> -- <%= haiku.user.name %> | <%= pluralize haiku.reputation_value_for(:votes).to_i, "vote" %> <% if current_user && !current_user.voted_for?(haiku) %> | <%= link_to "up", vote_haiku_path(haiku, type: "up"), method: "post" %> | <%= link_to "down", vote_haiku_path(haiku, type: "down"), method: "post" %> <% end %> </em> </div>
ページをリロードすると、投票した俳句のリンクが消えました。
このアプリケーションにさらに手を加えることも可能です。例えば、上記の制限をコントローラアクションに追加したり、ユーザが自分自身の俳句には投票できないようにするなどが考えられますが、ここでは触れません。
投票機能をゼロから作る
ここまでActiveRecord Reputation Systemを駆け足で紹介しました。便利なgemですが、同じ機能をゼロから作るのもそれほど難しくないのではないかという気もします。実際そのとおりで、そのソースコードをGithubで見ることができます。今回のエピソードの最後の部分を使ってこのコードの中身を簡単に見ていきます。
ここにはHaikuVote
モデルがあり、Haiku
とUser
の両方に属して(belong to)います。このアプローチのいいところは、カスタムの検証を置くことができるという点です。これによって例えば受け入れた投票数の値や、投票しているユーザが自分自身の俳句に投票しているのかどうかなどを検証できます。
class HaikuVote < ActiveRecord::Base attr_accessible :value, :haiku, :haiku_id belongs_to :haiku belongs_to :user validates_uniqueness_of :haiku_id, scope: :user_id validates_inclusion_of :value, in: [1, -1] validate :ensure_not_author def ensure_not_author errors.add :user_id, "is the author of the haiku" if haiku.user_id == user_id end end
ゼロから作る場合にもっとも難しいのが、投票数に基づいて俳句をソートすることです。これはHaiku
モデルのby_votes
というクラスメソッドに実装されています。
class Haiku < ActiveRecord::Base attr_accessible :content belongs_to :user has_many :haiku_votes def self.by_votes select('haikus.*, coalesce(value, 0) as votes'). joins('left join haiku_votes on haiku_id=haikus.id'). order('votes desc') end def votes read_attribute(:votes) || haiku_votes.sum(:value) end end
この機能のためにはSQLのコーディングが多少必要ですが、これで目的は達せられます。
もう一つ厄介なのが、ユーザが受けた投票数を求める部分ですが、ActiveRecordのjoins
メソッドを利用できるのでここではSQLコードを使う必要はありません。
def total_votes HaikuVote.joins(:haiku).where(haikus: {user_id: self.id}).sum('value') end
では結局、この機能をゼロから書くべきか、gemを使うべきか、どちらがいいのでしょうか?複雑な設定の場合、特にreputationを処理するモデルが複数あるような場合にはこのgemが有効でしょう。ポリモーフィック関連を利用していることがここで役に立ちます。今回のサンプルアプリケーションのように比較的シンプルな設定の場合は、ゼロから作る方がいいでしょう。