ユーザとユーザを多対多で関連付けるモデルを共通化する
思いのほか前回の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のバージョニングは限局分岐でやるのが良い
ちょっと前にTwitterでAPIのバージョニングをどうやるかみたいな話をしていたのですが、そのへんもやもやしているので少し整理しておきたいなと。
APIのURLを/api/v1/*とかってやるの、やめたほうがいいとおもうんだけどなぁ。いざv2を作るとなったときに、大量のコピペが発生して後悔するよ、って伝えたい。
— Kenn Ejima (@kenn) February 28, 2014
さて、これについて色々と異論・反論も含めた意見が出たのですが、まずは、大昔に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の使用は必要最小限にとどめ、テストの記述にも注意が必要です。
議論
さて、ここまでの実装をふまえて、個々に議論していきたいと思います。
@kenn ああ、そういうことか。了解。まぁよほど変わらない限りv2は使わないよね。そもそも。コピペレベルじゃないときにv2を使うイメージ。
— Yuichiro MASUI (@masuidrive) February 28, 2014
まさしくその通り!
つまり、コントローラ分割だと、よほど根本的で大きな変更がないかぎり、v2を導入することができなくなります。言い換えると、99.9%のサービスでは、v2を導入する日は永遠にやってきません。
ぼくがやりたいのは逆で、もっと気軽にAPI versionを使って、APIの質をどんどん改善していくことです。非互換な変更を入れなければいけないときというのは、よっぽど慎重にデザインしていても、やっぱり発生します。そういうときに、互換性を壊さないために「改善しない」という選択をするのではなく、「古いクライアントはそのまま動く、新しいクライアントではより良く動く、時間とともに良いクライアントが増える、いつかは古いバージョンをディスコン」というオペレーションです。
.@kenn 僕は、古いAPI実装を壊したくないから、バージョンによる分岐よりも URI パス等で実装まるごと切り替えるアプローチのが好きですねー
— Kazuho Oku (@kazuho) February 28, 2014
これもわかります。というか、その昔にぼくがURLで丸ごと切り替えるという判断をした理由もこれでした。
とにかくコードは一行でもいじると壊れる可能性があるので、互換性を最大限に維持したいなら、一切いじらなくてすむのが一番。だから古いコードはさわらず、新しくv2として書き始める。
と考えたのですが、これには大きな罠があります。
それは、アプリが進化する限り、下回りで依存しているライブラリ(Rails本体、Gemなど)もアップデートし続ける必要があり、それにあわせて結局v1のコードも書き換え続ける必要がある、ということです。
v1, v2, v3と増えるごとに、書き換える対象が増え続ける、これぞまさしく「コピペが増える」という表現で伝えたかったことです。
これは結局、シングルソースの限局分岐をメンテするよりも多大な労力がかかるので、結果的に後方互換性を壊してしまうリスクは同等かそれ以上にある、ということになります。
それだったらば、ライブラリにあわせての変更は一箇所で済み、分岐が限局されているやり方のほうが、結果的に品質も高くなるのではないか、という考え方もあるかなと思います。
@kenn そうですね。さらにいうとコントローラーをコピペなどで分けても結局さらに DB などの層でバージョンニングを意識しないといけませんしね。
— Taro Minowa Higepon (@higepon) February 28, 2014
そうそう、極めつけはこれだと思います。
せっかくコントローラを分けても、モデル層やJSON生成層その他で発生した非互換な変更には無力なので、結局ぼくがやってるのと似たような仕組みでMVCを超えたバージョンの受け渡しが必要になります。コントローラだけを大局分岐してバージョン管理できた気になるというのは、やはり悪手だろうと思います。
@kenn 最近はRESTfulなエンドポイントは完全に後方互換なまま、クライアントごとにオーケストレーション層(radical versionin)を設けるという方向にシフトしようとしている。詳しくは http://t.co/zODm7mFr5B
— Tatsuhiko Miyagawa (@miyagawa) February 28, 2014
なんかすごそうなことをやってる。。。もうちょっとkwsk!というわけで、33の続報をRebuildできけることに期待。
いかがでしょうか。
ぼくなりの現時点でのテンポラリな結論としては以上なのですが、MVCがリークしているのでテストの書き方が難しいとか、まだまだ満足のいく水準にあるとはいえず、これという正解が見つかってない世界だと思います。
このやり方でうまくいってるという方法があれば、ぜひ教えてください。