flint>flint blog>2016年> 1月>10日>関係データベースにおける継承の表現

関係データベースにおける継承の表現

システムで扱われるデータの設計をしていると、「細部は異なるけれども、共通する部分を持つ」一連のバリエーションと呼べるものが出てくることが多々あります。 例えば、ある物販システムが扱う商品に、「食品」「衣料」「書籍」という区分があったとして、それぞれが次のようなデータ構造を持つとしましょう。

食品
商品ID, 名称, 取扱業者ID, 価格, 内容量, 消費期限, 品切れフラグ
衣料
商品ID, 名称, 取扱業者ID, 価格, サイズ下限, サイズ上限, 品切れフラグ
書籍
商品ID, 名称, 著者名, ISBN, 取扱業者ID, 価格, 品切れフラグ

こうしたデータ型をオブジェクト指向プログラミング言語で扱う場合、まず共通する属性をまとめて「商品」を表す抽象クラスとして定義し、これを継承して「食品」「衣料」「書籍」を表す具象クラスを定義するのが一般的なやり方です。

//商品
class Product {
    UINT    uID;      //ID
    String  name;     //名称
    UINT    uDealer;  //取扱業者ID
    int     nPrice;   //価格 (単位: 円)
    bool    bSoldOut; //品切れフラグ
};

//食品
class Food : Product {
    int     nNet;       //内容量 (単位: g)
    Date    dateExpire; //消費期限
};

//衣料
class Cloth : Product {
    int nHeightMin; //サイズ下限 (身長 / 単位: cm)
    int nHeightMax; //サイズ上限 (身長 / 単位: cm)
};

//書籍
class Book : Product {
    String  author; //著者名
    String  isbn;   //ISBN
};

こうした設計手法は業界的にも概ね「常識」として定着しているようで、ここまでのところであまり問題のある開発現場に遭遇することはありません。 ところが、このデータ構造を関係データベース上で表現する段になると、途端におかしな設計が目に付くようになります。

とりあえず愚直に実装?

最初に紹介するのは、抽象化などは一切考慮せず、各種の商品タイプごとに必要な属性をすべて列挙するという愚直なやり方。 さすがにこのような設計に出食わすことはあまりない......と言いたいところなのですが、実はあちこちの現場で用いられていたり。 派生するデータ型の種類が少ない(と言っても5~7くらいだったりする) 場合に、設計・実装の「手抜き」をするために採用されることが多いようです。

テーブル名 カラム名 制約 説明 備考
food id INTEGER PRIMARY KEY 商品ID
name TEXT NOT NULL 商品名
dealer INTEGER NOT NULL 取扱業者ID
price INTEGER NOT NULL 価格 単位: 円
soldout INTEGER NOT NULL 品切れフラグ [ 0: 在庫あり, 1: 在庫なし ]
net INTEGER NOT NULL 内容量 単位: g
date_expire DATE NOT NULL 消費期限
cloth id INTEGER PRIMARY KEY 商品ID
name TEXT NOT NULL 商品名
dealer INTEGER NOT NULL 取扱業者ID
price INTEGER NOT NULL 価格 単位: 円
soldout INTEGER NOT NULL 品切れフラグ [ 0: 在庫あり, 1: 在庫なし ]
size_min INTEGER NOT NULL サイズ下限 身長, 単位: cm
size_max INTEGER NOT NULL サイズ上限 身長, 単位: cm
book id INTEGER PRIMARY KEY 商品ID
dealer INTEGER NOT NULL 取扱業者ID
name TEXT NOT NULL 商品名
price INTEGER NOT NULL 価格 単位: 円
soldout INTEGER NOT NULL 品切れフラグ [ 0: 在庫あり, 1: 在庫なし ]
author TEXT - 著者名
isbn INTEGER - ISBN

こうした設計には問題が多々ありますが、特に深刻なのは次のようなものでしょうか:

  • 主キーである「商品ID」が商品タイプ (=テーブル) ごとに管理されているので、重複がないことの保証が難しい。
  • 共通属性 (例えば「取扱業者」) を指定して商品情報を抽出しようすると、商品タイプ (=テーブル) の数だけ選択 (SELECT) を実行しなければならない。
  • 共通属性の追加や変更, 削除が発生した場合、すべてのテーブルに対して変更 (ALTER) を実行しなければならない。

すべてのテーブルには id, dealer, name, ... といった共通の属性を表すカラムが設定されていますが、「これらのカラムは同じものを表している」ことを知っているのは、ただ設計した人間のみ。 それらの間には客観的に読み取れる (=プログラム的に処理できる) 何の論理的な対応関係もありません。 そのため、長期の運用・改修を経た後にはテーブル間での共通属性の食い違いや、データの不整合 (商品IDの重複など) が発生しているというケースが割とよく見受けられます。

全部まとめればいいじゃない?

共通情報が複数のテーブルに散らばっていると扱いが大変になる、という教訓から、一部の設計者は「すべての商品タイプをひとつのテーブルで扱えばいいじゃないか」という発想に至ります。 そこで問題になるのが、個々の商品タイプ間が独自に持っている属性をどう扱うかですが、とりあえず共通属性の後ろに列挙して、すべてをカバーできるテーブルを作ることにしましょう。

テーブル名 カラム名 制約 説明 備考
product id INTEGER PRIMARY KEY 商品ID
name TEXT NOT NULL 商品名
dealer INTEGER NOT NULL 取扱業者ID
price INTEGER NOT NULL 価格 単位: 円
soldout INTEGER NOT NULL 品切れフラグ [ 0: 在庫あり, 1: 在庫なし ]
class INTEGER NOT NULL 商品タイプ [ 1: 食品, 2: 衣料, 3: 書籍 ]
net INTEGER - [食品] 内容量 単位: g
date_expire DATE - [食品] 消費期限
size_min INTEGER - [衣料] サイズ下限 身長, 単位: cm
size_max INTEGER - [衣料] サイズ上限 身長, 単位: cm
author TEXT - [書籍] 著者名
isbn INTEGER - [書籍] ISBN

食品, 衣料, 書籍の情報をすべてを、以下のように同一のテーブルに格納することができるようになりました。 共通・個別の属性を指定しての検索や抽出も難しくはなさそうです。

id name dealer price solidout class net date_expire size_min size_max author isbn
1 鶏モモ肉 105 598 0 1 300 2016-01-26 NULL NULL NULL NULL
2 蓮根 107 218 1 1 200 2016-01-30 NULL NULL NULL NULL
3 Tシャツ (黒) M 225 1080 0 2 NULL NULL 150 175 NULL NULL
4 ジョジョリオン (11) 642 423 0 3 NULL NULL NULL NULL 荒木 飛呂彦 978-4-08-880548-1

しかしながら、このカラムの多さは大変気になるところ。 新しい商品タイプが追加されるごとに、このテーブル product には複数のカラムが追加されることになりますが、それらの値は特定タイプの商品を表すレコード以外では使用されません。 使用されないフィールドはNULLに設定されていることが望ましいのですが、そうなると、そのカラムにはNOT NULL 制約を付けることができなくなってしまい、これまたデータの整合性を保つことを難しくする要因となります。 そもそも、カラムの数が運用とともに際限なく増えていくことが見込まれるテーブルが定義される時点で、設計として何かが間違っていると考えるべきでしょう。 (参照: でかいテーブルの恐怖)

[2016/01/12] 追記

この設計手法は単一テーブル継承という名前が付いているとのこと。 派生クラスの数が余程限定され、そのフィールド数も少なく、なおかつ将来的にも増える見込みがない、という状況であればこれを採用するのもアリかもしれませんが、一般的にはアンチパターンと見做すべきものではないかと思うのですが、そうした意見はあまり見られないようです。

TEXTなら何でも入るぞ?

そうした面倒を避けるため、設計者に対して「商品タイプが増えても既存データが入っているテーブルの定義を変更しなくてもいいようにせよ」という要求が出さることがあります。 普通程度の技量の持ち主であれば、この時点で商品タイプごとにテーブルを分ける決断を下すわけですが、世の中は広いもので、ときに (実際は結構な頻度で) こちらの予想の斜め下の解決策を繰り出してくる猛者が現れることも。

テーブル名 カラム名 制約 説明 備考
product id INTEGER PRIMARY KEY 商品ID
name TEXT NOT NULL 商品名
dealer INTEGER NOT NULL 取扱業者ID
price INTEGER NOT NULL 価格 単位: 円
soldout INTEGER NOT NULL 品切れフラグ [ 0: 在庫あり, 1: 在庫なし ]
class INTEGER NOT NULL 商品タイプ [ 1: 食品, 2: 衣料, 3: 書籍 ]
data TEXT - 商品データ カンマ区切り
- 食品: 内容量, 消費期限
- 衣料: サイズ下限, サイズ上限
- 書籍: 著者名, ISBN

前節で示したテーブル定義にあった商品タイプ別の属性を保持するカラムがごそっと消え、その代わりに data なるカラムがひとつ設けられています。 TEXT型に「カンマ区切り」? 嫌な予感を覚えつつ、その内容を表示してみると、

id name dealer price solidout class data
1 鶏モモ肉 105 598 0 1 300,2016-01-26
2 蓮根 107 218 1 1 200,2016-01-30
3 Tシャツ (黒) M 225 1080 0 2 150,175
4 ジョジョリオン (11) 642 423 0 3 荒木 飛呂彦,978-4-08-880548-1

ilili_| ̄|○ilili

これは完全に駄目なパターン。 属性が単一のフィールド内に収められているため、検索などの操作を行う際にいちいちパース処理を施す必要があります。 取り回しが最悪な上に、パフォーマンス的にも問題のあるデータ設計だと言えるでしょう。 さらに、外部キー (FOREIGN KEY) 制約や、一意性 (UNIQUE) 制約によるチェックの恩恵も受けることができなくなってしまいます。 「著者名」やその他将来的に拡張されるテキスト型フィールドの値がカンマが入ってきた場合のエスケープ処理はどうするつもりなのか......等々、指弾すべき問題点は尽きることがありません。 (参照: 関係データベースにおける配列の表現#TEXT型なら何でも入るぞー?)

そんなわけで、「個々のフィールドはそれぞれ単一のスカラー値を保持すること」という要求が出されるわけですが、ありがちな駄目設計はその制約すら斜め下に掻い潜ってくるものなのです。

テーブル名 カラム名 制約 説明 備考
product id INTEGER PRIMARY KEY 商品ID
name TEXT NOT NULL 商品名
dealer INTEGER NOT NULL 取扱業者ID
price INTEGER NOT NULL 価格 単位: 円
soldout INTEGER NOT NULL 品切れフラグ [ 0: 在庫あり, 1: 在庫なし ]
class INTEGER NOT NULL 商品タイプ [ 1: 食品, 2: 衣料, 3: 書籍 ]
param1 TEXT - 商品タイプ別属性 (1) - 食品: 内容量 (単位: g)
- 衣料: サイズ下限 (身長 / 単位: cm)
- 書籍: 著者名
param2 TEXT - 商品タイプ別属性 (2) - 食品: 消費期限
- 衣料: サイズ上限 (身長 / 単位: cm)
- 書籍: ISBN
param3 TEXT - 商品タイプ別属性 (3) 拡張用フィールド (未使用)
id name dealer price solidout class param1 param2 param3
1 鶏モモ肉 105 598 0 1 300 2016-01-26 NULL
2 蓮根 107 218 1 1 200 2016-01-30 NULL
3 Tシャツ (黒) M 225 1080 0 2 150 175 NULL
4 ジョジョリオン (11) 642 423 0 3 荒木 飛呂彦 978-4-08-880548-1 NULL

確かに「1フィールド1スカラ」という要求は満たされていますね。 この設計のポイント (?) は、末尾に設けられている拡張用フィールド。 その数を十分大きく取る、例えば param9 くらいまで定義しておけば、将来的にどんな商品タイプが追加されたとしても、テーブル定義を変更する必要はないでしょう。

しかし、この設計にもまだ致命的な欠点があります。 例えば、整数値のフィールドもテキスト (文字列) として格納されているため、普通に <> などの演算子による比較を行うと、その大小関係は辞書順で判定されるため、例えば、値 352016 を比較した場合には、

35 > 2016

のようなおかしな判定が行われることになります。 また、「整数」が入っているべきフィールドに数値以外の文字を含む文字列が入っていたり、「日付」が入っているべきフィールドに '2016-02-31' や '2/28' のような文字列が入っている場合の挙動はどうなるのかも不安なところ。 こうした条件に応じて異なる型のデータを格納されるテキスト (文字列) 型のカラムを見るといつも、ある小説内のエピソードが思い出されます。

東海林: 引数に申請日付を渡しているが、なぜこの引数はStringになっているのか?
高杉: 処理完了日付は、8けたの文字列になっている。
東海林: なぜ文字列なのか?

東海林: 例えば日付計算を行う場合はどのようにするのか?
高杉: 年と月と日に分解して計算すればいい。そのような共通ロジックはすでにAフレ共通機能として存在している。
東海林: それは、うるう年も考慮しているのか?
高杉: 当然、考慮しているはずだ。
橋本: 確認しておく。
東海林: それでもまだ不安が残る。
高杉:どのような不安か?
東海林: レコードに格納されている値が、正しい日付であることが保証されていない。
高杉:意味が分からない。
東海林: 例えば 0000000099999999 が格納されている可能性はないのか? また、そのような格納を防止する手段はあるのか?

「なんか間が抜けたことやってますね」ぼくはつい正直な感想を口にしてしまった。 「なんで、Calendar の add メソッドとか使わないんでしょう?」
「さあね。正しい日付が入ってくるという自信がなかったのか、そもそも知らなかったのか。そんなことじゃなくて、2月の計算のところだ」
「あ」一目見て何が問題なのかわかった。「これはまずくないですか?」
「まずいに決まってる」東海林さんはぶっきらぼうに言った。「200x年はうるう年だ」
このメソッドで日付を足すと、2月29日は永久に出現しないことになる。手抜きもいいところだ。まさかうるう年の計算方法を知らんわけでもあるまいに。
「これは、どこで使ってるんですか?」
「とりあえずバッチに関係するところだと、承認期限の算出ロジックだな。他でもいろいろ使ってるが……まあ、次のうるう年の2月までは影響ないか」
「ということは、プロセス6の2月分の承認データがおかしくなってるってことですか」
「正解」
隣でぼくたちの話を聞いていた橋本さんの顔が青ざめた。

当たり前の形に

では、関係データベース上で継承データモデルを表現するにはどうすれば良いのか? 実はここまで散々「駄目な設計」の例を見てきましたが、実はそのひとつひとつは部分的には「正解」に手が届いていました。

テーブル名 カラム名 制約 説明 備考
product id INTEGER PRIMARY KEY 商品ID
name TEXT NOT NULL 商品名
dealer INTEGER NOT NULL 取扱業者ID
price INTEGER NOT NULL 価格 単位: 円
soldout INTEGER NOT NULL 品切れフラグ [ 0: 在庫あり, 1: 在庫なし ]
class INTEGER NOT NULL 商品タイプ [ 1: 食品, 2: 衣料, 3: 書籍 ]
food product INTEGER NOT NULL 商品ID
net INTEGER NOT NULL 内容量 単位: g
date_expire DATE NOT NULL 消費期限
cloth product INTEGER PRIMARY KEY 商品ID
size_min INTEGER NOT NULL サイズ下限 身長, 単位: cm
size_max INTEGER NOT NULL サイズ上限 身長, 単位: cm
book product INTEGER PRIMARY KEY 商品ID
author TEXT - 著者名
isbn INTEGER - ISBN

すべてタイプの商品データをひとつのテーブルに入れる代わりに、共通部分のデータだけをまとめてやれば、そして、それぞれのタイプの商品ごとに独立したテーブルを定義する代わりに、各タイプに固有の部分だけを別のテーブルに括りだしてやれば良かったのです。 こうして示されれば、「一体何故こんな当たり前の設計に気付かなかったのだろう」と思うのですが、こうした設計をある種の「定石」として認識していない状態では、目の前の要求仕様からこのデータの形を想起することはなかなかに難しいことのようです。 これらのテーブルから、例えば書籍の情報を取り出すSQLは以下のようになりますが、何の衒いもないシンプルな操作であることがお分かり頂けるでしょう。

SELECT * FROM "product" JOIN "book" ON "product"."id" = "book"."product";

唯一説明が必要だとすれば、「商品タイプ」を表すカラム class でしょうか。 このフィールドがないと、商品IDの値からのデータ検索に、product とすべての商品タイプのテーブル (food, cloth, book, ...) の付き合わせが必要になってしまいます。 また、"class" という名前は多くの言語で予約語となっているためトラブルサムであるように思えますが、この情報はデータをデータベースから抽出してオブジェクトにマッピングする際に、「どのクラスのオブジェクトを作るか」の選択に用いられるものであり、これをオブジェクトにメンバ (フィールド) として持たせる必要はありません。 従って、このカラム名 "class" に対応する識別子 (変数名) を考える必要はないので問題とはならない、というわけです。 こうしたオブジェクト指向プログラミング言語における class というキーワードに対応する情報であることを明示するために、敢えて "class" というカラム名を使用している、という側面もあります。

運用で楽をすることを考える

このエントリでは「正解」を示す前に、「駄目な設計」の例とその問題点について解説してきましたが、そもそもこうした「駄目な設計」はどうして為されてしまうのでしょうか。 もちろん、設計者の力量不足に起因するものも少なからずあることでしょう。 しかしながら、複数の開発現場を渡り歩いた経験からすると、ある程度の知識を有し、それなりの経験を積んだエンジニアがこうした「駄目な設計」を行う例の方が圧倒的に多いのです。

その理由のひとつに、「設計は最低限守るべきラインを示すもの」「品質は実装・運用のフェイズで担保すべきもの」という考えがあるように思われてなりません。 実装フェイズではコミットやリリースの段階ごとに微に入り細を穿つテストが実施され、運用フェイズでは週・月ごとにブ厚い作業報告書が作成・提出させることになりますが、それと比較すると、設計の良否を評価するレビュー等の作業に割り当てられる時間や人手は本当に微々たるもの。 特に、スケジュールに遅延が発生している場合や、不具合修正などの緊急対応では、「設計に時間を掛けるくらいなら、一日でも早く製造 (実装) に入りたい」という焦りから、設計の工数短縮への圧力が目に見えて強まります。

しかし、その後の実装・運用の経過まで含めて観察すれば、設計段階での「手抜き」が業務に与える悪影響の大きさは明らかに見えてくるはず。 不具合対応は作業の優先度が高く、加えて発生タイミングが予測できないため、これが頻発すると、後続の案件の開発スケジュールの進行は著しく阻害されることになります。 データ構造に起因する不具合は修正作業のためにサービスを停止させなければならなくなる場合が多く、クライアントへの負担も大きなものに。 何より懸念すべきは、そうした泥沼の作業による実装・運用チームの疲弊と士気低下でしょう。 こうなってくると、今まで上手く回っていたはずの業務までが綻び始めます。

私の経験からするに、システム設計というものは、データ構造を決めた時点でその7割が完了しています。 それ以外の部分、例えばユーザインターフェイスなどの表面的な挙動については、実装のフェイズで対応できる部分が殆ど。 (まれにそうでない部分もありますが。) そして、データ構造が上手く計画されているかどうかは、テーブルの間の参照関係、即ちIDの使われ方を見れば、おおよその成否を把握することができます。

設計をしっかり行いさえすれば、現在システム開発・運用の現場で生じている混乱のかなりの部分が解消されるはず。 少々大袈裟な言い方のような気もしますが、「業務がキツいな」と思っているならば、一度騙されたと思って設計工程の強化に取り組んでみることをお勧めします。

成田 (愚者は成事に闇く、智者は未萌に見る。)
このエントリーをはてなブックマークに追加

コメント

投稿者
URI
メールアドレス
表題
本文