はじめに
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
というテーブルを Car
、 Motorcycle
、 Bicycle
というモデルで共有する例が紹介されています。
簡単に説明すると 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を利用すると、モデル毎にロジックを変えることも容易で、呼び出し側からはロジックを意識せずに利用することも出来ます。
例えば、日本では、 Car
、 Motorcycle
、 Bicycle
では、自動車税の計算式が異なります。
仮に、自動車税額を返すメソッド #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を利用し、適切にインデックスを貼っていた場合と比べ、非常に無駄の多いロジックになってしまいます。
STI を RDB で利用した時の気になる点
この様なメリットのある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_track
、rear_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 |
この方法では、無駄なカラムが増えないメリットはありますが、次の問題が出てきます。
vehicles
のtype
がCar
の時にcar_specs
があるとは限らないvehicles
のtype
がCar
以外の時にcar_specs
が存在する可能性がある
RDBでのSTIでは、この様にDBで整合性を保ちにくくなる点から、良い印象を持ってない人が少なくないのではないかと思っています。
MongoDB と STI の相性の良さ
本章では、 MongoDB と STI の相性の良さについて説明していきます。 MongoDB は、 RDB と比較すると下記の様な特徴を持っています。
まず 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::Document
を include
したモデルを継承すると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と聞くとネガティブなイメージを持たれる方も少なくないかもしれませんが、今回紹介した内容を参考に、開発に活かしていただければと思います。
-
2021年8月18日のイベントでチームメンバーのプレゼンがある様なので気になる方は是非参加してみてください。 hey.connpass.com herp.careers↩