こんにちは、アジャイル事業部 9sako6 です。
私のいるプロジェクトで大きなエンハンスが行われ、その中で Polymorphic Association(ポリモーフィック関連) を使う場面がありました。 ポリモーフィック関連を選択した理由や行った作業、注意点について話します。
架空のサービスを例に説明を行います。
サービス概要
私たちが運営するサービスでは、Magician(魔法使い)と、その魔法使いが扱える Magic(魔法)を調べることができます。 この世界の Magic(魔法)に同一のものはなく、 ユニークです。Magic(魔法)はMagician(魔法使い)に属しています。
class Magic < ApplicationRecord belongs_to :magician end class Magician < ApplicationRecord has_many :magics end
ところが、つい半年ほど前でしょうか、魔法を扱える動物 MagicAnimal(魔法動物)の存在が確認されました。 私たちは、魔法動物もサービスに登録できるようエンハンスを行いました。
私たちは、Magic(魔法)を Magician(魔法使い)と MagicAnimal(魔法動物)に従属させることにしました。ポリモーフィック関連付けを行ってできたモデルは以下です。
class Magic < ApplicationRecord belongs_to :magicable, polymorphic: true end class Magician < ApplicationRecord has_many :magics, as: :magicable end class MagicAnimal < ApplicationRecord has_many :magics, as: :magicable end
これを実現するために以下のマイグレーションを行いました。
class AddMagicablePolymorphicColumnsToMagics < ActiveRecord::Migration[6.0] def up # `magician_id` -> `magicable_id` rename_column :magics, :magician_id, :magicable_id # Magician か MagicAnimal かを区別するカラム。 # Magician なら 'Magician'、MagicAnimal なら 'MagicAnimal' を値としてもつ。 add_column :magics, :magicable_type, :string # 既存レコードの `magicable_type` に値を入れる。 Magic.reset_column_information Magic.update_all(magicable_type: 'Magician') change_column_null :magics, :magicable_type, false # `magicable_id` と `magicable_type` の複合インデックスを追加。 add_index :magics, [:magicable_id, :magicable_type], options: :online end def down remove_index :magics, column: [:magicable_id, :magicable_type] remove_column :magics, :magicable_type rename_column :magics, :magicable_id, :magician_id end end
ポリモーフィック関連を選択した理由
STI や delegated types、テーブル追加ではなくモリモーフィック関連にした理由を説明します。
既存への影響を小さくしたい
私たちが開発するサービスは Rails 4 の時代からスタートし、何年もの歴史を持っています。コードベースもデータ量も多く、なるべく修正コストが小さくなるようにしたいという気持ちがありました。
魔法を一覧表示したい
魔法使いと魔法動物のどちらに属するかを区別せず、魔法を一覧表示したい場面がありました。 そこではページネーションを Solr + Sunspot で行っており、1つのモデル、1つのテーブルの方が扱いやすいという前提があります。 したがって、Magic(魔法)を複数モデル、複数テーブルにすると処理が複雑になることが予想されました。
私たちのプロジェクトでは、既存影響なども考慮に入れると Magic(魔法)は1つのモデルにしておくメリットが大きく、STI や別テーブル追加にはしないことにしました。
Rails 6.1 から入った delegated type も一瞬検討候補に上がりましたが、私たちの Rails は 6.0.x だったので使えませんでした。
過去に開発ブログで delegated type について解説を行っているので、詳しく知りたい方はご覧ください。
DRY
Magic(魔法)は、Magician(魔法使い)から見ても MagicAnimal(魔法動物)から見ても本質的には同じものです。 同じものは1つのモデルで表すのが筋が良いと考えました。
Magic(魔法)に関しては DRY になるので、後々はメンテナンスが楽になると考えました。 もし新規モデルを追加し、魔法についてのコードが別れている状態になっていたら、片方に機能追加漏れやバグ修正漏れがないよう気を張りつめる必要があります。 開発メンバーは入れ替わっていくものですし、レビューのたびに気をつけなければならない観点は増やしたくありません。
Rails だから
ポリモーフィック関連を使用すると、外部キー制約による参照整合性が失われます。
しかし、Rails が提供する方法に則れば、アプリケーションのロジックがある程度リスクを低減してくれます。 今後 Rails から離れる予定もないため、ポリモーフィック関連を採用しました。
既存コードへの影響
ポリモーフィック関連付けにあたって必要だった修正について述べます。
なお、現在のところコードを自動で修正してくれる魔法は開発されていません。
joins
, eager_load
できない
ポリモーフィック関連にすると、以下のように joins
や eager_load
できなくなります。
Magic.joins(:magicable) # => Cannot eagerly load the polymorphic association :magicable (ActiveRecord::EagerLoadPolymorphicError)
クエリを工夫し、パフォーマンスに影響がないよう joins
や eager_load
を使わないように修正する必要があります。
magician
から magicable
への書き換え
ポリモーフィック関連付けしたことにより、魔法テーブルは magician_id
ではなく magicable_id
を持つようになりました。
create_table "magics" do |t| - t.integer "magician_id", precision: 38, null: false + t.integer "magicable_id", precision: 38, null: false + t.string "magicable_type", null: false end
従来 magician
としていた箇所は、必要に応じて magicable
に書き換えます。
- Magician.find(@magic.magician_id) + Magician.find(@magic.magicable_id) - Magic.where(magician_id: current_magician.id) + Magic.where(magicable_id: current_magician.id, magicable_type: 'Magician') - magician = magic.magician + magician = magic.magicable
Magician(魔法使い)と MagicAnimal(魔法動物)で同じ id
をとりうる場合、magicable_type
を漏れなく指定する必要があります。
こういった変更は、バッチ処理用のスクリプトや管理画面 (RailsAdmin 製)にも及びました。
Solr インデックス
プロジェクトでは、全文検索に Solr が使われています。
先ほどと似た話で、もともと magician_id
を渡していた箇所を magicable_id
に修正する必要があります。
integer :magician_id, as: 'Index.magician_id' do - magic.magician_id + magic.magicable_id end
しかし、MagicAnimal(魔法動物)も magicable_id
で関連付いているので、上記の修正により検索対象が変わってしまいます。
ここでは Magician(魔法使い)だけを対象としたいです。
他の手として、インデックス名 Index.magician_id
を Index.magicable_id
に変更し、Index.magicable_type
インデックスを追加する方法があります。綺麗な姿に見えますが、実現するためには既存レコードの再インデックスが必要です。
再インデックス処理には長い時間がかかるため、現実的ではありませんでした。1
そこで、magicable_type
で分岐して Solr に渡す値を変えることにしました。
integer :magician_id, as: 'Index.magician_id' do - magic.magician_id + magic.magicable_id if magic.magicable_type == 'Magician' end
上記のように magicable_type
で分岐させることで、既存のインデックス済みのレコードに影響を与えずに Solr を使うことができました。
バリデーションの場合分け
Magician(魔法使い)が使う魔法には、必ず rank
(階級)が定められています。Magic(魔法)はこの rank
カラムを持っていますが、MagicAnimal(魔法動物)の扱う魔法には階級がありません。
Magician(魔法使い)がもつ Magic(魔法)のときだけ rank
にバリデーションをかけたいです。
enumerize :rank, in: MAGIC_RANKS, predicates: true - validates :rank, presence: true + validates :rank, presence: true, if -> { magicable_type == 'Magician' }
このように片方に必要で片方に必要ないカラムがあるなら、バリデーションを分ける必要があります。
そして、Magician(魔法使い)と MagicAnimal(魔法動物)のための別々のカラムが増えるほど、Magic(魔法)のバリデーションは複雑になっていきます。 実際に、開発途中で魔法動物に関する法律が制定されたことで要求に変化があり、 MagicAnimal(魔法動物)にだけ必要なカラムが増えてしまいました。 結果、バリデーションは結構複雑なことになりました。これは仕方がないので受け入れています。
View での分岐
ある場面では、magicable
が Magician か MagicAnimal か判定するために分岐が増えてしまうかもしれません。
例えば、Magician#employed?
は、魔法使いが雇われているかそうでないかを返す predicate method です。魔法動物には雇用の概念がないので、employed?
は実装されていません。
これまで、雇われ魔法使いなら雇用企業の情報を、非雇われ魔法使いなら卒業学校の情報を表示していました。
しかし、魔法動物は働かないし学校に行ったことがないので、魔法使いにだけ employed?
を呼び出す必要があります。
- - if @magic.magician.employed? - = render 'company', magician: @magic.magician - - else - = render 'school', magician: @magic.magician + - if @magic.magicable_type == 'Magician' + - if @magic.magicable.employed? + = render 'company', magician: @magic.magicable + - else + = render 'school', magician: @magic.magicable
リリース後の話
リリースして様子を見ていたところ、パフォーマンスリグレッションが起きました。 インデックスの貼り方に問題があったのです。
magicable_type
の漏れや、複合インデックスの順番ミスによってインデックスが効いていませんでした。
すぐに修正し、その日のうちに問題は解決されました。
- t.index ["magicable_id", "name"], ... + t.index ["magicable_id", "magicable_type", "name"], ... - t.index ["magicable_type", "magicable_id"], ... + t.index ["magicable_id", "magicable_type"], ...
それ以外は大きな問題なく、リリース後Nヶ月月経った今でもきちんと動いています。
さいごに
本記事では、ポリモーフィック関連を採用するに至った理由や、ポリモーフィック関連付けにするための修正について書きました。 もし同様の問題解決に迫られた時、本記事で述べた観点が技術選択のヒントになると幸いです。
最後に、永和システムマネジメント アジャイル事業部では Web アプリケーションを開発する仲間を募集しています。 Ruby や Rails に馴染みがあったり、会社に興味をもっていただけた方はぜひご応募ください!
-
再インデックスする場合は、バッチ処理で行います。対象件数を絞って再インデックスする処理を繰り返し、何日もかけて処理が完了します。今回はリリースタイミングの都合などで採用できない案でした。↩