32. Mnesiaと記憶術

あなたは一番身近にいる友達です。 数多くの友達の中からです。 そのうち何人かは長くからの友人です。そう、あなたのように。 友達は、シチリアからニューヨークまで、世界中にいます。 友達はお互いに経緯を払って、あなたやあなたの友達に気をかけて、あなたも友達に気をかけます。

../_images/the-codefather.png

例外的に、友達はあなたにお願いをします。なぜならあなたには権力があり、信頼があるからです。 彼らはあなたの親友なので、あなたは期待に応えます。 しかし、友情はコストです。 気づいてしまったお願いは十分に記憶に残って、将来のある時点で、あなたはお返しを期待するでしょう。

あなたはいつも約束を守り、信頼の柱とも言えるべき人です。 これが周りがあなたの友達をボスと呼び、あなたを相談役とよぶ理由です。 そしてこれがあなたが最も尊敬されるマフィアファミリーを率いている理由なのです。

しかしながら、すべての友人関係を覚えておくことは非常に難しく、そしてあなたの影響が世界中に及ぶにつれて、どの友人があなたに貸しがあり、あなたがどの友人に借りがあるかを覚えておくのは徐々に難しくなってきます。

あなたは助けになる助言者なので、従来のシステムである様々な場所にこっそりと保存されたノートからErlangを使ったシステムに更新することを決めました。

まずはじめに、ETSやDTSを使えば完璧だと思いました。 しかしながら、ボスから離れて海外旅行に行っているときに、同期を取るのが少々難しくなって来ました。

ETSやDETSの上に複雑なレイヤを書くこともできました。 書くこともできましたが、間違いを犯して、バグがあるソフトウェアを書いてしまうことも知っていました。人間だもの。 友人関係がとても重要な場合、こういったミスは避けるべきですので、あなたはシステムを確実に動作させる方法をネットで検索しました。

これがあなたがこの章、Mnesiaという、このような問題を解決するためのErlang分散データベースについて説明した章を読み始めた時のことでした。

32.1. Mnesiaとは何か

MnesiaはETSやDETSの上に、多くの機能を追加するために作られたレイヤです。 Mnesiaには、多くの開発者がETSやDETSを使いこもうと独自に開発しようとして断念したものを含んでいます。 たとえば、ETSとDETSの両方に自動的に書き込める機能や、DETSの永続性とETSの性能の両方を持っていることや、データベースを多くの異なるErlangノードに自動的にレプリケートしたりすることができたりします。

ほかの機能で便利だと思われるのはトランザクションです。 トランザクションは基本的には1つ以上のテーブルに複数の操作を行うときに、あたかもそれらを行なっているプロセスだけがテーブルにアクセスしているかのように扱えるということです。 この機能は、1単位で読み取りと書き込みの両方の操作を混ぜて並行に行う必要があるときに、必須だとすぐに分かります。 1つの例としては、ユーザ名が既に取得されているかを確認するためにデータベースにを読み込んで、もし空いていればそのユーザを作成するというものです。 トランザクション無しでは、テーブルを見て値を探すことと、その値を登録することは2つのはっきりと異なる操作でお互いに見失う可能性があります。たとえば、あるタイミングで、2つ以上のプロセスが同時に一意なユーザを作成する権利があると信じた場合、これは大きな混乱の元になります。 トランザクションはこの問題を、複数の操作を1単位にすることで、解決します。

Mnesiaで素晴らしいのは、(本書執筆時には)Erlangをインストールしてすぐに利用できる、Erlang項をそのまま保存したり返したりすることができる、唯一のフル機能のデータベースだという点です。 欠点は、あるモードではDETSのすべての制限を継承してしまうという点です。たとえば、ディスク上で各テーブル2GBまでしか保存できない(これは実際は フラグメンテーション と呼ばれる特性によって迂回できます)といった制限です。

CAP定理を引用すると、MnesiaはAP側というよりもCP側に倒していて、つまり結局は一貫性はなく、サーバスプリットにおいてはある状況においてはかなり悪い状況になりますが、ネットワークが信頼に足る場合は強い一貫性を保証してくれます。(時にネットワーク信頼性は期待できませんが)

Mnesiaは通常のSQLデータベースを置き換えるものではなく、またNoSQLの巨人と言われるような多くのデータセンター間をまたがって数テラバイトを扱うようなものでもない、ということに留意してください。 Mnesiaは少量のデータで、限られたノード数で処理するために作られたものです。 大量のノード上で使うこともできますが、だいたい10ノード前後が実用の限度だと知られています。 通常Mnesiaは、決まったノード数で実行し、どれくらいのデータが必要かわかっていて、主にETSやDETSで出来る方法でデータにアクセスする必要しかないとわかっているときに使いたくなるでしょう。

MnesiaはErlangとはどれくらい近い存在なのでしょうか。 Mnesiaはテーブル構造を定義するためにレコードの考え方を据えています。 したがって各テーブルは同様のレコードを大量に保存することになり、またレコードに突っ込めるものはMnesiaテーブルに保存できます。たとえばアトム、Pid、参照といったものすべてMnesia内に保存可能です。

32.2. データストアは何を保存すべきか

../_images/bff.png

Mnesiaを使うためのはじめの一歩は、マフィアの友人追跡アプリケーション(これをmafiappと名づけます)にはどのようなテーブル構造が必要になるかを見極めることです。 友人に関連して保存したい情報は次に挙げるものでしょう:

  • 友人の名前。これは何かを依頼するとき、あるいは依頼されたときに誰に話しているのかを知るためです。
  • 友人の連絡先。これはどのように彼らに接触するかを知るためです。 これはメールアドレス、携帯電話番号、あるいはその人がよく出かける場所でも、なんでもいいでしょう。
  • 追加情報として、誕生日、職業、趣味、特異体質などがあります。
  • 一意な技能、友人の強み。このフィールドは独立して作りました。なぜなら、これこそが特に私たちが知りたいことだからです。 技能や強みとして料理が得意な人がいて、仕出しが急に必要になったら、彼を呼ぶことでしょう。 私たちが窮地にあって、しばらく隠る必要が出てきたら、飛行機のパイロットやカモフラージュの達人、あるいはすばらしいマジシャンを呼ぶことでしょう。 これは重宝します。

それから、友人と私たちの間の奉仕を考えなければなりません。 何を知る必要があるでしょうか。 思いつく事柄をいくつか挙げてみます:

  1. だれが奉仕を提供するのか。 おそらく相談役であるあなたでしょう。 おそらく後見人でしょう。 あるいはあなたの代わりの、友人の友人でしょう。 あるいはあなたの友人になるであろう誰かでしょう。 それを知る必要があります。
  2. だれが奉仕を受けるのか。 上の項目と同じですが、こちらは受ける方です。
  3. いつ奉仕を与えるのか。 一般的に、誰かの記憶を新たにすることができるのは便利で、特にお返しを期待するときには便利です。
  4. 直前の点に関係して、奉仕の詳細に関して保存できると良いでしょう。 日付に加えて、私たちが与えた奉仕に関係するどのような些細な情報でも記憶しておくことは大変すばらし(く、より恐ろし)いことでしょう。

前の節で触れたように、Mnesiaはレコードとテーブル(ETSとDETS)を基にしています。 正確には、Erlangレコードを定義して、Mnesiaにその定義をテーブルに変換するように伝えることができます。 基本的に、もし次のような形式のレコードを定義した場合:

-record(recipe, {name, ingredients=[], instructions=[]}).

Mnesiaに recipe テーブルを作るように命令することができます。このテーブルはいくらでも #recipe{} レコードをテーブル行として保存します。 したがって、ピザのレシピは次のように保存できます:

#recipe{name=pizza,
        ingredients=[sauce,tomatoes,meat,dough],
        instructions=["order by phone"]}

スープのレシピはこうです:

#recipe{name=soup,
        ingredients=["who knows"],
        instructions=["open unlabeled can, hope for the best"]}

そして、この両方をそのまま recipe テーブルに挿入できます。 それから全く同じレコードをテーブルから取得して他の場所で使うことができます。

テーブル内で最も参照が速いフィールドである、主キーはレシピ名になります。 なぜなら name#recipe{} レコードの定義内の最初の要素だからです。 またピザのレシピで材料名にアトムを使い、スープのレシピではstringを使ったことに気がついたことでしょう。 SQLテーブルと違い、テーブル自体のタプル構造に従う限り、Mnesiaテーブルには組込み型制限がありません。

とにかく、マフィアアプリケーションの話に戻りましょう。 どのように私たちの友人や奉仕の情報を表したらいいでしょうか。 1つのテーブルですべての情報を表すべきでしょうか。

-record(friends, {name,
                  contact=[],
                  info=[],
                  expertise,
                  service=[]}). % {To, From, Date, Description} for services?

しかし、これは最適な選択ではありません。 友人に関係するデータ内に奉仕に関するデータを入れ子にすることは、奉仕に関する情報を追加や修正するときに、友人に関する情報も同時に変更する必要がある、ということを意味します。 これはおそらく面倒な事で、特に奉仕は少なくとも2人の人間が関わってくるので面倒です。 奉仕ごとに、たとえ友人の事だけに関わる情報は何も変更する必要がなくても、2人の友人のレコードを取得して、それらを更新する必要があります。

より柔軟なモデルは、保存する必要がある各データの種類に対して各々テーブルを使うというものです:

-record(mafiapp_friends, {name,
                          contact=[],
                          info=[],
                          expertise}).
-record(mafiapp_services, {from,
                           to,
                           date,
                           description}).

2つのテーブルがあることで、情報を検索したり、それを修正したりするのために必要な柔軟性が得られ、またオーバーヘッドも小さくなります。 この重要な情報の扱い方に触れる前に、テーブルの初期化を行わなければなりません。

Don’t Drink Too Much Kool-Aid:

friendsservices のレコードに mafiapp_ という接頭辞を付けた事に気づいたでしょう。 この理由は、レコードはモジュール内にローカルに定義される一方で、Mnesiaテーブルはクラスタ内のすべてのノードに対してグローバルだからです。 これは暗に、注意しないと高い確率で名前が衝突する可能性があることを示しています。 こういった理由から、手動でテーブルに名前空間をつけるのは得策と言えるでしょう。

32.3. レコードからテーブルへ

これで何を保存したいかが分かったので、次のステップはどのようにそれを保存するかです。 MnesiaはETSとEDTSの上に作られているということを覚えているでしょうか。 これによって、2種類の保存方法が可能になっています。つまりディスク上かメモリ上かの2種類です。 戦略を選択しましょう! オプションは次のとおりです:

ram_copies:このオプションはすべてのデータをETSだけに保存するようにします。つまりメモリだけです。 メモリは32ビットでコンパイルされた仮想マシンでは理論上は4GB程度(実際は3GB程度)に制限され、64ビット(ハーフワード)でコンパイルされたものに関しては、4GB以上のメモリが使える場合には、ずっと多くのメモリが使えるようになります。
disc_only_copies:
 このオプションでは、データはDETSにのみ保存されます。 ディスクのみなので、ストレージはDETSの2GB制限に引っかかります。
disc_copies:このオプションはデータはETSとDETSの両方に保存されます。つまりメモリ上とハードディスク上の両方に保存されます。 Mnesiaはトランザクションログとチェックポイントに複雑なシステムを使っていて、メモリ上のテーブルをディスク上にバックアップするため、 disc_copies テーブルはDETSの制限には引っかかりません。

私たちの現在のアプリケーションでは、 disc_copies を使おうと思います。 この理由は、少なくともディスクの永続性が必要なためです。 私たちの友人と築き上げた関係は、長く続くものである必要があり、したがって永続的にデータを保存することは意味が通ります。 停電のあとに起きてみて、苦労して記したすべての友情に関するデータが消えていたら、本当に最悪です。 なぜ disc_only_copies を使わないのでしょうか。 メモリ上にコピーを持っていると、通常は複雑な問い合わせや検索をするときに、ディスクにアクセスすることなく行えるほうが便利です。ディスクにアクセスするというのはコンピュータで保存データにアクセスする際に最も遅い部分で、特にハードディスクのときはそれが顕著です。

私たちの貴重なデータをデータベースに保存する際にもう1つハードルがあります。 ETSとDETSの動作の仕組みから、テーブルのタイプを定義する必要があります。 このオプションは setbagordered_set です。 ordered_set は明示的には disc_only_copies をサポートしていません。 これらのタイプのテーブルが何をするかを覚えていなのであれば、 ETSの章 を読むことをおすすめします。

Note

duplicate_bag タイプのテーブルはどの保存タイプでも利用できません。 なぜこれができないかについての明確な説明はありません。

良いお知らせは、保存方法の決め方についてはほぼおしまいだということです。 悪いお知らせは、本当にMnesiaを使い始めて見る前に、まだ理解することがあるということです。

32.4. スキーマとMnesia

Mnesiaは独立したノード上ではうまく動作する一方で、多くのノードへ分散したりレプリケートしたりすることもサポートしています。 テーブルをディスク上にどのように保存するか、テーブルをどのように読み込むか、他のノードは何を同期すべきなのかを知るためには、Mnesiaはこのような情報をすべて持つ スキーマ と呼ばれるものを持つ必要があります。 デフォルトでは、Mnesiaが作られたときに、メモリ上にスキーマを直接作成します。 これはRAMだけで保存されるテーブルではうまく動作しますが、すべてのノードがMnesiaクラスタと呼ばれるものの一部で、スキーマが多くのVMが再起動するような環境で生き抜く必要がある場合には、物事は少し複雑になります。

../_images/chicken-egg.png

Mnesiaはスキーマに依存していますが、Mnesiaはスキーマも作成します。 これは、Mnesiaに作られる必要があるスキーマが、Mnesia無しに動くというおかしな状況を作り出しています! これは現実の問題として解決すると単純です。 Mnesiaを起動する前に mnesia:create_schema(ListOfNodes) という関数を呼ばなければなりません。 これが各ノードに、必要となるテーブル情報を保存した大量のファイルを作成します。 他のノードを呼び出すときには接続しておく必要はありませんが、ノードを起動しておく必要はあります。その関数が接続をして、あなたのために働いてくれます。

デフォルトでは、Erlangノードが稼働しているときには、スキーマはカレントワーキングディレクトリに作成されます。 これを変更するために、Mnesiaアプリケーションには dir 変数があり、これでどこにスキーマを保存するかを選択します。 したがって、ノードを erl -name SomeName -mnesia dir where/to/store/the/db あるいは動的に application:set_env(mnesia, dir, "where/to/store/the/db") で設定することができます。

Note

スキーマは次の理由で作成に失敗することがあります。スキーマが既に存在している、Mnesiaがスキーマが存在すべきノード上で稼働している、Mnesiaが書き込みたいディレクトリに書き込み権限がない、などです。

一旦スキーマが作成されると、Mnesiaを起動することができ、テーブルを作り始めることができます。 mnesia:create_table/2 が私たちが使う必要がある関数です。 この関数は2つの引数を取ります。テーブル名とオプションのリストです。オプションは下にバッスして説明します。

{attributes, List}:
 これはテーブル内のすべての項目のリストです。 デフォルトでは、 -record(TableName, {key,val}) の形式のレコードには [key, value] の形式をとります。 ほとんどの開発者は少しずるをして、特別な構造(実際はコンパイラがサポートするマクロ)を使います。このマクロはレコードから要素名を抜き出します。 この構造は関数呼び出しに似ています。 これを私たちのfriendレコードで使うためには、 {attributes, record_info(fields, mafiapp_friends)} という形で渡します。
{disc_copies, NodeList},:
 
{disc_only_copies, NodeList},:
 
{ram_copies, NodeList}:
 これは、 レコードからテーブルへ の章で説明されているように、どこにテーブルを保存するかを指定します。 例として、これら3つのオプションを使って、テーブルXをマスターノード上でディスクとRAM上に保存されるようにし、スレーブ上ではRAMにのみ保存され、バックアップ専用ノードにはディスクにのみ保存されるように定義できます。
{index, ListOfIntegers}:
 MnesiaテーブルではETSとDETSの基本的な機能に加えて、インデックスを持つことができます。 これは主キー以外のレコードフィールドで検索をしようと思っているときに便利です。 例として、私たちのfriendsテーブルではexpertiseフィールドにインデックスが必要になるでしょう。 このようなインデックスを {index, [#mafiapp_friends.expertise]} という具合に宣言できます。 一般的に、そして非常に多くのデータベースであてはまることですが、ほとんどのエントリで似た値をあまり持っていないフィールドにだけインデックスを張るようにしたほうがいいでしょう。 大量のエントリをもったテーブルでは、インデックスが多くてもテーブルを2つのグループにしか別けない場合、インデックスは大量に生成されていますが、利益はほとんどありません。 たとえば、同じテーブルを10以下の要素を N グループに分けるようなインデックスの場合には、リソースをより有効に使っていると言えるでしょう。 レコードの最初のフィールドには、デフォルトでインデックスが張られるため、改めてそれを行う必要がないということに留意して下さい。
{record_name, Atom}:
 これは、レコードが使っている名前以外の別の名前をテーブルに付けたいときに便利です。 しかしながら、これをおこなうと、テーブルを操作する際に、通常使うものとは異なる関数を使わなければならならなくなります。 本当にこれが必要になるとき以外は、このオプションを使うことはおすすめしません。
{type, Type}:Typesetordered_setbag のいずれかです。 これは先の レコードからテーブルへ で説明したものと同じです。
{local_content, true | false}:
 デフォルトでは、すべてのMnesiaテーブルはこのオプションを false に設定しています。 スキーマの一部になっているすべてのノード上でテーブルをそのデータをレプリケートしたい場合には、 false のままにしておいて下さい。( disc_copiesdisc_only_copiesram_copies のオプションが指定されている場合) このオプションを true にすると、すべてのノードにすべてのテーブルを作成しますが、コンテンツはローカルのものだけとなります。なにも共有されません。 この場合、Mnesiaは多くのノード上に空のテーブルを同様に初期化するエンジンになります。

簡単に言うと、これらがMnesiaスキーマとテーブルを設定する際に起こる一連のイベントです:

  • 初めてMnesiaを起動するとメモリ上にスキーマが作成されます。これは ram_copies には良いことです。 ほかのテーブルではうまく動作しません。
  • Mnesiaを起動する前(あるいは停止した後)にスキーマを手動で作成する場合は、ディスク上にあるテーブルを作成する事ができます。
  • Mnesiaを起動して、テーブルを作成し始められます。 テーブルはMnesiaが稼働していない間には作成できません。

Note

3つ目の方法があります。 稼働しているMnesiaノードとディスク上に移したいテーブルがあるときにはいつでも、 mnesia:change_table_copy_type(Table, Node, NewType) 関数を呼び出すことでテーブルをディスク上に移すことができます。

取り分け、スキーマをディスク上に作成し忘れた場合は、 mnesia:change_table_copy_type(schema, node(), disc_copies) を呼ぶことで、RAMスキーマを取り出して、ディスクスキーマに切り替えることができます。

これでテーブルとスキーマの作成に関して大まかな考え方を学びました。 ようやくMnesiaを使い始めるには十分な知識が得られたでしょう。

32.5. 実際にテーブルを作成する

アプリケーションとそれが使うテーブルの作成を、Common Testを使って、ちょっとしたTDD形式のプログラミングで行なって行きましょう。 TDDの考え方が嫌いかもしれませんが、私に付いてきて下さい。設計を行うためのガイドとして使うだけなので、気楽なやり方でやっていきましょう。 「テストを必ず失敗するように実行する」という類のものではありません。(もちろん、やりたければそのようにやってもらってかまいません) 最終的にテストを良い副作用にするだけで、テストをすることを目的としているわけではありません。 私たちが気にしているのは、Erlangシェルを使って確認をすることなく mafiapp のインターフェースをどのように定義するかというところです。 テストは分散すらしていませんが、それでもなおMnesiaの勉強をしながら、同時にCommon Testの実践的に使える貴重な機会でしょう。

そのために、 mafiapp-1.0.0 と名付けたディレクトリを次のような標準的なOTP構造にするところから始めましょう:

ebin/
logs/
src/
test/

まずはどのようにデータベースをインストールしたいのか、というところからはっきりさせましょう。 まず最初にスキーマとテーブルの初期化を行う必要があるので、install関数ですべてのテストを設定する必要があります。この関数は理想的にはCommon Testの priv_dir ディレクトリにインストールしてくれるでしょう。 test/ ディレクトリ下に保存される mafiapp_SUITE という基本的なテストスイートから始めましょう:

-module(mafiapp_SUITE).
-include_lib("common_test/include/ct.hrl").
-export([init_per_suite/1, end_per_suite/1,
         all/0]).
all() -> [].

init_per_suite(Config) ->
    Priv = ?config(priv_dir, Config),
    application:set_env(mnesia, dir, Priv),
    mafiapp:install([node()]),
    application:start(mnesia),
    application:start(mafiapp),
    Config.

end_per_suite(_Config) ->
    application:stop(mnesia),
    ok.

このテストスイートにはまだテストはありませんが、処理がどのように行われるべきかについて初めての仕様が書かれています。 まずどこにMnesiaスキーマとデータベースファイルを置くかを、 dir 変数の値を priv_dir に設定することで指定しています。 この設定によって、スキーマとデータベースの各インスタンスはCommon Testによって生成されたプライベートディレクトリに保存され、それ以前のテストが原因の問題やクラッシュが無いことを保証してくれます。 また、インストール関数を install と命名し、インストールするノードのリストを渡していることもお気づきでしょう。 リストをこのように渡すことは、より柔軟な方法なので、一般的には install 関数内にハードコードするよりも良い方法とされています。 この設定が終わったらMnesiaとmafiappを起動しましょう。

次は src/mafiapp.erl の中身を見て、install関数がどのように動作するか確認してみましょう。 まずはじめに、先ほどのレコード定義をここに持ってくる必要があります:

-module(mafiapp).
-export([install/1]).

-record(mafiapp_friends, {name,
                          contact=[],
                          info=[],
                          expertise}).
-record(mafiapp_services, {from,
                           to,
                           date,
                           description}).

良さそうですね。 次に install/1 関数です:

install(Nodes) ->
    ok = mnesia:create_schema(Nodes),
    application:start(mnesia),
    mnesia:create_table(mafiapp_friends,
                        [{attributes, record_info(fields, mafiapp_friends)},
                         {index, [#mafiapp_friends.expertise]},
                         {disc_copies, Nodes}]),
    mnesia:create_table(mafiapp_services,
                        [{attributes, record_info(fields, mafiapp_services)},
                         {index, [#mafiapp_services.to]},
                         {disc_copies, Nodes},
                         {type, bag}]),
    application:stop(mnesia).

まず、 Nodes のリスト内で指定されたノード上のスキーマを作成します。 その後、Mnesiaを起動します。これはテーブルを作成するのに必要な作業です。 いま、 #mafiapp_friends{}#mafiapp_services{} というレコード名にちなんだ、2つのテーブルを作ります。 先程も触れたように、必要になったときは得意なこと( expertise )を基準にして友達を探すので、 expertise にインデックスを張ります。

../_images/moneybag.png

また、サービスに関するテーブルの種類が bag であることもお分かりでしょう。 これは、同じ送り主、受け取り主が複数のサービスを施すことがありうるためです。 set のテーブルを使ってしまうと、1つの送り主しか扱えませんが、 bag のテーブルではこれをうまく処理できます。 また to のフィールドにもインデックスがあることにもお気づきでしょう。 これはサービスのテーブルを誰が受け取ったか、または誰が施したかで探し、インデックスを使うと検索が早くなることから行なっています。

最後に留意すべきなのは、テーブルを作った後にMnesiaを停止することです。 これはテストに書いたもの動作に合うようにするための処置です。 テスト内には想定されるコードの使われ方が書かれてあるので、コードをそれに沿うように書いたほうが良いでしょう。 しかしながら、インストール後にMnesiaを稼働させたままにしても特に問題はありません。

さて、Common Testのテストスイート内のテストケースがうまく書けたら、初期化の段階ではこの install 関数でうまくいくでしょう。 しかしながら、多くのノードで試してみると、Erlangシェルに失敗のメッセージが表示されます。 なぜか分かりますか。 その理由を図示してみました:

Node A                     Node B
------                     ------
create_schema -----------> create_schema
start Mnesia
creating table ----------> ???
creating table ----------> ???
stop Mnesia

テーブルが全ノードで作成されるためには、Mnesiaを全ノードで稼働させる必要があります。 スキーマを作成するのであれば、Mnesiaをノード上で稼働させる必要は全くありません。 理想的にはMnesiaの起動と停止をリモートで行いたいところです。 幸いにもそれは可能です。 Distribunomicon でRPCモジュールについてお話したのを覚えているでしょうか。 rpc:multicall(Nodes, Module, Function, Args) という関数でリモートで関数を呼ぶことができました。 では install/1 関数の定義を次のように変更してみましょう:

install(Nodes) ->
ok = mnesia:create_schema(Nodes),
rpc:multicall(Nodes, application, start, [mnesia]),
mnesia:create_table(mafiapp_friends,
                    [{attributes, record_info(fields, mafiapp_friends)},
                     {index, [#mafiapp_friends.expertise]},
                     {disc_copies, Nodes}]),
mnesia:create_table(mafiapp_services,
                    [{attributes, record_info(fields, mafiapp_services)},
                     {index, [#mafiapp_services.to]},
                     {disc_copies, Nodes},
                     {type, bag}]),
rpc:multicall(Nodes, application, stop, [mnesia]).

RPCを使うことで、Mnesiaを全ノードで稼働させることができました。 スキーマはこのようになっています:

Node A                     Node B
------                     ------
create_schema -----------> create_schema
start Mnesia ------------> start Mnesia
creating table ----------> replicating table
creating table ----------> replicating table
stop Mnesia -------------> stop Mnesia

いいですね、すごくいいです。

次に init_per_suite/1 関数で mafiapp を起動する部分に手を入れなければいけません。 正確に言えば、その必要はありません。なぜなら、私たちのアプリケーション税帯がMnesiaに依存しているからです。 つまり、Mnesiaを起動すれば、アプリケーションが起動するのです。 しかしながら、Mnesiaが起動してからすべてのテーブルをディスクから読み込み終えるまでの時間差は無視できません。特にテーブルが大きい時などは顕著です。 このような場合、 mafiappstart/2 関数などは、このような待機を行うには完璧な場所でしょう。たとえ、通常の操作においてはまったくプロセスが必要ない場合においてもです。

mafiapp.erl をアプリケーションビヘイビア( -behaviour(application). )として実装し、次の2つコールバックを書きます(エクスポートするのを忘れないで下さい):

start(normal, []) ->
    mafiapp_sup:start_link([mafiapp_friends,
                            mafiapp_services]).

stop(_) -> ok.

これだけではスーパバイザが何をすべきかがよくわかりませんが、ここで行なっていることは、関数に読み込み終えるのを待ちたいテーブルのリストを渡しているだけです。 実際にどのようにテーブルの読み込みを待つかを観るために、 mafiapp_sup という名前の新しいモジュールを作って、このスーパバイザを実装してみましょう:

-module(mafiapp_sup).
-behaviour(supervisor).
-export([start_link/1]).
-export([init/1]).

start_link(Tables) ->
    supervisor:start_link(?MODULE, Tables).

%% This does absolutely nothing, only there to wait for tables.
init(Tables) ->
    mnesia:wait_for_tables(Tables, 5000),
    {ok, {{one_for_one, 1, 1}, []}}.

ここでポイントとなっているのは mnesia:wait_for_tables(TableList, TimeOut) 関数です。 この関数は最大で5秒(5秒というのは任意なので、あなたの状況に合う数字に変更して下さい)、あるいはテーブルが読み込み終わるまで待機します。

通常はスーパバイザは何もしませんが、OTPプロセスの init の段階は同期なので、実際このような同期を行う場所としてはここが最適です。

最後に、次のような mafiapp.app ファイルを ebin/ ディレクトリに追加して、アプリケーションがきちんと起動するようにしましょう:

{application, mafiapp,
 [{description, "Help the boss keep track of his friends"},
  {vsn, "1.0.0"},
  {modules, [mafiapp, mafiapp_sup]},
  {applications, [stdlib, kernel, mnesia]}]}.

これで実際にテストやアプリケーションを書く準備ができました。 しかし、本当にそうでしょうか。

32.6. アクセスとコンテキスト

アプリケーションの実装を始める前に、テーブルへの操作をするためにMnesiaを使い方を知るべきでしょう。

データベースへのあらゆる変更、あるいは読込ですら アクティビティアクセスコンテキスト と呼ばれるものの中で行われる必要があります。 これらは種類の異なるトランザクション、あるいはクエリの「実行方法」です。 次のようなオプションがあります:

32.6.1. transaction

Mnesiaのトランザクションでは、データベースへの操作を1つの関数ブロックとして実行することができます。 ブロック全体が全ノード上で行われるか、あるいは全く行われないかのどちらかです。つまりブロック全体が成功するか、あるいはブロック全体が失敗するかのどちらかです。 トランザクションから戻ってきたら、テーブルのデータは一貫したものになっていると保証され、異なるトランザクションは、たとえ同じデータへの操作を行おうとしていても、お互いに干渉しません。

この種のアクティビティコンテキストは、部分的に非同期です。 ローカルノードにおいては操作は同期ですが、他のノードがトランザクションを終了したかではなく、コミットを行ったことのみを待機します。 Mnesiaの動作においては、トランザクションがローカルで行われ、他のノードがそれに同意すれば、これでうまくいくのです。 ネットワークやハードウェアの障害によってうまく行かなければ、トランザクションは後ほど前の状態に戻されます。 効率化のためにMnesiaのプロトコルはこの動作を許容していますが、あとでロールバックされたときにトランザクションは成功したと立証してくれます。

32.6.2. sync_transaction

このアクティビティコンテキストは transaction とほぼ同じですが、同期という点が異なります。 おかしなエラーによって失敗しているかもしれないのに、トランザクション自体は成功したと通知してくるような方法が気に入らない場合、特に成功すると(外部サービスに通知する、プロセスを生成するといった)副作用が起こるようなことをしたい場合に、トランザクションの保証として十分でないと思のであれば、 sync_transaction で求めている挙動となります。 同期トランザクションは、通知を戻す前に、他の全ノードが確実にすべてが完了したという、最終確認をするまで待機します。

面白いユースケースとしては、多くのトランザクションをしていて、他のノードに過負荷をかけてしまいそうなときに、同期モードに切り替えることで、ブロックとなる処理をあまり増やさずに処理を遅くし、アプリケーション内の負荷を低減することができます。

32.6.3. async_dirty

async_dirty というアクティビティコンテキストは、基本的にはすべてのトランザクションプロトコルを無視し、アクティビティをロックします。(しかし、処理を進める前に動作しているトランザクションは待機することに注意しましょう) しかしながら、ログ、レプリケーションといった処理はすべて継続します。 async_dirty アクティビティコンテキストは、すべての処理をローカルで試みてから処理を戻します。他ノードのレプリケーションは非同期に作動します。

32.6.4. sync_dirty

このアクティビティコンテキストは、 transaction に対する sync_transaction と同様の関係で、 async_dirty に対してあるものです。 これはリモートノードで処理がきちんと行われたという確認を待ちますが、ロックしているあるいはトランザクション中のコンテキストは巻き込まないようにします。 ダーティコンテキストは一般的にトランザクションよりも速いですが、その設計からして完全によりリスクが高いです。 用心して使いましょう。

32.6.5. ets

利用できる最後のアクティビティコンテキストは ets です。 これは基本的には、Mnesiaが行うことをすべて無視して、可能であれば、基になっているETSテーブル上で一連の生の操作を行う方法です。 レプリケーションは全く行われません。 ets アクティビティコンテキストは通常は使う必要がなく、したがって使たいとすら思うべきではありません。 「疑わしいなら、使うな。必要な時がいずれ来る。」という他の事例です。

Mnesiaの操作を実行できるコンテキストはこれですべてです。 これらの操作は、無名関数 fun にラップされ、 mnesia:activity(Context, Fun). と呼び出すことで実行できます。 この無名関数 fun はどのようなErlangの関数呼び出しを入れることができますが、トランザクションが失敗したり、他のトランザクションに邪魔されたりした場合には何度も呼び出される可能性があることに注意しましょう。

つまり、もしテーブルから値を読み込むようなトランザクションが、何かを書き込む前にメッセージを送ることもするとしたら、メッセージが何十回も送られる可能性も十分あり得るということです。 こういったことから、トランザクション内では副作用のない処理が行われるべきです。

../_images/guestbook.png

32.7. 読み、書き、Mnesia

これまで、テーブルを変更する関数をたくさん紹介してきました。そろそろその定義を確認しましょう。 ほとんどはETSやDETSが提供するものと似ています。

32.7.1. write

mnesia:write(Record) を呼ぶと、 Record とテーブルに挿入することができます。ここでテーブル名はレコード名と同じものです。 テーブルが setordered_set で、主キー(レコードの2番目のフィールド、レコード名ではなく、タプルの形式)が存在した場合には、その要素は置き換えられます。 bag のテーブルでは、レコード全体が同様である必要があります。

writeの操作が成功したら、 write/1ok を返します。 失敗した場合には、トランザクションを中止する例外を投げます。 例外が投げられることはあまりありません。 この例外が投げられるのはたいていMnesiaが稼働していないとき、テーブルが見つからないとき、あるいはレコードが正しくないときです。

32.7.2. delete

mnesia:delete(TableName, Key) という関数を使います。 主キーを共有するレコードはテーブルから削除されます。 mnesia:write/1 と同様のセマンティクスで、処理が終了するときは ok を返すか、例外を投げます。

32.7.3. read

mnesia:read({TableName, Key}) という関数で処理を行います。この関数は主キーが Key に一致するレコードのリストを返します。 ets:lookup/2 と同様に、常にリストを返し、 set のテーブルのように主キーに一致する結果が絶対に1つしかないようなテーブルでもリストを返します。 レコードが見つからない場合は、空のリストが返されます。 deleteやwriteの操作と同様に、失敗した場合には例外が投げられます。

32.7.4. match_object

この関数はETSの match_object 関数に似ています。 マッチを擦る で説明したようなパターンを使って、データベースのテーブルからレコード全体を返します。 たとえば、ある特技を持った友達を素早く探すには、 mnesia:match_object(#mafiapp_friends{_ = _', expertise = given}) と呼べばいいでしょう。 そうするとテーブル内で一致するエントリがすべてリストの形式で返ってきます。 再度になりますが、失敗した場合には例外が投げられます。

32.7.5. select

これはETSの select 関数に似ています。 マッチスペックを使うか、検索を行うために ets:fun2ms を使ってこの関数を使います。 ETSの select 関数の使い方を忘れてしまっていたら、 あなたは選ばれました を見返して、マッチスペックをうまく使えるようになることをおすすめします。 Mnesiaでのselectは mnesia:select(TableName, MatchSpec) を呼ぶことで行なえ、マッチスペックに一致するすべての要素をリストの形式で返します。 また再度になりますが、失敗した場合には、例外が投げられます。

32.7.6. 他の操作

Mnesiaテーブルには他にもたくさんの操作が行えますが、いま紹介した操作がこれから先を学ぶ上で、一番の基礎となります。 他の操作を知りたい場合には、 Mnesiaのリファレンスマニュアル で探してみましょう。たとえばイテレーションには firstlastnextprev といった操作が、テーブル全体に対する畳込みに関しては foldlfoldr が、あるいは他にもテーブル自体を操作する transform_table (特にレコードやテーブルでフィールドを追加あるいは削除したいときに便利)や add_table_index などがあります。

これでたくさんの関数が作れます。 実際にこれらの使い方を、少しテストを書いて確認してみましょう。

32.8. 初めてのリクエストを実装する

リクエストを実装するために、まずアプリケーションに期待する動作を確認する簡単なテストを書きましょう。 このテストは、サービスを追加を行うものですが、それ以外の機能も裏で確認します:

[...]
-export([init_per_suite/1, end_per_suite/1,
         init_per_testcase/2, end_per_testcase/2,
         all/0]).
-export([add_service/1]).

all() -> [add_service].
[...]

init_per_testcase(add_service, Config) ->
    Config.

end_per_testcase(_, _Config) ->
    ok.

ここで行なっていることはたいていのCommon Testのテストスイートで追加する必要のある、標準的な初期化の手続きです。 ではテスト自体を書いてみましょう:

%% services can go both way: from a friend to the boss, or
%% from the boss to a friend! A boss friend is required!
add_service(_Config) ->
    {error, unknown_friend} = mafiapp:add_service("from name",
                                                  "to name",
                                                  {1946,5,23},
                                                  "a fake service"),
    ok = mafiapp:add_friend("Don Corleone", [], [boss], boss),
    ok = mafiapp:add_friend("Alan Parsons",
                            [{twitter,"@ArtScienceSound"}],
                            [{born, {1948,12,20}},
                             musician, 'audio engineer',
                             producer, "has projects"],
                             mixing),
    ok = mafiapp:add_service("Alan Parsons", "Don Corleone",
                             {1973,3,1},
                             "Helped release a Pink Floyd album").

サービスを追加しているので、やり取りの相手となる友達を追加すべきでしょう。 mafiapp:add_friend(Name, Contact, Info, Expertise) という関数がこの処理を行います。 友達が追加されたら、ようやく実際にサービスを追加できます。

Note

これまでに他のMnesiaのチュートリアルを読んだことがあれば、関数の中で直接レコードを扱いたがっている例も見たかもしれません。(たとえば mafiapp:add_friend(#mafiapp_friend{name=...}) という具合です。) このような例は、レコードはプライベートとして扱われるべきだという意思のもと、本書では積極的に避けている方法です。 レコードを変更する実装により、それを元にするレコードの表現を破壊してしまうかもしれません。 この事自体は問題ではないのですが、レコードの定義を変更すると、再コンパイルが必要になり、可能であれば、そのレコードを使っているモジュールすべてが、稼働中のアプリケーション内で動作し続けられるように、それらをアトミックに更新する必要があるでしょう。

レコードの変更のような操作を関数でラップすることでインターフェースが簡潔になり、レコードを .hrl ファイル経由でインクルードするために、あなたのデータベースやアプリケーションを使っているモジュールを必要がなくなります。これで苛立たしい作業から解放されます。

ここでお気づきかもしれませんが、ここで定義したテストではサービスを探していません。 その理由は、このアプリケーションでは、ユーザを探すときにサービスを探そうと考えているからです。 いまのところは、Mnesiaのトランザクションを使うために必要な機能をテストするものを実装しましょう。 mafiapp.erl に追加すべき最初の関数は、データベースにユーザを追加する関数です:

add_friend(Name, Contact, Info, Expertise) ->
    F = fun() ->
        mnesia:write(#mafiapp_friends{name=Name,
                                      contact=Contact,
                                      info=Info,
                                      expertise=Expertise})
    end,
    mnesia:activity(transaction, F).

#mafiapp_friends{} というレコードを書き込む関数を1つ定義しました。 これは簡単なトランザクションです。 add_services/4 はもう少し複雑になります:

add_service(From, To, Date, Description) ->
    F = fun() ->
            case mnesia:read({mafiapp_friends, From}) =:= [] orelse
                 mnesia:read({mafiapp_friends, To}) =:= [] of
                true ->
                    {error, unknown_friend};
                false ->
                    mnesia:write(#mafiapp_services{from=From,
                                                   to=To,
                                                   date=Date,
                                                   description=Description})
             end
    end,
    mnesia:activity(transaction,F).

トランザクション内で、まず1つまたは2つの読込処理を行なって、追加しようとしている友達がデータベース内にあるかを確認している事が分かるでしょう。 どちらかがデータベースになければ、テストスペックにしたがって {error, unknown_friend} が返されます。 両方ともデータベースにあれば、データベースにサービスを書き込みます。

Note

入力の検証はあなたの裁量に任されています。 検証をするといっても、Erlangで他のプログラムを書くときと同様にErlangのコード書くだけです。 トランザクションコンテキストの外側で、できる限り可能な限り検証を行うことは良い考えです。 トランザクション内のコードは何度も呼び出される可能性があり、データベースリソースを奪ってしまう可能性があります。 トランザクション内ではコードを小さくするのが賢明でしょう。

これらのコードから、最初のテストバッチを走らせることができます。 私は次のようなテストスペック mafiapp.spec を使います。(プロジェクトルートに置いて下さい):

{alias, root, "./test/"}.
{logdir, "./logs/"}.
{suites, root, all}.

次のようなEmakefileも使います。(これもプロジェクトルートに置いて下さい):

{["src/*", "test/*"],
 [{i,"include"}, {outdir, "ebin"}]}.

テストを実行します:

$ erl -make
Recompile: src/mafiapp_sup
Recompile: src/mafiapp
$ ct_run -pa ebin/ -spec mafiapp.spec
...
Common Test: Running make in test directories...
Recompile: mafiapp_SUITE
...
Testing learn-you-some-erlang.wiptests: Starting test, 1 test cases
...
Testing learn-you-some-erlang.wiptests: TEST COMPLETE, 1 ok, 0 failed of 1 test cases
...

いいですね、テストを通過しました。 次のテストに移りましょう。

Note

Common Testのテストスイートを実行するときに、ディレクトリが見つからない等のエラーが出ることがあります。 ct_run -pa ebin/erl -name ct -pa \`pwd\`/ebin (あるいはフルパス)を使うことで解決できます。 Erlangシェルではカレントディレクトリをノードのカレントディレクトリにしますが、 ct:run_test/1 を呼ぶと、カレントディレクトリを変更してしまいます。 それが原因で ./ebin/ などの相対パスが解釈できなくなるわけです。 問題を解決するためには絶対パスを使いましょう。

add_service/1 のテストでは友達とサービスの両方を追加できました。 次のテストでは、参照ができるようにしましょう。 簡潔さのために、これから先のテストケースすべてにボスを追加します:

init_per_testcase(add_service, Config) ->
    Config;
init_per_testcase(_, Config) ->
    ok = mafiapp:add_friend("Don Corleone", [], [boss], boss),
    Config.

重点的にテストしたいのは、友達を名前で検索するというユースケースです。 サービスのみで検索するのは非常にうまくいきましたが、実用では人の検索は行動履歴よりも名前で行いたいですよね。 ボスも「誰が誰にギターを配達したか、もう一度言ってみろ」とは言わないでしょう。 「誰が俺の友達のPete Cityshendにギターを配達したんだ」と訊くほうが自然ですし、サービスの詳細を探すためにの名前で履歴を検索しようとするでしょう。

したがって、次のテストは friend_by_name/1 となります:

-export([add_service/1, friend_by_name/1]).

all() -> [add_service, friend_by_name].
...
friend_by_name(_Config) ->
    ok = mafiapp:add_friend("Pete Cityshend",
                            [{phone, "418-542-3000"},
                             {email, "quadrophonia@example.org"},
                             {other, "yell real loud"}],
                            [{born, {1945,5,19}},
                             musician, popular],
                            music),
{"Pete Cityshend",
 _Contact, _Info, music,
 _Services} = mafiapp:friend_by_name("Pete Cityshend"),
undefined = mafiapp:friend_by_name(make_ref()).

このテストでは、友人をデータベースに挿入して、検索し、さらにそんな名前の友人がいなかった場合に何が返されるべきかを検証します。 あらゆる詳細を返すタプル構造を用意して、例えばサービスなんかもそれに含まれます。いまは人を検索したいだけなので、サービス等の内容は無視しますが、テストをより厳密にするためにデータの複製はします。

mafiapp:friend_by_name/1 の実装はMnesiaへの読込を1度行うだけで完了します。 #mafiapp_friends{} のレコード定義では、友人の名前をテーブル(レコードで最初に定義されたもの)の主キーとします。 mnesia:read({Table, Key}) を使うことで、実装を簡単に、かつテストを行う上で最小限のラップで済みます:

friend_by_name(Name) ->
    F = fun() ->
        case mnesia:read({mafiapp_friends, Name}) of
            [#mafiapp_friends{contact=C, info=I, expertise=E}] ->
                {Name,C,I,E,find_services(Name)};
            [] ->
                undefined
        end
    end,
    mnesia:activity(transaction, F).

この関数だけで、エクスポートすることを忘れさえしなければ、テストを通すことは可能です。 いまは find_services(Name) については気にしていないので、スタブだけ用意しておきます:

%%% PRIVATE FUNCTIONS
find_services(_Name) -> undefined.

さて、これが終わったら、新しいテストは通るでしょう:

$ erl -make
...
$ ct_run -pa ebin/ -spec mafiapp.spec
...
Testing learn-you-some-erlang.wiptests: TEST COMPLETE, 2 ok, 0 failed of 2 test cases
...

リクエストでサービスに関する詳細をもう少し詰めてもいいでしょう。 そのテストを次のように書いてみました:

-export([add_service/1, friend_by_name/1, friend_with_services/1]).

all() -> [add_service, friend_by_name, friend_with_services].
...
friend_with_services(_Config) ->
    ok = mafiapp:add_friend("Someone", [{other, "at the fruit stand"}],
                            [weird, mysterious], shadiness),
    ok = mafiapp:add_service("Don Corleone", "Someone",
                             {1949,2,14}, "Increased business"),
    ok = mafiapp:add_service("Someone", "Don Corleone",
                             {1949,12,25}, "Gave a Christmas gift"),
    %% We don't care about the order. The test was made to fit
    %% whatever the functions returned.
    {"Someone",
     _Contact, _Info, shadiness,
     [{to, "Don Corleone", {1949,12,25}, "Gave a Christmas gift"},
      {from, "Don Corleone", {1949,2,14}, "Increased business"}]} =
    mafiapp:friend_by_name("Someone").

このテストでは、ドン・コルレオーネが果物屋の屋台にいる怪しい人物が商売をうまく行くように手伝いをしました。 この果物屋の屋台にいた怪しい人物は後にボスであり、施しを決して忘れることのない、ドン・コルレオーネにクリスマスプレゼントを渡しました。

ここで人を探すために friend_by_name/1 をまだ使っていることに気がつくでしょう。 テストは汎用的で、完成したとは言いがたいですが、一応やりたいことはできています。幸いにも、メンテナンス要件と言ったものはまったく決めていないので、このような不完全な状態でも問題ありません。

find_services/1 の実装はこれよりももう少し複雑になります。 friend_by_name/1 は主キーを検索するだけでできましたが、サービスの中では友人の名前は from フィールドで検索したときに主キーとなります。 まだ to フィールドの処理をする必要があります。 to フィールドを扱う方法はいくつかあって、たとえば match_object を何度も使ったり、テーブル全体を読み込んで手動でフィルターするなどの方法があります。 私はマッチスペックと ets:fun2ms/1 というパース変換を使うことにしました:

-include_lib("stdlib/include/ms_transform.hrl").
...
find_services(Name) ->
    Match = ets:fun2ms(
            fun(#mafiapp_services{from=From, to=To, date=D, description=Desc})
                when From =:= Name ->
                    {to, To, D, Desc};
               (#mafiapp_services{from=From, to=To, date=D, description=Desc})
                when To =:= Name ->
                    {from, From, D, Desc}
            end
    ),
    mnesia:select(mafiapp_services, Match).

このマッチスペックは2つの節からなります。 FromName に一致するときは {to, ToName, Date, Description} タプルを返します。逆に NameTo に一致するときは、 {from, FromName, Date, Description} を返します。これによって1つの操作で施したサービスと施されたサービスの両方を扱うことができます。

find_services/1 はトランザクションを扱っていないことに気がつくでしょう。 その理由は、この関数がトランザクション内で呼ばれる関数である friend_by_name/1 内で呼ばれているからです。 Mnesiaは入れ子のトランザクションを実行することもできますが、この例の場合はそれは無意味なので避けることにしました。

もう一度テストを実行してみると、実際にこれら3つのテストが動くことが分かるでしょう。

最後のユースケースは、友人を特技で検索することです。 たとえば、次のテストケースは、ある仕事で登るのが得意な人が必要なときに、私たちの友人であるレッドパンダをを見つけるか、という様子を示しています:

-export([add_service/1, friend_by_name/1, friend_with_services/1,
         friend_by_expertise/1]).

all() -> [add_service, friend_by_name, friend_with_services,
          friend_by_expertise].
...
friend_by_expertise(_Config) ->
    ok = mafiapp:add_friend("A Red Panda",
                            [{location, "in a zoo"}],
                            [animal,cute],
                            climbing),
    [{"A Red Panda",
      _Contact, _Info, climbing,
     _Services}] = mafiapp:friend_by_expertise(climbing),
    [] = mafiapp:friend_by_expertise(make_ref()).

これを実装するために、主キー以外のものが必要になるでしょう。 これにはマッチスペックを使うことができますが、すでにその方法は使いました。 加えて、1つのフィールドだけマッチさせればいいのです。 mnesia:match_object/1 関数はこの用途に適しています:

friend_by_expertise(Expertise) ->
    Pattern = #mafiapp_friends{_ = '_',
                               expertise = Expertise},
    F = fun() ->
            Res = mnesia:match_object(Pattern),
            [{Name,C,I,Expertise,find_services(Name)} ||
                #mafiapp_friends{name=Name,
                                 contact=C,
                                 info=I} <- Res]
    end,
    mnesia:activity(transaction, F).

この関数ではまずパターンを宣言しています。 すべての未定義値をmatch-allスペック( '_' )として宣言するために _ = '_' を使う必要があります。 そうしないと、 match_object/1 関数は特技以外の項目が undefined というアトムになっているエントリだけ探してしまいます。

結果が取得できたら、レコードをタプルに直して、テストを実施できるようにします。 再度、コンパイルしてテストを実行すると、この実装がきちんと動作することが分かります。 やりました、仕様を全部実装しました!

32.9. アカウントと新たなニーズ

ソフトウェアプロジェクトはまだ終わったわけではありません。 システムを使うユーザは、注目すべき新たな要望を出したり、あるいは予想外の方法でシステムを壊したりします。 ボスは、私たちの新しいソフトウェアプロジェクトを使い始めるずっと前から、友人すべてを素早く検索して、誰に借りがあって、誰に貸しがあるかを確認できる機能を求めていました。

そのテストが次のコードです:

...
init_per_testcase(accounts, Config) ->
ok = mafiapp:add_friend("Consigliere", [], [you], consigliere),
Config;
...
accounts(_Config) ->
ok = mafiapp:add_friend("Gill Bates", [{email, "ceo@macrohard.com"}],
                        [clever,rich], computers),
ok = mafiapp:add_service("Consigliere", "Gill Bates",
                         {1985,11,20}, "Bought 15 copies of software"),
ok = mafiapp:add_service("Gill Bates", "Consigliere",
                         {1986,8,17}, "Made computer faster"),
ok = mafiapp:add_friend("Pierre Gauthier", [{other, "city arena"}],
                        [{job, "sports team GM"}], sports),
ok = mafiapp:add_service("Pierre Gauthier", "Consigliere", {2009,6,30},
                         "Took on a huge, bad contract"),
ok = mafiapp:add_friend("Wayne Gretzky", [{other, "Canada"}],
                        [{born, {1961,1,26}}, "hockey legend"],
                        hockey),
ok = mafiapp:add_service("Consigliere", "Wayne Gretzky", {1964,1,26},
                         "Gave first pair of ice skates"),
%% Wayne Gretzky owes us something so the debt is negative
%% Gill Bates are equal
%% Gauthier is owed a service.
[{-1,"Wayne Gretzky"},
 {0,"Gill Bates"},
 {1,"Pierre Gauthier"}] = mafiapp:debts("Consigliere"),
[{1, "Consigliere"}] = mafiapp:debts("Wayne Gretzky").

ここでは、Gill Bates、Pierre Gauthier、そしてホッケーの殿堂入りしたWayne Gretzkyの3人の友人を追加しています。 あなた、相談役を含めた5人でそれぞれサービスのやり取りがあります。(前のテストでボスを使ってしまったので今回は使いません。ボスを使うと結果がおかしくなってしまいます!)

mafiapp:debts(Name) 関数は、名前を検索して、その人が関係するサービスをすべて数えます。 私たちが誰かに貸しがある場合、値は負になります。貸し借りのなしの時は 0 に、私たちが誰かに借りがある場合は値は 1 になります。 したがって、 debt/1 関数は各友人との貸し借りの数を返すと言えます。

この関数の実装は少々複雑です:

-export([install/1, add_friend/4, add_service/4, friend_by_name/1,
friend_by_expertise/1, debts/1]).
...
debts(Name) ->
    Match = ets:fun2ms(
            fun(#mafiapp_services{from=From, to=To}) when From =:= Name ->
                {To,-1};
                (#mafiapp_services{from=From, to=To}) when To =:= Name ->
                {From,1}
            end),
    F = fun() -> mnesia:select(mafiapp_services, Match) end,
    Dict = lists:foldl(fun({Person,N}, Dict) ->
                        dict:update(Person, fun(X) -> X + N end, N, Dict)
                       end,
                       dict:new(),
                       mnesia:activity(transaction, F)),
    lists:sort([{V,K} || {K,V} <- dict:to_list(Dict)]).

Mnesiaへのクエリが複雑になった場合は、通常はマッチスペックが解決の一助となります。 マッチスペックを使えば基本的なErlang関数を実行でき、特定の結果を返したいときにはすこぶる有益だと分かるでしょう。 いま実装したこの関数では、マッチスペックは Name の人からサービスを施されたときは値が -1 になるように使われています。(私たちがサービスを施したときは、友人が私たちに借りが1つできた状態になります) NameTo に一致するときは、 1 が返されます。(私たちがサービスを施されたときは、私たちの借りが1になります) 両方の場合において、値はその友人の名前を含むタプルが一対になります。

../_images/iou.png

名前は次ステップの計算において必要なので含めています。ここで、各人に施したサービスを数え、各人の一意な合計値を表示します。 再度になりますが、この処理を行う方法はたくさんあります。 私は、極力トランザクション内のコードを減らして、データベースとのやり取りがあるコードを減らす方法を選びました。 mafiapp程度では意味はありませんが、高パフォーマンスが必要な場合には、この方法は主にリソース競合を削減します。

とにかく、私が選択した方法はすべての値を持ってきて、辞書内に置き、辞書の dict:update(Key, Operation) 関数を使って、サービスが施されたのか施したのかを見て、値をインクリメントしたりデクリメントしたりします。 これをMnesiaより返された結果に対してfoldしたものに入れると、必要な値すべてがあるリストを取得できます。

最後のステップは、値をひっくり返して( {Key, Debt} から {Debt, Key} に変える)、これに基づいてソートするという作業です。 これで求めていた結果が取得できます。

32.10. ボスに会う

プロダクションレベルでは、ソフトウェア製品は少なくとも1度は実行されるべきです。 私たちはボスが使うノードを設定して、そのあとあなたが使うノードを設定することで、この作業を行います。

$ erl -name corleone -pa ebin/
$ erl -name genco -pa ebin/

両ノードが起動したら、それぞれにアクセスしてアプリケーションをインストールします:

(corleone@ferdmbp.local)1> net_kernel:connect('genco@ferdmbp.local').
true
(corleone@ferdmbp.local)2> mafiapp:install([node()|nodes()]).
{[ok,ok],[]}
(corleone@ferdmbp.local)3>
=INFO REPORT==== 8-Apr-2012::20:02:26 ===
    application: mnesia
    exited: stopped
    type: temporary

その後、 application:start(mnesia)application:start(mafiapp) を呼んで、MnesiaとMafiappを両ノードで起動します。 これが終わったら、 mnesia:system_info() を実行してきちんと動いていることを確認しましょう。うまく行っていれば、現在の状態を表示してくれます:

(genco@ferdmbp.local)2> mnesia:system_info().
===> System info in version "4.7", debug level = none <===
opt_disc. Directory "/Users/ferd/.../Mnesia.genco@ferdmbp.local" is used.
use fallback at restart = false
running db nodes   = ['corleone@ferdmbp.local','genco@ferdmbp.local']
stopped db nodes   = []
master node tables = []
remote             = []
ram_copies         = []
disc_copies        = [mafiapp_friends,mafiapp_services,schema]
disc_only_copies   = []
[{'corleone@...',disc_copies},{'genco@...',disc_copies}] = [schema,
                                                            mafiapp_friends,
                                                            mafiapp_services]
 5 transactions committed, 0 aborted, 0 restarted, 2 logged to disc
 0 held locks, 0 in queue; 0 local transactions, 0 remote
 0 transactions waits for other nodes: []
yes

両ノードがデータベースノードを実行していることが確認できます。テーブルやスキーマはディスクとRAM( disc_copies )ないに書き込まれています。 データベースからデータの読み書きをしてみましょう。 もちろん、ドンに関する情報からデータベースに追加していくのがいいでしょう:

(corleone@ferdmbp.local)4> ok = mafiapp:add_friend("Don Corleone", [], [boss], boss).
ok
(corleone@ferdmbp.local)5> mafiapp:add_friend(
(corleone@ferdmbp.local)5>    "Albert Einstein",
(corleone@ferdmbp.local)5>    [{city, "Princeton, New Jersey, USA"}],
(corleone@ferdmbp.local)5>    [physicist, savant,
(corleone@ferdmbp.local)5>        [{awards, [{1921, "Nobel Prize"}]}]],
(corleone@ferdmbp.local)5>    physicist).
ok

さて、これで corleone ノードから友人が追加されました。 サービスを genco ノードから追加してみましょう:

(genco@ferdmbp.local)3> mafiapp:add_service("Don Corleone",
(genco@ferdmbp.local)3>                     "Albert Einstein",
(genco@ferdmbp.local)3>                     {1905, '?', '?'},
(genco@ferdmbp.local)3>                     "Added the square to E = MC").
ok
(genco@ferdmbp.local)4> mafiapp:debts("Albert Einstein").
[{1,"Don Corleone"}]

これらの変更は corleone ノードにも反映されています:

(corleone@ferdmbp.local)6> mafiapp:friend_by_expertise(physicist).
[{"Albert Einstein",
  [{city,"Princeton, New Jersey, USA"}],
  [physicist,savant,[{awards,[{1921,"Nobel Prize"}]}]],
  physicist,
  [{from,"Don Corleone",
         {1905,'?','?'},
         "Added the square to E = MC"}]}]

すばらしい! ここで、もし片方のノードを落として再起動しても、きちんと動いていることが確認できます:

(corleone@ferdmbp.local)7> init:stop().
ok

$ erl -name corleone -pa ebin
...
(corleone@ferdmbp.local)1> net_kernel:connect('genco@ferdmbp.local').
true
(corleone@ferdmbp.local)2> application:start(mnesia), application:start(mafiapp).
ok
(corleone@ferdmbp.local)3> mafiapp:friend_by_expertise(physicist).
[{"Albert Einstein",
...
         "Added the square to E = MC"}]}]

素敵だと思いませんか。 もうこれでMnesiaについては詳しくなりましたね!

Note

テーブルがごちゃごちゃになったシステムを扱わなければいけなくなったり、あるいは単にテーブルの全体像を見てみたくなった場合は、 tv:start() 関数を実行してみましょう。 この関数はグラフィカルなテーブルビューアを起動して、コード越しではなく、視覚的にテーブルを操作することができます。

32.11. 削除して、証明された

ちょっと待って下さい。 データベースからレコードを 削除 に関する話をまるっと飛ばしてしまいました。 これはいけません! 早速それに必要なテーブルを追加しましょう。

これを行うために、ボスとあなた向けにちょっとした機能を追加します。この機能で個人的な理由による個人的な敵を保存することができます:

-record(mafiapp_enemies, {name,
                          info=[]}).

これは個人的な敵なので、テーブルのインストールはこれまでと少々異なる設定で、 local_content というオプションを使ってインストールを行う必要があります。 このオプションはテーブルを各ノード上でプライベートにすることができ、そのためうっかり他人の個人的な敵が見えてしまうことはありません。(RPCを使えばこれは取るに足らず回避できてしまいますが)

ここで、mafiappの start/2 関数に先行して読み込まれる、新しいテーブル向けに変更されたインストール関数を載せます:

start(normal, []) ->
    mafiapp_sup:start_link([mafiapp_friends,
                            mafiapp_services,
                            mafiapp_enemies]).
...
install(Nodes) ->
    ok = mnesia:create_schema(Nodes),
    application:start(mnesia),
    mnesia:create_table(mafiapp_friends,
                        [{attributes, record_info(fields, mafiapp_friends)},
                         {index, [#mafiapp_friends.expertise]},
                         {disc_copies, Nodes}]),
    mnesia:create_table(mafiapp_services,
                        [{attributes, record_info(fields, mafiapp_services)},
                         {index, [#mafiapp_services.to]},
                         {disc_copies, Nodes},
                         {type, bag}]),
    mnesia:create_table(mafiapp_enemies,
                        [{attributes, record_info(fields, mafiapp_enemies)},
                         {disc_copies, Nodes},
                         {local_content, true}]),
    application:stop(mnesia).

start/2 関数は、すべて生かしておくために mafiapp_enemies をスーパバイザ経由で送信します。 install/1 関数はテストや新たにインストールするときに便利ですが、プロダクション環境では、テーブルを追加するときには mnesia:create_table/2 を直接呼びましょう。 しかし、システムの負荷と稼働しているノードの数に応じて、ステージング環境でまず数回練習したくはなるでしょう。

とにかく、これが終わったら、簡単なテストを書いて、データベースときちんとやり取りができているかを確認してみましょう。 mafiapp_SUITE に書きます:

...
-export([add_service/1, friend_by_name/1, friend_by_expertise/1,
         friend_with_services/1, accounts/1, enemies/1]).

all() -> [add_service, friend_by_name, friend_by_expertise,
          friend_with_services, accounts, enemies].
...
enemies(_Config) ->
    undefined = mafiapp:find_enemy("Edward"),
    ok = mafiapp:add_enemy("Edward", [{bio, "Vampire"},
                                  {comment, "He sucks (blood)"}]),
    {"Edward", [{bio, "Vampire"},
                {comment, "He sucks (blood)"}]} =
       mafiapp:find_enemy("Edward"),
    ok = mafiapp:enemy_killed("Edward"),
    undefined = mafiapp:find_enemy("Edward").

これは add_enemy/2find_enemy/1 にとって、これまで実行してきたテストとよく似た流れになっています。 ここで必要なのは前者では基本的な挿入の処理を行なって、後者では主キーに基づいて mnesia:read/1 を行うだけです:

add_enemy(Name, Info) ->
    F = fun() -> mnesia:write(#mafiapp_enemies{name=Name, info=Info}) end,
    mnesia:activity(transaction, F).

find_enemy(Name) ->
    F = fun() -> mnesia:read({mafiapp_enemies, Name}) end,
    case mnesia:activity(transaction, F) of
        [] -> undefined;
        [#mafiapp_enemies{name=N, info=I}] -> {N,I}
    end.

enemy_killed/1 関数は少々趣が異なります:

enemy_killed(Name) ->
    F = fun() -> mnesia:delete({mafiapp_enemies, Name}) end,
    mnesia:activity(transaction, F).

これで基本的な削除に関する処理は終わりです。 関数をエクスポートして、ストスイートを実行して、すべてのテストが無事に通っていることを確認できるでしょう。

2つのノード上で試しているとき(先ほどのスキーマを削除した後、あるいはもしかしたら単に create_table 関数を呼んだ後)、テーブル間でデータが共有されていないことが確認できると思います:

$ erl -name corleone -pa ebin
$ erl -name genco -pa ebin

ノードを起動した後、データベースを再インストールします:

(corleone@ferdmbp.local)1> net_kernel:connect('genco@ferdmbp.local').
true
(corleone@ferdmbp.local)2> mafiapp:install([node()|nodes()]).

=INFO REPORT==== 8-Apr-2012::21:21:47 ===
...
{[ok,ok],[]}

アプリケーションを稼働させます:

(genco@ferdmbp.local)1> application:start(mnesia), application:start(mafiapp).
ok
(corleone@ferdmbp.local)3> application:start(mnesia), application:start(mafiapp).
ok
(corleone@ferdmbp.local)4> mafiapp:add_enemy("Some Guy", "Disrespected his family").
ok
(corleone@ferdmbp.local)5> mafiapp:find_enemy("Some Guy").
{"Some Guy","Disrespected his family"}
(genco@ferdmbp.local)2> mafiapp:find_enemy("Some Guy").
undefined

見ての通り、データは1つも共有されていません。 エントリの削除も簡潔です:

(corleone@ferdmbp.local)6> mafiapp:enemy_killed("Some Guy").
ok
(corleone@ferdmbp.local)7> mafiapp:find_enemy("Some Guy").
undefined

ようやくここまでできました!

32.12. クエリリスト内包

もしあなたが「ふざけんなよ、Mnesiaのやり方が気に食わない」と思いながら、この章を黙々と読んできたのであれば(あるいは最悪この節まで飛ばしてきたのであれば!)、この節は気に入ると思います。 ここまで読んでMnesiaのことを好きになった人も、この節は気に入るでしょう。 そして、リスト内包が好きな人であれば、確実にこの節を気に入るでしょう。

クエリリスト内包は、基本的にはパース変換を使ったコンパイラトリックで、リスト内包を検索やイテレーションができるデータ構造に使う方法です。 これはMnesia、DETS、ETSで実装されていますが、 gb_trees のようなものにも実装可能です。

モジュールに -include_lib("stdlib/include/qlc.hrl"). を追加すると、ジェネレータとして クエリハンドル と呼ばれるものと一緒にリスト内包を使うことができるようになります。 クエリハンドルは、イテレーション可能なデータ構造を、クエリリスト内包で扱えるようにするものです。 Mnesiaの場合、 mnesia:table(TableName) をリスト内包ジェネレータとして使って、そのあと``qlc:q(...)`` とラップして呼び出すことで、あらゆるデータベーステーブルをリスト内包を使って検索することができるようになります。

これは返り値として修正済みクエリハンドルを返します。これは普通にテーブルが返す値よりも詳細です。 クエリハンドルは qlc:sort/1-2 といった関数を使って修正したり、 qlc:eval/1qlc:fold/1 を使って評価したりできます。

早速実際に使ってみましょう。 mafiappの関数をいくつか書きなおしてみます。 mafiapp-1.0.0 をコピーして、 mafiapp-1.0.1 とします。( .app ファイル内のバージョンを挙げるのを忘れないように)

最初に書きなおす関数は friend_by_expertise です。 これは今のところ mnesia:match_object/1 を使って実装しています。 これをクエリリスト内包を使って書き直したのがこれです:

friend_by_expertise(Expertise) ->
    F = fun() ->
        qlc:eval(qlc:q(
            [{Name,C,I,E,find_services(Name)} ||
             #mafiapp_friends{name=Name,
                              contact=C,
                              info=I,
                              expertise=E} <- mnesia:table(mafiapp_friends),
             E =:= Expertise]))
    end,
    mnesia:activity(transaction, F).

qlc:eval/1qlc:q/1 を呼ぶ部分以外は、普通のリスト内包だと気がつくでしょう。 最終的な表記である {Name,C,I,E,find_services(Name)}#mafiapp{...} <- mnesia:table(...) 内のジェネレータ、そして条件である E =:= Expertise があります。 これで、データベーステーブルの検索は、いくらか自然に、Erlang風になりました。

これでクエリリスト内包についてはおしまいです。 本当です。 しかし、もう少し複雑な例に挑戦してみたいと思います。 debts/1 関数を見てみましょう。 これはマッチスペックを使った後に、辞書にfoldをかけていました。 クエリリスト内包を使うとどうなるか見てみましょう:

debts(Name) ->
F = fun() ->
    QH = qlc:q(
        [if Name =:= To -> {From,1};
            Name =:= From -> {To,-1}
         end || #mafiapp_services{from=From, to=To} <-
                  mnesia:table(mafiapp_services),
                Name =:= To orelse Name =:= From]),
    qlc:fold(fun({Person,N}, Dict) ->
              dict:update(Person, fun(X) -> X + N end, N, Dict)
             end,
             dict:new(),
             QH)
end,
lists:sort([{V,K} || {K,V} <- dict:to_list(mnesia:activity(transaction, F))]).

マッチスペックはもう必要ありません。 リスト内包(クエリハンドルに保存されています)がその機能を担います。 foldはトランザクション内に移され、クエリハンドルの評価をする方法として使われています。 結果として返される辞書は、先の実装で lists:foldl/3 が返していたものと同じです。 最後のソートの部分ですが、これはトランザクションの外で扱われ、 mnesia:activity/1 が返すあらゆる辞書を受け取って、リストに変換しています。

簡単でしょう。 これらの関数をあなたの mafiapp-1.0.1 アプリケーションで再実装して、テストスイートを実行しても、6つのテストはすべて通るはずです。

../_images/trace.png

32.13. Mnesiaを忘れるな

これでMnesiaについてはおしまいです。 Mnesiaはかなり複雑なデータベースで、ここで見たのはほんの基本だけでした。 より応用的なことをしたいのであれば、Erlangのマニュアルを読んで、コードを深く読んでいく必要があります。 何年も稼働し続けるような、Mnesiaを使った大きくスケーラブルなシステムを本当のプロダクション環境で使った経験のあるプログラマは稀です。 そういった方々はメーリングリスト上で見つけることができ、時々いくつか質問にも答えてくれますが、そういう人はたいてい忙しい人々です。

そんな環境でなくても、ストレージ層を選定するのが面倒な小さなアプリケーションや、先に例として挙げたようなノードの数が予めわかっているような大きめのアプリケーションでは、Mnesiaはとても良いツールです。 Erlang項を直接保存してレプリケートできるということは非常に素敵な機能です。–他の言語では何年もORマッパーを使って書こうとしていた機能ですから。

面白いことに、SQLデータベースや他のイテレーション可能なストレージ向けのクエリリスト内包のセレクタを書くことに専念できるということです。

Mnesiaやそのツールチェーンはこれから先に作るであろうアプリケーションで、非常に便利な機能を有しています。 けれどもいまのところは、Erlangシステムを開発する上で助けとなるDialyzerとTypErというさらなるツールの話に移りましょう。