RailsのDelegated Typesで複数モデルを扱うフィード機能を作る
背景
個人 PF でニュースフィードみたいな機能を作りたい。そのフィードで表示する情報は複数のモデルから獲得して、時間順や人気順で表示する。
たとえば、Facebook のタイムラインように、友達の投稿だけでなく、誕生日の通知やアクティビティ更新、イベント情報などいろんな情報を表示する。
PF ではまだ二つ種類だけの投稿を表示する予定なので、一番簡単なのは、その二つ種類の投稿モデルを一つのモデルにする方法。
ただコラムが異なる部分が多いのと、今後他の情報を追加表示したいなら、また解決方法を考える必要になるので、今回でも正面から解決したいと思う。
仮に今回のフィードで下記のような二つモデルを扱う予定。
- 投稿記事
1
2
3
4
5
6
7
8
# == Schema
# Table name: posts
# id :integer
# user_id :integer
# title :string
# content :text
- イベント情報
1
2
3
4
5
6
7
8
9
# == Schema
# Table name: events
# id :integer
# user_id :integer
# title :string
# content :text
# place :string
解決策
調べたら、主に四つの解決策があるようで、 single table inheritance (STI、単一テーブル継承) 、 Abstract Classes(抽象クラス)、Polymorphic Association(ポリモーフィック関連)と Delegated Types(委譲型)。
STI や抽象クラスなどそれぞれのデメリットがあるため、
結論としては、Delegated Types が一番良い。
1. STI 単一テーブル継承を使う
STI を使うと、二つのテーブルを一つの feeds テーブルに合併するようになる。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# == Schema
# Table name: feeds
# id :integer
# user_id :integer
# title :string
# content :text
# place :string
# type :string #['Post', 'Event']
class Feed < ApplicationRecord
belongs_to :user
end
class post < Feed; end
class Event < Feed; end
データは一つのテーブルに保存しているので、フィードで投稿とイベントを時間順で表示するのはFeed.order(created_at: :desc)で簡単にできる。
ただデメリットとしては、テーブルが膨大になるし、NULL 値がいっぱい発生する。
STI なら、やはり性質上が同じで、コラムもほぼ重複するようなモデルを扱う場合の方が良い。
2. Abstract Classes(抽象クラス)
これはモデルそれぞれのテーブルを保有して、相互間のつながりは一つの抽象ベースモデルを介して行うやり方。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Feed < ApplicationRecord
self.abstract_class = true
end
# == Schema
# Table name: posts
# id :integer
# user_id :integer
# title :string
# content :text
class Post < Feed
belongs_to :user
end
# == Schema
# Table name: events
# id :integer
# user_id :integer
# title :string
# content :text
# place :string
class Event < Feed
belongs_to :user
end
これでユニークな属性を個別のテーブルに分離することができるけど、重複するようなコラム(user, title, content)が削減できていない。 それより、Feed.allなど DB クエリ操作ができないので、フィード機能自体の実現がややこしくなる。
3. Polymorphic Association(ポリモーフィック関連)
これは Rails ガイドで説明があり、以前使ったこともある。
Rails ガイド:ポリモーフィック関連付け
Feed の実装方法を検索した時、Stack Overflow で出ている回答はほとんどポリモーフィック関連を使うやり方。
フィード生成用の feeds テーブルを作成して、投稿とイベントを作成するたびに、feed レコードも作成する。 ビューで表示するとき、@feed.feedableで親のレコードを取得できる。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# == Schema
# Table name: feeds
# id :integer
# feedable_type :string
# feedable_id :string
class Feed < ApplicationRecord
belongs_to :feedable, polymorphic: true
end
# == Schema
# Table name: posts
# id :integer
# user_id :integer
# title :string
# content :text
class Post < ApplicationRecord
belongs_to :user
has_many :feeds, as: :feedable
end
# == Schema
# Table name: events
# id :integer
# user_id :integer
# title :string
# content :text
# place :string
class Event < ApplicationRecord
belongs_to :user
has_many :feeds, as: :feedable
end
これで機能自体が実現できているけど、ちょっとだけ違和感がある。
一つクエリ操作でレコードを全部取得するため、新しい feeds テーブルを作ったけど、 feed と post、event の間は、一対一の関係になるはずなのに、 関係付けはhas_manyになっている。
4. Delegated Types
実は Delegated Types を実装するとき、ポリモーフィックと似たような感じで、この二つの違いを迷っていた。
下記の記事のおかげで、ようやく違いが理解できた。
Delegated Types in Rails, handling models with overlapping attributes
ポリモーフィック関連が「has many」関係のインタフェースを定義する。 一方、Delegated Types は「has one」関係のインタフェースを定義する。
なので、今回は Delegated Types の方が一番相応しい解決策。 (Delegated Types は Rails 6.1 以降リリースの機能で、Rails 6.1 以前のバージョンが使えない。)
Delegated Types は STI と抽象クラスの良いところを吸収して、一つのソリューションに融合させたもの。 それで、共通属性を親テーブルに格納でき、独自の属性だけをそれぞれのテーブルに保存する。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# == Schema
# Table name: feeds
# id :integer
# user_id :integer
# title :string
# content :text
# feedable_type :string
# feedable_id :string
class Feed < ApplicationRecord
belongs_to :user
delegated_type :feedable, types: %w[Post Event]
end
# == Schema
# Table name: posts
# id :integer
class Post < ApplicationRecord
has_one :feed, as: :feedable
end
# == Schema
# Table name: events
# id :integer
# place :string
class Event < ApplicationRecord
has_one :feed, as: :feedable
end
Feed モデルに delegated_type :feedable を含めることで、feedable にポリモーフィックな belongs_to リレーションシップを裏で追加している。
構文には少し違いがあるけど、大体は通常のポリモーフィック関連を設定するのと同じ。
ただ delegated_type は一対一の関係なので、post.feed.titleで親モデルの属性を取得できる。 これは一対多のポリモーフィック関連ではできない。
レコードの作成も簡単になる。
1
2
3
4
5
6
Feed.create(
feedable: Event.create(place: "Tokyo"),
title: "東京もくもく会",
content: "Rails勉強会",
user: current_user,
)
Rails 7 から導入されたaccepts_nested_attributes_for を利用してさらに簡単。
1
2
3
4
5
6
class Feed < ApplicationRecord
belongs_to :user
delegated_type :feedable, types: %w[Post Event]
accepts_nested_attributes_for :feedable
end
1
2
3
4
5
6
7
8
9
Feed.create(
feedable_type: "Event",
title: "東京もくもく会",
content: "Rails勉強会",
user: current_user,
feedable_attributes: {
place: "Tokyo",
},
)
ビューで情報表示する時、共通属性と独自属性データをそれぞれ取得する。
1
2
3
4
5
6
Feed.first.feedable
# <Event id: 1, place: 'Tokyo'>
Event.first.feed
# <Feed id: 1, feedable_type: 'Event', feedable_id: 1,
# title: '東京もくもく会', content: 'Rails勉強会', user_id: 1>
これで、重複するコラムと大量の NULL 値がなくなり、DB クエリでそれぞれのデータを取得できる。モデルそれぞれの振る舞いも設定できる。完璧な解決案!
参照
Delegated Types in Rails, handling models with overlapping attributes
ActiveRecord::DelegatedType(Rails API ドキュメント)
Feed from multiple models (sorted by the time they were added to the feed) 単一テーブル継承(STI)について
Rails: ActiveRecord::DelegatedType API ドキュメント(翻訳)