ユーザとユーザを多対多で関連付けるモデルを共通化する
思いのほか前回のRailsプチ・デザインパターンの紹介に反応があったので、こういう小ネタも出していったほうがいいのかな、ということで第二弾。
ソーシャル系アプリだと、ユーザとユーザを関連付ける多対多のモデルがたくさんでてきます。たとえば、一般的なところではフォローとかブロックとか足あととか。さらにデーティングサイトになると、ウィンクだったり、Secret admirer(こっそりlikeするけど両思いだったらおめでとうって通知がくるってやつ)だったり、いろいろなモデルがこのパターンにあてはまります。
この場合、「AがBをフォローしている」「BがAをフォローしている」「AとBがお互いにフォローしている」という3つの状態があるわけですが、相互フォローの状態は「AがBをフォローし、かつBがAをフォローしている」と読み替えてSQLでも記述可能なので、以下ではシンプルに単方向のグラフで全てを扱うものとします。こうすることで、フレンドリクエストとそれへの承認というのも、同じ枠組みで扱えるようになります。
一般的にはこういう感じの記述になるでしょう。
class User < ActiveRecord::Base has_many :follows has_many :follows_as_target, class_name: 'User', foreign_key: 'target_user_id' end class Follow < ActiveRecord::Base belongs_to :user belongs_to :target_user, class_name: 'User', foreign_key: 'target_user_id' end
ここでUser側にhas_many :through
も定義したくなるかもしれませんが、user.follows.preload(:target_user).map(&:target_user)
などとすればFollowモデルの向こう側にいるユーザ一覧も簡単にとれるので、あえて定義しないことにします。
followsテーブルの構造はこんな感じ。
create_table :follows do |t| t.integer :user_id, null: false t.integer :target_user_id, null: false t.datetime :created_at, null: false t.index [:user_id, :target_user_id], unique: true t.index :target_user_id end
(user_id, target_user_id)の組み合わせにユニーク制約つきのインデックスをはると同時に、target_user_id単独のインデックスもはります。この理由は、組み合わせのインデックスの左側のプレフィックスは検索に使えるため、user_id側から検索する場合には(user_id, target_user_id)のインデックスが使えるけれども、target_user_id側から検索する場合にはそれが使えないので、単独のインデックスを用意してやります。
さらに、あるユーザを削除するときには、関連するfollowsレコードを、まずuser_id側から探して消し、またtarget_user_id側からも探して消してやる必要があります。なので両側にdependent: :delete_all
を定義して、ユーザと一緒にカスケード削除されるようにします。
has_many :follows , dependent: :delete_all has_many :follows_as_target, dependent: :delete_all, class_name: 'User', foreign_key: 'target_user_id'
さて、ここでBlockやVisitやWinkといったFollowにそっくりなモデルが増えてくると、User側のコードは上記のコピペでどんどん膨れ上がっていきます。
そこで、これらの共通処理をRails 4から正式な機能へと格上げされたActiveSupport::ConcernでDRYにすることを検討してみましょう。
module GraphicalHasMany extend ActiveSupport::Concern module ClassMethods def graphical_has_many(name) has_many name , dependent: :delete_all has_many :"#{name}_as_target", dependent: :delete_all, class_name: 'User', foreign_key: 'target_user_id' end end end
上記のコードを、app/models/concerns/graphical_has_many.rb
に保存し、
module GraphicalBelongsTo extend ActiveSupport::Concern included do belongs_to :user belongs_to :target_user, class_name: 'User', foreign_key: 'target_user_id' validates :target_user_id, uniqueness: { scope: :user_id } end end
をapp/models/concerns/graphical_belongs_to.rb
に保存します。
なお、Rails 3系の場合は、config/application.rb
で
config.eager_load_paths << "#{config.root}/app/models/concerns"
を追加しておけば、Rails 4と同様に動きます。
さて、上記の準備ができれば、モデルは以下のように書き換えられます。
class User < ActiveRecord::Base include GraphicalHasMany graphical_has_many :follows graphical_has_many :blocks graphical_has_many :visits ... end class Follow < ActiveRecord::Base include GraphicalBelongsTo end
さて、ここでいったん一区切りとし、冒頭に少し触れた「相互フォロー状態はSQLで記述可能」という部分にたちもどってみましょう。アイデアとしては、「AがBをフォローし、かつBがAをフォローしている」というAND集合を取り出せばよいので、followsテーブルをセルフジョインしてやるのがよさそうです。なので、ベタにJOIN句を文字列で書くならば
scope :reciprocal, -> { joins("INNER JOIN follows AS f ON follows.target_user_id = f.user_id AND f.target_user_id = follows.user_id") }
というスコープをFollowクラスに定義してやれば、user.follows.reciprocal
で相互フォローしているユーザ一覧がとれるようになります。しかし、これではテーブル名が固定になってしまうので、汎用化することができません。また、Rails標準の機能ではジョイン時にテーブルエイリアスを指定することができない(これができるようになるだけで数々の問題が解決するのですが。。。)ので、ジョインの記述を生のArelを使って組み立てることにし、reciprocalメソッドとしてGraphicalBelongsToに追加します。
module GraphicalBelongsTo extend ActiveSupport::Concern included do belongs_to :user belongs_to :target_user, class_name: 'User', foreign_key: 'target_user_id' validates :target_user_id, uniqueness: { scope: :user_id } end module ClassMethods def reciprocal t1 = arel_table t2 = arel_table.alias('alt') join_node = t1.join(t2).on(t1.create_and([t1[:target_user_id].eq(t2[:user_id]), t1[:user_id].eq(t2[:target_user_id])])).join_sources joins(join_node) end end end
ちょっと煩雑に見えますが、やってることは基本的に文字列のベタ書きと同じで、違いは、どのクラスでも動くということです。
こんな感じで、モデル間で共通する処理はどんどんくくりだしていくことができます。
このアプローチの何がいいかというと、共通部分を奥にしまいこんで隠せることで、各モデルに固有の処理をする部分がよりハイライトされる・それに専念できるということです。
ユーザの持ち物を扱うメソッドがUserに集中しすぎて、Userクラスが肥大化してしまうというのはよくあることですが、せっかくFollowやBlockといったクラスがあるのだから、各モデル固有の処理はそちら側に書いてやるほうがコンテキストに沿った分離がしやすいはずです。
そのために、has_many
にはextend
というオプションがあり、外部モジュールを取り込むことができます。これをうまく使ってやることで、拡張をUserクラスに直接書かずに済みます。というわけで、GraphicalHasManyを拡張して、オプションをとるようにしましょう。
module GraphicalHasMany extend ActiveSupport::Concern module ClassMethods def graphical_has_many(name, options = {}) has_many name , options.merge(dependent: :delete_all) has_many :"#{name}_as_target", options.merge(dependent: :delete_all, class_name: 'User', foreign_key: 'target_user_id') end end end
そして、モデルにはこう書きます。
class User < ActiveRecord::Base include GraphicalHasMany graphical_has_many :follows, extend: Follow::UserScopes end
class Follow < ActiveRecord::Base include GraphicalBelongsTo module UserScopes def since(time) where('follows.created_at > ?', time) end end end
こうすることで、user.follows.since(3.days.ago)
のようなFollowの拡張を、Followクラス自身に書いていくことが可能となり、Userクラスはスッキリしたまま、将来的にも変更がいらない状態になりました。
さて、今回のパターンは、あくまでコードを読みやすく整理する、という観点ではあるのですが、実際にBefore / Afterでコードを見比べてみると、その違いを体感できるでしょう。
ちなみに、今回のようなコード共通化をSingle Table Inheritanceを使って実現する方法もあって、そちらのほうがコード自体はシンプルになるのですが、多対多のテーブルはそれ自体がユーザ数の二乗で増えていくのに加えて、複数のモデルが同一テーブルに同居することにより、レコードの増加スピードはさらに加速されるのでボトルネックになりやすく、最初からテーブルを分けておくのが得策です。
さらに続きの話としては、default_scope { where(deleted_at: nil) }
のようなデフォルトスコープを使って論理削除を実現している場合にも対応するため、GraphicalHasManyやGraphicalBelongsToをもう一段深く拡張していくこともできるのですが、だいぶ話が長くなったので今回はここまで。
それにしても、ORMもここまで来ると次元が違いますね。もう生SQLの時代には戻れない。。。