こんにちは!サーバーサイドエンジニアの@hokita222です!
有酸素運動は脳を活性化させると聞いて、最近は朝会社に出社せずにランニングしております!
それはさておき、これは Enigmo Advent Calendar 2019 23日目の記事です!
今回は弊社が運営するサイトのBUYMA (Ruby on Rails)に追加した機能で、STI
、ポリモーフィック関連
を使ってみたので、どういう設計にしたかを書いていこうと思います。
※使ってみたって話で、それぞれどういう特徴なのかなどの詳しい説明はしておりません。
どんな機能作ったの?
「〇〇キャンペーン」などの施策で、その日あった取引の中で特定の条件(商品ID、カテゴリーID、何円以上など)のものを絞り込み、その対象の取引に対して特定のアクションをさせます。
今回はこの機能の「特定の条件で絞る」の設計を説明していきたいと思います。
設計するなかで実現したかったこと
「特定の条件で絞る」機能を作るなかで重要視していたのは、「条件が増える可能性が高い」ことです。今は商品ID、カテゴリーIDで絞れるけど、将来的にはブランドID、購入数などで絞れるようにするかもしれません。なので条件が容易に追加できることを意識しました。
テーブル設計
テーブル名 | 説明 |
---|---|
promotions | 施策テーブル(施策名、施策期間など) |
rules | 施策の条件テーブル(どの取引を対象にするかの条件) |
actions | 施策のアクションテーブル(対象の取引に対しての行うアクション)※今回こちらは割愛 |
rule_targets | 施策の条件テーブルとターゲット(itemsやcategoriesなど)との中間テーブル |
items | 商品テーブル(元々あるテーブル) |
categories | カテゴリーテーブル(元々あるテーブル) |
※promotions, rules, actionsの形はsolidusを参考にさせてもらいました。
STI
rulesテーブル(ruleモデル)に対してitem
, category
, price_gte
というサブタイプクラスを作成しました。
なぜSTIか
- STIだと条件が増えたときにクラスの追加のみで済む。
- 具象テーブル継承、クラステーブル継承だとテーブルまで作らないといけないので面倒くさい。
- 将来的に追加されるカラムが少ない。
- あるサブタイプクラスしか使わないカラムが増えていくと、テーブルにカラムが増えすぎるってこともあると思いますが、今回は問題ないと判断。
- サブタイプクラスで共通のメソッドでも、それぞれ処理が異なる。
- 処理が同じなら親クラスで共通のメソッド生やせばすむし、結果enumを使って異なる処理だけを例外的に書けばいいよねってなりますが、今回はそれぞれ異なります。
RailsのSTIの機能は使わなかった
Rails標準のSTIの機能は使いませんでした。理由としては「継承」を使いたくないから。今回は委譲で対応しております。(RailsのSTIを期待されてた方すみません。)
※と言ってみたは良いものの、Railsから外れるツラミも結構大きいです。今回の機能では問題ないのですが、あまりおすすめはしないです。
ソースコード
# 施策(調整予約)ルールテーブル # # カラム # - type: どの条件か(どの条件のクラスを使用するか。例: item, category, price_gte) # - value: 条件に必要な任意の値(〇〇円以上とか。商品IDとかはここには入らない。) # class Rule < ActiveRecord::Base self.inheritance_column = nil belongs_to :promotion has_many :rule_targets delegate :build_relation to: :sub_rule private # サブタイプクラス def sub_rule "Rules::#{type.classify}" .constantize .new(self) end end
継承を使用しないので、サブタイプクラスを呼ぶメソッドを作ってます。
module Subtypeable extend ActiveSupport::Concern attr_accessor :rule delegate :value, :rule_targets, to: :@rule def initialize(rule) @rule = rule end # 特定のrelationをactiverecordのメソッドを使用して絞り込む def build_relation(relation) relation end end
rubyではダックタイピングできるので基本インターフェースは不要ですが、他のエンジニアにもこのメソッド使ってくれという願いを込めてmodule作りました。(ちょっとインターフェース以上のこと書いているのはご愛嬌)
# 商品ルール class Syohin include Subtypeable # override def build_relation(relation) targetable_ids = rule_targets.pluck(:targetable_id) relation.where('trades.item_id in (?)', targetable_ids) end end
引数のrelation
に取引のRelationを渡すことによって中間テーブルにある対象のIDたちで絞ることができます。(正確にはRelationに条件を付加します。)
# 価格(以上)ルール class PriceGte include Subtypeable # override def build_relation(relation) relation.where('trades.price >= ?', value) end end
こちらは商品ルールとは異なり、rulesテーブルのvalue
カラムを使用します。
Polymorphic関連
なぜPolymorphic関連か
rule_targets
モデルに対して、複数のモデルを紐付けたかったため。rule_targets
からはitems
もcategories
もtargets
という同類の関係rules
とtargets
は多対多なので中間テーブルrule_targets
とのPolymorphic関連
ソースコード
# 中間テーブル class RuleTarget < ActiveRecord::Base belongs_to :rule belongs_to :targetable, polymorphic: true end
class Item < ActiveRecord::Base has_many :rule_targets, as: :targetable end
class Cateogry < ActiveRecord::Base has_many :rule_targets, as: :targetable end
※本当はSTIと同じくインターフェース用のmodule作ったほうがいいのですが、共通のメソッドがないので作ってません。
ちなみに
promotionモデルはこうなっております。
# 施策テーブル class Promotion < ActiveRecord::Base has_many :rules has_many :actions # いろんな条件をまとめたrelationを受け取ることができる。 def detect(relation) rules.inject(relation) do |rel, rule| rule.build_relation(rel) end end end
Promotionモデルが窓口となっており、コントローラーなどの呼び出し元はdetect
メソッドを呼ぶだけで条件での絞り込みが可能になります。
各クラスがそれぞれの仕事を担ってくれているので結構シンプルなのではないでしょうか。またSTI、Polymorphic関連で実装したおかげでif
文, case
文が全くありません。
さいごに
現状新しい施策条件が増えた場合でもクラス一つ作るだけで解決するので、小一時間あれば条件の追加が可能となりました。
明日は弊社新卒の平井くんです!どんな記事書くんでしょうね。楽しみ!わくわく
弊社では一緒にレガシーコード脱却を目指すエンジニア大募集中です! hrmos.co