紙箱

覚えたことをため込んでいく

実践Immutable Data Model

はじめに

この記事では、Immutable Data Modelと呼ばれる設計手法をもとに、リレーショナル・データベースにおける、テーブル設計の話を書いています。また、今回の実践で利用する、別の考え方の背景を理解するために、Out of the tar pitという小論文の内容にも言及します。

「状態とは何か?」というややこしい話がたくさん出てきますし、データベースのテーブル設計についての話であることから、たくさんのSQLが出てきます。なので、データモデリングとか状態管理とか、特にSQLとかに興味がない人には面白くないと思います。

そのあたりに興味ある方は、読んでみて欲しいです。 Immutable Data Modelを、実際のアプリケーションで使うデータベースに採用するにあたり、どういう考え方で、どのようにテーブルを構成したか、自分なりの経験を書いています。

当たり前ですが、実践したものであるとはいえ、一つのアイデアであるので、別にベストな手法であると主張もしません。「ここはこうしたほうがわかりやすくなるのでは」とか「ウチではこうやってる」みたいなものがあれば、ご自身のブログで書くなどして示していただければ、発展的な話になるかなあと思っています。

Immutable Data Modelとは

プログラミング言語の世界には、イミュータブルなデータの方が不具合が起こりにくく安全である、という考え方が古くからあり、一部言語では、そもそもデータ自体を改変することができない(改変した新しいデータを作ることしかできない)言語も存在します。Clojureとか。

データベースのテーブル設計においても、テーブルのCreate, Update, Deleteのうち、特にUpdateが、システムを複雑化する要因の一つだという考え方があります。Updateをなるべく発生させないためには、データを改変できない(つまりCreateしかできない)モデルの方が、長期的に安定した、安全なシステムが作れるはず、という考え方です。

Immutable Data Modelと呼ばれるようですが、知る限りでは、Kawasimaさんが名付け親のはず。

イミュータブルデータモデル

Web+DB Pressのデータモデル回で、結構詳しく解説されているのでおすすめです。

WEB+DB PRESS Vol.130

考え方であり、解答ではない

多くの設計論がそうであるように、Immutable Data Modelは「考え方」であり、「細かい手続きとルールがあって、そのとおりにすれば自動的に良くなるような教科書」ではありません。データモデリングするときに、土台として選択できる、一つの方法論です。この考え方をもとに、実際にどのようにテーブルを用意するかは、自分で考える必要があります。

前述のWeb+DB Pressの記事においても、概ねこういうことをやりたいのだ、という説明や、提案は出てきますが、「これが答えなので一字一句、この通りやればいい」という物は出てきません。

手っ取り早く回答を知りたい人向けではないのです。

なので、この記事も、その考え方をもとに、こういう設計を実際にやってみているけどどうだろうか?という意味合いで読んで欲しいです。

同じ考え方をベースに、もっとエレガントな方法があるならば、どんどん採用していけばいいのです。

Out of the tar pit

Out of the tar pitという、割と有名な小論文があって、いろんな設計理論で引用されています。システムにおける状態(State)とロジックにどのような区別があるのか、本当に必要な状態やロジックとは、あるいは、そうでないものは?といったことを論じ、区分けすることで、安定したシステムを作るにはどうしたらいいのか?を論じています。

Out of the tar pitについては、私が前にブログで詳しく紹介した記事があるので、そちらを読んでみてください。

システムの複雑さはどこから来るのか – Out of the tar pitを読む

こちらで紹介しているように、Out of the tar pitでは、システムの状態ロジックを、

  • 必須の状態(Essential State)
  • 必須のロジック(Essential Logic)

と、システムの都合により生まれる、付随的なもの、

  • 付随的な状態(Accidental State)
  • 付随的なロジック(Accidental Logic)

とに分け、何か状態で、何がロジックなのか、何が必須で何か付随的なのかを説明しています。

なお、Essential/Accidentalの訳語としては、「本質的な」「偶有的な」という語が使われることが多いです(ブログに書いているように、この言葉の大元である『人月の神話』の翻訳で、その訳語が使われているからです)。「必須の」「付随的な」という語は私がブログにおいて使ったものですが、ここではそれを引き継ぎます。

状態とロジック

Out of the tar pitに書かれている特徴的な話に、我々が状態と思っているものは、実際には状態ではなく、ロジックである、という話があります。

架空の話として、計算時間が常にゼロの、数学的世界にいると仮定するすると、ゲームにおけるプレイヤーの現在位置は、状態ではなくロジックだという話をしています。なぜなら、計算時間がゼロなのであれば、「スタート地点」という状態と、「移動操作の全履歴」という2つの状態から計算すれば、現在位置は計算で導き出せるからです。現在位置とは、「スタート地点」と「移動操作の全履歴」をパラメータとした「現在位置」関数だということです。

つまり、スタート地点と移動操作の全履歴の二つは状態だが、「現在位置」は、実は状態ではなかったのだ、という話です。

一方、必須付随的の区別については、Out of the tar pitにおいて、何が必須の(Essential)状態なのかははっきりされていて、「ユーザーの入力」だけが必須の状態です。それ以外は全て必須の状態ではありません。ユーザーの入力だけは、計算で導き出すことはできないし、後から取り戻すこともできないのです。ユーザーの入力を記録する以外に、今後そのデータを得る方法がないのです。

なぜこのOut of the tar pitの話をしているかというと、Immutable Data Modelを実践する上で「ほんとうにデータベースに保存すべき情報はなんなのか?」ということを考える上で重要だからです。

ユーザーの入力はすべて「必須の状態」です。

なので、ユーザーの入力は、もれなく全て保管したい。でもそれ以外は必須ではないので、可能な限り、ロジックで表現したい。ここをスタート地点とします。

必須のロジック

では、「必須のロジック」はOut of the tar pitにおいてどう扱われているか。必須のロジックは、必須の状態から計算によって直接導き出せるものです。

なにが「必須の状態」であり、なにが「ロジック」なのかが区別できれば、テーブルとして用意すべきものは何なのかがはっきりしてきます。本当に保存が必要なのは、「必須の状態」だけです。

もちろん、現実の世界では、計算時間はゼロではないので、なにかしらの手当てをしなければいけません。ですが、最初の段階で、これは必須の状態である、これは実は状態ではなくロジックだ、と区分けができていることは大事です。「必須のロジック」の部分には、本来は、データベーステーブルは必要なかった、という認識が重要なのです。

では、リレーショナル・データベースにおける、「必須のロジック」はどのように表現されるべきでしょうか。

私はここに「ビュー」を使うことにしました。 「必須の状態」の組み合わせで表現できるものは、ビューで表現するのです。

付随的な状態・付随的なロジック

とはいえ、なんの手当てもないままビューだけで全てを表現できるかというと、インデックスも全く効かないビューができたり、そもそもビューとして定義できないものも出てきます。それを解決するために、効率的にデータを取るための補完的なテーブルやビューが必要になることがあるでしょう。

それらは、システムの都合です。このような、システムの都合上用意したテーブルやビューを、「付随的な状態」(もしくはロジック)とみなすことにします。それらを、あとから「ああ、このテーブルは、必須の状態ではなく、付随的な状態なのだな」などとわかるように、視覚的に表現することを目指します。テーブル名やビュー名を見るだけで、ああこれはユーザー入力の保存というよりは、システムテーブルの一種なのだな、とわかるようにしたいのです。

これらの前提のもとで、Immutable Data Model的なテーブル設計を考えて、やってみた結果を紹介するのがこの記事の目的です。

テーブル設計

ユーザー入力は原則として、全てテーブルに保存したい

「必須の状態」であるユーザーからの入力は、すべてテーブルに保存します。

あるデータがユーザーからの入力かどうかの識別となる情報に、データベーステーブルによくある「created_at」「created_by」とかの名前で記録される、操作者と操作日時を記録するカラムがあります。

これらの情報は、ここにユーザー操作があることを示しています。

Immutable Data Modelの元記事においても、日時情報カラムはそれが個別のデータであることを示していて、一つのテーブルに複数の日時情報カラムがあるのは、UPDATEの必要性を増すので良くなく、別テーブルにすべき、という話が出てきます。

そして、Out of the tar pitの「必須の状態」の観点から見ても、日時情報(と操作者情報)があるということは、これはユーザー入力の記録なのであり、ちゃんと状態として管理すべきだと考えられます。

なので、日時情報が必要な情報があれば、それらは全て、個別のテーブルにINSERTすることにします。原則として、別のテーブルを用意して、別の「状態」として保存します。これらは、それ自体が個別に記録すべきユーザー操作なのです。

このようなユーザー操作の記録を、「イベント」と呼ぶことにします。データベースには、入力データそのものを格納する「データテーブル」と、ユーザー操作を記録する「イベントテーブル」があり、どちらもユーザー入力から作られるので、必須の状態として扱います。

更新はイベント

いうまでもありませんが、既存データの更新は、明らかにユーザー入力であり、「更新」というイベントです。Immutable Data Modelでは、データは原則としてUPDATEされないので、更新操作をINSERTとして実現したいです。

なので、データの更新は、データテーブルそのものへの、新しいレコードのINSERTとして表現します。

ユーザーがデータを10回更新したら、そのデータを保存するデータテーブルには、(最初に作成したデータを合わせて)合計11個のレコードが登録されることになります。11個のレコードのうち、最新のものが、このデータの「今の状態」です。

つまり、ユーザーが更新できるデータのテーブルは、大抵は、履歴状になるということです。履歴のうち、最新のものが現在のデータを表しています。また、同じデータの複数の履歴が存在するため、データのIDとは別に、(おそらくはauto generatedな)サロゲートキーが存在するでしょう。

単純なタスク管理システムを考えてみましょう。タスクを表すtaskテーブルは

Task
  history_id (primary key)
  task_id
  title
  description
  created_at
  created_by

こんな感じになるでしょう。 タスクの更新があった場合は、同じtask_idで、新しいデータをINSERTします。create_atがもっとも新しいものが、そのタスクの「今」の状態です。

付随的な状態の導入

履歴状のデータテーブルにおける、「今の」状態は、(計算時間を無視すれば)計算で導き出せるものなので、ロジックです。また、あるユーザー入力データの今の状態というのは、ユーザー入力を記録したものからの直接的派生データであり、システムの都合で内部的に利用するロジックではありませんから、「付随的なロジック」ではなく、「必須のロジック」でしょう。

ただ、履歴の最新のものだけを集めるというロジックは、現実の世界では、かなり非効率なものになります。毎回、task_id単位でソートして、先頭のレコードを取り出す必要があります。これはかなり複雑なクエリを書けば、ビューとして実現できますが、現実世界では、かなりパフォーマンスが悪そうです。

クエリで「今のタスク」を取得するクエリの例:

WITH RankedTasks AS (
  SELECT
    *,
    ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at DESC) AS rn
  FROM
    task
)
SELECT
  history_id,
  task_id,
  description,
  created_at
FROM
  RankedTasks
WHERE
  rn = 1;

見るからに、このクエリでデータを取得するのは、かなりパフォーマンスが悪そうです。 複雑なSQLクエリは、パフォーマンスだけではなく、プログラムの保守性も下げますので、避けたいものです。 上記のクエリを、パッとみてすぐ理解できる人は、多くはないでしょう。

なので、付随的な状態を導入することで、クエリを簡単にしつつ、パフォーマンスを改善します。

シンプルに、最新データを表す「ポインターテーブル」を用意します。GitのHEADのように、どのデータが最新データなのか、そのIDを記録します。

タスクの例で示せば

current_task_cache
  task_id (pk)
  history_id (Taskテーブルのhistory_idを指す)

こんな、シンプルなテーブルになるでしょう。 このテーブルは、パフォーマンス向上のために、システムの都合で用意したテーブルであり、ユーザー入力の記録ではありません。だから、このテーブルは、Out of the tar pitでいうところの「付随的な状態」です。

付随的な状態は、out of the tar pitの考え方を徹底的に採用するならば、「このテーブルを削除しても、パフォーマンスは劣化するものの、システムはちゃんと動く」ところを目指さねばなりません。そこはシステム開発のトレードオフで、どこまで追求するかは、各自で決めねばなりません。「このテーブルがあるならば利用し、ないならばロジックで導き出す」のようにすることも、頑張ればできるでしょうが、私はそこまではおこなっていません。付随的なテーブルだが、プログラム的には必ず必要なテーブル(ないと動かない)、という扱いをしています。

いずれにせよ、このテーブルの内容は、ユーザー入力の記録ではなく、仮にテーブルが失われても、ロジックにより再作成可能であるという事実は変わりません。なので、付随的なものであることを、視覚的に、名前で表現したいです。

今回は、付随的な状態を表すテーブルには、接尾辞「_cache」をつけることにしました。

キャッシュというのは、本来はなくても動く、という意味を込めています。今回は、なくても動くところまでは作り込まないことにしたものの、位置付けとしてはそういうテーブルであることを込めています。

ただそのあたりの名前づけは、特にルールはありませんので、自由に決めれば良いと思います。しかし、ユーザー入力を記録したテーブルではない、付随的な状態を記録しているのだ、ということがわかるような名前付けをすることをお勧めします。

ポインターテーブルが導入されたことで、最新のタスクを集めたテーブルは、ロジックにより簡単に求めることができます。

SELECT task.* FROM task INNER JOIN current_task_cache c ON c.task_id = task.task_id AND c.history_id = task.history_id;

この計算を、ビューとして定義しておきます。「必須のロジック」の導入です。

CREATE VIEW current_task AS
  SELECT task.*
  FROM task
  INNER JOIN current_task_cache c ON c.task_id = task.task_id;

これで、current_taskからSELECTすれば、いつでも、最新のタスクを集めたテーブルであるかのように、データを取得できます。

このような形で、「まずは必須の状態を記録するためのテーブルを作る」「そこから低コストで計算可能な派生データは、ビューで定義する」「低コストで計算できない場合は、低コストで計算できるようにするための付随的な状態を導入し、それを使ってビューを作る」という方針でいきます。

このデータ構造では、データの更新作業におけるデータベーステーブルの操作は、

  1. 必須の状態のINSERT
  2. 付随的な状態のINSERTもしくはUPDATE

しか存在しないことになります。上記の例で言えば、Taskデータの更新とは、

  1. taskテーブルへの新データのINSERT
  2. current_task_cacheテーブルへの、ポインタデータのINSERTもしくはUPDATE

で表現されることになります。もし、「必須の状態」であるテーブルに、UPDATEなどの、INSERT以外の操作が行われたら、バグか設計ミスの可能性を検討します。

データの状態変更はイベントである

さて、このタスクが完了したらどうすべきでしょうか。 割と一般的なデザインだと、テーブルにcompleted_atのような日付カラムがあり、このカラムに値があれば、そのタスクは完了しているとみなす、というものです。

しかしImmutable Data Modelでは、テーブルのUPDATEを行わない(今回の設計では「必須の状態」のUPDATEは行わない)方針ですので、このやり方は取りません。

さらに言えば、タスクを完了させたというのはユーザーの入力であり、ユーザーの入力は全て「必須の状態」なので、テーブルに個別のデータとして保存されるべきです。

このような、既存のデータの状態変更を「イベント」と呼びます。イベントはイベントとして、そのイベントが起きたということを記録しなくてはいけません。

リレーショナル・データベース(RDB)のデータモデリングに詳しい方であれば、データだけではなく「イベント」をきちんと見極めてテーブルとして分離して記録すべき、という考え方は、かなり前から提唱されている考え方だということはご存知だと思います。例えば、私がデータベース・モデリングを学ぶ上で今でもお勧めしている本の一つに『楽々ERDレッスン』という本があります。

こちらは2006年の本でかなり古いものですが、その頃でも、イベントをイベントとして認識するのが大事で、イベントをデータテーブルにカラムとして組み込むのは良くなく、ちゃんとイベントとして記録すべき、という考え方が強く奨励されています。

つまり、データベーステーブル設計の考え方では、かなり前から同じことが奨励されていたのですが、世の中ではテーブルに「completed_at」などのカラムを用意する、という作り方の方がずっとずっと多いままだったのですが、今、Immutable Data Modelという設計手法を介して、私たちは再び同じところに戻ってきたのです。

リレーショナル・データベースのモデリングで奨励されていた考え方と、Immutable Data Modelと、"Out of the tar pit"における状態管理の考え方とが、今ここで綺麗に繋がったとも言えるかもしれません。

イベントをテーブルとして記録する

ここでは、あるタスクが完了したことを、ユーザー操作であると捉え、「必須の状態」の一つとして記録します。task_completedテーブルは以下のような構成になるでしょう。

task_completed
  task_id (PK)
  completed_at
  completed_by

シンプルに、終了したタスクへのリンクとなるtask_idと、完了した日時と完了者を記録します。「事実を記録する」という観点で最もシンプルなテーブル定義でしょう。リレーショナル・データベースでは制約が使えますから、task_idは外部キーとして設定するのが良いでしょう。この場合、taskに直接FOREIGN KEYで結びつけることができない点に注意してください。taskは履歴状になっているため、task_idはプライマリキーではありません。外部キーを貼る場合は、current_task_cacheテーブルに張ることになるでしょう。

`cache`と名づけているテーブルにつなげるのに違和感を持つ方もいるかもしれませんが、その違和感は、この名付けがうまくいっている証明です。外部キー制約はユーザー入力の記録ではなく、むしろシステム都合のものですので、システム都合で用意した`cache`テーブルにつなげるのは別に誤りではありません。むしろ、名付けにより、外部キー制約でリンクを貼るという行為が、「ユーザー入力の記録」とは関係がないシステムロジックであることが気づけるようになっているとも言えそうです。

さて、これで「ユーザーがタスクを完了したという状態」の記録はできましたが、実際にアプリケーションで必要となるのは「完了したタスク」の一覧であって「タスクを完了したイベントのデータ」ではないでしょう。tasktask_completedから、「完了したタスク」一覧を計算するのが、ロジックの役割です。ロジックは以下のようにビューで表現できます。

CREATE VIEW completed_task AS
  SELECT task.*, comp.completed_at, comp.completed_by
  FROM current_task task -- current_taskは、前述した、最新のタスクだけを取得できるビューです
  INNER JOIN task_completed comp ON comp.task_id = task.task_id;

良さそうですね。task_completedテーブルにデータがないタスクはそもそも完了していないのですから、INNER JOINできないタスクは全て排除して良いわけで、シンプルにINNER JOINだけで表現できます。

なお、特に明記しませんが、以降の内容で、JOINや問い合わせを効率的に行うためのINDEXの作成は、別途行なっているものと考えてください

また、ここではビューの定義のためにcurrent_task ビューを利用していますので、いわば、既存のロジックに新しいロジックを積み上げて、新しいロジックを生み出しているとも言えます。

このように、イベントを個別に記録し、ロジック(ビュー)で組み合わせるという方法であれば、表現したい状態が増えたとしても、シンプルに解決できます。 例えば、タスクは保留できるとした場合、その「保留した」というイベントも、シンプルに、「保留した」という事実を記録するテーブルを用意するだけで事足りそうです。

task_delayed
  task_id (PK)
  delayed_until
  delayed_at
  delayed_by

「いつまで保留したか」を記録するためにdelayed_untilというカラムが増えていますが、基本的な構造はtask_completedと同じです。「保留されたタスク」も、「完了したタスク」と同じように、シンプルなビューで表現できます。

CREATE VIEW delayed_task AS
  SELECT task.*, delayed.delayed_until, delayed.delayed_at, delayed.delayed_by
  FROM current_task task
  INNER JOIN task_delayed delayed ON delayed.task_id = task.task_id;

このように、タスクに対する新しい状態が増えたとしても、その状態変化を記録するイベントテーブルを用意するだけでよく、元のtaskテーブルに手を加えることなく、新たな状態をどんどん追加できます。状態を記録しさえすれば、欲しいデータは、ロジック(ビュー)で導き出すことができます。

削除もイベント

タスクが削除された場合のことを考えてみましょう。

そもそも「タスクが削除された」というのはなんでしょうか。「そのタスクはもうやらないことにした」というユーザーの意思決定であり、その操作は記録されるべきです。結局、前述の「タスクを完了した」とか「タスクを保留した」とかと同じなのです。

なのでやることも同じです。

まず、下記のような、イベントを記録するテーブルを作り、

task_abandoned
  task_id (PK)
  abandoned_at
  abandoned_by

放棄されたタスクを計算するビューを作ればOKそうです。

CREATE VIEW abandoned_task AS
  SELECT task.*, ab.abandoned_at, ab.abandoned_by
  FROM current_task task
  INNER JOIN task_abandoned ab ON ab.task_id = task.task_id;

これで良さそうです。

今アクティブなタスクを取得する

放棄されたタスクは、もはやアクティブなタスクではありません。普通のデータベース設計でいえば、DELETEされたデータに近いものなので、もはやそのタスクは存在しない、という扱いをしたいですね。

加えて、よく考えてみれば、完了したタスクも、消えてはいないものの、もはや、処理対象となるような「生きた」タスクではありません。

「保留したタスク」は、保留しているだけで、まだ生きていると考えることにします。

アプリケーションに表示するとしたら、まだ処理していないタスク一覧には表示したくないデータでしょう。

つまりは、最新タスクデータcurrent_taskから、「完了したタスク」「放棄したタスク」を全て省いたものが、今アクティブなタスクということになりそうです。

これは、今までに記録したユーザー操作の記録から、ロジックで取得できそうですね。例えば、以下のようなビューを作ればどうでしょうか。

CREATE VIEW active_task AS
  SELECT task.*
  FROM current_task task
  LEFT JOIN task_completed comp ON comp.task_id = task.task_id
  LEFT JOIN task_abandoned ab ON ab.task_id = task.task_id
  WHERE comp.completed_at IS NULL
    AND ab.abandoned_at IS NULL;

task_completedともtask_abandonedとも、いずれともJOINできないタスクを探す、という計算を行うわけです。

これで今アクティブなタスクを取れるようになった…と言いたいところなのですが、正直、このクエリはとても遅いでしょう。

データが少ないうちはいいでしょうが、多くなってくると、IS NULLクエリのせいでデータベースINDEXの効果は期待できないでしょう。ビューで表現はできても、実用としては遅すぎるロジックということになりそうです。

これは、current_task_cacheを導入した時と同じケースです。taskテーブルには全更新履歴が入っているため、「今のデータ」を得るためには、毎回ソートして最新値を取得する必要がありました。そのためには、かなり複雑なSQLを書く必要があったため、SQLをシンプルにし、パフォーマンスを改善するために、current_task_cacheテーブルを「付随的な状態」として導入したのでした。

今回、各テーブルとLEFT JOINしたいのは、「状態を記録したテーブルにデータがないこと」を調べるためでした。リレーショナル・データベースは、「あるデータがあること」を調べるのは高速で行えますが、「ないこと」を調べるのは遅いものです(おそらく全データを走査しないと「ないこと」を確定できないでしょう)。

であれば、単純に「今の状態」を記録しておけばいいでしょう。

task_status_cache
  task_id (PK)
  status

statusには、activecompletedabandonedのいずれかの値が入ります。ENUMデータ型が存在するデータベースであれば、ENUMとして定義してもいいでしょう。

このテーブルには、あるタスクの「現在の状態」を記録します。

データ作成直後であればactiveになり、完了するとcompleted、放棄するとabandonedになるというわけです。

このテーブルを使う時は、statusで検索することが多いでしょうから、task_idstatusでINDEXを作っておいた方が、パフォーマンス的に有利でしょう。

このテーブルにデータを入れるタイミングには、色々考え方があるでしょう。一番単純な方法は、作成した時、完了した時、放棄した時に、ついでにこのテーブルのINSERTもしくはUPDATEを行うことです。

つまり、「タスクの完了」を例にとれば、「完了」という操作は、

  1. task_completedへのデータのINSERT
  2. task_status_cacheの、対象データ行のstatuscompletedUPDATEする

という2つの操作になるわけです。

実際のプログラムでは、おそらく、「タスクを完了する」という関数やメソッドがあるはずで、その関数内でこの操作を行うため、「タスクを完了したい場合は必ずこの関数を呼ぶ」というプログラム構造を作れば、関数を呼ぶ側からは上記の2操作は隠蔽されるでしょう。

あるいは、「タスクの最新状態を再計算する関数」をプログラム内に用意しておき、task_completedtask_abandonedなど、状態が変わるテーブルへのINSERT後にその関数を呼び出す、という方法もあるでしょう。もし、データの登録が即座に反映されないでも良いケース(更新がユーザーに見えるのは少し後になっても良いケース)であれば、この関数を非同期に実行してもいいでしょう。

後述する「完了状態から復元」などの操作をした際に、taskの「今」のstatusを再設定したい場合は、こちらの方法の方が、実装が簡単になるでしょう(完了したタスクを復元した場合、activeステータスにするのが正しいかどうかは、アプリケーションの仕様によります。例えば、「保留(delayed)」がステータスの一つとして扱われているアプリケーションの場合、完了したタスクを復元したときに、activeになるべきかdelayedになるべきかは、アプリケーションの仕様によるでしょう。アプリケーションで共通のタスク状態の再計算関数があれば、単にそれを呼び出すだけで正しいステータスになるはずです)

あるいは、定期的に再計算関数を非同期に起動し、task_completedtask_abandonedへのデータのINSERTした後、放置しておけば、どこかのタイミングで勝手に完了状態や放棄状態になるだろう、という作りでもいいかもしれません。

なんにせよ、この付随的な状態テーブルであるtask_status_cacheを導入すれば、アクティブなタスクを取得するロジック(クエリ)は一気に単純(かつ高速)になります。

CREATE VIEW active_task AS
  SELECT task.*
  FROM current_task task
  INNER JOIN task_status_cache sts ON sts.task_id = task.task_id
  WHERE sts.status = 'active';

これで、「現在アクティブなタスク」を取得するためのロジック(ビュー)が完成しました。

ついでに、「完了したタスク」「放棄したタスク」を計算するためのビューも、以下のように書き直してもいいでしょう。 特に、task_status_cacheの更新を遅延させている場合は、こちらの方が、task_status_cacheの内容によってステータスを確定しているので、都合が良いかもしれません。

CREATE VIEW completed_task AS
  SELECT task.*, comp.completed_at, comp.completed_by
  FROM current_task task
  INNER JOIN task_status_cache sts ON sts.task_id = task.task_id
  INNER JOIN task_completed comp ON comp.task_id = task.task_id
  WHERE sts.status = 'completed';

CREATE VIEW abandoned_task AS
  SELECT task.*, ab.abandoned_at, ab.abandoned_by
  FROM current_task task
  INNER JOIN task_status_cache sts ON sts.task_id = task.task_id
  INNER JOIN task_abandoned ab ON ab.stask_id = task.task_id
  WHERE sts.status = 'abandoned';

保留したタスク(delayed_task)についても、対象タスクが、もはやアクティブでない(完了したり放棄されたりした)タスクだった場合には、保留したタスクとしても不要となるので、ビューを少し変更し、active_task ビューに依存するように変更した方が良さそうです。

CREATE VIEW delayed_task AS
  SELECT task.*, delayed.delayed_until, delayed.delayed_at, delayed.delayed_by
  FROM active_task task
  INNER JOIN task_delayed delayed ON delayed.task_id = task.task_id;

見比べてみるとわかりますが、FROM句が、current_taskからactive_taskに変わっただけです。このように、ビューというロジックを導入したことで、ビュー同士を組み合わせ、新しいロジックを導入することができるわけです。

これまでの過程で、「必須の状態」を記録したテーブルには一切手を加えず、付随的な状態の導入と、ビューの定義変更だけで対応しました。ロジック(ビュー)で、状態とイベントを組み合わせて、欲しいデータを作り出す手法なので、一番大切な「必須の状態」を記録したテーブルには手を加えることなく、ロジックを再構成するだけで、欲しいデータを得ることができているわけです。

要素をシンプルなままにし、組み合わせることで、欲しいデータを作り出す

Immutable Data Modelでは、データのUPDATEを行わないことが理想的ですが、実際のデータベース設計では、本当にINSERTだけで構成すると、パフォーマンスなどの多くの問題が発生することがあります。

なので、"Out of the tar pit"の考え方をベースに、「ユーザー入力」を記録する「必須の状態」であるテーブルと、そうでないシステム都合のテーブルとを分離し、「必須の状態」であるテーブルにはImmutable Data Modelの考え方を徹底してINSERTしか行わず、それによるパフォーマンスやクエリの複雑化なのどの問題は、「付随的な状態」として導入したシステムテーブルによって補う、という戦術を取りました。

どれがユーザー入力を表す「必須の状態」であり、どれがシステム都合の「付随的な状態」なのかを明確にするために、付随的な状態を記録するテーブル名には、意図的に「_cache」接尾辞をつけることで視覚化を行なっています。

また、実際にアプリケーションで処理したいデータは、ユーザー入力の組み合わせによって得られる「計算結果」の方であることが多いので、それらのロジックをビューで表現しています。

この作りでは、アプリケーションのデータアクセス(変更と取得)は、

  • アプリケーションがデータを変更する時には、「必須の状態」と「付随的な状態」を更新する
  • データを保存するためには、専用の関数やメソッドを用意し、プログラムからは必ずその関数経由で保存するようなシステム構成を作ることで、「必須の状態」と「付随的な状態」が確実に更新されるようにする
  • アプリケーションがデータを取得する時には、「必須のロジック」であるビューからデータを取得する

というのが基本的な構成になります。

必須の状態、付随的な状態を分離し、必須の状態の中でも、イベントを見極めて細かい小さなテーブルに分割しました。これにより、要素は増えましたが、一つ一つのテーブルは、一種類のデータしか管理していないようにできました。このような単純なものを、ビューという「ロジック」を使って組み合わせることで、実際に欲しいデータを作り出しています。

このような考え方は、Clojure開発者のRich Hickeyが発表した「Simple Made Easy」というプレゼンテーションで説明した「シンプルさ」と同様なものです。Rich Hickeyは、「シンプルさ」を、「簡単である」とは区別して説明しました。シンプルであるとは、それが、一つの事柄しか扱っていない、ということです。その逆が、一つのものでたくさんのものを扱っている状態であり、そのような状態を「Complect」といって、たくさんの紐が絡まり合っているような状態だと説明しています。

「シンプルな」システムとは、それぞれが一つの役割しか持っていないものを、組み合わせることで作られたシステムであり、そのように作られたシステムは、組み合わせ方を変えることで、自由に再構成できるのです。

また、Rich Hickeyは、物事をシンプルにしようとすると、要素の数は増える、とも言っています。実際、今回の設計では、日付カラムを参考に、データの記録とイベントの記録を分離した結果、一つのテーブルにたくさんの日付カラムがあるようなテーブルよりも、テーブル数が増えています。さらに、表現したいイベントが増えるたびに、テーブル数は増えていくことでしょう。

一方で、一つ一つをシンプルにして分割したからこそ、ロジックによって、組み合わせによって欲しいデータを作り出すことができています。今回の設計では、Out of the tar pitの「状態とロジックの分離」「必須と付随的の分離」を取り入れることで、データベースに保存するデータを、必須の状態、付随的な状態、それらを組み合わせるためのロジック(ビュー)という形に分解し、「テーブルという形で表現された状態を、ビューという形で表現されたロジックによって組み合わせる」という方法を採用しました。

同時に、古くからのリレーショナル・データベースのER設計で提唱されていた、イベントの見極めと分離も行えています

この方法であれば、個々のテーブルをシンプルに維持しつつ、より大きなシステムを構成することができるのではないでしょうか。

発展的に構成を変化させる

ここで説明した作り方は、単なるアイデアというわけではなく、実際のアプリケーションでも利用していて、実用性があるものですが、実際のアプリケーションでは、もっと込み入った状態変化を管理しなければいけないケースもあります。

例えば、「間違って完了したタスクは、復元することができる」という仕様があるとしたら、どうでしょうか。

  1. 誤った操作なのだから、単に誤って挿入されたtask_completedを削除(DELETE)して、タスクのstatusを再計算すればいい
  2. 完了したタスクを復元するのはユーザー操作なのだから、それも記録されなければいけない

どちらの手がいいでしょうか。1を取るか、2を取るかは、アプリケーションの仕様と、トレードオフが絡んでくるので、一概にこれということはできません。単なるアンドゥ処理であると考えれば、1の選択肢がいいでしょう。ユーザー入力を記録した必須の状態であるtask_completedDELETEを実行することになりますが、アンドゥだと考えれば、システム的に元の状態に復帰するという意味で、選択的にはありでしょう。もちろん、1の選択肢は、「必須の状態」であるはずのtask_completedテーブルに対してDELETEを実行する、という禁忌を犯している!と考えることもできるでしょう。

2を選択した場合は、task_completion_revertedのような、「完了したタスクを復元した」ことを記録するテーブルが必要になりそうです。

同じタスクに対して、「完了」操作が複数回発生することになるため、task_completedは、task_idをキーとしたテーブルではダメになります。history_idのような、行を特定するキーが必要になるでしょう。

複数のtask_completedのどれがアクティブなのかを特定するために、taskテーブルのように、ポインターテーブルを用意して最新のレコードを特定する方法もありでしょうが、こちらは履歴情報というよりは、revertされたtask_completedと、activeなtask_completedがある、ということなので、前述のtask_status_cacheのように、task_completedに対して現在のステータスを表すシステムテーブルを用意するという手もあるでしょう。

あるテーブルの関連テーブルとして、状態を表すテーブルを導入する(例えば、taskに対してtask_delayedという関連テーブルを用意する)場合の対応方法を一定に保つと、テーブル構成全体の理解が簡単になりますので、ここは、ステータステーブルを導入する方法でいきましょう。

task_completed_status_cacheテーブルを導入します。

task_completed
  history_id (PK)
  task_id
  completed_at
  completed_by
task_completed_status_cache
  task_completed_history_id (PK)
  status

statusカラムは、activeもしくはrevertedで、task_completed作成時にはactiveとしてINSERTしておき、完了したタスクの復元が行われたタイミングで、

  1. task_completion_revertedテーブルにINSERTします(復元の事実を記録する)
  2. task_completed_status_cachestatusrevertedUPDATEする
  3. taskのステータスを更新する関数を呼び出す

上記操作をすることで、復元されます。

今までのtask_completedと同じデータを取るためのビューを用意することで、他の部分はビューを参照すれば、今までと同じ状態を維持できます。

CREATE VIEW active_task_completed AS
  SELECT comp.*
  FROM task_completed comp
  INNER JOIN task_completed_status_cache sts ON sts.task_completed_history_id = comp.history_id
  WHERE sts.status = 'active';

このビューは、元々のtask_completedと同じデータを返すはずです。

つまり、taskテーブルで行なったのと同じことを淡々と実施していけば、必要な情報は漏れなく記録できるし、ビューを介して、元のテーブルと同じデータを簡便に取れるように作ることができるわけです。どのテーブルでも考え方は一貫しており、データ利用側から見ると、今までtask_completedを直接見れば良かったところが、active_task_completed ビューを参照するように変わるだけでしょう。

図1は、今行おうとしている拡張を行う前の状態を表しています。

変更前のテーブル構成

task_completedを、taskと同じような履歴状のテーブルに変えると、おそらく、以下のような構成になるでしょう。

変更後のテーブル構成

taskで行ったのと同じ手法を、task_completedでも行い、active_task_completedというロジック(ビュー)によって、元のtask_completedと同じ状態を作り出しています。しかも、アプリケーションがデータを読み取るときには、completed_taskなどの(図の一番下にある)ビューを使って取得しているはずなので、アプリケーションコードがデータを利用している部分に影響を与えることはありません(もちろん、保存部分には修正が必要ですが)。

このように、このモデリングの利点は、状況が複雑化しても、「テーブルを追加する」「必要ならば付随的な状態となるcacheテーブルを追加する」「それらを組み合わせて欲しいデータを計算するビューを作る」という作業を繰り返していくことで、つまり、同じやり方を繰り返し適用していくだけで、モデルを発展させていくことができるところにあります。手法の一貫性は、全体を把握することを簡単にしてくれます。

全体が複雑に見えても「このテーブルが、基本となる必須の状態を表すテーブルなのだな」「そしてこちらは、システム都合の付随的状態テーブルなんだな」「値を取るときは、こちらのビューを参照すれば取れる」という、大枠が分かるように、名前づけなどで工夫すれば、読み解くことができるのです。

銀の弾丸じゃない

Immutable Data Modelを採用すると、INSERTだけでデータを構成していく関係上、必ず、履歴状のデータが現れます。履歴上のデータは、ある段階でのデータがどうだったのか、計算すれば特定できるという点で利点も多いですが、全てのシステムで必要というわけでもありません。

多くのビジネスプロセスでは、「全てのユーザー操作を記録すべきか」という質問への回答は、実は「Yes」になることの方が多いです。紙の伝票管理だった時代でも、伝票はあとで書き換えることはできず、「赤伝票(赤伝)」でキャンセルを記録し、「黒伝票(黒伝)」で修正データを記録する、など、時系列に変更処理が全てわかるようになっていたくらいで、ビジネスプロセスでは、コンピュータ処理がされる前から、履歴を記録するのは当たり前だったのです。それらが急に必要なくなることはありません。現代でも、発注がキャンセルされたからといって、発注データを消していいわけではないのです。むしろ、履歴は記録されるべきだ、と考えた方が現実のビジネス実態に合っているはずです。それらの環境に、Immutable Data Modelと、ここで書いた手法は役立つでしょう。

でも、世の中には、ビジネスプロセスを表現してるわけじゃないアプリケーションもたくさんあるわけで、それらでImmutable Data Modelを採用すると、今までUPDATE/DELETEすれば良かったところに、急に履歴管理や最新データ特定などの処理が入り込んでくることになります。

なので、「すべてがImmutable Data Modelになるべき」と考える必要はないでしょう。

一方で、「Immutable Data Modelがものすごく活きる」領域というのも間違いなくあるので(前述のビジネスプロセスなど)、今作ろうとしているアプリケーションがなんなのかによって、採用すべきかどうかきっちり判断することが大事でしょう。

Immutable Data Modelは、考え方だけが先行して広まっている状態なので、どう実現するかは、個別に検討する必要がありました。その実際の実現手法として、ひとつの「こういう方法でクエリパフォーマンスも維持しながら実現できるよ」という案として参考にしてもらえればと思います。

終わり