Help us understand the problem. What is going on with this article?

みんなRailsのSTIを誤解してないか!?

More than 3 years have passed since last update.

この記事はfreee Engineers Advent Calendar 2016の4 日目です。

こんにちは、freeeでエンジニアをやっている @yebihara です。
今日はRailsの単一テーブル継承/Single Table Inheritance(以下STI)にまつわる誤解を自分なりに解いてみようと思います。

クラス継承

Rubyのようなオブジェクト指向型のプログラミング言語でシステムを作る場合に、クラス継承をうまく使うことはエレガントな設計には必須です。

freeeではアプリケーション開発に主にRuby on Railsを利用していますが、Railsフレームワーク自体の各所で継承が使われている他、アプリケーションコードで継承を利用することももちろん可能です。

中でもRailsのモデルクラスにはSTIという仕組みが用意されており、継承関係にある複数のモデルクラスの扱いを容易にしてくれます。

STIは活用されているのか?

そんなSTIですが、自分の周囲では実はそれほど積極的に活用されていない気がしていました。
というわけで、アンケートを取ってみた結果がこちら。

RailsのSTIに関するアンケート_-_Google_フォーム.png

モデル設計時に自分でSTIを適用したことがある人が約1割。やはり少ない。

RailsのSTIに関するアンケート_-_Google_フォーム.png

ポジティブな意見がゼロ!
そして結構嫌われているのね...

STIを使わない/使いたくない理由も聞いてみました。

理由 票数
使いこなすほどにはSTIのことを理解していないから 8
適用すべき場面の判断がよく分からないから 10
RailsのSTIの実装には欠点があると思うから 0
STIという設計コンセプトそのものに欠点があると思うから 5
設計はきれいだけど開発・デバッグが面倒だから 2

な、なるほど...!?
いや、みんなちょっと誤解してるんじゃないか?
よーし、お父さん、みんながSTIを使いたくなるようにちょっと頑張っちゃうぞ!

STIとは

STIは、単一の継承階層に所属するクラス群を、ただひとつのテーブルを使って永続化する手法です。

PofEAA

Railsのドキュメントを漁ると、次のような記述が見つかります。
http://api.rubyonrails.org/classes/ActiveRecord/Inheritance.html

Note, all the attributes for all the cases are kept in the same table. Read more: www.martinfowler.com/eaaCatalog/singleTableInheritance.html

このリンク先は、リファクタリングで有名なMartin Fowler氏のパターンカタログサイトの、Single Table Inheritanceを紹介しているページです。彼の著作の中にエンタープライズアプリケーションアーキテクチャパターン(通称 PofEAA)という有名な書籍があり、STIはその中で取り上げられたパターンの1つなのです。

同じサイトでClass Table Inheritance(クラステーブル継承)、Concrete Table Inheritance(具象テーブル継承)というパターンも紹介されていますが、STIも含めたこれら3つは、「オブジェクト指向設計で抽出されたスーパークラス・サブクラスから成る継承階層をリレーショナルデータベースのテーブルとして実装するためのパターン」という意味で同類です。

そう、STIとはRails独自のものではなく、ORマッピングのための汎用的な手法のひとつだったのです。

ERモデルとSTI

実のところ、これら3つの手法自体は、オブジェクト指向とは無関係に、ERモデリング(Entity-Relationship Modeling)の世界には以前から存在していました。
ERモデリングというのはあの、ER図を描くやつです。

ER図のことをリレーショナルデータベースの設計図だと思っている人も多いですが、実際にはもっと汎用的で、論理レベルまでのER図というのはRDBとは無関係に利用できます。
詳しくはWikipediaの実体関連モデルの項でも見てもらうとよいでしょう。

で、使いこなしている人はあまりいませんが、ERモデルではスーパータイプ/サブタイプというものを記述することができます。

機能的には、UMLのクラス図からメソッドの情報を省略したものがほぼER図であるとも言えるので、これらはオブジェクト指向設計におけるスーパークラス/サブクラスに相当するものと考えて差し支えありません。

ERモデルをRDB上に実装する場合、スーパータイプ/サブタイプはそのままではテーブル化できないので、物理データモデルの時点で別の形に変形されます。
そのよく知られた変形方法がまさに先に挙がった3つの手法そのものなのです。

Martin Fowlerがそれらに名前を付けて「パターン」としたことで広く知られるようになりましたが、これらはオブジェクト指向が流行る以前から存在していた定石なわけです。
そしてそのうちSTIだけがRailsのモデルで標準サポートされました。

STIは古来より伝わる由緒正しき設計技法で、手法自体に大きな問題があるというものではないと言えます。

3つの継承ORマッピングパターン

ではSTIを含む3つのORマッピングパターンの特徴や長所・短所を、より詳しく理解していきましょう。
ここでは「顧客」クラスを例に、以下のようなクラス階層を考えます。

Single_Table_Inheritance_のコピー_-_Google_スライド.png

最上位のスーパークラスであるCustomerクラスは抽象クラスであり、全ての顧客に共通する属性を持っています。
顧客は大きく「個人」と「法人」に分類されます。

Personは個人事業主を含む個人顧客です。
山田 太郎さんが山田商店という屋号のお店を自営業で営んでいる場合、顧客名(Customer#name)が「山田商店」、実名(Person.real_name)が「山田 太郎」となります。

すべての法人顧客は法人番号(corporate_number)を持っています。法人には企業や官公庁などがありますが、企業は資本金(capital)という固有の属性を持っているので、Companyクラスとしてさらにサブクラス化します。

つまりCustomerクラス以外の3つのクラスが具象クラスということになります。

Single Table Inheritance / 単一テーブル継承

RailsのSTIを利用する場合、3つの具象クラスの基底テーブルはどれも、最上位のスーパークラスであるCustomerクラスより導かれるcustomersテーブルとなります。
customersテーブルにはtypeという名前の列が必要で、そこには各レコードが表すサブクラスの名前が保存されます。

customersテーブル

id type code name real_name corporate_number capital
1 Person C-001 山田商店 山田 太郎 null null
2 Corporation C-002 品川区役所 null 6000020131091 null
3 Company C-003 フリー株式会社 null 7010401100770 62億円
  • 長所
    • 必要なテーブルは1つのみで、サブクラスが増えてもテーブル数は増えない
    • 論理的なレコード全体を取得するのにジョイン不要
    • レコードのサブクラスの変更が容易
    • Railsでネイティブサポートされているのでコーディングが容易
  • 短所
    • 特定のサブクラスに固有の属性に対してNOT NULL制約を適用できない
    • 特定のサブクラスのみを参照すべき他テーブルの外部キー制約が、誤ったサブクラスを参照することを防げない
    • テーブルのカラム数が多くなりやすい
    • 一部のサブクラスでしか使われない列の値はNULLばかりとなり、見た目がスパースになる

Class Table Inheritance / クラステーブル継承

Class Table Inheritanceパターンに従うと、4つのクラスそれぞれに対してテーブルを作成します。
各クラスに対応するテーブルは、そのクラス自身とサブクラスに固有の共通属性のためのカラムのみを持ちます。

Railsのモデルにはこの方法のサポートはないので、通常通り各クラスに対応したテーブルを作成することになります。

customersテーブル

id code name
1 C-001 山田商店
2 C-002 品川区役所
3 C-003 フリー株式会社

personsテーブル

id customer_id real_name
1 1 山田 太郎

corporationsテーブル

id customer_id corporate_number
1 2 6000020131091
2 3 7010401100770

companiesテーブル

id customer_id capital
1 3 62億円
  • 長所
    • STIにおけるNOT NULL制約や外部キー制約の問題が発生しない
    • NULLの発生を抑制できる
  • 短所
    • サブクラスが増えるとテーブルも増える
    • 論理的なレコード全体を取得するのにジョインが必要
    • Railsでは、サブクラス固有属性を取得するためにコードを書く必要がある

Concrete Class Inheritance / 具象クラス継承

Concrete Class Inheritanceパターンでは、具象クラスにのみ対応するテーブルを作成します。
各テーブルはスーパークラスから継承する共通属性も含め、その具象クラスが持つすべての属性のためのカラムを持ちます。

personsテーブル

id code name real_name
1 C-001 山田商店 山田 太郎

corporationsテーブル

id code name corporate_number
1 C-002 品川区役所 6000020131091

companiesテーブル

id code name corporate_number capital
1 C-003 フリー株式会社 7010401100770 62億円
  • 長所
    • 論理的なレコード全体を取得するのにジョイン不要
    • STIにおけるNOT NULL制約の問題が発生しない
    • NULLの発生を抑制できる
    • テーブルが肥大化しにくい
  • 短所
    • 具象サブクラスが増えるとテーブルも増える
    • スーパークラスの共通属性にユニーク制約を適用できない
      • ユニーク制約は複数のテーブルをまたがれないので、そのようなユニーク性はアプリケーションで保証する必要がある
      • 例におけるcode
    • 別テーブルの外部キー制約で参照できるのは単一の具象クラスのみ
      • 外部キー制約は単一のテーブルしか参照できないので、複数の具象クラスを参照できる場合には実装できない
    • スーパークラスの共通属性をキーとするクエリーでは、全ての具象クラステーブルを検索する必要がある
    • RailsではCustomer.whereなど、スーパークラスのクエリーメソッドを自前で実装する必要がある

STIに対する誤解を解く

ここまででSTIと代替手法の長所・短所を理解しました。

アンケートでは自由記述形式でSTIを使わない・使いたくない理由を尋ねたので、それに対して回答する形で、RailsのSTIに対する誤解を解いていきたいと思います。

「異なるクラスで真に共通の振る舞いが必要になることは稀だし、その場合はモジュールとか委譲を使えばよいのではないか」

これまで見てきたように、STIはORマッピングの技術です。
関心の対象は、継承関係を持つクラスの属性をRDBにどのように永続化するかであり、振る舞いは関心の対象外です。

振る舞いの共通化が真に問題なのだとしたら、それはSTIの問題ではなく、モデルで継承を利用すべきかそうでないかの議論に帰着できると考えられます。

「STIよりポリモーフィック関連のほうが好き」

STIは「継承」の実装、ポリモーフィック関連は「関連」の実装なので、両者を比較するのは不適切です。

STIの比較対象はClass Table InheritanceとConcrete Table Inheritanceであるべきです。

「STIを使わなくても特には困らない」

継承やポリモーフィズムの持つ強力な機能を活かしきれておらず、再利用性やメンテナンス性の低いコードを書いてしまっている可能性はありませんか?

オブジェクト指向やデザインパターンの再学習を推奨します。

「カラムが多くなりすぎるのが嫌」

SELECT *したときに横長で見づらくて困る! くらいの理由であれば、SELECT *するな、でお終いです。

ただしあまりに多くなると、RDBMSのテーブル毎のカラム数上限に引っ掛かる可能性がある点には注意が必要です。
例えばMySQLのInnoDBでは1000、PostgreSQLでは1600が上限となっています。

もっとも、STIはクラステーブル継承や具象クラス継承と組み合わせて利用することもできるので、使える範囲内でSTIを使うという選択肢もあります。

ところで、STIを採用しない場合にテーブルがどんどん増えるのは気にならないのでしょうか?

「NULLのカラムばかりでテーブルがスパースになり領域がもったいない」

MySQL(InnoDB)やPostgreSQLなど、最近の普及しているデータベースではNULLのカラムは領域を全く消費しないので、見た目がスパースでも、実際には格納効率・転送効率が悪いわけではありません。

「サブクラス固有のカラムにNOT NULL制約を指定できないのはやっぱりちょっと...」

これは確かに大きなデメリットです。

ただ、純粋にDB設計の問題として考えたとき、「このカラムは○○の条件が満たされるときは必ず非NULLでなければならない」というのはよくある話しです。
例えば「ステータスが『完了』の場合は『完了日時』にタイムスタンプがセットされていなければならない」など。

STIの文脈に置き換えた場合の「typePersonならreal_name列は非NULLでなければならない」も同じようなものと考えることもできるのではないでしょうか。

結局のところ、データモデル上の制約を常にデータベース制約として実装できるわけではないので、重要性のレベルによってはNOT NULLの保証をアプリ側に任せてもよいケースは多いと思います。
RailsであればActiveRecordのバリデーションとして宣言することになるでしょう。

また、一般的にはCHECK制約で代替できるのと、CHECK制約のないMySQLでもGenerated ColumnでCHECK制約っぽいことをやるというハックがあるようです。

おわりに

書き始めたらずいぶんと長くなってしまいました。

自分はfreeeのフルタイムエンジニアの中では最年長(4◯歳)で、考え方も古めですが、それでもこの歳までエンジニアを続けてこられたのはデータモデリングの知識・技術を身に着けていたからだと感じています。
そして拡張性に富み、保守性の高いシステムを作るには、柔軟かつ堅牢なデータモデルが必要だと強く信じています。

Railsで開発するのであればSTIはそのための重要なツールになるはず!
しかしその割に評価が低いのが不憫でならなかったので、この記事でその思いを吐き出すことができてスッキリしました。

freeeはこれからクラウドERPとしてさらなる成長を遂げていき、データモデルの重要性はますます高まっていくでしょう。
freeeではデータモデリングに一家言お持ちの方を募集しています。
一緒に未来のfreeeを作り上げていきませんか?

さて、明日のfreee Engineers Advent Calendar 2016には、freeeのフロントエンドに革命をもたらす男、 @joe-re が登場します。
お楽しみに!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした