Mnesiaと記憶の技術

あなたは、多くの友人を持つ男性の親友です。その中には、あなたのように長年付き合っている友人たちもいます。彼らはシチリアからニューヨークまで、世界中から集まっています。友人たちは敬意を払い、あなたとあなたの友人を気にかけ、あなたたちも彼らを気にかけます。

A parody of 'The Godfather' logo instead saying 'The Codefather'

特別な状況では、あなた方が力と信頼の人物であるため、彼らは頼み事をします。彼らはあなたの親友なので、あなたはそれを引き受けます。しかし、友情には代償があります。実現した頼みごとはすべて記録され、将来のある時点で、あなたは恩返しを求めるかもしれませんし、求めないかもしれません。

あなたは常に約束を守り、信頼の柱です。だからこそ、彼らはあなたの友人をボス、あなたをコンシリエーレと呼び、あなたが最も尊敬されるマフィアファミリーの1つを率いているのです。

しかし、すべての友情を覚えているのは大変になり、勢力範囲が世界中に広がるにつれて、友人から何を借りているか、友人に何を借りているかを把握することがますます難しくなります。

あなたは有益な相談役であるため、様々な場所に秘密裏に保管されているメモからの伝統的なシステムを、Erlangを使用したものにアップグレードすることにします。

最初はETSとDETSテーブルを使用するのが完璧だと考えました。しかし、ボスから離れて海外旅行に出かけていると、同期を維持することがやや難しくなります。

ETSとDETSテーブルの上に複雑なレイヤーを作成して、すべてを管理することができます。それはできますが、人間である以上、間違いを犯してバグのあるソフトウェアを作成する可能性があります。友情が非常に重要であるため、そのような間違いは避けなければなりません。そこで、システムが正しく動作するようにする方法をオンラインで検索します。

そこで、このような問題を解決するために構築されたErlang分散データベースであるMnesiaを説明するこの章を読み始めます。

Mnesiaとは何か

Mnesiaは、ETSとDETSの上に構築されたレイヤーであり、これら2つのデータベースに多くの機能を追加します。それは主に、集中的に使用する場合、多くの開発者が自分で作成することになるようなものを含んでいます。機能としては、ETSとDETSの両方に自動的に書き込む機能、DETSの永続性とETSのパフォーマンスの両方を備える機能、またはデータベースを多くの異なるErlangノードに自動的に複製する機能などがあります。

もう一つ便利な機能として、トランザクションがあります。トランザクションとは基本的に、複数の操作を1つ以上のテーブルに対して、それらを実行するプロセスがテーブルにアクセスする唯一のプロセスであるかのように実行できることを意味します。これは、単一の単位として読み取りと書き込みを組み合わせた同時実行操作が必要になったときに非常に重要になります。一例として、データベースを読み込んでユーザー名が使用されているかどうかを確認し、空いている場合はユーザーを作成することが挙げられます。トランザクションがない場合、テーブル内の値を検索して登録することは、互いに干渉する可能性のある2つの異なる操作としてカウントされます。タイミングによっては、複数のプロセスが同時に一意のユーザーを作成する権利があると考える可能性があり、多くの混乱につながります。トランザクションは、多くの操作を単一の単位として動作させることで、この問題を解決します。

Mnesiaの良い点は、ネイティブで任意のErlang項を格納して返すことができる、ほぼ唯一のフル機能のデータベースであることです(執筆時点)。欠点は、DETSテーブルのすべての制限をいくつかのモードで継承することです。たとえば、ディスク上の単一のテーブルに2GBを超えるデータを格納できないことなどがあります(これは実際にはフラグメンテーションと呼ばれる機能で回避できます)。

CAP定理を参照すると、MnesiaはAP側ではなくCP側に位置しており、最終的な整合性を実行せず、場合によってはネットワーク分割に非常に悪影響を与えますが、ネットワークが信頼できる(そして時には信頼できない)と予想される場合、強力な整合性の保証を提供します。

Mnesiaは標準的なSQLデータベースを置き換えることを目的としたものではなく、NoSQLの世界の巨人によってしばしば主張されるように、多数のデータセンターにわたってテラバイト規模のデータを処理することを目的としたものでもありません。Mnesiaはむしろ、限られた数のノード上の少量のデータのために作られています。多数のノードで使用することは可能ですが、ほとんどの人は実用的な限界が約10個程度であることに気づきます。固定数のノードで実行されること、必要なデータ量の見込みがあり、主に通常の状況でETSとDETSが許可する方法でErlangからデータにアクセスする必要があることがわかっている場合に、Mnesiaを使用することをお勧めします。

Erlangとの親和性はどうですか?Mnesiaは、レコードを使用してテーブルの構造を定義するというアイデアを中心としています。したがって、各テーブルは多くの類似したレコードを格納でき、レコードに含まれるものはすべてMnesiaテーブルに格納できます。アトム、PID、参照などが含まれます。

ストアに何を格納するか

a Best Friends Forever necklace

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{}のレコード定義の最初の項目であるため、nameになります。また、ピザのレシピではアトムを材料として使用し、スープのレシピでは文字列を使用していることにも気付くでしょう。SQLテーブルとは異なり、Mnesiaテーブルには、テーブル自体のタプルの構造を尊重する限り、組み込みの型制約はありません

さて、マフィアアプリケーションに戻りましょう。友人とサービスの情報をどのように表現するべきですか?すべてを行う1つのテーブルとして?

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

しかし、これは最適な選択肢ではありません。友人関連データ内にサービスのデータをネストすると、サービス関連情報の追加または変更には、同時に友人の変更が必要になります。これは、特にサービスには少なくとも2人が関与するため、煩わしい場合があります。各サービスについて、友人のレコードを2つ取得して更新する必要があります。変更する必要がある友人固有の情報がない場合でも同様です。

より柔軟なモデルでは、必要なデータの種類ごとに1つのテーブルを使用します。

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

2つのテーブルを使用することで、情報の検索、変更をオーバーヘッドを最小限に抑えて行うことができます。貴重な情報をすべて処理する方法に入る前に、テーブルを初期化する必要があります。

クーレイドを飲みすぎないで
friendsレコードとservicesレコードの両方にmafiapp_というプレフィックスを付けたことに気付くでしょう。その理由は、レコードはモジュール内でローカルに定義されていますが、Mnesiaテーブルは、そのクラスタの一部となるすべてのノードに対してグローバルであるためです。これは、注意しないと名前の衝突の可能性が高くなることを意味します。そのため、テーブルを手動で名前空間化することをお勧めします。

レコードからテーブルへ

保存するものがわかったら、次に論理的なステップは、どのように保存するかを決定することです。MnesiaはETSとDETSテーブルを使用して構築されていることを忘れないでください。これにより、ディスクまたはメモリという2つの保存手段が提供されます。戦略を選択する必要があります!オプションを以下に示します。

ram_copies
このオプションでは、すべてのデータがETS(メモリのみ)に排他的に格納されます。メモリは、32ビットでコンパイルされた仮想マシンの場合は理論上の4GB(実際には約3GB)、64ビット仮想マシンの場合は、利用可能なメモリが4GBを超えることを前提として、この制限はさらに押し下げられます。
disc_only_copies
このオプションでは、データはDETSのみに格納されます。ディスクのみなので、ストレージはDETSの2GBの制限に制限されます。
disc_copies
このオプションでは、データはETSとディスクの両方に格納されます。メモリとハードディスクの両方です。disc_copiesテーブルは、Mnesiaがトランザクションログとチェックポイントの複雑なシステムを使用してメモリのテーブルのディスクベースのバックアップを作成できるため、DETSの制限によって制限されません

現在のアプリケーションでは、disc_copiesを使用します。これは、ディスクへの永続性を最低限必要とするためです。友人との関係は長続きする必要があるため、永続的に情報を保存できることが理にかなっています。停電後に目を覚まし、苦労して築き上げた友情を失っていることに気付くのは、非常に迷惑です。なぜdisc_only_copiesを使わないのかと尋ねるかもしれませんね?メモリにコピーがあると、ディスクにアクセスする必要がないため、より複雑なクエリや検索を行う際に便利です。ディスクアクセスは、特にハードディスクの場合、コンピュータのメモリアクセスの中で最も遅い部分であることが多いからです。

データベースに貴重なデータを入力する際には、もう一つの課題があります。ETSとDETSの動作方法から、テーブルの種類を定義する必要があります。使用可能な型は、ETSとDETSの対応物と同じ定義を持ちます。選択肢はsetbag、およびordered_setです。特にordered_setは、disc_only_copiesテーブルではサポートされていません。これらの型の機能を覚えていない場合は、ETSに関する章を参照することをお勧めします。

注記: duplicate_bag型のテーブルは、どのストレージタイプでも使用できません。その理由については明確な説明がありません。

良いニュースは、ストレージ方法の決定がほぼ完了したことです。悪いニュースは、本格的に始める前に、Mnesiaについてさらに理解すべき点があるということです。

スキーマとMnesiaについて

Mnesiaは単一ノードでも問題なく動作しますが、複数のノードへの分散とレプリケーションもサポートしています。ディスクへのテーブルの保存方法、読み込み方法、同期する他のノードをMnesiaが認識するには、スキーマと呼ばれるものが必要です。このスキーマには、それらのすべての情報が保持されます。デフォルトでは、Mnesiaは作成時にメモリに直接スキーマを作成します。RAMのみで動作するテーブルには問題ありませんが、スキーマが複数のVMの再起動後も、Mnesiaクラスタを構成するすべてのノードで生き残る必要がある場合、問題は少し複雑になります。

A chicken and an egg with arrows pointing both ways to denotate the chicken and egg problem

Mnesiaはスキーマに依存しますが、Mnesia自身もスキーマを作成する必要があります。これにより、Mnesiaを最初に実行せずにスキーマをMnesiaで作成する必要があるという奇妙な状況が生じます。実際には、これは非常に簡単に解決できます。mnesia:create_schema(ListOfNodes)関数をMnesiaを開始する前に呼び出すだけです。これにより、各ノードに多くのファイルが作成され、必要なすべてのテーブル情報が保存されます。呼び出し時には他のノードに接続する必要はありませんが、他のノードは実行されている必要があります。この関数は接続を設定し、すべてを動作させます。

デフォルトでは、スキーマはErlangノードが実行されている現在の作業ディレクトリに作成されます。これを変更するには、Mnesiaアプリケーションのdir変数を設定して、スキーマの保存場所を選択できます。したがって、ノードをerl -name SomeName -mnesia dir where/to/store/the/dbとして開始するか、application:set_env(mnesia, dir, "where/to/store/the/db").を使用して動的に設定できます。

注記: スキーマの作成に失敗する理由は、次のとおりです。すでに存在する場合、Mnesiaがスキーマが存在する必要があるノードの1つで実行されている場合、Mnesiaが書き込もうとしているディレクトリに書き込むことができない場合など。

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

{attributes, List}
これは、テーブル内のすべての項目のリストです。デフォルトでは[key, value]の形式を取ります。つまり、-record(TableName, {key,val}).という形式のレコードが必要です。ほとんどの人は少しズルをして、レコードから要素名を抽出する特別な構成(実際にはコンパイラでサポートされているマクロ)を使用します。この構成は関数呼び出しのように見えます。友人レコードを使用してこれを行うには、{attributes, record_info(fields, mafiapp_friends)}として渡します。
{disc_copies, NodeList},
{disc_only_copies, NodeList},
{ram_copies, NodeList}
レコードからテーブルへで説明されているように、テーブルの保存方法を指定します。これらのオプションを複数同時に使用できます。例として、マスターノードではディスクとRAMに、すべてのスレーブノードではRAMのみに、専用のバックアップノードではディスクのみにテーブルXを保存するように定義できます。
{index, ListOfIntegers}
Mnesiaテーブルでは、基本的なETSとDETSの機能に加えて、インデックスを使用できます。これは、主キー以外のレコードフィールドに検索を構築する予定の場合に役立ちます。例として、友人テーブルには、専門分野のインデックスが必要です。このようなインデックスは、{index, [#mafiapp_friends.expertise]}として宣言できます。一般的に、多くのデータベースで当てはまることですが、ほとんどのエントリ間で類似性が高すぎないフィールドにのみインデックスを作成することをお勧めします。数十万エントリのテーブルの場合、インデックスがテーブルを2つのグループに分割した場合、非常に少ないメリットに対して多くの場所を占有することになります。たとえば、同じテーブルを10個以下の要素のN個のグループに分割するインデックスの方が、使用するリソースに対してより有用です。最初のフィールドにはデフォルトでインデックスが付けられるため、インデックスを追加する必要はありません。
{record_name, Atom}
これは、レコードで使用されているものとは異なる名前のテーブルを持つ場合に役立ちます。ただし、そうすると、一般的に誰もが使用する関数とは異なる関数を使用してテーブルを操作する必要があります。本当に必要でない限り、このオプションの使用はお勧めしません。
{type, Type}
Typeは、setordered_set、またはbagテーブルのいずれかです。レコードからテーブルへで以前に説明した内容と同じです。
{local_content, true | false}
デフォルトでは、すべてのMnesiaテーブルでこのオプションはfalseに設定されています。スキーマ(およびdisc_copiesdisc_only_copiesram_copiesオプションで指定されたもの)の一部であるすべてのノードでテーブルとそのデータがレプリケートされるようにする場合は、そのままにしておく必要があります。このオプションをtrueに設定すると、すべてのノードにすべてのテーブルが作成されますが、内容はローカルコンテンツのみになります。何も共有されません。この場合、Mnesiaは多くのノードで同様の空のテーブルを初期化するエンジンになります。

簡単に言うと、Mnesiaスキーマとテーブルを設定する際に発生する可能性のあるイベントのシーケンスは次のとおりです。

注記: 方法には3つ目の方法があります。Mnesiaノードが実行されていて、ディスクに移行したいテーブルが作成されている場合は、mnesia:change_table_copy_type(Table, Node, NewType)関数を呼び出して、テーブルをディスクに移動できます。

具体的には、mnesia:change_table_copy_type(schema, node(), disc_copies)を呼び出してディスク上にスキーマを作成するのを忘れた場合、RAMスキーマを取り出してディスクスキーマに変換します。

これで、テーブルとスキーマを作成する方法について大まかに理解できました。これで始めるのに十分かもしれません。

本格的なテーブルの作成

Common Testを使用して、弱いTDDスタイルのプログラミングでアプリケーションとそのテーブルの作成を処理します。TDDの考え方が気に入らないかもしれませんが、もう少し付き合ってください。リラックスした方法で、何よりも設計をガイドする方法として行います。「テストを実行して失敗することを確認する」といったことはありません(ただし、必要であれば自由に実行できます)。最終的にテストがあることは、単なる良い副作用であり、目的自体ではありません。Erlangシェルからすべてを行うのではなく、mafiappの動作と外観のインターフェースを定義することに重点を置きます。テストは分散されませんが、Mnesiaを学習しながらCommon Testを実際に使用できる良い機会となります。

そのためには、標準的なOTP構造に従って、mafiapp-1.0.0という名前のディレクトリを作成する必要があります。

ebin/
logs/
src/
test/

最初に、データベースのインストール方法を考えます。スキーマとテーブルの初期化が必要なため、Common Testのpriv_dirディレクトリに理想的にインストールするインストール関数を使用して、すべてのテストを設定する必要があります。基本的なテストスイートmafiapp_SUITEから始めましょう。これはtest/ディレクトリに保存されています。

-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.

このテストスイートにはまだテストがありませんが、作業方法の最初の仕様が得られます。まず、dir変数をpriv_dirの値に設定することで、Mnesiaスキーマとデータベースファイルの保存場所を選択します。これにより、スキーマとデータベースの各インスタンスがCommon Testで生成されたプライベートディレクトリに配置され、以前のテスト実行からの問題や競合が発生しません。インストール関数をinstallと名付け、インストール先のノードのリストを渡すことにしました。これは、install関数内でハードコーディングするよりも、柔軟性が高いため、一般的に優れた方法です。これが完了したら、Mnesiaとmafiappを起動する必要があります。

src/mafiapp.erlで、インストール関数の動作方法を考え始めましょう。まず、以前のレコード定義を取り戻す必要があります。

-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つのテーブルを作成します。前に述べたように、専門分野で友人を検索する必要があるため、専門分野にインデックスがあります。

A bag of money with a big dollar sign on it

サービステーブルはbag型であることにも注目してください。これは、同じ送信者と受信者を持つ複数のサービスが存在する可能性があるためです。setテーブルを使用すると、一意の送信者のみを処理できますが、bagテーブルではこれが問題なく処理されます。また、テーブルのtoフィールドにインデックスがあることに注意してください。これは、受信者または送信者によってサービスを検索する必要があるためであり、インデックスにより、任意のフィールドの検索を高速化できます。

最後に注意すべき点は、テーブルの作成後にMnesiaを停止していることです。これは、テストで記述した動作に合わせるためです。テストの内容がコードの使用方法であるため、コードをそのアイデアに合わせる必要があります。ただし、インストール後にMnesiaを実行し続けることにも問題はありません。

Common Testスイートでテストケースが正常に実行された場合、このインストール関数を使用して初期化フェーズが成功します。ただし、多くのノードで試行すると、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が起動してからディスクからすべてのテーブルの読み込みが完了するまでには、顕著な遅延が生じる可能性があります。このような状況では、`mafiapp`の`start/2`のような関数は、通常の操作でプロセスがまったく必要ない場合でも、そのような待機を行うのに最適な場所となる可能性があります。

mafiapp.erlでアプリケーション動作を実装し(`-behaviour(application).`)、次の2つのコールバックをファイルに追加します(エクスポートすることを忘れないでください)。

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

stop(_) -> ok.

秘訣は`mnesia:wait_for_tables(TableList, TimeOut)`関数です。これは、最大5秒(任意の数値で、データに合わせて調整してください)、またはテーブルが使用可能になるまで待ちます。

これは、スーパーバイザが何をするべきかについてはあまり教えてくれませんが、それはmafiapp_supがあまりやるべきことがないためです。

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

start_link() ->
    supervisor:start_link(?MODULE, []).

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

スーパーバイザは何もしませんが、OTPアプリケーションの起動は同期しているため、実際にはこのような同期ポイントを配置する最適な場所の1つです。

最後に、アプリケーションを起動できるように、`ebin/`ディレクトリに次のmafiapp.appファイルを追加します。

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

これで、実際のテストを書いてアプリケーションを実装する準備ができました。それとも、まだでしょうか?

アクセスとコンテキスト

アプリケーションの実装に移る前に、Mnesiaを使用してテーブルを操作する方法の概要を把握しておくことは有益かもしれません。

データベーステーブルへのすべての変更や読み取りは、アクティビティアクセスコンテキストと呼ばれるもので実行する必要があります。これらは、トランザクションまたはクエリを実行する「方法」の種類が異なります。オプションは以下のとおりです。

トランザクション

Mnesiaトランザクションを使用すると、一連のデータベース操作を単一の機能ブロックとして実行できます。ブロック全体がすべてのノードで実行されるか、どのノードでも実行されません。完全に成功するか、完全に失敗します。トランザクションが戻ると、テーブルが一貫した状態に保たれ、たとえ同じデータを操作しようとしても、異なるトランザクションがお互いに干渉しなかったことが保証されます。

このタイプのアクティビティコンテキストは部分的に非同期です。ローカルノードでの操作については同期しますが、他のノードからトランザクションをコミットするという確認(完了したという確認ではない)を待つだけです。Mnesiaの動作方法では、トランザクションがローカルで機能し、他のすべての人が実行することに同意した場合、他のすべての場所でも機能するはずです。ネットワークまたはハードウェアの障害などにより機能しない場合、トランザクションは後でロールバックされます。プロトコルは効率のためにこれを許容しますが、後でロールバックされる可能性があるトランザクションが成功したという確認を行う可能性があります。

同期トランザクション (sync_transaction)

このアクティビティコンテキストは`transaction`とほぼ同じですが、同期しています。奇妙なエラーのために失敗した可能性があるトランザクションが成功したと通知されるというアイデアが好きではない場合、またはトランザクションの成功に関連する副作用(外部サービスへの通知、プロセスの生成など)を実行したい場合など、`transaction`の保証では不十分な場合、`sync_transaction`を使用するのが最適です。同期トランザクションは、すべてが100%正常に完了したことを確認するために、他のすべてのノードからの最終確認を待つまで戻りません。

興味深いユースケースとしては、他のノードに過負荷をかけるほどの多くのトランザクションを実行している場合、同期モードに切り替えることで、速度を落としながらバックログの蓄積を少なくし、アプリケーションの上位レベルで過負荷の問題を解決できます。

非同期ダーティ (async_dirty)

`async_dirty`アクティビティコンテキストは基本的に、すべてのトランザクションプロトコルとロックアクティビティをバイパスします(ただし、アクティブなトランザクションが終了するまで待機することに注意してください)。ただし、ログ、レプリケーションなどを含むすべての操作は実行されます。`async_dirty`アクティビティコンテキストは、すべての操作をローカルで実行してから戻り、他のノードのレプリケーションを非同期に実行します。

同期ダーティ (sync_dirty)

このアクティビティコンテキストは、`async_dirty`に対する`sync_transaction`と`transaction`の関係と同じです。リモートノードで問題なく動作したという確認を待ちますが、それでもすべてのロックまたはトランザクションコンテキストから外れたままです。ダーティコンテキストは一般的にトランザクションよりも高速ですが、設計上非常にリスクが高いです。注意して取り扱ってください。

ets

最後のアクティビティコンテキストは`ets`です。これは基本的に、Mnesiaが行うすべてのことをバイパスし、基礎となるETSテーブル(存在する場合)に対して一連の生の操作を実行する方法です。レプリケーションは実行されません。`ets`アクティビティコンテキストは、通常は必要とするものではなく、したがって使用したくないものです。「疑わしい場合は使用しないでください。必要になったときにわかります。」というもう一つのケースです。

これらはすべて、一般的なMnesia操作を実行できるコンテキストです。これらの操作自体は`fun`でラップされ、`mnesia:activity(Context, Fun)`を呼び出すことによって実行されます。`fun`には任意のErlang関数呼び出しを含めることができますが、障害や他のトランザクションによる中断が発生した場合、トランザクションが複数回実行される可能性があることに注意してください。

つまり、テーブルから値を読み取るトランザクションが何かを書き戻す前にメッセージを送信する場合、メッセージが数十回送信される可能性があります。そのため、そのような副作用をトランザクションに含めるべきではありません

a pen writing 'sign my guestbook'

読み取り、書き込み、その他

これらのテーブル変更関数のことを何度も言及してきましたが、ここでそれらを定義する時です。それらのほとんどは、ETSとDETSが提供したものと驚くほど似ています。

書き込み (write)

レコードの名前がテーブルの名前である`mnesia:write(Record)`を呼び出すことで、テーブルにRecordを挿入できます。テーブルが`set`または`ordered_set`タイプで、主キー(レコードの2番目のフィールド、名前ではなく、タプルの形式)の場合、要素は置き換えられます。`bag`テーブルの場合、レコード全体が類似している必要があります。

書き込み操作が成功すると、`write/1`は`ok`を返します。そうでない場合、トランザクションを中止する例外をスローします。そのような例外をスローすることは頻繁にはありません。Mnesiaが実行されていない場合、テーブルが見つからない場合、またはレコードが無効な場合に発生する可能性が高くなります。

削除 (delete)

関数は`mnesia:delete(TableName, Key)`として呼び出されます。このキーを共有するレコードはテーブルから削除されます。`ok`を返すか、例外をスローします。セマンティクスは`mnesia:write/1`と似ています。

読み取り (read)

`mnesia:read({TableName, Key})`として呼び出されるこの関数は、主キーがKeyと一致するレコードのリストを返します。`ets:lookup/2`と同様に、キーと一致する結果が1つしかない`set`タイプのテーブルでも、常にリストを返します。一致するレコードがない場合は、空のリストが返されます。削除と書き込み操作の場合と同様に、失敗した場合、例外がスローされます。

match_object

この関数はETSの`match_object`関数に似ています。 Meeting Your Matchで説明されているようなパターンを使用して、データベーステーブルからレコード全体を返します。たとえば、特定の専門知識を持つ友人を検索する簡単な方法は、`mnesia:match_object(#mafiapp_friends{_ = '_', expertise = given})`で行うことができます。その後、テーブル内のすべてのマッチングエントリのリストを返します。繰り返しますが、失敗すると例外がスローされます。

select

これはETSの`select`関数に似ています。マッチング仕様または`ets:fun2ms`を使用してクエリを実行します。この動作を覚えていない場合は、You Have Been Selectedを参照してマッチングスキルを復習することをお勧めします。関数は`mnesia:select(TableName, MatchSpec)`として呼び出すことができ、マッチング仕様に適合するすべてのアイテムのリストを返します。そして再び、失敗した場合、例外がスローされます。

その他の操作

Mnesiaテーブルには他にも多くの操作が用意されています。しかし、前に説明したものは、私たちが前進するための堅実な基盤を構成しています。他の操作に興味がある場合は、Mnesiaリファレンスマニュアルを参照して、` first`、` last`、` next`、` prev`などの個々の反復のための関数、テーブル全体のfoldのための` foldl`と` foldr`、または` transform_table`(レコードとテーブルにフィールドを追加または削除するのに特に便利)や` add_table_index`などのテーブル自体を操作するその他の関数を見つけることができます。

多くの関数があります。それらを現実的に使用する方法を見るために、テストを進めていきましょう。

最初のリクエストの実装

リクエストを実装するために、まず、アプリケーションから期待する動作を示す比較的単純なテストを作成します。テストはサービスの追加に関するものですが、より多くの機能の暗黙的なテストが含まれます。

[...]
-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.

これは、ほとんどのCTスイートに追加する必要がある標準的な初期化処理です。では、テスト自体に移りましょう。

%% 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").

サービスを追加するので、交換に関わる2人の友人を追加する必要があります。そのために`mafiapp:add_friend(Name, Contact, Info, Expertise)`関数が使用されます。友人を追加したら、実際にサービスを追加できます。

注記:他の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{}`レコードを書き込む単一の関数を定義しています。これは比較的単純なトランザクションです。`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}`が返されます。トランザクションの両方のメンバーが見つかった場合は、代わりにサービスをデータベースに書き込みます。

注記:入力の検証は、あなたの裁量に任されています。そのためには、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
...

うまくいきました。合格です。次のテストに進みましょう。

注記:CTスイートを実行すると、一部のディレクトリが見つからないというエラーが表示される場合があります。解決策は`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.

強調したいユースケースは、名前で友人を検索することです。サービスのみを検索することもできますが、実際にはアクションよりも名前で人を検索したい場合があります。ボスが「あのギターを誰に誰が配達したのか、もう一度教えてくれ」と尋ねることはめったにありません。いいえ、彼はより多くの可能性で「ギターを私たちの友人ピート・シティシェンドに配達したのは誰ですか?」と尋ね、彼の名前を通して彼の履歴を調べ、サービスに関する詳細を見つけようとします。

そのため、次のテストは`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読み取りを使用して行うことができます。`#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_service/1`の実装は、以前のものよりも少し複雑になる必要があります。`friend_by_name/1`は主キーを照会するだけで機能しましたが、サービスの友人の名前は`from`フィールドを検索する場合にのみ主キーです。`to`フィールドも処理する必要があります。`match_object`を何度も使用したり、テーブル全体を読み込んで手動でフィルタリングしたりするなど、これを処理する方法はたくさんあります。私はMatch Specificationsと`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つの節があります。`From`が`Name`と一致するたびに、`{to, ToName, Date, Description}`タプルを返します。代わりに`Name`が`To`と一致する場合は、`{from, FromName, Date, Description}`形式のタプルを返します。これにより、送受信された両方のサービスを含む単一の操作を実行できます。

`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_object/1`関数は、専門性以外のすべてがアトム`undefined`であるエントリのみを探します。

結果が得られたら、テストを満たすためにレコードをタプルにフォーマットします。繰り返しますが、テストをコンパイルして実行すると、この実装が機能することがわかります。やった!仕様全体を実装しました!

アカウントと新しいニーズ

ソフトウェアプロジェクトは本当に完了することはありません。システムを使用するユーザーは、新しいニーズを明らかにしたり、予期しない方法でシステムを破壊したりします。ボスは、私たちの真新しいソフトウェア製品を使用する前から、友人をすべてすばやく確認し、誰にお金を借りているか、誰がお金を借りているかをすぐに確認できる機能が欲しいと決めました。

そのためのテストを以下に示します。

...
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").

ジル・ベイツ、ピエール・ゴーティエ、ホッケー殿堂入り選手のウェイン・グレッツキーという3人のテストフレンドを追加します。彼らとあなた、コンシリエーレの間でサービスの交換が行われます(ボスはこのテストでは使用されません。他のテストで使用されており、結果がめちゃくちゃになるためです!)

`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(サービスを提供したので、彼らは私たちにお金を借りている)であることを検索します。`Name`が`To`と一致する場合、返される値は1(サービスを受け取ったため、私たちがお金を借りている)になります。どちらの場合も、値は名前を含むタプルに結合されています。

A sheet of paper with 'I.O.U. 1 horse head -Fred' written on it

名前を含めることは、計算の2番目のステップに必要です。そこで、各人について提供されたすべてのサービスをカウントし、一意の累積値を算出します。これを行う方法はたくさんあります。データベースから分離できるコードをできるだけ多く残せるように、トランザクション内でできるだけ短い時間にとどまることを必要とするものを選びました。これはmafiappでは役に立ちませんが、高性能なケースでは、リソースの競合を大幅に削減できます。

とにかく、私が選んだ解決策は、すべての値を取得し、辞書に入れ、辞書の`dict:update(Key, Operation)`関数を使用して、移動が私たちのためか私たちからのものかに基づいて値を増減することです。Mnesiaによって返された結果に対してフォールドを実行することで、必要なすべての値のリストを取得します。

最後のステップは、値を反転させ(`{Key,Debt}`から`{Debt, Key}`へ)て、これに基づいてソートすることです。これにより、必要な結果が得られます。

ボスとの出会い

私たちのソフトウェア製品は、少なくとも本番環境で一度は試してみる必要があります。ボスが使用するノードとあなたのノードを設定することでこれを行います。

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

両方のノードが開始されたら、それらを接続してアプリをインストールできます。

(corleone@ferdmbp.local)1> net_kernel:connect_node('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

両方のノードが実行中のDBノードにあり、両方のテーブルとスキーマがディスクとRAMに書き込まれている(`disc_copies`)ことがわかります。データベースへのデータの書き込みと読み込みを開始できます。もちろん、ドンをDBに含めることは良い第一歩です。

(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"}]}]

素晴らしい!ノードの1つをシャットダウンして再起動しても、問題ありません。

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

$ erl -name corleone -pa ebin
...
(corleone@ferdmbp.local)1> net_kernel:connect_node('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について詳しくなりました!

注記:テーブルが混乱し始めたり、テーブル全体を確認したい場合は、関数observer:start()を呼び出してください。これにより、コードではなく視覚的にテーブルを操作できるテーブルビューアタブ付きのグラフィカルインターフェースが起動します。observerアプリケーションがまだ存在しない古いErlangリリースでは、tv:start()を呼び出すと、その前身が起動します。

削除のデモンストレーション

ちょっと待ってください。データベースからレコードを削除することを完全にスキップしましたか?いけません!それ用のテーブルを追加しましょう。

あなたと上司が個人的な理由で個人的な敵を保存できるように、小さな機能を作成することで行います。

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

これは個人的な敵であるため、テーブルをインストールする際に、オプションとしてlocal_contentを使用して、少し異なるテーブル設定を使用してテーブルをインストールする必要があります。これにより、テーブルは各ノードにプライベートになり、誰かが他の人の個人的な敵を誤って読むことがなくなります(ただし、RPCを使用すると簡単に回避できます)。

新しいテーブルに変更されたmafiappのstart/2関数の前に、新しいインストール関数が表示されます。

start(normal, []) ->
    mnesia:wait_for_tables([mafiapp_friends,
                            mafiapp_services,
                            mafiapp_enemies], 5000),
    mafiapp_sup:start_link().
...
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を直接呼び出してテーブルを追加できます。システムの負荷とノードの数によっては、事前にステージングで何度か練習しておきたい場合もあります。

とにかく、これが完了したら、DBを使って簡単なテストを作成し、それがどのように進むかを確認できます。これは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

ノードが起動したら、DBを再インストールします。

(corleone@ferdmbp.local)1> net_kernel:connect_node('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

ご覧のとおり、データは共有されていません。エントリの削除も簡単です。

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

ついに!

クエリリスト内包表記

この章を黙って読んできた(もしくは最悪、この部分までスキップしてきた)あなたで、「Mnesiaの見方が好きじゃない」と思っているなら、このセクションは気に入るかもしれません。Mnesiaの見方が気に入ったなら、このセクションも気に入るかもしれません。リスト内包表記が好きなら、間違いなくこのセクションも気に入るでしょう。

クエリリスト内包表記は基本的に、解析変換を使用するコンパイラテクニックで、検索および反復処理できるデータ構造に対してリスト内包表記を使用できます。Mnesia、DETS、ETSで実装されていますが、gb_treesのようなものにも実装できます。

モジュールに-include_lib("stdlib/include/qlc.hrl").を追加すると、ジェネレータとしてクエリハンドルと呼ばれるものを使用してリスト内包表記を使用できるようになります。クエリハンドルは、反復可能なデータ構造がQLCで機能することを可能にするものです。Mnesiaの場合、リスト内包表記のジェネレータとしてmnesia:table(TableName)を使用でき、そこからqlc:q(...)への呼び出しでラップすることで、データベーステーブルをクエリできます。

これにより、テーブルによって返されるものよりも詳細な情報を含む、修正されたクエリハンドルが返されます。この新しいハンドルは、qlc:sort/1-2などの関数を使用してさらに修正でき、qlc:eval/1またはqlc:fold/1を使用して評価できます。

早速実践してみましょう。mafiapp関数のいくつかを書き直します。mafiapp-1.0.0のコピーを作成し、mafiapp-1.0.1と呼びましょう(.appファイルのバージョンを上げるのを忘れないでください)。

最初に修正する関数はfriend_by_expertiseです。これは現在、mnesia:match_object/1を使用して実装されています。QLCを使用したバージョンを以下に示します。

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関数を見てみましょう。これは、マッチング仕様と辞書へのフォールドを使用して実装されました。QLCを使用した方法を以下に示します。

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))]).

マッチング仕様は不要になりました。リスト内包表記(QHクエリハンドルに保存)がその部分を実行します。フォールドはトランザクションに移動され、クエリハンドルの評価方法として使用されます。結果の辞書は、以前lists:foldl/3によって返されたものと同じです。最後の部分であるソートは、mnesia:activity/1が返した辞書を取り、リストに変換することで、トランザクションの外側で行われます。

以上です。これらの関数をmafiapp-1.0.1アプリケーションに書き込んでテストスイートを実行すると、6つのテストはすべて引き続きパスします。

a chalk outline of a dead body

Mnesiaを覚えておきましょう

Mnesiaは以上です。これは非常に複雑なデータベースであり、その機能のごく一部しか見ていません。さらに先に進むには、Erlangのマニュアルを読んでコードに深く入り込む必要があります。長年にわたって稼働している大規模でスケーラブルなシステムでMnesiaを実際に本番環境で使用した経験を持つプログラマーは、非常に稀です。メーリングリストでそのようなプログラマーを見つけることができ、質問に答えてくれる場合もありますが、一般的には多忙な人たちです。

それ以外の場合、Mnesiaは、ストレージレイヤーの選択が非常に面倒な場合の小さなアプリケーション、または前に述べたようにノード数が既知である大きなアプリケーションにも非常に優れたツールです。Erlang項を直接保存およびレプリケートできることは非常に優れたことであり、他の言語では長年にわたってオブジェクトリレーショナルマッパーを使用して記述しようとしてきました。

興味深いことに、熱心に取り組む人がいれば、SQLデータベースや反復処理が可能なその他のストレージに対してQLCセレクタを作成できる可能性があります。

Mnesiaとそのツールチェーンは、将来のアプリケーションで非常に役立つ可能性があります。しかし今は、Dialyzerを使用してErlangシステムの開発を支援する追加のツールに移行します。