ActiveRecordの3つのポリモーフィズム設計パターン:STI、Delegated Type、ポリモーフィック関連の違いと使い所まとめ
masaki です。
ポリモーフィズム(多態性)は、プログラミングにおいて異なるオブジェクトを同じインターフェースで扱うための便利な仕組みです。
Rails では、この概念が ActiveRecord に組み込まれており、複雑なデータモデルをシンプルに管理することができます。
これにより、共通の機能を持つ複数のモデルを効率的に設計でき、コードの再利用性が高まります。
ソーシャルPLUS のバックエンドでもさまざまなユースケースでポリモーフィズムのパターンを使い分けています。
今日は Rails の ActiveRecord で利用できる 3 つの主要なポリモーフィズム設計パターンである「STI(Single Table Inheritance)」、「Delegated Type」、そして「ポリモーフィック関連 (Polymorphic Associations)」について、それぞれの特徴とどんなシーンで使うとよいかについてまとめてみたいと思います。
この記事の対象者
- Rails と ActiveRecord について知見がある
- ActiveRecord のポリモーフィズム設計パターンの違いや使い所を知りたい
STI(Single Table Inheritance)とは?
STI はその名前のとおり「一つのテーブルを継承するパターン」で、複数の子モデルのデータを一つのテーブルで管理します。
この方法を使うと、親モデルと子モデルがすべて同じテーブルに格納され、各レコードにはどの子モデルに属するかを示す type
カラムが追加されます。
メリットは複数のモデルのデータを一つのテーブルで管理できるため、モデルが増えてもテーブルが増えず、データベース構造がシンプルに保てる、ということです。
また、コード面では親モデルのバリデーションやメソッドなどのコードを子モデルで共有でき重複したコードを書く必要がないため、コードの再利用性が高くなります。
デメリットは「テーブルが肥大化する」「モデル間の違いを扱いづらい」などです。
最初は全く同じ属性を持つモデルだとしても、プロダクトが成長していくに従い、個別の属性を持たせたくなることが多いでしょう。
そうすると一方のモデルでは必要な属性でも、別のモデルでは不要な属性だったり、ということが発生します。
これが STI において最も嫌われるパターンではないでしょうか。
不要なカラムが増えると取得したオブジェクトが肥大化するなどパフォーマンスの面でもデメリットになります。
以下は STI で実装した場合のテーブル構造と Ruby コードのサンプルです。
カラム名 | データ型 | 制約 | 説明 |
---|---|---|---|
id | int | PRIMARY KEY | ユニークな識別子 |
name | string | NOT NULL | 従業員の名前 |
type | string | NOT NULL | STI を実現するためのカラム (Manager または Developer ) |
programming_language | string | - |
Developer 専用のカラム(Manager には NULL) |
budget_approval_limit | int | - |
Manager 専用のカラム(Developer には NULL) |
class Worker < ApplicationRecord
# Worker としての必須メソッド
def work_hard!
raise RuntimeError
end
end
class Manager < Worker
def work_hard!
# Manager だけが使用する budget_approval_limit カラムを用いたメソッド
end
end
class Developer < Worker
def work_hard!
# Developer だけが使用する programming_language カラムを用いたメソッド
end
end
Worker.first.work_hard!
テーブル上は programming_language
とbudget_approval_limit
のカラムが全てのレコードに存在しますが、コード上では、Manager
とDeveloper
が必要なカラムのみを使用しています。
このように子モデルで特有のデータが増えていくに従い不要なカラムが生まれ、テーブルが肥大化するというデメリットがあります。
以上のことから STI に向いているのは「モデル間の属性の違いが無い、または極めて少ない」かつ「将来的に属性を変更するケースが少ない」といったケースが向いているのではないかと思います。
Delegated Type とは?
Delegated Type は似たモデルで一部だけ異なる属性を持たせたいといった場合に実装される設計パターンで、Rails 6.1 から導入されました。
一つのテーブルで異なるモデルのデータを共有する STI と違い、共通データを持つテーブルと個別のデータを持つテーブルでデータを管理できることがポイントです。
メリットはデータが個別のテーブルに分かれているため、整合性が保たれやすいことです。
STI のように不要なカラムを追加する必要はありません。
デメリットは各タイプごとに個別のテーブルを用意する必要があるため、データベース設計が複雑になることです。
データが複数のテーブルに分かれるため、複数のモデルにまたがるクエリが必要な場合、処理がやや複雑になる可能性があります。
また、DB のデータ構造の仕組み上、共通データを持つテーブル(workers
)に個別データを持つテーブル(managers
developers
)への外部キー制約を付与できないため、関連データの整合性について気をつける必要があります。
先ほど STI で実装した設計を Delegated Type に置き換えてみましょう。
- workers テーブル
カラム名 | データ型 | 制約 | 説明 |
---|---|---|---|
id | int | PRIMARY KEY | ユニークな識別子 |
name | string | NOT NULL | 従業員の名前 |
workerable_type | string | NOT NULL |
Manager または Developer を示すカラム |
workerable_id | int | NOT NULL |
managers または developers テーブルの外部キー |
- managers テーブル
カラム名 | データ型 | 制約 | 説明 |
---|---|---|---|
id | int | PRIMARY KEY | ユニークな識別子 |
budget_approval_limit | int | NOT NULL | Manager の予算承認限度額 |
- developers テーブル
カラム名 | データ型 | 制約 | 説明 |
---|---|---|---|
id | int | PRIMARY KEY | ユニークな識別子 |
programming_language | string | NOT NULL | Developer のプログラミング言語 |
class Worker < ApplicationRecord
delegated_type :workerable, types: %w[Manager Developer]
delegate :work_hard!, to: :workerable
end
module Workerable
extend ActiveSupport::Concern
included do
has_one :worker, as: :workerable
end
def work_hard!
raise RuntimeError
end
end
class Manager < ApplicationRecord
include Workerable
def work_hard!
# Manager だけが使用する budget_approval_limit カラムを用いたメソッド
end
end
class Developer < ApplicationRecord
include Workerable
def work_hard!
# Developer だけが使用する programming_language カラムを用いたメソッド
end
end
Worker.first.work_hard!
この構造では、workers
テーブルは workerable_type
と workerable_id
を使って、managers
や developers
テーブルと関連付けられます。
それぞれの特有のデータは、個別のテーブル(managers
やdevelopers
)に保存され、Worker
の共通データはworkers
テーブルに保存されます。
また、Workerable
なモデルの実装を delegate
で Worker
に移譲させることで、STI と同じように #work_hard!
という振る舞いを持たせることが可能です。
以上のことから「STI の代替として共通の属性以外に個別の属性がある」といったケースが Delegated Type に向いていると思います。
STI で実装後、「継承先のモデルごとに個別の属性が必要となる」というケースが発生したときに最も検討すべき設計パターンだと思います。
ソーシャルPLUS でも、最初は STI で実装し、途中で Delegated Type に移行したケースがありました。
移行した話などは別のブログで紹介するかもしれません。
ポリモーフィック関連 (Polymorphic Associations) とは?
ポリモーフィック関連 (Polymorphic Associations) は、Rails の ActiveRecord で提供されるもう一つのポリモーフィズムの方法です。
ポリモーフィック関連を使うと、複数の異なるモデルが同じ関連を持つことができるため、同じ機能を複数のモデルに簡単に追加できます。
たとえば、Memo
モデルがあり、Post
やPhoto
に対してメモを追加できるようにしたい場合、ポリモーフィック関連を使うことで、Memo
モデルがどのモデルにも関連付けられるようになります。
メリットは一つのモデルが複数の異なるモデルに関連付けられるため、柔軟なデータ構造が可能になることです。
たとえばコメントやタグ付けなど、共通の機能を複数のモデルに適用するのに便利です。
デメリットは Delegated Type と同じく複数のモデルにまたがるクエリが必要な場合、処理がやや複雑になる可能性などがあります。
また、Delegated Type と同じく仕組み上、外部キー制約を付与することはできません。
以下は、Memo
モデルがポリモーフィック関連を使用して、Post
とPhoto
に関連付けられている例です。
- posts テーブル
カラム名 | データ型 | 制約 | 説明 |
---|---|---|---|
id | int | PRIMARY KEY | ユニークな識別子 |
title | string | NOT NULL | タイトル |
content | string | NOT NULL | コンテンツ |
- photos テーブル
カラム名 | データ型 | 制約 | 説明 |
---|---|---|---|
id | int | PRIMARY KEY | ユニークな識別子 |
title | string | NOT NULL | タイトル |
url | string | NOT NULL | 写真の URL |
- memos テーブル
カラム名 | データ型 | 制約 | 説明 |
---|---|---|---|
id | int | PRIMARY KEY | ユニークな識別子 |
body | text | NOT NULL | コメント本文 |
memoable_id | int | NOT NULL | 関連する Post または Photo の ID |
memoable_type | string | NOT NULL | 関連するモデル名(Post または Photo ) |
class Memo < ApplicationRecord
belongs_to :memoable, polymorphic: true
end
class Post < ApplicationRecord
has_one :memo, as: :memoable
end
class Photo < ApplicationRecord
has_one :memo, as: :memoable
end
ポリモーフィック関連と Delegated Type の違いと使い所
ポリモーフィック関連と Delegated Type は、DB のデータ構造は同じです。
また、Delegated Type の以下のコードは、
delegated_type :workerable, types: %w[Manager Developer]
以下のようにポリモーフィック関連で定義することとほぼ同じです。
belongs_to :workerable, polymorphic: true
では何が異なるのでしょうか?
以下の Delegated Type に追加された機能がそのヒントになると思っています。
Worker.managers
Worker.developers
class Worker < ApplicationRecord
delegated_type :workerable, types: %w[Manager Developer]
accepts_nested_attributes_for :workerable
end
※ ポリモーフィック関連では belongs_to, polymorphic: true
側(前述のとおり Delegated Type の delegated_type :workerable
側と同義)からの accepts_nested_attributes_for
はサポートされていません。
これらはいずれも Worker
側から Manager
や Developer
を操作する機能です。
つまり、Worker
を親モデル、Manager
や Developer
を子モデルとして扱う親子関係で使うことが想定された機能です。
通常、子は親なしでは存在できませんから、Manager
や Developer
は Worker
とセットになって機能が完結する、ということになります。
DB 構造や relation
(belongs_to
has_one
)の関係だけで見ると、Manager
や Developer
が親、Worker
が子ですが、Delegated Type によって逆転させています。
※ 弊社佐藤が執筆した、親子関係を逆転させていることによりバリデーションに工夫が必要だった、という記事もぜひご覧ください。
Delegated Type の公式ドキュメントでも、STI(継承)の代替として Delegated Type(委譲)が紹介されている理由にも納得できます。
Delegated Type は親子セットでその機能が完結する、という性質から依存関係が強い実装になりやすいですが、ポリモーフィック関連で親子の依存関係が強い実装をしてしまうとバッドプラクティスになりやすいです。
以上のことから「Delegated Type」は親子関係のセットとして実装するような依存関係が強いパターンに向いており、「ポリモーフィック関連」は共有モデルが個別モデルを補助するような依存関係が少ないパターンに向いているのではないかと思われます。
まとめ
それぞれのパターンの比較と使い所のまとめです。
項目 | STI | Delegated Type | ポリモーフィック関連 |
---|---|---|---|
テーブル構造 | 1 つのテーブルに全てのデータを格納 | 共通テーブルと個別テーブルに分ける | 共通テーブルと個別テーブルに分ける |
メリット | テーブルが 1 つでシンプル、コードの再利用性が高い | 共通データと個別データが分離、データの整合性が高い | 1 つのモデルで複数モデルと関連可能、柔軟な構造 |
デメリット | テーブルが肥大化し、不要なカラムが増える | データベース設計が複雑、複数テーブルを跨ぐクエリが必要 | クエリが複雑になる、外部キー制約がない |
-
STI
- 属性がほぼ同じモデルを 1 つのテーブルで管理したい
- 再利用性を高くし、シンプルにしたい
-
Delegated Type
- 共通データを管理しつつ、個別モデルごとに異なるデータを管理したい
- STI の代替として依存関係の強い親子関係のセットで実装したい
-
ポリモーフィック関連
- 複数の異なるモデルに共通機能を提供したい
- 共有モデルが個別モデルを補助するような依存関係が少ない実装をしたい
ポリモーフィズムの各設計パターンには、それぞれ利点と欠点があります。
最終的には、プロジェクトの具体的なニーズに応じて、最適なパターンを選択することが重要です。
これらのパターンを理解し、適切に使いこなすことで、Rails アプリケーションの設計がより柔軟で強力なものになります。
今後のプロジェクトでこれらの知識を活用し、効果的なデータベース設計を実現してください。
Discussion