データベース設計とドメインオブジェクト
概要
この記事は、現場で役立つシステム設計の原則〜変更を楽で安全にするオブジェクト指向の実践技法を読み学習した内容を Ruby on Rails のコードに例を変換してまとめ直したものです。
また、書籍では Java のコードを例にしており、Ruby on Rails の慣習とは一部異なる部分があるので、その部分はオミットした内容となっています。
この記事では主にオブジェクト指向設計における適切なデータベース設計の方法と、ドメインオブジェクトとテーブルの適切な関係について記述しています。
テーブル設計が悪いとプログラムの変更が大変になる
以下のような設計上の問題を抱えるデータベースを扱うプログラムは、データを扱うための if 文が増えたり、取り扱うための暗黙の知識が必要となるなど、見通しが悪く変更がしづらいものになりがち。
- 用途がわかりにくいカラム
- 巨大なテーブル
- テーブル間の関係のわかりにくさ
それぞれ、具体的には以下のような問題がある。
用途がわかりにくいカラム
以下のようなカラムは意味が分かりにくく、カラムの参照やデータの挿入を行うプログラムも複雑で分かりにくいものとなってしまう。
- カラム名が省略形
- NULL 値が入ったカラム
- 他のカラムの内容に依存して値の意味が変わるカラム
- カラムから取得した文字列を、プログラムで分解する必要がある
- 意味が読み取れないコード(0,1,9...などのマジックナンバー)が付いている
また、任意の文字列を任意の用途で使う拡張用カラム(「自由項目」「予備項目」など)は、そのカラムの意図が曖昧になりやすく、使い方もバラバラとなるのでさらに避けるべきアンチパターンとなる。
色々な用途に使う巨大なテーブル
様々な用途に使うデータを乱暴に一つのテーブルにまとめてしまうのも扱いづらい。
例えば、ある機能を実現するために必要なのは 3,4 つのカラムであるのに、テーブルに 100 以上のカラムがあり、そのすべてを考慮しなければならないパターンなど、カラム数の多いテーブルでは以下の傾向が強くなる。
- 似たようなカラムが多く、その使い分けがわからない
- NULL 値が多い
このようなテーブルは内容の理解が大変であり、本当に必要なカラムかどうかわからないカラムにまで注意を払う必要があり、場合によっては if 文による条件分岐が多数必要になってくるなど、プログラムの見通しが悪く変更も大変になる。
テーブル間の関係が分かりにくい
以下のようなテーブル設計だと、テーブル間の関係がはっきりしない。
- 外部キー制約がない
- キーとなるカラムの名前に一貫性がない
このようなテーブルを操作するプログラムは、読みづらく、実行時の SQL を見ないと内容を解読できないという状況になりがち。
データベース設計をスッキリさせる
前項で挙げたような拙いテーブル設計は、プログラムを無駄に複雑にしてしまう。
反対に、テーブルをうまく設計し、データをわかりやすく管理できているデータベースを対象にしたプログラムは、シンプルでわかりやすくなる。
以下では、そのようなデータベース設計をスッキリさせるためのアプローチについて解説していく。
基本的な工夫を丁寧に実践する
データベース設計で大切なのは、難しい理論や高度な分析能力ではなく、以下のような基本を丁寧に実践すること。
- 名前を省略しない
- 適切なデータ型を使う
- 制約をきちんと使う
以下では、それぞれの基本を丁寧に実践する方法について解説していく。
名前を省略しない
「prd_cd_m」などカラム名に省略形を使わないこと。意図を明確にするために、意味の明確な普通の単語を使う。
適切なデータ型を使う
適切なデータ型の指定は、不正なデータを防ぐ基本となる。
例えば、数値データや日付データを文字列型にしてもプログラムは動作するが、文字列型にはどのようなデータも入力できてしまうので不正なデータが入りやすくなる。
また、桁数の指定も現実的な桁数を指定すること。
例えば、商品数量を扱うカラムに整数 10 桁のカラムを指定すると、10 億個以上の商品を扱うことができてしまうが、そのような桁数のデータは現実的には考えられない不正なデータである。
制約をきちんと使う
データが重複し、カラムのデータ内容や意味が推測しにくく、テーブル間の関係がはっきりしないデータベースは以下の「制約」の仕組みをほとんど使っていない。
- NOT NULL 制約
- 一意性制約
- 外部キー制約
データベースをきちんと設計されると、これらの制約は自然に満たされているはずなので、これらの制約がないカラムやテーブルは設計に問題があると疑った方が良い。
以下では、それぞれの制約について解説していく。
NOT NULL 制約でテーブル設計を正規化する
NULL は有効なデータはなく演算不能な「未知」ということ。
そもそも「既知」の事実の記録を行うデータベースに「未知」の NULL を記録するのは掟破りと言える。
以下のように NOT NULL 制約を用いることで自然に正規化されたテーブル設計となる。
- カラムには NULL を含まないのが基本。そのため、カラムはすべて NOT NULL 制約を付与する。
- NULL 値を含むカラムがどうしても必要な場合は別のテーブルに分けることを検討する。
- カラムに値がない場合に「unknown」「9999」を入れるといった「NULL 逃れ」をしない。
一意性制約でデータの重複を防ぐ
データを正しく記録するためには、データの重複を徹底的に取り除くことが重要。そのための基本手段が一意性制約である。
一意性制約は重複したデータの記録を防ぐ仕組みであり、単独のカラムだけでなく複数のカラムを組み合わせた一意性制約も設定できる。
そして、すべてのカラムとその組み合わせが基本的には、一意性制約の候補であると考えることが大切。
正しくデータを管理するためにどのカラムの組み合わせが一意になる/一意にならないかを常に意識して、一つの事実が重複して記録されないようにする。
外部キー制約でテーブル間の関係を明確にする
NOT NULL 制約と一意性制約を追加していくと、テーブルも自然と正規化されていく。
これらの制約が追加できない場合は、データの重複や不正なデータが混入している可能性が高い。
そして、データに不正がないのに制約が追加できない場合は、テーブルを分割することで制約が使えるようになる。この制約を使うためのテーブル分割は、正規化と対応する。
そして、テーブル分割の際に重要なことが、テーブル間の関係を明確にすること。
そのようなデータ間の関係を記録することを強制する方法が外部キー制約である。
関連したデータを別のテーブルに分けて持つ場合、必ず外部キー制約でその関連付けを保証することで、データの整合性が確保できる。
コトに注目するデータベース設計
業務アプリケーションの中核の関心事である「コト」の管理
業務アプリケーションでデータベースが重要なのは、以下のようなコトを正しく記録し、参照するため。
- 現実に起きたコトの記録
- 将来起きるコトの記録(約束の記録)
前項までに述べた、NOT NULL 制約と一意性制約、外部キー制約を使うことで、これらのコトを正しく記録し、参照することができる。
ヒトやモノとの関係を正確に記録するための工夫
コトは主体(ヒト)と対象(ヒトやモノ)との関係として定義できる。
つまり、コトの記録はヒトへの関係とモノへの関係も合わせて記録する必要がある。
そして、それらの関係性も含めて記録するには、データの不整合を防ぐために、ヒトやモノを一意に識別するキーを使った外部キー制約が必須となる。
さらに、以下で事実を正しく記録するための三つの工夫について解説していく。
記録のタイミングが異なるデータはテーブルを分ける
記録のタイミングが異なるデータを一つのテーブルで記録しようとすると、 NULL 可能なカラムが必要となる。
そのため、コトの記録で NOT NULL 制約を徹底するためには、記録のタイミング(コトの発生のタイミング)が異なる事実は、別のテーブルに記録する。
そして、そのテーブル間の関係を明示するために外部キー制約を使う。
変更の記録を禁止する
過去の記録であるため、コトの記録テーブルのデータを UPDATE 文で変更してはいけない。
過去の記録を修正したい場合は、まず過去の記録の「取り消し」を記録する。
そして、修正する事実を別の記録として追加する。
そうすると以下の三つの記録データが残る。
- 元データ
- 取り消しデータ
- 新データ
この方法であれば、取り消しも修正も、データを追加する INSERT 文の処理で実現できる。
カラムの追加はテーブルを追加する
今まで記録していなかったデータを記録するためにデータベース設計の変更を行う場合は、既存テーブルへのカラムの追加をするのは好ましくない。
追加するカラムには過去データが存在しないため、 NULL を許容するか、 NOT NULL 制約を逃れるための「虚」のデータを登録する必要があるからである。
このような場合は、テーブルを追加することで対応する。
- 元のテーブルはそのまま利用する
- 追加するデータ項目をカラムに持つテーブルを新しく作る
- 追加したテーブルから元のテーブルに外部キー制約を宣言する
参照をわかりやすくする工夫
コトの記録に注力したテーブル設計の問題
NOT NULL 制約を徹底したデータベース設計では、データの整合性が保証され信頼性も向上するが、一方で以下のような状態となる。
- カラム数の少ないテーブルがたくさん生まれる。
- 必要な情報を取り出すために多くのテーブルを結合する複雑さが生まれる。
以下では、コトの記録に注力しつつ、データの整合性を保ち、参照をわかりやすくするための工夫について解説していく。
状態の参照
現在の「状態」は理論的にはコトの記録のみで導出可能。(例えば、銀行口座の現在の残高は、その口座に対するすべての入金と出金の記録の合計から導出できる)
ゆえに、残高や現在の状態を参照しやすくするための冗長なテーブルやカラムは理論上は不要であり、データの重複や不正なデータの混入の原因となるので避けるべきである。
しかし、実際には複雑なロジックで状態を動的に算出するのを避けて、容易に状態を参照したいというニーズも多くあるため、そのような場合は、以下のような工夫をする。
- 基本はコトの記録のテーブル
- 導出の性能を考慮して、コトの記録のたびに状態を更新するテーブルも用意する
- 状態を更新するテーブルはコトの記録からいつでも再構築可能な二次的な導出データ
例えば、口座に入金があったら入金テーブルにコトを記録した後に、残高テーブルのその口座の残高も増やす。
口座から出金があれば出金テーブルにコトを記録した後に、残高テーブルのその口座の残高を減らす。
UPDATE 文は使わない
状態を記録するテーブルの更新は、 UPDATE 文を使わず INSERT 文と DELETE 文の対で実行する。
これにより、 UPDATE 文で実行する場合の「記録の同時性」の違反を避け、既存口座の更新も新規口座の作成もすべて同じテーブル内で INSERT 文で処理可能となるなど、ロジックもシンプルとなる。
状態の更新は同時でなくても良い
コトの記録はデータの本質的な記録であり、それに付随する状態テーブルの更新は二次的な導出処理であるため、それらをトランザクションで同時に処理するのはデータ記録の考え方としては正しくない。
状態の更新が失敗した際に何らかの対応をとる必要はあるが、その仕組みは本来のコトの記録からは独立させて行うべき。
状態の更新は一箇所でなくても良い
厳密な同時性がなくても良いのなら、例えば残高更新は以下のように複数のサーバで非同期処理できる。
- コトの発生を顧客管理サーバに通知すると、顧客管理サーバは顧客単位の残高を更新する
- 同じコトの発生を営業管理サーバに通知すると、売上部門別の売上高を更新する
様々な情報を色々なタイミングで異なる利用者が別の角度から利用したい場合は、単独のデータベースで同期と整合性を確保する方式のみでは限界があるため、非同期メッセージングで複数データベースを連動させる手法も有力な選択肢の一つ。
派生的な情報は転記して作成する
コトの記録を徹底し、コトの発生を非同期メッセージングで分配すれば、必要に応じて集計情報、コトの記録の複製、コトの記録のサブセットを並行して作成できる。
そのように、コトの記録を基本にして、そこから派生する様々な情報を目的別に記録する方式をイベントソーシングと呼ぶ。
参照系の情報は、コトの記録とは分離して、用途別に様々な情報を並行して作成しておくことで、参照系のプログラムをシンプルにすることができ、目的ごとに柔軟に修正・拡張することができる。
コトの記録から状態を動的に導出する場合のメリット
「状態」を逐一記録せず、現在の状態を起きたコトの記録から動的に導出する方法のメリットとしては、現在の状態と未来の状態を同じロジックで算出可能であること。
例えば、売上実績のみを記録し、特定期間の売上合計は動的に合計する仕組みの場合、売上実績管理に使う以外に、未来の売上を擬似的に発生させることで売上予算のシミュレーションも同じロジックで実現できる。
また、このロジックの場合、過去のデータのサブセットを用意して投入すれば、過去のある時点での状況を擬似的に作成できるので、業務アプリケーションにおける業務プロセスの途中段階の機能のテストが作成しやすいというメリットもある。
オブジェクトの設計とテーブルの設計
オブジェクトとテーブルは似てくる
コトの記録を徹底し、現在の状態を独立して導出するようにテーブルを設計していくと、コトを記録するテーブルとコトを表現するドメインオブジェクトがほぼ一対一に対応してくる。
業務の関心事として同じものを取り扱っているのである意味では当然であるが、両者は似ているだけで本質的には異なるという点を意識しておくことが重要である。
違うものとして明示的に対応させてマッピングする
オブジェクトとテーブルは最終的に似たものに落ち着く部分があるが、設計の着眼点や設計改善のプロセスは以下のように大きく異なってくる。
| 特性 | オブジェクト | テーブル |
|---|---|---|
| 目的 | データとロジック、特にロジックの整理 | データの整理 |
| 関心事 | 導出や加工のロジック、データを使った判断ロジック | 導出や加工のもとになるデータ |
| アプローチ | ある部分のデータとロジックに注目しながら、部分から段階的に全体へ組み合わせるボトムアップ | データの整合性を確保するために、関連するデータを全体的に洗い出してから部分ごとの関係を明確に設計するトップダウン |
| 設計変更のリズム | 頻繁 | 緩やか |
このため、オブジェクトとテーブルを自動的にマッピングするアプローチは、どちらかの設計のアプローチ・リズムに大きく左右されて、うまくいかない。
オブジェクトはオブジェクトらしく、テーブルはテーブルらしく
前項までで述べたように、オブジェクトとテーブルは別物であるため、それぞれ別々に設計と改善を進め、オブジェクトとテーブルの間のマッピングは、その両方の設計の進度に合わせながら明示的に定義する。
こうすることで、お互いの設計変更の影響をマッピング定義に局所化でき、オブジェクト設計とテーブル設計の両方がより良くなる。
また、オブジェクトとテーブルのマッピングの仕組みは、様々なフレームワークが利用できる。
しかし、ドメインオブジェクトの記述においては、設計を歪めて以下のようなフレームワークの約束事・都合を持ち込んではならない。
- クラス名やメソッド名の命名方法
- フレームワークが要求するメソッドの追加
- マッピングの情報をアノテーションで埋め込む
ドメインオブジェクトは業務の関心事を表現する仕組みであるので、データと業務のロジックをどう整理するかをが設計の中心課題となる。
しかし、上記のようなフレームワークの約束事・都合を持ち込んでドメインオブジェクトの設計をフレームワークの都合へ合わせ始めると、データベース操作などの技術的な関心事が混在することとなり、本来のロジックの整理が曖昧となってしまったり、コードの記述量も増えてしまう。
オブジェクト設計とテーブル設計の独立性を保ちながら、別々に設計し易いフレームワークとして、MyBatis SQL Mapper がある。
このフレームワークはドメインオブジェクトのコードにテーブルやデータベース操作に関する知識を一切記述せず、テーブルを意識したアノテーションをドメインオブジェクトに一切持ち込まないことが可能。
業務ロジックはオブジェクトで、事実の記録はテーブルで
前項までで述べたように、業務ロジックを整理するドメインオブジェクト設計と、事実を記録するためのデータベースの設計は、別々に行うべきなので、両者を適切に関連づけることが必要である。
しかし、そのためにドメインオブジェクトとテーブルを自動化し機械的にマッピングする方法は、設計に不要な制約が持ち込まれ易いので好ましくない。
別々に正しく設計された両者を明示的に正しくマッピングすることが大切となる。
参考文献
この記事は以下の情報を参考にして執筆しました。