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

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

APIのバージョニングは限局分岐でやるのが良い

ちょっと前にTwitterAPIのバージョニングをどうやるかみたいな話をしていたのですが、そのへんもやもやしているので少し整理しておきたいなと。

さて、これについて色々と異論・反論も含めた意見が出たのですが、まずは、大昔にURL方式(=コントローラ分割)でやってきて後悔したぼくが、(5年ぐらい前から)現在はどうやってAPIのバージョンを管理しているか?について紹介します。

基本原理としては、コピペが多発する根っこで分岐(=コントローラ分割)じゃなくて、必要最小限のところで限局的に分岐するのがいい、という考え方に基づきます。

一言でいうと、「パラメータとしてAPI versionを渡し、それをリクエスト単位でスレッドローカル変数に保存し、必要に応じて分岐する」というやり方です。

API versionの渡し方

具体的なAPI versionの渡し方としては、おおまかに二種類あります。

まずは、ログインしてセッションを生成する段階でパラメータとして渡す方法。ものすごく簡素化していうと

/api/session/create?id=foo&password=bar&api_version=1

みたいな感じで、認証に成功したら短命なsession_tokenを受け取り、以降はサーバ側で保存されたAPI versionが適用されるので、クライアント側からはapi_versionのような付帯情報を毎回送る必要がなく、session_tokenだけをキーとして送ればよくなります。

このやり方は、グループチャットのLingrや対戦ゲームインフラのPankiaのような、オンライン・オフライン状態の識別がクリティカルなサービスの場合に有効です。

もう一つは、API Versioning - O'Reilly Broadcastでも紹介されていますが、毎回HTTP Headerにapi_versionを埋め込む方法です。

X-Api-Version: 1

このやり方は、データベースのリモートバックアップサービスDumperで採用していますが、毎回送るべき情報が少なく、オンライン・オフラインの区別がほとんどないサービスに有効です。OAuthベースのシステムとも親和性が高いでしょう。

リクエスト・コンテキスト

さて、なんらかの方法でAPI versionがリクエストにのってサーバに受け渡されたとして、それを使って分岐する可能性のある場所は、コントローラに限りません。データベースの構造が変わった場合や、JSONの構造が変わった場合など、モデルやその他のクラスでもAPI versionを参照する必要がでてきます。

そこで、リクエスト単位をライフサイクルとするスレッドローカルな変数を使い、コードのどこからでもAPI versionにアクセスできるようにします。

具体的にRailsのコードで見ていきましょう。まずは、「APIというコンテキスト」を扱うオブジェクトを定義します。ここではOpenStructを使って、ApiContext.api_version = 1のような形でアサインすると、自動でスレッドローカルに保存してくれるようにします。以下のコードをlib/api_context.rbに置きます。

require 'ostruct'

class ApiContext
  class << self
    def method_missing(method_name, *args)
      Thread.current[:api_context] ||= OpenStruct.new
      Thread.current[:api_context].send(method_name, *args)
    end
end

そして、app/controllers/application_controller.rb(あるいはapp/controllers/api/base_controller.rb)に

class ApplicationController < ActionController::Base
  before_action :clear_context_variables

  def clear_context_variables
    Thread.current[:api_context] = nil
  end
end

のようにしてグローバルなbefore_actionを定義してやり、リクエスト毎に必ずスレッドローカル変数がクリアされるようにしておき、

ApiContext.api_version = request.headers['x-api-version'].to_i

のような形でアサインします。以降はこのApiContext.api_versionを参照して、コントローラやモデルなど、どこにでも分岐を記述できるようになり、

if ApiContext.api_version > 1
  { result: [{ name: 'foo', age: 20 }] }
else
  { result: { name: 'foo', age: 20 } }
end

のように局所的に分岐できるようになります。

ところで余談ですが、スレッドローカル変数はいわゆる(スレッドセーフな)グローバル変数なので、条件反射的に「使うべきではない」という反応をする人たちがいます。しかしRails本体でも、たとえばi18nでリクエストごとの言語ロケールをセットするところなどはスレッドローカルで実装されており、リクエストをライフサイクルとする広域変数にスレッドローカル変数を使うのはむしろ定番です。

ただし、当然ながら副作用としてスコープリークが発生し、MVCの分離が甘くなるので、ApiContextの使用は必要最小限にとどめ、テストの記述にも注意が必要です。

議論

さて、ここまでの実装をふまえて、個々に議論していきたいと思います。

まさしくその通り!

つまり、コントローラ分割だと、よほど根本的で大きな変更がないかぎり、v2を導入することができなくなります。言い換えると、99.9%のサービスでは、v2を導入する日は永遠にやってきません。

ぼくがやりたいのは逆で、もっと気軽にAPI versionを使って、APIの質をどんどん改善していくことです。非互換な変更を入れなければいけないときというのは、よっぽど慎重にデザインしていても、やっぱり発生します。そういうときに、互換性を壊さないために「改善しない」という選択をするのではなく、「古いクライアントはそのまま動く、新しいクライアントではより良く動く、時間とともに良いクライアントが増える、いつかは古いバージョンをディスコン」というオペレーションです。

これもわかります。というか、その昔にぼくがURLで丸ごと切り替えるという判断をした理由もこれでした。

とにかくコードは一行でもいじると壊れる可能性があるので、互換性を最大限に維持したいなら、一切いじらなくてすむのが一番。だから古いコードはさわらず、新しくv2として書き始める。

と考えたのですが、これには大きな罠があります。

それは、アプリが進化する限り、下回りで依存しているライブラリ(Rails本体、Gemなど)もアップデートし続ける必要があり、それにあわせて結局v1のコードも書き換え続ける必要がある、ということです。

v1, v2, v3と増えるごとに、書き換える対象が増え続ける、これぞまさしく「コピペが増える」という表現で伝えたかったことです。

これは結局、シングルソースの限局分岐をメンテするよりも多大な労力がかかるので、結果的に後方互換性を壊してしまうリスクは同等かそれ以上にある、ということになります。

それだったらば、ライブラリにあわせての変更は一箇所で済み、分岐が限局されているやり方のほうが、結果的に品質も高くなるのではないか、という考え方もあるかなと思います。

そうそう、極めつけはこれだと思います。

せっかくコントローラを分けても、モデル層やJSON生成層その他で発生した非互換な変更には無力なので、結局ぼくがやってるのと似たような仕組みでMVCを超えたバージョンの受け渡しが必要になります。コントローラだけを大局分岐してバージョン管理できた気になるというのは、やはり悪手だろうと思います。

なんかすごそうなことをやってる。。。もうちょっとkwsk!というわけで、33の続報をRebuildできけることに期待。

いかがでしょうか。

ぼくなりの現時点でのテンポラリな結論としては以上なのですが、MVCがリークしているのでテストの書き方が難しいとか、まだまだ満足のいく水準にあるとはいえず、これという正解が見つかってない世界だと思います。

このやり方でうまくいってるという方法があれば、ぜひ教えてください。