紙箱

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

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

終わり

いまどきのClojureのはじめかた

Clojure 1.9あたりから、Clojureの始め方が大きく変わったのですが、その辺りをまとめた記事が見当たらず、すでにClojureをやってる人しか知らない状態っぽいので、急ぎで書いてみました。

大きく変わったのは、 clojure および clj というコマンドが導入されたことです。これまではClojureの実行には Leiningen のようなビルドツールを使うのが一般的で、スクリプト的なコードを書くのには向いてない印象でしたが、1.9からは、 clojure コマンドに .clj ファイルを渡すと実行できるようになりました。また、コマンドが用意されたことで、シェル・スクリプト冒頭に #!clojure コマンドへのパスを書くことで、シェルスクリプトとしてClojureコードを記述できるようになりました。

この二つのコマンドをインストールする手順が、環境ごとに用意されています。

インストール

Mac

homebrewに対応してます。

brew install clojure

Linux

まだapt-getやyumとかには対応していませんが、インストール・スクリプトが用意されているので、それを実行すれば簡単にインストールできます。

こちらのページに書いてある3行のコマンドを実行すると、Clojureがインストールされます。

https://clojure.org/guides/getting_started#_installation_on_linux

Windows

残念ながら、まだ提供できていないようです。 とはいえ、clojureは単なるjarファイルなので、clojure.jarを手動配置することでなら、もちろん実行可能です。

しかし、Microsoft自身がWindowsで動くLinux環境を提供しているので、そっちで実行した方が簡単なように思います。

REPL

なにはともあれ、REPLです。 Clojureをインストールすると、cljコマンドとclojureコマンドの2つのコマンドが使えるようになります。

cljコマンドのほうがREPL実行用コマンドです。単にclojure REPLの実行だけではなく、REPL上でのコマンドヒストリ機能などがオンになるよう、セットアップしてくれます。

clojureコマンドは.cljファイルを実行するためのコマンドです。こちらがあるので、clojureがインストールされている環境であれば、 #!/usr/bin/env clojureスクリプトファイルの先頭行に書くことで、シェルスクリプトのようにclojureプログラムを実行することもできます。

というわけで、cljコマンドを実行すると、Clojure REPLが起動します。

(+ 1 2)
;;=> 3

(require '[clojure.string :refer [split upper-case]])
;=> nil
(->> (split "aaa,bbb,ccc" #",")
    (map upper-case))
;=> ("AAA" "BBB" "CCC")

Clojureの関数を覚えてくると、ちょっとした計算とかはREPLでささっとプログラム書いたりしてしまいます。

使い終わったら、Ctrl+dで終了です。

スクリプトを書く

Clojure 1.9からは、Clojure自体に依存ライブラリを処理する機能が組み込まれました。1.8までは、Leiningenなどのビルドツールが必要でしたが、必要なことが「依存ライブラリの自動ダウンロード」だけなのなら、Clojure単体でできるようになったのです。

もちろん、ビルドツールには依存性管理以外にもいろんな機能があります。そちらを使いたい場合(「プロジェクト」規模になると大抵は必要でしょう)は、Clojure単体では難しいので、すなおにビルドツールを使いましょう。一番メジャーなのは Leiningen です。こちらもインストールスクリプトがあります(Windows用のbatファイルもあり)

さて、ちょっとしたスクリプトを書きたいけど、そのためには外部ライブラリが必要だ、というケースはよくあります。たとえばClojureでhttpアクセスが必要な場合、clj-http という有名なライブラリを使うことが多いですが、当然これは、別途用意する必要がありました。

Clojure 1.9には、 deps.edn という特殊なファイルをカレント・ディレクトリに配置することで、必要な依存ライブラリを自動ダウンロードする機能が追加されました。

clojureコマンドを実行するときに、カレント・ディレクトリにdeps.ednがあれば、その中に記述された依存ライブラリをすべてダウンロードしてから、スクリプトを実行します。つまり、スクリプトとして書いたclojureプログラム(テキストファイル)といっしょにdeps.ednを配ることで、スクリプトから外部ライブラリを利用することができます。

deps.ednの解釈は、clojureコマンド実行時だけでなく、cljコマンド実行時にも行われますので、REPLで作業したいけど、その作業には外部ライブラリが必要、というときにも、ささっとdeps.ednを書けば、REPL内で外部ライブラリを使うことができます。

deps.ednは次のような、Clojureのマップ文法で書かれたファイルです。

{:deps
 {clj-http {:mvn/version "3.9.0"}}}

このようなファイルがおいてあるディレクトリでcljコマンドを実行してみましょう。REPL起動時にclj-httpのダウンロードが行われます。

ちょっと、http接続を試してみましょうか。

(require '[clj-http.client :as http])
;=> nil
(http/get "https://google.co.jp/" {})
;=> GoogleページのHTMLテキスト

こんな感じで、必要なライブラリを使ってREPLで作業できるわけです。

deps.ednを使ってもう少し複雑なプログラムを組む

1ファイルというほど小さくはないけど、ビルドツール使うほどでもない、使い捨てのツールプログラムを作る、みたいなこともあるでしょう。Clojureだと、プログラムは名前空間に分割して書いていくのが普通で、そうするとファイルも複数になりますし、名前空間に階層がある場合は、ディレクトリも必要です。

deps.ednと同じ場所に src というディレクトリがあれば、その下にソースファイルが配置されているものと解釈します。srcの下に、名前空間に合わせたディレクトリ階層を作れば、ちゃんとソースを見つけ出してくれます。

- deps.edn
- helloworld.clj
- src/
 - util/
   - string.clj
   - net.clj

上記のように配置すれば、このプログラムは、 helloworld.clj という実行用スクリプトとは別に、

  • util.string
  • util.net

の2つの名前空間が追加されたプログラムとなるわけです。たとえば、 helloworld.clj から、requireを使って、util.stringutil.net名前空間を利用することができます。

Clojure単体でも、deps.ednと組み合わせることで、ちょっと規模の大きめのプログラムでも、作ることができるわけです。

エディタを用意する

ClojureLISP系の言語ですので括弧を多用しますが、世のLISPerたちがどう括弧を扱ってるかというと、括弧の対を「見づらいもの」としてではなく、むしろ利用すべき「構造」と捉えています。全てが括弧で囲われているのだから、括弧単位で移動したりカットしたりできる、専用のエディタ機能を使っています。この操作に慣れると、括弧にはむしろありがたみを感じるのです。

Clojureプログラマは、エディタの力を使って、括弧を巧みに利用します。ただのテキストエディタで括弧を扱うのは、誰でもつらいものです。それは、他の言語でも同じです。おなじみの言語を、何のサポートもないテキストエディタで書こうとすると、結構大変なはずです。

また、書いているプログラムをREPLを使ってさっと動作確認しつつ書いていく、というスタイルがメジャーなのもあり、エディタから簡単にREPLを起動したり、REPLにソースを送り込んだり、REPLから作成中のプログラムの名前空間にアクセスして関数を実行したり、プロジェクト内の関数を探したりしたい。いわば、IDE的な機能が欲しいです。

これらのサポートなしにClojureコードを書くのはやはり大変で、ただのテキストエディタJavaを書く的なしんどさがあります。だから、Clojureをサポートしたまともなエディタを用意しましょう。

Clojureでもっともメジャーな開発環境は

Emacs + CIDER

です。エディタとしてemacsを、そのプラグインのCIDERをIDE機能として開発します。

もし、Emacsは得意じゃないけどVimは使える、という方々は

Spacemacs + CIDER

です。SpacemacsはVimキーバインドで使えるemacsのようなもので、CIDERも使えます。

しかし、どちらも心得がない、という方が、プログラム言語をはじめるのにEmacsVimのような複雑なエディタを覚えるところから始めないといけない、というのはあまりにもハードルが高いと思います。

なので、私の一押しは、最近いろんな言語をサポートしてることでユーザーの多い、IntelliJ IDEAを使うことです。IDEAのプラグインとして Cursive というClojure開発支援プラグインがあります。こちらはビジネスで使う場合は有料ですが、オープンソース開発や学習用とでは無料です。有料版は、個人に紐付くライセンスだと99ドルです。企業契約で個人に紐付かせない(いわゆる「ライセンス数」で買うタイプ)だともうちょっと高いです。

とりあえず、学習用に試す分には無料ですのでおすすめします。基本的なエディタ操作は、IDEAの標準操作がそのまま使えますので、すでにIDEA使っているなら学習コストも低いです。CursiveにはREPLサポートもあるし、作業中の関数をREPLに送る機能なども備わってます。

(ビジネスでつかうなら、自分が仕事で使うツールを作ってくれた作者への敬意として、ちゃんとお金を払おう)

構造編集 (Structural Editing)

いずれのエディタを使うにしても、括弧をかっこよく効率的に扱うための 構造編集(Structural Editing) を覚えることをおすすめします。PareditとかParinferが有名です。Spacemacsだと、SPC+kを押したら表示されるメニューに、構造編集のための項目があります。

Clojureでコードを書いていると、「この括弧内に次の行の括弧を丸ごと移動したい」とか「この部分を括弧の外に追い出したい」とか「この括弧内のコード、もう不要だから丸ごと消したい」とか「この括弧全体を丸ごとコピーしたい」といったことがしょっちゅうあります。構造編集でこれができます。

(my-great-func {:name "yano", :place "japan"})

のようなプログラムがあるとして、「おっと、この my-great-func を実行するには条件があるんだった。whenかifで囲わないと...」と考えたとします。

(when condition (my-great-func {:name "yano", :place "japan"})

とか書いてバグるわけです(上のコードは、閉じ括弧が足りません)。

まず、構造編集使用中は、括弧は常にペアで書き込まれます。開き括弧を書くと閉じ括弧も自動的に打ち込まれます。次のような感じになるでしょう。

(when condition)
(my-great-func {:name "yano", :place "japan"})

勝手にペアになるので、(when conditionと書いていくと、必ず括弧の内側になります。

ここで、conditionの後ろに、(my-great-func ...)の括弧を丸ごと持ってきたいわけです。

キー操作はエディタによって異なると思いますが、Spacemacs+CIDERなら、when condition の括弧の内側にカーソルがある状態で、「SPC k s」とキーを打つと、「Slurp(吸い取る)」という操作が実行されます。つまり、(when ...) の次にある(my-great-func ...)という括弧のブロックを、(when ...)の括弧の中に吸い込むわけです。

結果として、次のようなコードに変わります

(when condition
   (my-great-func {:name "yano", :place "japan"}))
  • 括弧を打ち込むと常にペアで打ち込まれる
  • 括弧単位で、隣の括弧を吸い込んだり、今の括弧を外に追い出したりすることで、カット&ペーストによって間違って括弧を消してしまうなどのミスが起きなくなる
  • 括弧単位で移動するので単なるカット&ペーストより圧倒的にミスりにくい

Clojureプログラマ(そしておそらくはLISPプログラマ)は構造編集で括弧を扱っているので、括弧を「どこで閉じてるのかよくわからない読みにくいもの」ではなく「括弧単位で追い出したり吸い込んだり、カットしたりペーストしたりできる、便利な編集単位だ」と思ってるのです。かならず開き括弧と閉じ括弧で囲われている、というLISPの特性があってこそなのです。

余談ですが、Clojureは括弧の種類が () だけではなく、[], {}, なども使うので、エディタ上ではこれらが色分けされてちょっと読みやすい、という利点もあったりします。

構造編集にはいろんな機能があって、正直私も全部覚えてはいないです。

  • Slurp (隣の括弧を吸い込む)
  • Barf (括弧を追い出す)
  • カット (括弧単位でのカット)
  • コピー (括弧単位でのコピー)
  • 削除 (括弧を丸ごと消す)

この5つを覚えるだけで、作業効率が全く変わってくるので、おすすめです。

SlurpとBarfは、「前を吸い込む」と「後ろを吸い込む」など、前後どっちを処理するかで、それぞれ2種類ありますが、まずは「後ろを吸い込む」「後ろに追い出す」のほうを覚えることをおすすめします。実際のコーディングでやりたいことは、大抵こっちのはずです。慣れたら前も処理できるように覚えればいいでしょう。

構造編集は、Emacs, Spacemacs, Cursiveのいずれのエディタでも使えます。ただし、これらの機能を呼び出すキー操作はエディタによって異なっているので、自分の好きなエディタでの操作を調べてみてください。

まとめ

JavaVMが入っている前提であれば、Clojure 1.9からは、 clojureコマンドのインストール だけで、簡単にClojureを始められ、シェル・スクリプトのようにClojureコードを実行することができるようになりました。若干ハードルが下がったように思います。また、 deps.edn が導入されたことで、外部ライブラリをスクリプトコードから簡単に利用することできるようになりました。

ちょっと、試してみるのはどうでしょうか。

オブジェクト指向とはまったく違うClojureの世界と実際のWeb開発

今回は、記事ではなく、【京都】LINE Developer Meetup #38で、ClojureとWeb開発について話したので、その時のスライドを紹介します。

こちらからご覧ください。(注:Slideshareの仕様なのか、元のスライドでは改行位置など揃っていたのが崩れてしまっています)

Clojure + core.async による非同期&並列プロセスの世界

core.asyncによる非同期プログラミング

core.asyncClojure用の、事実上標準の非同期プログラミングのライブラリです。

core.asyncの一番わかりやすい説明は、「Go-langのchannelのClojure版」という言い方でしょう。goマクロによってgo-blockを作り、そのブロック内が非同期に動きます。このブロックが常駐すれば、軽量プロセスというやつになります。プロセス同士のやりとりをする口として、チャネル(channel)があります。core.asyncを使ったプログラムでは、チャネルへの入出力を介して非同期軽量プロセスにデータを処理させることで、全体のシステムを作り上げます。

goマクロはステートマシンを作り、チャネルへの入力があるたびにマシンが1回転します。この一回転時に、チャネルを待ち受けていたgoブロックにスレッドが割り当てられ、次のチャネル入出力までCPUを使って処理が動き、チャネルの入出力でまた別のgoブロックに処理が映り、という形で、限られたCPU上で、スレッドを山ほど起動することもなく、効率よく動作するのが売りの一つです。

このような仕組み(OSの協調型マルチプロセスと同じような原理)なので、goブロックは実際にはプロセスでもスレッドではなく、ステートマシンによって管理されたプログラム単位に過ぎません。よって、core.asyncはスレッドが一つであってもちゃんと動きます。core.async開発当初から、ClojureScript(JavaScriptをホスト言語としたClojure実装)でも動くことを想定して作っていたということですので、ならではの実装でしょう。

シンプルな仕組み

core.asyncの使い方については公式ドキュメントとかのほうが詳しいので詳細はそちらを見てもらうとして、簡単に概要だけを書くと、実行単位をgoで囲み、そのなかでchannelを読んだり書いたりすると、goで書かれた実行単位が次々と切り替わって実行されます。

以下は、CloureのREPLに打ち込めばそのまま動作する、core.asyncを使ったプログラムコードです。

(import '[java.util Date])
(require '[clojure.core.async :refer [chan go-loop >! <! timeout] :as async])
(def ch (chan)) ; チャネルを作る

;; 書き込み非同期ブロック
(go-loop []
 (when (>! ch (Date.)) ; チャネルに書く
   (<! (timeout 2000)) ; 2秒待つ
   (recur)))

;; 読み込み非同期ブロック
(go-loop []
 (when-let [date (<! ch)] ; チャネルを読む
   (println "now:" date)
   (recur)))  

go-loop

(go 
 (loop []
   ;; 処理
   ))

の省略形で、多用するので用意されています。

単純なgoブロックは、非同期処理が終わるともう2度と実行されません。しかし、loopすれば「ずっと動き続ける非同期ブロック」を作れます。これが「軽量プロセス」に近いものです。軽量プロセス同士がチャネルを使ってデータをやりとりする、というプログラムを作るには、毎回goとloopを書かなければいけなくて、少々面倒なので、二つをまとめたgo-loopマクロが用意されています。

このプログラムは、片方のgoブロックが現在日時をチャネルに書き込んで2秒待つ、もう一つのgoブロックは同じチャネルを読み込み、読めたらそれを画面に出力します。いずれのgoループもチャネルが閉じられるまでloopし続けます。現在時刻を生成するプロセスと、受け取った時刻を出力するプロセスの、2つの軽量プロセスが動いていると考えればいいでしょう。

このように、core.asyncでは、プログラムを処理単位ごとにgoブロックで囲って非同期処理にし、そのgoブロック間でのデータのやりとりにはチャネルを使います。
goブロックは、チャネルからデータを読み書きしようとして、もしチャネルにまだデータがなかったり、チャネルにまだ書けない状態だったら、park(待機)状態になりスレッドを解放します。そして準備ができればまたスレッドに割り当てられて動き出します。
非常に少ないスレッド数で、たくさんの非同期ブロックを実行できるわけです。

複雑なスレッド制御を書かなくとも、シンプルにチャネルを読み書きするところをgoで囲むことで、簡単に効率よい並列プログラムを書けるのがcore.asyncの強みです。プログラムを書く側は単純にやりたいことを上から下へ書いていくだけでいいのです。callback hellと呼ばれるような、非同期コールバック関数が何段にも重なるようなことはありません。goブロックを上からどんどん書いていけばいいのです。

と、ここまでは、core.asyncにもともと備わっていた基本機能です。ここに、Clojure 1.7の言語拡張により、新しい要素が追加されました。この機能により、core.asyncは一段と便利になりました。

Transducerの登場

core.asyncのために、Clojureの言語レベルでの拡張までも行われました。それが、Clojure 1.7でのTransducersの導入です。

Transducerというのはおおざっぱに言うと、map処理やfilter処理から、対象となるオブジェクト(コレクション)を省いて、変換処理だけを抜き出して抽象化したものです。(map change-fn coll)という処理なら、重要なものは「ひとつひとつの要素にchange-fnを適用する」という変換処理であって、collは引数に過ぎない、だったらその変換処理部分だけを抜き出して別のオブジェクトとして扱えるようにしよう、というわけです。

実際、transducerの作り方は、上の説明通りのものです。

(map change-fn coll) ; いつものmap処理。collの各要素にchange-fnを適用する遅延リストを作り出す
(let [xf (map change-fn)] ; 同様の処理を行うtransducerを生成する。引数は後から渡すことができる。
  (sequence xf coll)) ; collの各要素にxfというtransducerを適用する遅延リストを作る

いつものmapやfilter関数呼び出し時に、対象となるcollectionを渡さなければ、変換処理だけを取り出したtransducerになるのです。

さらに、transducerは合成することもできます。transducerは特定のルールに則って実装された関数に過ぎないので、Clojure標準の関数合成関数 comp で簡単に合成できます。

(comp 第1のtransducer 第2のtransducer 第3のtransducer)

ただの関数をcompした場合、一番後ろの関数から順に実行されます。しかしtransducerの場合、その構造上、実際の実行は頭から行われます。上の例では、第1のtransducerから順に、3まで実行するような、合成transducerができます。

(require '[clojure.string :as string])
;; string/upper-caseで大文字にmapした後、
;; T以外のものにfilterするtransducerを作る
(def xf (comp (map string/upper-case) 
               (filter #(not= % "T"))))

;; sequenceは引数にtransducerを適用したリストを作る
;; 大文字に変換された後、Tでない文字だけにfilterされます。
(apply str (sequence xf "TEST"))
; => "ES"

なぜこのようなアイデアが用意されたかというと、(経緯はいろいろあるんでしょうが、私の認識では)core.asyncの開発途上で必要性が認識されたからです。もともとcore.asyncには、チャネルに入出力するデータに対してmapやfilterを実行するための、専用の関数がたくさん用意されていました。 clojure.core.async/map> とか。でも、標準のmap関数との違いは、処理対象がコレクションかチャネルか、という違いだけで、実際にやりたいこと(mapしたい、filterしたい)は同じなわけです。だったら、その同じ部分だけを抜き出そうというのは自然な発想です。

というわけで、Clojure 1.7にはTransducerが導入され、mapやfilterといったもともとあった多くの関数が拡張されて、いままでの処理のほかに、transducerを作り出す機能が追加されました。
同時に、core.asyncにあった、専用のmapやfilterといった関数群は、deprecated扱いとなりました。代わりに、チャネルに対してtransducerを設定することができるようになりました。これにより、チャネル入出力=関数の実行、という世界ができあがりました。

現在のcore.asyncは、単なるClojure版goルーチン実装の域を超えて、transducerの導入により、関数実行エンジンとしての機能を備えました。

;; 入力した文字列を大文字化するtransducerを設定したチャネルを生成する
(require '[clojure.string :as string] 
         '[clojure.core.async :refer [chan go >! <!]])
(let [ch (chan 1 (map string/upper-case))] ; 大文字化するtransducerをセットしたチャネルを作る
  ; 小文字を書き込んでみる
  (go (>! ch "test"))
  ; 読み込んで出力すると、大文字になっている!
  (go (println "result:" (<! ch))))

;;=> result: TEST

core.asyncには、チャネルとチャネルを結合する pipe という関数があるので、これを使って変換処理をつなげることもできます。

(require '[clojure.core.async :refer [chan go-loop >! <! pipe onto-chan]])
(let [only-odd-ch (chan 1 (filter odd?)) ; 奇数だけを通すチャネル
      double-ch   (chan 1 (map #(* % 2))) ; 2倍にするチャネル
      ch          (chan)
      piped       (-> ch
                      (pipe only-odd-ch) ; ch を only-odd-ch に連結する
                      (pipe double-ch))] ; 前行の連結結果をさらに double-ch に連結する

  ;; onto-chan関数は内部でgoブロックを使ってコレクションの中身を非同期にチャネルに書き込む
  (onto-chan ch [1 2 3 4 5 6]) 

  ;; 連結した末尾にあるpipedから、非同期にデータを一つずつ読む
  (go-loop []
    (when-let [data (<! piped)] 
      (println "data:" data)
      (recur))))

;; 奇数の1, 3, 5 が2倍になって順番に出力される
; => data: 2
; => data: 6
; => data: 10

pipeによってチャネルを結合してtransducerを順次実行していくことができるわけです。

しかしこれだけでは、関数を順次実行しているだけで、直接関数を呼ぶのに比べて、めんどくさくなってるだけではないでしょうか。

実はこれだけでも、関数を直接呼び出すのとは違う利点もあるのですが、そこは一旦置きましょう。core.asyncは並列実行ライブラリです。transducerの実行を並列化しましょう!

pipelineによる関数の並列化

transducerの登場で、データの変換操作を簡単に抽象化することができるようになりました。チャネルの入出力と関数を紐付けることが可能になったわけです。

ここでpipelineが登場です。
pipelineというのは、その名の通り(チャネルとチャネルの)パイプラインを構築する関数です。パイプラインの構築には、入力と出力のチャネルに加えて、実行したい関数、それに並列数を指定できます。

pipeline関数とは、チャネルからチャネルにデータを転送するときに関数を適用するという処理の、「関数を適用する」部分を並列化しよう、というものです。

(pipeline 4 out-ch my-great-transducer in-ch)

この1行で、in-chからout-chへのパイプラインが構築されて、in-chにデータを入れると、out-chから出力されます。その途中に、my-great-transducerによりデータが処理されるのですが、このtransducerの実行は、最大で4並列で処理されます(ちなみに、out-chへはin-chにデータが入ってきた順序で結果が出力されることが保証されているので、順番は壊れません)。チャネルに立て続けに4つデータが入ってくれば、それらは並列で処理されるということです。

チャネル、transducer、pipelineの3つで、core.asyncの役者がそろいました。core.asyncはClojureでgoブロックを実現するライブラリでありつつ、その上に、「transducerという変換関数を並列実行するpipelineを構築して、そのpipeline同士をつなげることで並列プログラムを構築する」という世界ができてるわけです。

この仕組みは、関数によるプログラムモデルにもよい意味での影響を与えます。

たとえば、たくさんのリクエストを受け付けるサーバプログラムで、データ処理プロセスが一つ存在して、それに処理を依頼するようなプログラムモデルを考えてみましょう。サーバプログラムは、たくさんのリクエストを並列に受け付けますから、このエンジンももちろん並列で動いてほしいです。ならば、データ処理を行うpipelineを構築して、その入力チャネルにデータを入れればいいのです! pipelineが、並列処理を実行する軽量プロセスとなるのです。

pipelineは入力がない限り待機してCPUを消費しませんが、入力チャネルに書き込めばいつでも動きます。go-loopで処理を繰り返すのと同じことを、pipelineは行っています。ただ、go-loopは処理を非同期化してくれはしても、並列化はしてくれません。pipelineは、transducerを並列に実行してくれます。

効率的なデータ処理エンジンを構築する

このプログラムはサーバプログラムなので、たくさんのリクエストを外部から受け付けます。秒間100とかもっととか、とにかく並列にたくさんの要求を受け付けます。

一方でサーバのCPU数には限りがあるわけで、すべてのリクエストごとにデータ変換関数を無制限には実行したくありません。一度に実行する変換処理は、例えばCPUコア数までとかに絞りたいわけです。そこで、プログラムの中心に「データ変換エンジン」と呼ぶ、入出力を受け付けるプロセスを用意します。リクエストを受け付けたら、このエンジンにデータを投入すると、並列にデータ変換を実行し、最後にレスポンスを返すものとしましょう。

リクエストを受け付けるサーバ自体の実装は今回のテーマではないので無視することにして、とりあえず read-ch を読み込むとリクエストが読み込めて、 write-ch に書き込むとレスポンスが返る、ということにしておきましょう。

用意するものは

  • リクエストをパイプラインに流し込む関数(始端処理)
  • データを変換する関数
  • レスポンスを出力する関数(終端処理)

これだけです。パイプラインには必ず始まりと終わりがあるので、はじまりには何かしらの始端処理が、終わりには何かしらの終端関数があるはずで、今回の場合の始端処理はリクエストをエンジンに渡す(パイプラインに流す)ような何かで、終端処理は、「レスポンスを返す」ということになります。終端処理は、たいていの場合は、パイプラインの最後の出力を読み取って、それを(ファイルとかネットワークとか)どこかに書き出す、という処理を行うような、goブロックです。

エンジンを作る

データを変換する関数の内容自体は今回は重要ではないので、とりあえず great-convert-xf というすごいtransducer関数があることにします。多分、すごい関数をtransducer化してさらにcompを使って合成したようなものです。これを使って並列パイプラインを作ります。

(require '[clojure.core.async :refer [chan pipeline]])
(def in-ch (chan))
(def out-ch (chan))
(def engine (pipeline 8 out-ch great-convert-xf in-ch))

なんと今回はこれでエンジンは完成ですね。engineと名付けたこのpipelineは、in-chにデータが書き込まれると、great-convert関数を最大8並列で実行してくれます!

もちろん今回は一番大変な great-convert-xf transducerの実装を省略しているから、これで済んでいるわけですが、関数さえ存在すれば、それを並列エンジン化するのは簡単だということがわかると思います。

始端処理

始端処理が行うべきは、ネットワークからのリクエストが入ってくるread-chを読み取って、エンジンの入力であるin-chに流し込むだけです。 ネットワークから来るデータが、そのままエンジンに流し込めるデータ構造の場合は、とても簡単です。

(pipe read-ch in-ch false)

read-chとin-chを直接つなげちゃえばいいのです! 最後の引数falseは、read-chがcloseされた場合に接続先チャネル(in-ch)をcloseしない、という意味です。これを忘れると、一個のリクエストを処理したところでエンジンの入力チャネルが閉じられてしまうので注意です。

ですが実際には、ネットワークから来るデータは、バイナリだったり、JSON文字列だったりするので、エンジンが処理できるデータ形式に変えてから流し込むことになります。go-loop関数で実現できます。

(require '[clojure.core.async :refer [go-loop <! >!]])
(go-loop []
 (when-let [req (<! read-ch)]
   (when (>! in-ch {:data (convert-request req)
                    :write-ch write-ch})
     (recur))))

このgo-loopは、read-chが閉じられる(読み取り結果がnilになる)か、エンジンの入力チャネルが閉じられる(書き込みでfalseが返る)かするまで、無限にループします。ポイントは、これはgoブロックなので、無駄にループしてCPUを消費しないことです。チャネルから読み取れるデータがあれば、core.asyncによってgoブロックに処理スレッドに割り当てられます。そして次のチャネル入出力時に再び休止状態に戻り、ほかのgoブロックに処理が回ります。CPUを効率的に利用できるのです。

エンジンに流し込むデータは、加工済みデータと、レスポンスを出力するためのチャネルの、二つの要素の入ったマップです。

終端処理を作る

終端処理は始端処理の逆ですので、似たような処理となります。エンジンからは、エンジンに流し込んだのと同じ形式のマップとしてデータが出力されるものとします。

(go-loop []
 (when-let [{:keys [data write-ch]} (<! out-ch)]
   (>! write-ch data)
   (recur)))

この終端処理は、エンジンの出力チャネル(out-ch)が閉じられるまで、無限にループします。始端処理と同じく、ループによってCPUを無駄に消費することはありません。

これで、始端処理→エンジン→終端処理というサイクルができあがりました。エンジンはcore.asyncのpipelineを使うことで並列に実行されます。エンジンが処理する関数がひとつだけの、とてもシンプルな例ですが、core.asyncで並列処理サーバプログラムを作るときの基礎がちゃんと入っています。

f:id:t_yano:20171029031328j:plain

pipelineを拡張する

ポイントは、一度この構造ができてしまえば、エンジンの部分は容易に変更できるということです。プログラムの大枠として、始端→エンジン→終端、という構造さえできていればよくて、エンジンの部分をもっと拡張しても、この構造さえ変わらなければ問題ないのです。

そりゃそうだろ、エンジン部分のプログラムが難しいんであって、そこを書いてないのだから、というのはその通りです。しかしポイントは、プログラムの拡張を、core.asyncのパイプラインの拡張という形で行えるというところです。

関数は密結合である!

Clojureの作者であるRich Hickeyが、core.asyncについて説明した プレゼンテーション があります。この動画をみると、core.asyncはただgoルーチンをclojureで実現したものというのとは別の観点もあることがわかります。Richは、このプレゼンテーションの中で、(オブジェクト指向の利点と欠点と対比しつつ)関数プログラムの利点と欠点を簡単に話しています。ここでRichが関数の利点としているのは、関数はロジックを抽象化できるということ(一方、オブジェクトはロジックというよりはそれ自体がマシンである、としてます)、欠点は、関数はどうしても密結合になりがちだ、ということです。

関数呼び出しの連鎖を分解して、途中に分岐(if文とか)を挟み込むのは、結構骨の折れる作業です。ロジックが変わるから事実上検証もやり直しです。

そこで、Richは、関数(データの入力と出力がある)を、キュー(データを入力して出力するという機能しかない単純な構造)と組み合わせることで、関数同士の密結合をほどくことができる、と言っています。

それゆえ、Richは、仮にcore.asyncの非同期機能を一切使わずに、キュー(チャネル)を介して関数と関数を結びつけるだけでも、利点はあると言っています。

データ処理エンジンは、複雑な分岐を含んだ巨大な関数です。これを、pipelineを使って、容易に再接続可能な関数の集まりとして作れるのです。何しろ、pipelineというのは、チャネルという名のキューを介して関数同士を接続するためのものだからです。

パイプラインに分岐を組み込む

パイプラインの入出力はチャネルなので、たくさんのパイプラインを用意して、あるパイプラインの出力チャネルを別のパイプラインの入力チャネルとして指定すれば(あるいは、pipe関数で互いの入力と出力チャネル同士をつなげれば)、パイプラインをつなぎ合わせた巨大なパイプラインを作ることができます。

さらに、core.asyncには、チャネルに入ってくるデータを、別の複数のチャネルに、条件によって振り分ける関数が用意されています。pubとsubです。

ある出力用チャネルにpub関数を適用すると、publicationという特殊なオブジェクトを作れます。publicationは、チャネルに入ってくるデータの種別を識別することができます。さらに、別のチャネルをsub関数を使ってpublicationに登録することができます。この登録時に「このチャネルには、データの種別がAのデータだけを流してください」という指定ができるのです。

たとえば、パイプラインに入ってくるデータが「新規データ」「更新データ」「削除データ」に分かれているケースを考えてみましょう。この三種類それぞれ、行うべき作業は微妙に異なります。こういうとき、pub/subを使って、チャネルを三つに分岐させることができます。

(let [publication (pub ch :type)]
  (sub publication :new new-data-ch)
  (sub publication :update update-data-ch)
  (sub publication :delete delete-data-ch))

イメージとしてはこんな感じです(雑な手書き図ですみません)

f:id:t_yano:20171029031238j:plain

ここで、「もともとは新規と削除しかないと聞いてた」→「急に、『実は更新データもありました…』とか言われてしまった」というケースを考えてみます。

core.asyncでは、もともとの「新規」用パイプラインと、「削除」用パイプラインに影響を与えずに、「更新」用パイプラインを追加することができます。単に、「更新」用パイプラインを作って、その入力チャネルを、分岐publicationに追加すればいいのです。

core.asyncは名前からも非同期処理で注目されますが、「密結合した関数呼び出しを疎結合にする」という目的も入っているのです。既存の関数(pipelineにtransducerとして組み込まれている)は変更されないので、この部分の再テストは不要です。新しい関数を用意し、テストし、transducer化し、pipelineを構築する。あとはチャネルとチャネルをどう接続するか、というだけの問題です。チャネル同士の接続を工夫することで、関数の分岐や実行順序を制御できるわけです。しかも、各関数は効率的に並列実行されるのです。

バイパスを作る

異常が発生したケースなど、正常なプログラムの流れを無視して、別の処理にジャンプしたいというケースは結構あります。実際、プログラミング言語の例外とキャッチというのは、正常なプログラムを流れを無視して例外処理へとジャンプする処理な訳で、関数をつなげてプログラムの流れを作るパイプラインであっても、やはり同様のことが必要になるケースはあり得ます。

core.asyncでは、pipelineで実行される関数は並列に実行される(別スレッドで動く)ので、単純に例外処理を行うことができません。その代わりに、exception-handlerと呼ばれる例外処理用の関数を使うことができます。exception-handlerは、引数として例外一つを受け取る関数であれば、なんでも構いません。

pipelineに設定したtransducer内でエラーが発生すると、(あれば)exception-handler関数が呼ばれます。exception-handlerは何をしてもよいですが、exception-handler関数の結果が、transducerの実行結果になります。

;; exception-handlerを設定したpipelineを作る例
(let [ex-handler (fn [ex data] (handle-error ex) nil)]
  (pipeline 8 out-ch (map my-greate-fn) in-ch false ex-handler))

ここでは使いませんが、実は、チャネルを作成する chan 関数にも、exception-handlerを渡すことができます。チャネルにtransducerをセットした時に、transducer内で発生したエラーを処理するために使います。

通常、エラーとなったデータはパイプラインの先に進めたくありません。そういうときは、exception-handler関数の戻り値をnilにします。core.asyncのチャネルにはnilを値として流すことができない仕様ですので、exception-handlerがnilを返すと、そのデータはパイプラインの先には流れません。 代わりに、exception-handlerに別の出力用チャネルを渡しておき、そこにデータを流すのです。このチャネルの先には、例外を処理するためのpipelineを設定しておきます。

;; バイパス用channelとしてbypass-chがすでにあるものとする
;; このex-handler関数は、受け取った例外をbypath-chに転送する。
(defn ex-handler [ex] (go (>! bypass-ch ex)) nil)

このようなexception-handlerをすべてのpipelineに設定しておけば、例外はすべて、bypass-chを経由して、例外処理用pipelineへと流れていくことになります。エラーのためのバイパスを作ったわけです。

core.asyncにはpub/subによる分岐とは別に、mergeによる連結機能もありますから、バイパスに流しておいて処理した後、最終的には同じ終端処理に接続する、といったことも可能です。

;; 終端処理につながる last-ch というチャネルがあるとする
;; すべてのチャネルを連結したall-data-chを作り、それをlast-chに
;; 連結する
(let [all-data-ch (merge [new-ch update-ch delete-ch bypass-ch])]
  (pipe all-data-ch last-ch))

例外を処理するpipelineまで含めると、このシステムの全体像はこんな感じになります。

f:id:t_yano:20171029031435j:plain

この柔軟性が、core.asyncの強みです。関数をtransducerとしてpipelineに組み込むことで並列化し、さらに、チャネルというキューを介して、pipelineを柔軟に結合して、システム全体を作るのです。Clojure自身の「データは基本的に不変である」という性質が、非同期処理の安全性を高めてくれています。入力と出力しかない「関数」と、同じく入力と出力しかない「キュー」を使うことでデータの流れを作り、さらにここに、このtransducerという変換処理を組み合わせると、pipelineという並列化関数を作り上げられるのです。これらを自由に組み替えられるわけです!

まとめ

ちょっとした例ですが、パイプラインをつなげて、分岐を伴うプログラムを作り上げるイメージができたでしょうか。

  • goとchannelの二つで、非同期ブロック間のデータのやりとりを実現する
  • transducerにより変換処理を抽象化し、合成可能にする
  • channelとtransducerを組み合わせて、transducerを並列実行するpipelineを作る
  • pipeline同士を、channelを介して分岐したり結合したりしてつなぎ合わせる
  • データがパイプラインを終端に向かって流れていくとき、すべての処理は自動的に並列に実行される!

go(非同期ブロック), channel(キュー), transducer(変換処理), pipeline(処理の並列実行)という4つの概念を組み合わせることで、並列プログラムができてしまいました!

それぞれが一つの仕事だけを行うような物を、組み合わせて複雑な処理を作り上げる、しかもそれらを混ぜ込まない(コンプレクトさせない)というのは、Clojureの目指す「Simple Made Easy」の世界観とも合っていますね。core.asyncは、Clojureらしい形で、並列プログラムの作り方を変える仕組みを実現した、Clojureのキラーライブラリの一つです。

core.asyncには、他にも、チャネルに入ってきたデータを複数のチャネルに(同じデータを)転送する multiple や、複数のチャネルをマージしつつ、チャネル単位で流れを一時的に停止できる mix、複数のチャネルから最初にデータが入ってきたものを選択できる alt! など、チャネルをつなぎ合わせるための関数が用意されています。これらを使えば、柔軟にチャネルの接続を動的に切り替えるなどの処理もできます。チャネルの組み合わせと並列化こそがcore.asyncの強さであり、ただのgoブロックではないということが分かるでしょうか。

宣伝

clj-ebisu で、core.asyncを使った時の「あるある」なトラブルと回避方法について、ちょっとしたプレゼンテーションをすることになりました。きてね。

connpass.com

Clojureと「Simple Made Easy」

プログラミング言語というのは、その作者が理想とする世界に合うようにデザインされているものだから(みんな信じないかもだけど、Javaですらそうなのですよ)、Clojureのことを理解するには、作者であるRich Hickeyのプログラミング観を知るのが手っ取り早いでしょう。

Rich Hickeyはさまざまなプレゼンテーションを発表していて、多くはネットで見られます。示唆に富んで皮肉も効いてておもしろいので、ファンも多くて、彼の独特の髪型(往年のロック歌手風)からか、「Rich Hickey’s Greatest Hits」というブログ記事もあったりします(プレゼンテーション動画へのリンク集です)。

ただ、彼のプレゼンテーションは難解な英語も出てきて、私のようなリスニング苦手人間には音声だけで聴くのは難しいです。そういう人は国外でも多いからか、有志が書きおこし(transcript)を公開していたりします。ですので、私は彼のプレゼンを、聴いたのではなく、読んだわけです。ただ動画でなにが起きてるのかわからないと文章だけでは意味が分からないところ(特に現場のジョークとか)があるので、動画も目を通すことをおすすめします。

彼の有名なプレゼンテーションに「Simple Made Easy」というものがあります。具体的なコーディングについてのプレゼンというよりは、シンプルさとは何か?を語ったものです。彼のプレゼンテーションには、そういう、実際のコーディングではなく、プログラミングの土台となる考え方や態度についてのものもあって、なかなか面白いのです。彼の哲学を語ったものとも言え、その考え方は、当然Clojureにも反映されているのだと思います。

Simple Made Easyの内容は、一度、株式会社ユーザベースでプレゼンテーションをする機会があったときに資料にまとめたことがあります。今回は、その資料をもとに、どういう内容のプレゼンテーションだったのか紹介したいと思います。ただ、これは「翻訳」ではなくて私の理解した内容の紹介であることに注意してください。Rich Hickeyが具体的にどう言っているのかは、彼のプレゼンを直接見てください。

シンプルと簡単は違う

世の中でシンプルだと言われるものの中には、実際にはシンプルではなく、単に「簡単」であるものがたくさん混ざってます。このツールを使えばコマンド一発でサーバが構築できてすごいシンプルだ!などなど。もちろん簡単なことはそれ自体価値のあることなのだろうけども(少なくともただ難しいよりは)、シンプルと簡単を混同するのは良くない。それらを混同するから、世の中には、(使うのは)簡単だけど猛烈に複雑なものが現れてくるわけです。

シンプルと簡単は違う。

簡単に使えるけども、ものすごく複雑なものというのは、たくさんあります。お気に入りのIDEはどうですか。JSならば、webpackはどうでしょうか? Spring Bootを使えばJavaで簡単にAPIサーバが作れますが、Spring Bootはシンプルでしょうか?

シンプルと簡単との違いを明確にするために、Richは、ここでいう「簡単」とはどういうものかを紐解いていきます。彼が挙げた、人が「簡単」なものに抱いているイメージは、次のようなものです。

  • 慣れている
  • すぐに使い始められる
  • 似たようなものをすでに知ってて、身近だ
  • 今の自分の能力の範疇内だ

「簡単」というのは、だいたいが「ほかと比べて」という比較が入るのです。慣れているにせよ、身近であるにせよ、「今の自分の知ってる何かと比べて」という比較なのです。

であるから、みなが「簡単」なものだけを選択していたら、誰も新しいことを始めることはできません。新しいことは、身近でもないし、慣れてもいないし、たいていは今の能力の範疇外だから。

シンプルとは

一方でシンプルというと、ぱっと「たくさんじゃなくてひとつだけ」だとか「そのツールは機能が少なくてシンプルだ」とか「このプログラムは構成要素が少なくてシンプルだ」とか、なんだか(ものなり機能なりの)数が少ない方がシンプルだ、という話になりがちです。ところが、実際には、ものごとをシンプルにすると、要素の数は多くなっていきます。

ClojureLISP系の言語ですが、たとえば、伝統的なLISPにおける括弧は、シンプルでしょうか。LISPは括弧だけで構成されているから、シンプルなのでしょうか。RichはLISPの括弧はシンプルではないといいます。なぜならLISPの括弧には、複数の意味があるからです。

LISPでは、括弧は「関数呼び出し」と「ものごとをグルーピングする」という二つの意味があります。let式でペアを表現するためのタプルとしての括弧、関数呼び出しとしての括弧、です。

つまりRichのいう「シンプル」というのは、「ひとつのものに、複数のことがらを混ぜ込まない」ということなのです。LISPの括弧には、ふたつの事柄が混ぜ込まれているので、シンプルではないと。

シンプルというのは、まっすぐの糸のようなもので、シンプルでないものというのは、糸が絡まっている状態です。「関数呼び出し」と「グルーピング」という2本のひもが、絡み合って、括弧となっているのが、LISPの括弧なわけです。

だから、「シンプルさ」というのは「オブジェクトが小さい実装である」とか「オブジェクトの詳細がちゃんと隠蔽されている」とかいう話とも関係がありません。これらはコーディング上では大事なプラクティスであるけど、「シンプルかどうか」という話とは、レイヤーの違う話なのです。

「シンプルでなくなる」という言葉を決めよう

何かを具体的につかむためには、名前をつけるのが大事です。「シンプル」のほうには名前がついてるので、その対義語の方に名前がほしい。もちろん、名詞なら「複雑(complication)」といえばいいかもしれないです。でもできれば、シンプルでないものを作ろうとしてるときに「おい、それは○○してる(シンプルじゃなくしてる)ぞ」と言えれば、シンプルさを保つ役に立ちそうです。

そこでRichは「コンプレクト(complect)」という英語(自動詞)を提案しています。何かがコンプレクトしてるから、そいつは複雑になるというわけです。コンプレクトとは、なにか複数のものが、絡み合い、もう分離できないくらいに結びついてしまうことです。

コンプレクトしてるものを探そう

言葉ができると、これはコンプレクトしてるぞ、ということができるようになります。プログラミングの世界には、身近にあってコンプレクトしているものがたくさんあります。RichはSimple Made Easyの中で、たくさんの「コンプレクトしてる」例を挙げています。

対象 何がコンプレクトしている?
状態(state) 状態を変更するすべてのもの
オブジェクト 状態と一意性と値
メソッド 関数と状態と名前空間
継承 複数の型
変数 状態と時間
アクター 「何を」と「誰が」
switch文/match文 「何を」と「誰が」とのペアが複数個混ざっている

私はこのリストを見たときに、なるほど、変数というのは「状態」と「時間」がコンプレクトしてるのか、なるほど!と思いました。言葉を定義すると、いろんなものが、簡単に表せるようになり、理解しやすくなります。

ものごとを絡み合わせない

コンプレクトしているものは、複数の要素が、もはや切り離せないレベルで結合してしまっています。この、切り離せない、という事実が、複雑さの現れるところなのです。

シンプルなものは違います。シンプルなものは、組み合わせることができます。組み合わせることで、コンプレクトしたものと同じこと実現できるでしょう。さらに、簡単に分離できます。だから合体させたり、分離してカスタマイズしたりが簡単にできます。シンプルなものは、絡み合っていないから、「簡単に」変更できるのです。

シンプルさが、簡単さを作るのです。

同じ城を作るにしても…

この図を見てください。これは実際に「Simple Made Easy」で使われた写真です。左側は毛糸細工で、毛糸を編んだり縫ったりして作る城です。右側はLEGOブロックで作った城です。

f:id:t_yano:20170515114854p:plain

毛糸細工は、パターンがわかっている場合、織り機を使えば簡単にできて面白いらしいです。馴染みがないなら、プラモデルとかに置き換えて考えるといいかもしれません。

毛糸の城と、LEGOブロックの城とでは、ツールがある前提に立てば、どちらも簡単に作れるものです。でも、一度作った後に変更しようと思ったら別です。これは重要な視点です。次の文言をじっくり考えてみてください。

テストスイートとリファクタリングツールがあれば、毛糸の城をLEGOブロックの城よりも簡単に変更できるんでしょうか?

強力なリファクタリングツールは複雑なものを安全に改変することをサポートしてくれますが、それは、LEGOブロックのような構造のプログラムと比べてどうなんでしょうか?あるいは、LEGOブロックのような構造のプログラムにリファクタリングツールを使った場合と比べてどうなんでしょうか?

テストスイートとリファクタリングツールは、あくまでも「ガードレール」であって、ガードレールなしでも簡単に変更できる、もともとシンプルなものの代用にはなり得ないんです。

「シンプルであること」は、あなたの選択だ

私たちには、複雑性の文化があります。放っておくと、なんでもこんがらがり、からみあい、コンプレクトしていくのです。そんな世界の中では、シンプルというのは、意図的に選ぶ選択なのです。

もし、シンプルなシステムが持ててないなら、シンプルであることを選択しなかった自分のせいになるのです。

テストや型システム、強力なリファクタリングは、安全性を高めてくれるでしょう。しかし、これらは強力なガードレールではあっても、シンプルさを保証してはくれません。シンプルさと、ものごとがコンプレクトしていくことの問題を解決してはくれません。だから、シンプルさというのは、常に自分の選択なんだ、とRichはプレゼンテーションで主張しています。

私たちはすぐにコンプレクトしてしまうので、常に「コンプレクトレーダー」を動かして監視しなくちゃいけない。 「コンプレクト」というワードを定義したこと自体が、そのレーダーとして役に立つはずです。

プログラムをシンプルにするには抽象化しなければいけない

プログラムやシステム全体をシンプルにするためには、ものごとを、コンプレクトしない形で結合したり分離したりできるようにしなければいけません。あっちとこっちの共通項を決めて、LEGOブロックのように、つなげたりはずしたりできなければいけません。そのためには、物事を抽象化しなければいけません。

抽象化というのは、「複雑さを隠す」ことではありません。

ものごとから、実装の詳細を取り除くことです。

そうすることで、ものごとは組み合わせ可能になって、たくさんのものを、共通の方法で扱えるようになります。LEGOブロックになるのです。

RDBは、「集合」という抽象化を使っています。であるから、テーブルやサブクエリも「集合」として共通の扱いができ、同じようにJOINしたりWHEREでフィルタしたりできます。

Clojureは、言語を貫く共通の抽象データ構造を定義していて、マップであれrecordであれ、あるいは独自に定義した何らかの型であれ、Associativeとして扱うことで、さまざまなものを共通の方法で扱えるようにしています。

データに抽象構造を見いだして、すべてをそこに集約していく必要が出てきます。LEGOブロック化するわけです。それはインタフェースを定義することかもしれないし、もっと大きなシステムであれば、サーバー間の関係をどう捉えるか?まで広がるかもしれません。

シンプルは選択だから、シンプルなシステムがほしいのであれば、システム全体を、コンプレクトしていない単位に分割できるか考えていかねばなりません。

そうしてコンプレクトしていない単位になり、抽象構造で接続されたシステムは、おそらくは「簡単」に変更できるでしょう。

まさしく Simple Made Easy なわけです。

とはいえ、現実の世界で「完全にシンプル」というものの実現は難しかったりもします。Clojureだって、グループ化としての括弧にはベクタなりマップなりを使うことで、丸括弧はおおむね「関数呼び出し」と見なしても大丈夫ですが、丸括弧をリストとして使えないわけではありませんし(でないとマクロを書けないので)。

だから現実にはトレードオフが存在するでしょうし、そのトレードオフこそが、選択なわけです。プログラムやシステムをシンプルにするのは、いつも、私たちの選択なんです。

この「Simple Made Easy」というプレゼンテーションで、RichはClojureの話をほとんど出さないので、具体的にどの部分に影響が、とかは語られてません。しかし、Clojureの並列化ライブラリ core.async を使って作ったプログラムが、「関数」と「チャネル」というそれぞれシンプルな構造を、transducerやpipelineを使って組み合わせる形で作られるところにも、この発想が現れているんだと私は思っています。

シンプルにすることはチャレンジでもあるので、うまくいったものやそうでないものもあるでしょうが、この考え方は、Clojureのいろんな部分に息づいているように思います。

今回は、「Simple Made Easy」というRich Hickeyのプレゼンテーションを通して、Clojureにおけるシンプルさの考え方について書いてみました。

次に何書くかは未定です。。

Clojureの世界観

ブログを書くのは久々です。
京都で小さな会社をやっていて、自社開発でClojureとClojureScriptを使用し続けて、概ね3年くらい使い続けています。その過程で、Clojure自体にも小さいながらソースレベルの貢献ができたりして、オープンソースプロジェクトとしても面白かったのですが、もともとオブジェクト指向言語ばかりやってきたところから、Clojureという、まったくオブジェクト指向言語ではない言語に飛び込んだ経験や考えたことなんかを、ブログにストックすると、何か他の人にも役立つこともあるかと思って、ブログに書くことにしました。

このところずっと、自社の仕事とは別に、恵比寿にある 株式会社ユーザベース さんのお仕事に参加しています(私が法人を作る前からなので、もう5、6年くらいになります)。そちらの方でもClojureやシステム設計の話(プレゼンなど)などを何度かさせてもらったり、ここ半年くらいは、ユーザベースさんでもClojureを実開発へ投入し始めたため、開発支援として携わりました(Clojureのシステムは現在も鋭意開発中です。ぶっちゃけClojure開発者募集中です)。
ユーザベースさんのシステムにおいても、Clojureでgo-blockを実現する並行処理ライブラリ core.async を使って並行処理システムを作ったりしているので、そこで得られた、Clojure+core.asyncでの並行プログラミングの知見なんかも書いていきたく、これからしばらく、不定期でClojureについてブログを書いていくつもりです。気力が続けば。

今回は、Clojureを使うことによって、Clojureが前提としている世界観が、自分が空気のように前提としていたものと異なってるところとして、Clojureにおける抽象データ構造と、ポリモフィズムの仕組みについて書いてみます。

少ない抽象データ構造と、たくさんの関数

Clojureでは、オブジェクト指向言語と異なり、関数とデータはおおよそ完全に分離しています。オブジェクト指向言語でないのだからそうなのだろうってことは皆、知ってはいるのだろうけども、関数(メソッド)をデータと結びつけて考えてしまうというのは、オブジェクト指向言語に慣れた人のみたいなものとして、染みついているところのようにも思います。

Java的な、クラスベースのオブジェクト指向言語の場合、メソッドを作るにはクラスを定義してそこにメソッドを足すわけなので、メソッド(関数)はあるデータの集まり(オブジェクト)を操作する専用の関数として定義しがちです。そりゃ、「そのオブジェクトを操作するためのメソッド」なのだから当たり前です。

一方、データと関数が分離している場合は、関数を特定のクラスやオブジェクトに結びつける意味はないですし、逆に、ある関数のために専用のクラスを作る意味もない。極端に考えれば、すべての関数が共通のデータ構造を処理するようにした方が、多様な関数を柔軟に活用できるようになる。

というよりも、オブジェクトに関数が結びついているからこそ「このメソッドはこのオブジェクト構造を処理するためのものだ(他の用途には使えない)」という風に専門化できていたんであって、データと関数を個別のものと扱う以上は、「この関数はこのオブジェクトだけを扱う」という前提を置けないのです(当たり前です)。あるオブジェクトと別のオブジェクトが、型であったりクラスであったりが異なったとしても、関数は、そのオブジェクトが、関数の処理できる構造であれば、処理できるべきなんです。関数とオブジェクトが独立しているというのはそういう意味であるべきです。であれば、すべての関数にまたがるような、共通の汎用データ構造があって、すべてのデータはその汎用性を担保してたほうがいい。

もちろん、「縦」と「横」という情報を持ったデータを使って「面積」を求める関数は、データが「縦」と「横」という情報を持っていることを求めるだろうけども、RectangleだとかTableだとかの特定のクラスにダイレクトに結びつくべきではない。

この課題にはいろんなアプローチがあるんでしょうが(Structural Typeとか?)、Clojureの場合は、言語全体が前提とする抽象データ構造があります。リスト状のものはSeqと呼ばれる抽象データ(代表例は配列)として、マップ状のものはAssociativeと呼ばれる抽象データ(代表例はマップ)として扱います。事実上ほとんどのデータがこの2つで表現されていて、多くの関数が、引数としてSeqまたはAssociativeを受け取る(あるいは内部で自動で変換する)し、SeqまたはAssociativeを出力します。
自分が関数を書くときにも、独自のデータ型ではなく、SeqかAssociativeを前提として書くのがオススメです。そうすることで、関数は他の関数のインプットになりえますし、他の関数の出力を入力できます(各関数の入力と出力が同じ抽象データ構造だから)。

f:id:t_yano:20170409211658p:plain

この抽象化は徹底していて、例えば、Clojureにはdefrecordがあり、これを使えば、 レコードと呼ばれる、独自のとフィールドを持ったデータ構造を作り出すことができます。さらに、レコードに対してプロトコルを実装することで、Javaにおけるインターフェースを実装するような感じのコードを書くこともできます。

(defrecord User [name age mail-address]
  IAuthenticate
  (auth [this param] (something-great param)))

これだけ見ると、まるでクラスを定義できるかのように感じます。ところがこのレコードはすべてAssociativeでもあります。つまり、すべてのレコードはAssociativeを処理する関数に渡すことが可能です。また、このデータを使う側は、データが実はレコードであるということを気にする必要も(原則としては)ありません。それはAssociativeだということさえわかればいい。実際、あるライブラリのある関数の戻り値が、あるバージョンまでマップ(典型的なAssociativeデータ)であったものが、次のバージョンからレコードに変わっていたとしても、特に影響はないはずです。

;; マップ
(def data1 {:name "t_yano"})

;; レコード
(defrecord User [name])
(def data2 (->User "t_yano"))

;; どちらもAssociativeなので、:name関数で:nameの値を取り出せる
(:name data1) ;=> "t_yano"
(:name data2) ;=> "t_yano"

このような構造はJavaのインタフェースのようなものがあれば、他でも実現できると思うだろうけども(実際、Associativeと Seqは、Javaのインタフェースとして定義されています)、それは「注意深く設計すればそのライブラリ内ではそうできる」という話であって、言語として標準の抽象データを定義して、すべてをそこに集約させよう、という世界では、他の言語機能が、すべて、このような仕組みを支援するように作られています。言語としての前提であるからです。たとえば、JavaですべてのデータをMapで作っても、苦しいだけで利点などないでしょう。そういう前提で言語が作られていないからです。

このような抽象データ構造があるからこそ、関数とデータを分離できるわけです。関数は共通の抽象データ構造しか見ていないからこそ、実際のクラスとは結びつく必要がないのです。

「10種類のデータ構造にそれぞれを扱う10個の関数があるよりも、ひとつのデータ構造を扱う100個の関数がある方が良い (It is better to have 100 functions operate on one data structure than to have 10 functions operate on 10 data structures)」という言葉あります。

データ構造にそれぞれ関数がある

一つのデータ構造を扱う100個の関数

この一文に賛成の人も反対の人もいるでしょうが、Clojureは明確に「ひとつのデータ構造を扱う100個の関数がある」ような世界の方がいい、という前提で作られているのです。

アドホックな多態(ポリモフィズム

さて、少ない抽象データ構造とたくさんの関数、という理屈はわかったとして、オブジェクトに結びついてない関数における、多態(ポリモフィズム)はどうなるんでしょうか。用途ごとに異なる関数を用意すればいいってことなんでしょうか。
もちろん、実際の開発には多態は必要です。多態なしで、MySQLPostgreSQLで異なる動きをするconnect関数をどうやって定義したらいいんでしょう。 mysql-connect と psql-connect を使い分ける、というようなことが避けたいところです。

ですから、Clojureにも多態関数を定義する仕組みがあります。defrecordによって独自の型を定義し、引数の型に関数実装を紐づけることもできます。あるいはdefmulti / defmethod を使ってマルチメソッドを定義することで、データの(実際には式の結果ですが)によって関数実装を切り替えることもできます。例えば、引数として渡されたDB接続定義のドライバ名に基づいて、connect関数の実装を切り替える、といった使い方もできます。

関数(メソッド)を多態にする仕組みについてはJavaScriptみたいなアプローチや、引数の型の組み合わせによって切り替えるマルチプルディスパッチがあったり、世の中にはすでに色々なものがあるので、Clojureの多態の仕組みがそれほど独自のものだというわけではないでしょう。強いていえば、Clojureのマルチメソッドで追加した関数は、動的に紐付けを追加したり切り離したりできるのが面白いところですが、それ自体も、他の言語(特に動的な言語)でできないというものでもありません。

多態を実現する仕組みよりも、Clojureの、多態に対する態度というか、考え方の方が、面白いのではないかと思います。
Clojureでは、Clojureの多態の仕組みのことをAd-hoc Polymophismと呼んでます。このワード自体は(Clojureよりも前に)結構昔からあるようなので、新しい概念ではないのでしょうが、それによる実際の開発への影響が結構面白い。

多態(ポリモフィズム)というのは、オブジェクト指向の世界では、「あるオブジェクトと別のオブジェクトが同じメソッドを持っているけども違う動作をする」とか、逆の視点から、「あるオブジェクトと別のオブジェクトは、実際には違う動作をするだろうが、同じメッセージに反応するので同じオブジェクトとみなせる」といった文脈で使われる感じがします。オブジェクト指向のメッセージ・パッシングの考え方にのっとれば、あるオブジェクトAとBに同じメッセージを送っても、実際に動く処理(メソッド)は別かもしれない、だが同じメッセージに応答できるのだから、両者は同じオブジェクトとみなせる、というわけです。
つまり、いつもオブジェクトとともに、オブジェクト同士の関係(同じメッセージに応答するのだから実質同じとして扱える、とか、サブタイプである、とか)として語られる傾向があります。

一方、Clojureにとって多態とは、関数ディスパッチの話と捉えられています。関数はデータ(オブジェクト)とダイレクトには結びついてないので、多態の説明として、オブジェクトやクラスと結びつけて語ることはできません。ある関数を呼んだ時に、実際に実行される関数実装はどれなのか?というディスパッチの問題に過ぎないのです。

ディスパッチの問題、ということは、実際のところ、関数を使う側にとっては、実は関数が多態であるかどうかということは、使用上あまり関係がないということです。関数は関数であって、それが多態であるかはユーザーには関係がない。
例えば、ただの関数は、その関数を呼ぶと関数本体に直接ディスパッチされます。これも関数ディスパッチのひとつの形です。
これがマルチメソッドであれば、引数の値(実際には値を計算した結果)によって実際に使う関数実装が探された後に呼び出されます。defrecord / defprotocol によるディスパッチでは、引数の型によってディスパッチされます。それは実装の詳細であって、関数を使う側にとっては、関数を呼べば関数が実行されればいいのです。

もしある別の人の作った関数実装が、内部でプログラムとしてif文を使って別の関数に処理をディスパッチしていたとしても、使う側にとっては関係ないのと同じです。if文での分岐は、手動の関数ディスパッチと考えれば、マルチメソッドと同じく関数ディスパッチだと言えますし。

使う側にとってはいずれにせよただの関数に見えるのですから、Clojureにおいて、関数はあとでいつでも多態に切り替えられる存在なわけです。

過去に私がDB操作ライブラリの一つ Korma に送ったパッチでは、列名をクオートする処理をMySQLPostgreSQLとで分ける必要に対応しました。対応は簡単で、クオートする関数をマルチメソッドに変更し、引数として渡ってくるDB接続定義の接続URLを使って適切なクオート処理にディスパッチするだけです。関数がマルチメソッドに変わったわけですが、関数を呼び出す側から見ると、やはりただの関数に見えますので、他の部分にはまったく影響しません。

つまり、コードを書いている時に、ある関数を多態にするかどうかというのは後から考えても大丈夫な仕組みなわけです。もちろん、作ってる段階で、設計として「ここの関数は外から拡張できるようにしたいから、マルチメソッドにしておこう」とか「ここはユーザー利便性を考えてプロトコルを定義しておくか」ということはありますが、そうでないところを後からマルチメソッドやプロトコル関数化することも、結構簡単に行えるのです。

だからAd-hoc(場当たり的な)ポリモフィズムと呼ぶわけです。もちろん注意すべきことはあって、対象の処理が関数に分離されてなければ、後で多態関数化することもできませんから、できれば関数は細かく分けておいた方が、後から対処しやすいと思います。そのような注意点さえクリアしておけば、柔軟に後からコード変更可能だ、というのも、Clojureの利点の一つです。

他の文化を無理やり持ち込まないの大事

プログラミング言語には、言語ごとに文化というか、大事にしている考え方があるものです。ClojureにはClojureの大事にしているものがあって、言語仕様自体が、その大事にしているものを前提に設計されているはずです。上に紹介したものも、Clojureという言語に意図的に組み込まれた仕様な訳です。

他の言語から新しい言語に移ってくると、最初は、今まで馴染んでいた言語の文化と、新しい言語のやり方とが、まったく異なっていることに混乱して、自分の馴染んでいる文化と同じにしようとしてしまいがちです。しかし、言語仕様とその言語の文化は強くつながってるものなので、文化を無視して別の文化で書こうとしても苦しいだけです。

そのためには、やはり、その言語が大事にしているものはなんなのか、どういう意図で、どういうことを実現したくてそういう言語デザインになっているのか、を知るのが大事だと思うのです。意図がわかれば、馴染めなかったものにも急に「なるほど、そういうことか」と納得感が得られるものです。抽象データ構造や多態の仕組みなんかも、そのような例の一つです。

ClojureJavaみたいな型ベースのオブジェクト指向言語とは、かなり違う言語なのですが、一見「これってJavaのインターフェースと同じか」とか「これってLombokで@Valueでクラス定義するようなもの?」とか思えてしまう機能もあったりします。しかしもちろん、それらはイコールではないし、意図してることも異なってることもあります。
他の言語から移ってきた時には、「なんでそういう仕組みになっているのか?その背景はなんなのか?」を把握するのがとても大事だと思います。意図さえわかってしまえば、とても簡潔で書きやすい言語ですから。

抽象データ構造やAd-hoc多態以外にも、Clojureには、シンプルで構造化しやすい言語を作るために、いろんなアイデアが取り込まれていて面白いので、今後も、理解できた範囲で書いていきたいところです。

Clojureのいろんな並行処理の使い分け

この記事はもともとTumblrに書いていた自分のブログ記事を転載したものです。投稿日時も当時の投稿日時を再現してあります。

Clojureには標準でもagent系のsend, send-offに加え、future関数というスレッド起動系関数があります。
core.asyncの登場で、ここにgoマクロとthreadマクロが加わりました。

これらはすべて、背後ではJavaのExecutorsを使ってスレッドプールを作り、一度生成したスレッドの再利用を行いますが、それぞれ使っているスレッドプールが異なります。さらに関数自体の機能も異なるため、どれをつかったらいいのか迷ってしまうことがあります。

自分用に整理したので、メモとしておいておきます。

IOバウンドとCPUバウンド

まず、Clojureのスレッド関連関数の用途は、大きく2種類にわけられます。それが、IOバウンドとCPUバウンドです。

IOバウンドな処理は、実行中の処理がCPUよりもIO処理に強く依存します。DBアクセスとかリモート通信とかですね。別スレッドでこの処理を実行した場合、スレッドは大部分を、IO処理待ち状態で過ごします。
CPUバウンドな処理は、途中にIO待ちのような「待機」が発生せず、CPUをぶん回し続けるような処理です。全データがメモリに載っていて、CPUがフル稼働でそれらを処理するようなケースです。

IOバウンドな処理は大半をIO待ちで過ごすため、CPUを占有しません。一方CPUバウンドな処理は、その名の通り、動いている間中、CPUを使い続けます。

CPUを使い続けるような処理は、CPU(コア)数以上のスレッドを起動してもあまり意味がありません。たくさんのCPU依存処理を起動する場合、全スレッドがタスク処理でCPUを占有しているのがもっとも効率の良い状態で、それ以上起動しても、単にスレッド切り替えコストが無駄になるだけだからです。 CPUバウンドな処理は、スレッド数をコア数に近い数にとどめ、ひとつひとつのタスクは小さくして、たくさんのタスクをどんどんコアで分散して処理していくのが効率がよいことになります。CPUバウンドな処理はCPUを使うしかないのだから効率良く使いたいわけです。

一方、IOバウンドな処理は、その大半は「IO待ち」だったりします。リモートAPIを呼ぶ処理は、大半を「レスポンスが返ってくるのを待つ」ことに費やしています。

ここでたくさんのIOバウンドな処理を、コア数分の固定数スレッドで実行したことを想像してください。スレッドが4つだとして、4つのIO処理を起動すると…すべてのスレッドが使われ、それ以降の処理は待つしかありません。
CPUバウンドな処理であれば、スレッドはCPUをフルに使って一所懸命にタスクを実行していることでしょう。だから待つしかありません。しかしIOバウンドな処理では、4つのスレッドは、おそらく、ただIO待ちをしているだけです。

だから、IOバウンドな処理でスレッド数をコア数近くに限定するのは、あまり意味がないということになります。スレッドがIO待ちをしている間に、ほかの処理が動けるかもしれないのですから。だから、コア数以上のスレッドを起動して、どんどんIO待ちさせ、IOが終わったスレッドから処理を行えばよいのです。

固定数スレッドプールとキャッシュ化スレッドプール

Clojureの関数は、その用途がCPUバウンドかIOバウンドかによって、使用するスレッドプールが異なっています。

agent実行関数sendが使うスレッドプールは固定数であり、JavaのExecutors.newFixedThreadPoolメソッドで作られます。
一方、send-offが使うスレッドプールはキャッシュ化された非制限プールで、Executors.newCachedThreadPoolで作られます。非制限といっても、キャッシュ化スレッドプールは、使われなくなったスレッドを60秒で破棄するので、たくさんのスレッドがゴミとして残ることはありません。

多くのClojure関係の本で、sendはCPUに依存する処理に、send-offはIOに依存する処理に使う、と書かれているのは、このように、背後で使っているスレッドプールがことなるからです。

背後で使われているスレッドプールの種類がわかれば、その関数が、CPUバウンドな処理を想定しているのか、IOバウンドな処理を想定しているのかがわかります。以下は、Clojureのマルチスレッド関数がどのスレッドプールを使っているのかの一覧です。

 poolの定義場所プールの種類スレッドプール生成方法スレッド数
sendclojure.lang.Agent/pooledExecutor固定数Executors.newFixedThreadPool2+コア数
send-offclojure.lang.Agent/soloExecutorキャッシュ化Executors.newCachedThreadPool制限なし
future / future-call / pmap / pcallsclojure.lang.Agent/soloExecutorキャッシュExecutors.newCachedThreadPool制限なし
goclojure.core.async.impl.exec.threadpool/the-executor固定数Executors.newFixedThreadPoolコア数 * 2 + 42
thread / thread-callclojure.core.async/thread-macro-executorキャッシュExecutors/newCachedThreadPool制限なし
reducersclojure.core.reducers/poolForkJoinPoolnew java.util.concurrent.ForkJoinPool自動制御

futureのところにはpmapとpcallsも書いていますが、pcallsはpmapを、pmapはfutureを呼び出すので、すべてfutureと同じ扱いです。

まとめてみると、core.asyncの解説で必ず取り上げられるgoマクロは、固定数のスレッドプールを使っていることがわかります。つまり、goマクロはCPUバウンドな処理を前提としているわけです。

goマクロが「コア数 * 2 + 42」というよくわからないスレッド数を使っていることについて、特に42という謎の数値を指定していることについてははっきりしないのですが、+42は後から付け加えられたらしく、メーリングリストのポストなどを追跡すると、前述した、IOバウンドな処理に固定数スレッドプールを使った場合のような、IO待ちで全スレッドが停止して並行処理がスタックしてしまうことをある程度抑止したい、というのが意図のようです。goマクロはあくまでCPUバウンドな処理を扱うものであることは変わらないそうです。

42という数値については「すべての答え」から取ったのでは、という説もありますが、いまだ謎です。「すべての答え」ネタを知らない人はググってください。

goがCPUバウンドであるかわりにthreadマクロが用意されています。
threadマクロはgoマクロとほぼ同じ使い勝手で使えますが、キャッシュ化スレッドプールを使うため、IOバウンドな処理に向いています。goマクロと異なるところは一点だけで、チャネルの操作に <! と >! は使えず、ブロック型のチャネル操作関数 <!! と >!! を使う、ということです。<!!, >!! では呼び出した段階でスレッドがブロックしますが、そもそもthreadを使った場合はネイティブスレッドに処理が割り当てられていて、そのスレッドがブロックするだけなので、メインスレッドは止まらず、問題ありません。

goマクロで起動した並行処理は、単純にひとつのスレッドに丸ごと渡されるわけではなく、コンパイル段階で全処理が式単位に分解され、ステートマシンに変換されます。S式ならではです。そして<!, >!でチャネルへのアクセスごとにスレッドが切り替わる、といった動きをするようです。<!!, >!! をgoブロックで使うと、このスレッド切り替えがうまく動かなくなるので、&gt! か !< を使います。
彼らはこれをIoC Threadと読んでいますが、いやいやそれはIoCというよりも、昔の協調型マルチタスクと似たものだから「協調型スレッド」と呼ぶべきだという意見もあります。私も強調型だって意見に賛成ですが、たぶんIoCのほうがかっこいいってことなんだと思います)

reducersだけは特殊で、reducersは内部では並行処理をJava 7以降のFork/Join APIに処理を丸投げしています(JVMがJava7未満の場合は互換ライブラリを使っているようです)。Fork/JoinはJavaではとても使いにくいAPIで、Java 8でラムダ式とパラレルストリームが導入されてやっと本気出せるようになったのですが、ClojureではJava 8よりももっと前に、早々に対応していたわけです。よって性質としてはFork/Joinと同等でして、Fork/Joinのドキュメントによると、CPUバウンドな処理を前提にスレッド数を自動制御し、IOバウンドな処理が混ざるとうまく自動制御できないようです。

スレッドプールも、Java 7でFork/Joinとともに導入された、ForkJoinPoolを使っています。このプールは、初期値はCPUコアと同数のスレッドを用意し、ダイナミックにワーカースレッドを追加したり停止したりします。
つまり、reducersはFork/Joinにすべておまかせ、ということです。

そもそもFork/Joinは、要素数がとても多いデータ(10万とか100万とか)を高速並列処理するためのAPIなので、並列化が目的なら、数個程度の並列化ではreducersではなく別の機構をつかったほうがいいです。reducersの機能は並列化だけではないので、そっち目当てならよいですが。

プールの違い

表をよく見るとわかりますが、同じスレッド化プールを使っている関数でも、threadマクロだけは、プールが異なります。send-offとfutureは、ともにClojure標準関数なだけはあって、両方が同じスレッドプールを使っています。これはつまり、send-offで生成されたスレッドは、futureでも再利用できることを意味します。

core.async/thread は、そもそもcore.async自体がClojureの「外部ライブラリ」な位置づけですから、独自に定義したスレッドプールを使っています。よって、futureとthreadとは、互いに生成済みスレッドを再利用できません。ちょっとした差ではありますが、効率的ではないことは知っておいて損はないでしょう。

core.asyncを使う人は、おおむね、スレッド処理はcore.asyncばかり使う傾向があるので、今後はfutureの代わりにthreadを使うことにすれば落着、と行きそうですが、両者は機能にも違いがあるのでなかなかそうは行きません。

機能の違い

  • send, send-off (agent系)
  • future
  • go, thread (core.async系)

この3種類は用途および使い方が違います。
sendとsend-off、goとthreadは、用途は同じですがCPUバウンドかIOバウンドかが異なります。
futureはどちらにも属しません。

sendとsend-offはどちらも、agent操作関数であり、目的はあくまでagentの実行と更新です。そもそもagentは、汎用的な並行処理起動のためにあるものではなく、かなり特殊な用途でつかうものなので、「ただスレッドを起動したい」だけでは使わないほうがいいです。

agentの特徴は、同じagentで起動した処理は「逐次実行される」点です。同じagentに何回もsend, send-offしても、それらが平行で処理されるわけではありません。sendやsend-offはagentのアクション実行キューにアクションを積むだけです(もちろん、複数のagentが存在すれば、それらは平行に動きます)。そもそもagentは「値」を持っていて、sendやsend-offで積んだアクションによって、agentの結果値が順番に変わっていく、というものだからです。

goとthreadはagentに比べてより汎用的な並行処理機構で、goやthreadブロックの処理は、スレッドプールの違いはあれ、すぐにスレッドに割り当てられて平行に動きます。いずれのマクロも、処理完了時の結果値が取り出せるチャネルを返します。とこれだけ書くと、threadはfutureと似ているように思えます。ともにIOバウンドな処理用で、結果値を取得できるオブジェクトを返します。futureを卒業して、core.asyncに「移行」すべきでしょうか?

futureは、処理をキャッシュ化スレッドプールに渡してくれる点でthreadと同じですが、futureはdelayオブジェクトでもある点が大きく異なります。

(let [result1 (future (my-remote-func1 ...))
       result2 (future (my-remote-func2 ...))]
  (my-long-processing-fn)
  {:age (-> (:base @result1) (+ 20))
   :address (str (:address @result1) " " (:address @result2))
   :name (:name @result2)})

futureはderef(の省略記号アットマーク)によって非同期処理の実行結果を取得できますが、derefは何回でも使えます。最初のderef時にまだ処理が終わってない場合は処理完了を待機しますが、以降は、キャッシュした結果値を返し続けます。

上記例では、my-remote-func1とmy-remote-func2というリモート呼び出しを平行化するためにfutureを使い、さらにmy-long-processing-fnという長い処理を行う関数を呼びました。my-long-processing-fn実行中も、別スレッドでリモートコールは実行されています。
最後にマップを作るときに、futureの結果値を参照していますが、result1もresult2も、2回参照している点に注目してください。

threadマクロはdelayオブジェクトではなく、チャネルを返します。(<!! ch) によって結果値を取り出せますが、derefと違って、<!!を繰り返し読んでも同じ結果が返ってくるわけではありません。チャネルはキューの一種で、チャネルへの <!! は呼ぶたびに新しい値を返し、値がなくなるとnilを返すので、チャネルを、delayのように繰り返し参照すべきではありません。

(let [ch1 (thread (my-remote-func1 ...))
       ch2 (thread (my-remote-func2 ...))]
  (my-long-processing-fn)
  (let [result1 (<!! ch1)
        result2 (<!! ch2)]
      {:age (-> (:base result1) (+ 20))
       :address (str (:address result1) " " (:address result2))
       :name (:name result2)}))

チャネルベースのthreadを、futureの代用として使う場合は、letを使ってチャネルからいったん値を取り出さなければいけない点で、使い勝手が異なってきます。

もちろん、ごく僅かな差ですし、go/threadには、複数のgo/threadブロックが共通の(しかもたくさんの)チャネルを介して値をやり取りしつつ並行処理を実行するという本来の目的がありますから、価値はいささかも減じません。ここで言いたいのは、threadはgoのIOバウンド版であって、全並行処理をcore.async化しようとして、futureのかわりにthreadを使おうというのは、アリではありますが、若干短絡的です。
core.asyncのパワーは、goあるいはthreadブロックが複数個起動していて、互いに(チャネルを介して)通信しあう時に発揮されます。もちろん、常に結果チャネルを返す点で汎用的なスレッド起動の仕組みとして使うことも配慮されていますが、上記のような違いを意識しておいたほうがよいでしょう。この例のように、複数のfutureでいくつもの並列処理が起動して、あとでその結果値を使う場合、threadの場合は、長いlet式でいったんチャネルをリードする必要があるかもしれません。

一方で、futureとthreadは使用するスレッドプールが異なるので、併用すると、互いにスレッドを共有してくれません。future同士はスレッドを共有しますし、thread同士も共有しますが、futureとthreadは共有しません。ここに若干のロスが存在します。

よって、用途に合わせてfutureとthreadと使い分けるか、あるいはスレッドプールの効率性を考えて片方に寄せるか(パワーを考えるとthreadの方が強力なので、ふつうはthreadに寄せるでしょう)は、正直、好みの次第です。実を言うと、私はfutureを使うシーンでもthreadを使うことがほとんどです。好みの問題です。

まとめ

  • agentは単なる並列処理起動用の機能ではないので、ちょっと考えて使え
  • reducersはすごい量のデータを処理でもしない限り、並列化機構だと思うな。
  • いまやりたい処理がCPUバウンドかIOバウンドかはちゃんと考えろ
  • futureにはちゃんとfutureに向いた処理がある。けどあえてthreadで代用も出来る。その場合、他の並列処理もなるべくcore.asyncを使うようにすれば、スレッドプールのキャッシュ効率は若干良い。