STI、Polymorphic関連を実際に使用した話

こんにちは!サーバーサイドエンジニアの@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

f:id:hokita:20191216185531p:plain:w400

rulesテーブル(ruleモデル)に対してitem, category, price_gteというサブタイプクラスを作成しました。

なぜSTI

  • STIだと条件が増えたときにクラスの追加のみで済む。
    • 具象テーブル継承、クラステーブル継承だとテーブルまで作らないといけないので面倒くさい。
  • 将来的に追加されるカラムが少ない。
    • あるサブタイプクラスしか使わないカラムが増えていくと、テーブルにカラムが増えすぎるってこともあると思いますが、今回は問題ないと判断。
  • サブタイプクラスで共通のメソッドでも、それぞれ処理が異なる。
    • 処理が同じなら親クラスで共通のメソッド生やせばすむし、結果enumを使って異なる処理だけを例外的に書けばいいよねってなりますが、今回はそれぞれ異なります。

RailsSTIの機能は使わなかった

Rails標準のSTIの機能は使いませんでした。理由としては「継承」を使いたくないから。今回は委譲で対応しております。(RailsSTIを期待されてた方すみません。)
※と言ってみたは良いものの、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関連

f:id:hokita:20191216185605p:plain:w400

なぜPolymorphic関連か

  • rule_targetsモデルに対して、複数のモデルを紐付けたかったため。
  • rule_targetsからはitemscategoriestargetsという同類の関係
  • rulestargetsは多対多なので中間テーブル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