hey Product Blog

こだわりを持ったお商売を支えるプラットフォーム「STORES」の開発チームによる技術ブログです。

STORES ECでのMongoDB(Mongoid)とSTIの使い方紹介

はじめに

hey のECとかレジのバックエンドエンジニアをやっている @ucks です。

前回は、Mongoid の基本的な使い方と MongoDB を利用した開発のメリットを紹介しました。 今回はもう少し踏み込んで、STORES (以下、区別のため STORES EC と表記)、STORES レジで利用している仕組みを紹介します。

タイトルにもありますが、 STI をご存知でしょうか? Single Table Inheritance の略で、日本語にすると単一テーブル継承と言うらしいです。 筆者は、初めて聞いた時、青い車向けのピンクのパーツを開発している会社しか思い当たりませんでした。 簡単に説明すると1つのテーブルで複数のモデルを永続化する手法です。

STI には良い印象を持ってない人も多いかと思いますが、STORES EC とレジでは、WebアプリケーションフレームワークRuby on Rails 、 データベースに MongoDB 、 ODM (RDB でいう ORM) に Mongoid を利用しており、 Mongoid で STI を活用しています。 今回は STI と MongoDB との相性の良さについて紹介していきます。

STIの紹介

まず、Railsで一般的なORMである Active Record での STI の利用方法を簡単に説明します。 Railsガイドでは、 vehicles というテーブルを CarMotorcycleBicycle というモデルで共有する例が紹介されています。

簡単に説明すると vehicles というテーブルに type というカラムを作り、下記の様にモデルを定義すると、共通のロジックはスーパークラスVehicle に、異なるロジックはサブクラスでそれぞれ記述することができる様になります。

class Vehicle < ApplicationRecord
end
class Car < Vehicle
end
class Motorcycle < Vehicle
end
class Bicycle < Vehicle
end

これだけでは、メリットが分かりづらいと思うので、各特徴について具体的に説明していきます。

ロジックの共通化

STIを利用した時のメリットの1つは、異なるモデル間で共通のフィールドやロジックを共有できる点です。 (STIを使わなくても共通化する方法はありますが…)

例えば、乗り物には加速性能の指標として、 Power-to-Weight Ratio という指標があります。 この指標は、乗り物の出力を重さで割った値で、値が高いほど加速性能が高いことを示します。 (日本では重さを出力で割ったパワーウエイトレシオが主流)

この計算式は、全ての乗り物で共通であるため、下記の様に Vehicle モデルにメソッドを定義することで、ロジックを共通化することができます。 ( #power が出力、 #weight が車重を返すと仮定、 Bicycle の出力は…0?)

class Vehicle < ApplicationRecord
  def power_to_weight_ratio
    power / weight
  end
end

STIでは、この様にスーパークラスにメソッドやバリデーションを追加することで、ロジックを共通化することができます。

ロジックの分離

また、STIを利用すると、モデル毎にロジックを変えることも容易で、呼び出し側からはロジックを意識せずに利用することも出来ます。

例えば、日本では、 CarMotorcycleBicycle では、自動車税の計算式が異なります。 仮に、自動車税額を返すメソッド #automobile_tax があった時は、下記の様に、各々のサブクラスにロジックを分けることができます。 ( #displacement が排気量 (cc) を返すと仮定、車両の大きさや種類、重課の考慮は割愛)

class Car < Vehicle
  def automobile_tax
    case displacement
    when ..660
      10_800
    when ..1_000
      25_000
    when ..1_500
      30_500
    when ..2_000
      36_000
    # 略
    end
  end
end
class Motorcycle < Vehicle
  def automobile_tax
    case displacement
    when ..90
      2_000
    when ..125
      2_400
    when ..250
      3_600
    else
      6_000
    end
  end
end
class Bicycle < Vehicle
  def automobile_tax
    0
  end
end

もし、これらのコードをSTIを利用せずに記述すると下記の様になります。

class Vehicle < ApplicationRecord
  def automobile_tax
    case type
    when 'Car'
      case displacement
      when ..660
        10_800
      when ..1_000
        25_000
      when ..1_500
        30_500
      when ..2_000
        36_000
      # 略
      end
    when 'Motorcycle'
      case displacement
      when ..90
        2_000
      when ..125
        2_400
      when ..250
        3_600
      else
        6_000
      end
    when 'Bicycle'
      0
    end
  end
end

この様に、STIを利用するとロジックを分割し、可読性を高めることができます。 また、特定のサブクラスのみに、特異なメソッドを定義して利用することも可能です。

データ検索の効率化

ここまでの話だと共通ロジックをモジュールに書き出して読み込んだり、テーブルを分けて個別に実装することでも対応できます。 しかし、STIを利用すると、更にモデルを跨いだ検索を行う場合に、より効率的にデータを取得することができます。

例えば、全ての Vehicle から特定条件で検索を行い、特定の項目でソートを行いページングをする場合を考えます。 STIを利用していれば、スーパークラスから下記の様に Active Record のメソッドのみで最低限のデータをフェッチできます。

Vehicle.where(conditions).order(...).limit(limit).offset(offset)

一方、テーブルを分割した場合、下記の様に条件に合致したデータを全て取得し、Rails上でソート、ページングする必要が出てきます。(UNION等で頑張る方法もあるが…)

(Car.where(conditions) + Motorcycle.where(conditions) + Bicycle.where(conditions)).sort_by { ... }.drop(offset).take(limit)

STIを利用しない場合、各テーブルの合致するデータを全てフェッチし、Rails内でソート、ページングする必要がでてきます。 これは、STIを利用し、適切にインデックスを貼っていた場合と比べ、非常に無駄の多いロジックになってしまいます。

STIRDB で利用した時の気になる点

この様なメリットのあるSTIですが、良い印象を持ってない人も少なくないと思います。

ロジックの共通化、分割という話であれば、STIに大きな問題はないかもしれません。 しかし、実際に実装を始めると、モデル毎に異なるデータ(カラム)を持ちたいということが少なくないと思います。 これをSTIで実現しようとするとDBスキーマによって整合性を保つことが難しくなってしまうことが、理由の一つに挙げられるかと思います。

例えば、 Car には前後にタイヤが2つずつ付いており、それぞれのタイヤの中心間の距離、トレッド ( track ) という情報があり、 Motorcycle にはありません。 (0で良いじゃん、とか三輪車は?とかはやめて…) この情報をDBに永続化する場合、主に次の2つの方法があります。

1つ目の方法は、下記の様に vehicles テーブルにカラムを追加し NULL を許容する方法です。

vehicles

id type model front_track rear_track
1 Car GC8 1470 1460
2 Motorcycle NC42 NULL NULL

この方法では、次の様な問題点が出てきます。

  • Car 以外では使わない ( NULL が入る) カラムが増える (スパースなカラムが増える)
  • type によって front_trackrear_track が必須/不要を制限したいが難しい
    • (CHECK制約で対応できるがメンテナンスが大変)

もう1つの方法は、 has_one なテーブル ( car_specs ) を用意する方法です。

vehicles

id type model
1 Car GC8
2 Motorcycle NC42

car_specs

id car_id front_track rear_track
1 1 1470 1460

この方法では、無駄なカラムが増えないメリットはありますが、次の問題が出てきます。

  • vehiclestypeCar の時に car_specs があるとは限らない
  • vehiclestypeCar 以外の時に car_specs が存在する可能性がある

RDBでのSTIでは、この様にDBで整合性を保ちにくくなる点から、良い印象を持ってない人が少なくないのではないかと思っています。

MongoDB と STI の相性の良さ

本章では、 MongoDB と STI の相性の良さについて説明していきます。 MongoDB は、 RDB と比較すると下記の様な特徴を持っています。

  • スキーマ定義がない
  • コレクション(テーブル)間の制約ができない
  • ドキュメント(レコード)がBSON(JSON)形式である
  • フィールド(カラム)の有無とNULLがある

まず RDB と MongoDB の大きな違いの1つがスキーマの有無です。 MongoDB はスキーマレスであり、DBのフィールドの制約や外部キー制約をかけることができません。(インデックスでユニーク制約等はできる) そもそも制約をかけることができないため、モデルの制約とDBの制約に差分で迷うことはなくなります。 MongoDB では、代わりにアプリケーションでしっかりと制約をかける必要がありますが、Mongoidには強力なバリデーション機能が備わっているので、心配は無用でしょう。

もう一つの大きな違いが、永続化する単位データの性質です。 RDB では、レコード単位でデータを永続化し、レコードには事前にカラム名とデータ型が定義されます。 一方、 MongoDB では、ドキュメントと呼ばれるBSON形式のデータ単位で、フィールド名や項目は書き込んだ時の情報で永続化されます。 つまり、ドキュメントは、フィールドが存在して値がNULLのデータと、フィールド自体が存在しないデータをそれぞれ表現できます。 そのため、 MongoDB ( Mongoid ) を利用したSTIでは、特定のモデルのみに存在するフィールドのみを書き込むことができ、 NULL の入った無駄なカラムが増えるという問題がなくなります。

トレッドの例をJSONに落とすと下記の様な表現が可能になります。

[
  {
    "_id": 1,
    "_type": "Car",
    "model": "GC8",
    "front_track": 1470,
    "rear_track": 1460
  },
  {
    "_id": 2,
    "_type": "Motorcycle",
    "model": "NC42"
  }
]

この様に、 MongoDB で STI を活用すると、モデルとDBスキーマの制約の差異と不要なフィールド (カラム) を気にすることなくデータを永続化できます。 特に Mongoid を利用すると、継承を利用したクラスの実装を行なっていくだけでDBに永続化できるモデルを作ることができます。

Mongoid で STI を利用する方法

それでは、Mongoid で STI を利用する方法について説明します。 下記の様に Mongoid::Documentinclude したモデルを継承するとSTIが利用可能になります。

class Vehicle
  include Mongoid::Document
end
class Car < Vehicle
end

STIが有効になると、MongoDBに書き込む際に _type フィールドが追加され、クラス名が格納されます。 また、DBからフェッチした時に _type フィールドがなかった場合、スーパークラスインスタンス化されるので、後からSTI化することも可能です。

ちなみに、トレッドの例をモデルクラスにすると下記の様になります。

class Vehicle
  include Mongoid::Document

  field :model, type: String

  validates :model, presence: true
end
class Car < Vehicle
  field :front_track, type: Integer
  field :rear_track, type: Integer

  validates :front_track, presence: true
  validates :rear_track, presence: true
end

メソッドの共通化やロジックの分離方法は Active Record のSTIと同じ方法で行うことができます。 (Active Recordと殆ど同じに使えて書くことがない…)

おわりに

今回は、 STI と MongoDB との相性の良さについて紹介しました。

STORES EC やレジでは、この様な STI と MongoDB の長所を活かした開発を行なっています。 例えば、特定の操作を行なった際に、あるデータAは外部サービス1に、あるデータBは外部サービス1とはインタフェースや利用手順が異なる外部サービス2と通信する場合にSTIを活用しています。1 Mongoid 経由の STI であるため、 NULL が入ったフィールド(カラム)を増やすことなく、外部サービス毎に適した情報を永続化できています。

STIやMongoDBと聞くとネガティブなイメージを持たれる方も少なくないかもしれませんが、今回紹介した内容を参考に、開発に活かしていただければと思います。


  1. 2021年8月18日のイベントでチームメンバーのプレゼンがある様なので気になる方は是非参加してみてください。 hey.connpass.com herp.careers