ユーザとユーザを多対多で関連付けるモデルを共通化する

思いのほか前回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の時代には戻れない。。。