並行アプリケーションの設計
ここまで順調に進んできました。概念は理解できましたが、本書の冒頭から扱ってきたのは、電卓、木構造、ヒースロー空港からロンドンへの経路など、あくまで簡単な例題です。もっと楽しく、教育的な内容に移りましょう。Erlangを用いて、小さな並行アプリケーションを作成します。アプリケーションは小さく、行ベースになりますが、それでも有用で、適度に拡張可能です。
私はやや整理整頓が苦手な人間です。宿題、家の雑用、この本の執筆、仕事、会議、予定など、あらゆることで頭の中は混乱しています。至る所にタスクのリストが山積みで、それでも忘れてしまったり、見落としたりします。皆さんもやるべきことを思い出させるリマインダーが必要なのかもしれません(私のように気が散りやすいわけではないと思いますが)。そこで、やるべきことを促し、予定を思い出させてくれるイベントリマインダーアプリケーションを作成しましょう。
問題の理解
最初のステップは、私たちが何をしているのかを理解することです。「リマインダーアプリですね」「そうです」と答えるのは簡単ですが、それだけではありません。ソフトウェアとのインタラクションはどのように計画するべきでしょうか?ソフトウェアに何をしてほしいのでしょうか?プロセスでプログラムをどのように表現するのでしょうか?どのようなメッセージを送信する必要があるのでしょうか?
よく言われるように、「水上を歩くことと、仕様書からソフトウェアを開発することは、どちらも凍っていれば簡単だ」 です。そこで、仕様を決め、それに従いましょう。私たちの小さなソフトウェアは、以下の機能を実現します。
- イベントの追加。イベントには、期限(警告する時間)、イベント名、説明が含まれます。
- 時間が来たら警告を表示する。
- イベント名を指定してイベントをキャンセルする。
- 永続的なディスクストレージは使用しません。ここで説明するアーキテクチャの概念を示すには必要ありません。実際のアプリケーションでは不便ですが、必要であれば挿入できる場所を示し、いくつかの便利な関数についても説明します。
- 永続ストレージがないため、実行中にコードを更新できる必要があります。
- ソフトウェアとのインタラクションはコマンドラインで行いますが、後で他の方法(GUI、ウェブページアクセス、インスタントメッセージングソフトウェア、電子メールなど)を使用できるように拡張することが可能です。
私が選択したプログラムの構造を以下に示します。
クライアント、イベントサーバー、x、y、zはすべてプロセスです。それぞれの機能は以下のとおりです。
イベントサーバー
- クライアントからのサブスクリプションを受け付ける。
- イベントプロセスからの通知を各サブスクライバーに転送する。
- イベントを追加するメッセージを受け付ける(そして必要なx、y、zプロセスを起動する)。
- イベントをキャンセルし、それに伴いイベントプロセスをキルするメッセージを受け付けることができる。
- クライアントによって終了できる。
- シェルからコードを再ロードできる。
クライアント
- イベントサーバーをサブスクライブし、メッセージとして通知を受け取る。そのため、イベントサーバーをサブスクライブする多くのクライアントを設計するのは簡単です。これらはそれぞれ、上記で述べたさまざまなインタラクションポイント(GUI、ウェブページ、インスタントメッセージングソフトウェア、電子メールなど)へのゲートウェイとなる可能性があります。
- サーバーにイベントとその詳細をすべて追加するよう要求する。
- サーバーにイベントをキャンセルするよう要求する。
- サーバーを監視する(サーバーがダウンしたかどうかを確認する)。
- 必要に応じてイベントサーバーをシャットダウンする。
x、y、z
- 発火を待つ通知を表す(基本的にイベントサーバーにリンクされたタイマーです)。
- 時間が来たらイベントサーバーにメッセージを送信する。
- キャンセルメッセージを受け取ると終了する。
すべてのクライアント(この本では実装されていないIM、メールなど)はすべてのイベントについて通知され、キャンセルはクライアントに警告するものではありません。このソフトウェアは私達のために書かれており、1人のユーザーだけが実行すると想定しています。
考えうるすべてのメッセージを含む、より複雑なグラフを以下に示します。
これは、私たちが持つすべてのプロセスを表しています。そこにすべての矢印を引き、それらがメッセージであると言うことで、高レベルのプロトコル、あるいは少なくともその骨格を作成しました。
リマインダーとして1つのイベントにつき1つのプロセスを使用することは、現実世界のアプリケーションではオーバースペックであり、スケールするのが困難になる可能性が高いことに注意してください。しかし、ユーザーが自分だけであるアプリケーションにとっては十分です。 timer:send_after/2-3 などの関数を使用することで、過剰なプロセスの生成を避けることができます。
プロトコルの定義
各コンポーネントが実行し、通信する必要があることが分かったので、送信されるすべてのメッセージをリスト化し、その形式を指定することが良いでしょう。まず、クライアントとイベントサーバー間の通信から始めましょう。
クライアントとサーバーの間には明らかな依存関係がないため、ここでは2つのモニターを使用することにしました。もちろん、サーバーがなければクライアントは機能しませんが、サーバーはクライアントがなくても動作できます。リンクでもここで十分でしたが、システムを多くのクライアントで拡張できるように、他のクライアントがすべてサーバーが死んだときにクラッシュすることを想定することはできません。また、クライアントをシステムプロセスに変え、サーバーが死んだ場合に終了をトラップできるとも想定できません。次のメッセージセットに移りましょう。
これにより、イベントサーバーにイベントが追加されます。何か問題が発生しない限り(タイムアウトのフォーマットが間違っているなど)、okアトムの形式で確認応答が送り返されます。逆の操作であるイベントの削除は、次のように行うことができます。
イベントサーバーは後で、イベントの期限が来たときに通知を送信できます。
次に、サーバーをシャットダウンしたい場合や、サーバーがクラッシュした場合の2つの特別なケースのみが必要です。
サーバーが死んだときに直接的な確認応答が送信されないのは、モニターがすでにそのことを警告してくれるからです。クライアントとイベントサーバー間で発生するイベントはほぼこれだけです。イベントサーバーとイベントプロセス自体の間のメッセージに移りましょう。
始める前に注意すべき点として、イベントサーバーをイベントにリンクすることが非常に役立つでしょう。これは、サーバーが終了した場合、すべてのイベントを終了させたいからです。サーバーがなければイベントは意味がありません。
さて、イベントに戻りましょう。イベントサーバーがイベントを開始すると、各イベントに特別な識別子(イベント名)が与えられます。これらのイベントのいずれかの時間が来たら、そのことを伝えるメッセージを送信する必要があります。
一方、イベントは、イベントサーバーからのキャンセル呼び出しを監視する必要があります。
これで終わりです。プロトコルには最後のメッセージ、サーバーをアップグレードするためのメッセージが必要です。
返信は必要ありません。実際にその機能をプログラムする際に理由が分かり、理にかなっていることが分かるでしょう。
プロトコルとプロセス階層の一般的なアイデアの両方が定義されたので、プロジェクトに取り掛かることができます。
基礎を築く
まず、標準的なErlangディレクトリ構造を構築しましょう。それは次のようになります。
ebin/ include/ priv/ src/
ebin/ディレクトリは、ファイルがコンパイルされた後に配置される場所です。include/ディレクトリは、他のアプリケーションによってインクルードされる.hrlファイルを格納するために使用されます。プライベートな.hrlファイルは通常、src/ディレクトリ内に保持されます。priv/ディレクトリは、特定のドライバーなど、Erlangとインタラクトする必要がある実行ファイルに使用されます。このプロジェクトではこのディレクトリは実際には使用しません。そして最後はsrc/ディレクトリで、すべての.erlファイルがここにあります。
標準的なErlangプロジェクトでは、このディレクトリ構造は多少異なる場合があります。特定の構成ファイル用のconf/ディレクトリ、ドキュメント用のdoc/ディレクトリ、アプリケーションの実行に必要なサードパーティライブラリ用のlib/ディレクトリを追加できます。市場に出回っているさまざまなErlang製品は、これらとは異なる名前を使用することがよくありますが、上記で述べた4つのディレクトリは、標準的なOTPプラクティスの一部であるため、通常は同じままです。
イベントモジュール
src/ディレクトリに入り、event.erlモジュールを作成します。これは、前の図のx、y、zイベントを実装します。このモジュールから始めるのは、依存関係が最も少ないからです。イベントサーバーやクライアント関数を実装する必要なく、実行を試すことができます。
コードを実際に書く前に、プロトコルが不完全であることを述べておく必要があります。プロセス間で送信されるデータを表すのに役立ちますが、その複雑さ、つまりアドレス指定の方法、参照または名前を使用するかどうかなどは表していません。ほとんどのメッセージは{Pid, Ref, Message}という形式でラップされます。ここでPidは送信者、Refは、どの応答が誰から来たのかを知るためのユニークなメッセージ識別子です。応答を探す前に多くのメッセージを送信する場合、参照がなければ、どの応答がどのメッセージに対応しているのか分かりません。
では始めましょう。event.erlのコードを実行するプロセスのコアはloop/1関数で、プロトコルを思い出せば、次のスケルトンに似ています。
loop(State) ->
receive
{Server, Ref, cancel} ->
...
after Delay ->
...
end.
これは、イベントが期限に達したことを発表するためにサポートする必要があるタイムアウトと、サーバーがイベントのキャンセルを要求する方法を示しています。ループにはState変数があることに注意してください。State変数は、タイムアウト値(秒単位)、イベント名({done, Id}メッセージを送信するため)、通知を送信するためのイベントサーバーのpidなどのデータを含む必要があります。
ループの状態に保持するのに適したすべてのものです。そのため、ファイルの先頭にstateレコードを宣言しましょう。
-module(event).
-compile(export_all).
-record(state, {server,
name="",
to_go=0}).
この状態が定義されると、ループをもう少し洗練できるはずです。
loop(S = #state{server=Server}) ->
receive
{Server, Ref, cancel} ->
Server ! {Ref, ok}
after S#state.to_go*1000 ->
Server ! {done, S#state.name}
end.
ここで、1000を掛けているのは、to_goの値を秒からミリ秒に変換するためです。
あまりKool-Aidを飲みすぎないでください。(訳注:Kool-Aidを飲みすぎる=盲信するという意味の比喩表現)
言語上の問題点です!関数ヘッダーで変数 'Server' をバインドしている理由は、receive セクションのパターンマッチングで使用されているためです。覚えておいてください。レコードはハックです! S#state.serverという式は、密かにelement(2, S)に展開されますが、これはパターンマッチングに有効なパターンではありません。
afterの部分の後では、S#state.to_goについては依然として問題なく動作します。これは、後で評価される式として残しておくことができるためです。
それでは、ループをテストしてみましょう。
6> c(event).
{ok,event}
7> rr(event, state).
[state]
8> spawn(event, loop, [#state{server=self(), name="test", to_go=5}]).
<0.60.0>
9> flush().
ok
10> flush().
Shell got {done,"test"}
ok
11> Pid = spawn(event, loop, [#state{server=self(), name="test", to_go=500}]).
<0.64.0>
12> ReplyRef = make_ref().
#Ref<0.0.0.210>
13> Pid ! {self(), ReplyRef, cancel}.
{<0.50.0>,#Ref<0.0.0.210>,cancel}
14> flush().
Shell got {#Ref<0.0.0.210>,ok}
ok
ここでは多くのことが確認できます。まず、rr(Mod)を使用して、イベントモジュールからレコードをインポートします。次に、シェルをサーバー(self())としてイベントループを生成します。このイベントは5秒後に発生するはずです。9番目の式は3秒後に、10番目の式は6秒後に実行されました。2回目の試行で{done, "test"}メッセージを受信したことがわかります。
その直後、キャンセル機能を試しています(入力するのに十分な500秒の時間があります)。参照を作成し、メッセージを送信し、同じ参照で返信を受け取ったため、受信したokがシステム上の他のプロセスではなく、このプロセスからのものであることがわかります。
キャンセルメッセージが参照でラップされているのに対し、doneメッセージがラップされていない理由は、特定の場所からのものではない(どこからでもかまいません。受信時に一致させる必要はありません)と、それに返信する必要がないためです。事前に実行したい別のテストがあります。来年発生するイベントはどうでしょうか?
15> spawn(event, loop, [#state{server=self(), name="test", to_go=365*24*60*60}]).
<0.69.0>
16>
=ERROR REPORT==== DD-MM-YYYY::HH:mm:SS ===
Error in process <0.69.0> with exit value: {timeout_value,[{event,loop,1}]}
痛い。実装の限界に達したようです。Erlangのタイムアウト値は、ミリ秒で約50日間に制限されていることがわかりました。重要ではないかもしれませんが、このエラーを3つの理由で示しています。
- モジュールを作成してテストしているときに、章の途中でこの問題に遭遇しました。
- Erlangは確かにあらゆるタスクに最適とは限らず、ここで見ているのは、実装者が意図していない方法でタイマーを使用することによる結果です。
- それは実際には問題ではありません。回避策を考えましょう。
この問題に対処するために私が適用することにした修正は、タイムアウト値が長すぎる場合はそれを多くの部分に分割する関数を記述することでした。これには、loop/1関数からのサポートも必要になります。そのため、時間を分割する方法は基本的に、(制限は約50日であるため)49日間の等しい部分に分割し、残りをこれらの等しい部分すべてに追加することです。秒のリストの合計は、元の時間になるはずです。
%% Because Erlang is limited to about 49 days (49*24*60*60*1000) in
%% milliseconds, the following function is used
normalize(N) ->
Limit = 49*24*60*60,
[N rem Limit | lists:duplicate(N div Limit, Limit)].
関数lists:duplicate/2は、2番目の引数として与えられた式を受け取り、最初の引数の値([a,a,a] = lists:duplicate(3, a))と同じ回数だけ複製します。normalize/1に値98*24*60*60+4を送信すると、[4,4233600,4233600]が返されます。新しい形式に対応するために、loop/1関数は次のようになります。
%% Loop uses a list for times in order to go around the ~49 days limit
%% on timeouts.
loop(S = #state{server=Server, to_go=[T|Next]}) ->
receive
{Server, Ref, cancel} ->
Server ! {Ref, ok}
after T*1000 ->
if Next =:= [] ->
Server ! {done, S#state.name};
Next =/= [] ->
loop(S#state{to_go=Next})
end
end.
試してみてください。通常どおり動作するはずです。ただし、これで数年単位のタイムアウトがサポートされるようになりました。これは、to_goリストの最初の要素を取得し、その期間全体を待機することによって機能します。これが完了すると、タイムアウトリストの次の要素が検証されます。空の場合は、タイムアウトが終了し、サーバーに通知されます。それ以外の場合は、リストが終了するまでループが続きます。
イベントプロセスが開始されるたびに、event:normalize(N)のようなものを手動で呼び出すのは非常に面倒です。特に、私たちの回避策はコードを使用するプログラマーにとって問題になるべきではないからです。これを行う標準的な方法は、ループ関数の正常な動作に必要なデータの初期化をすべて処理するinit関数を使用することです。ついでに、標準的なstart関数とstart_link関数も追加します。
start(EventName, Delay) ->
spawn(?MODULE, init, [self(), EventName, Delay]).
start_link(EventName, Delay) ->
spawn_link(?MODULE, init, [self(), EventName, Delay]).
%%% Event's innards
init(Server, EventName, Delay) ->
loop(#state{server=Server,
name=EventName,
to_go=normalize(Delay)}).
インターフェースははるかにきれいになりました。ただし、テストする前に、送信できる唯一のメッセージであるキャンセルにも、独自のインターフェース関数を持たせる方が良いでしょう。
cancel(Pid) ->
%% Monitor in case the process is already dead
Ref = erlang:monitor(process, Pid),
Pid ! {self(), Ref, cancel},
receive
{Ref, ok} ->
erlang:demonitor(Ref, [flush]),
ok;
{'DOWN', Ref, process, Pid, _Reason} ->
ok
end.
お!新しいテクニック!ここでは、モニターを使用して、プロセスが存在するかどうかを確認しています。プロセスがすでに終了している場合は、無駄な待機時間を避けて、プロトコルで指定されているとおりにokを返します。プロセスが参照を返信した場合、すぐに終了することがわかります。参照を削除して、もう気にしなくなったときに参照を受信しないようにします。flushオプションも提供することに注意してください。これにより、私たちがデーモン監視する時間がある前にDOWNメッセージが送信されていた場合、そのメッセージがパージされます。
これらをテストしてみましょう。
17> c(event).
{ok,event}
18> f().
ok
19> event:start("Event", 0).
<0.103.0>
20> flush().
Shell got {done,"Event"}
ok
21> Pid = event:start("Event", 500).
<0.106.0>
22> event:cancel(Pid).
ok
そして、それは機能します!イベントモジュールで最後に面倒な点は、残りの時間を秒単位で入力しなければならないことです。Erlangの日時({{Year, Month, Day}, {Hour, Minute, Second}})などの標準形式を使用できればはるかに優れています。コンピューターの現在時刻と挿入した遅延との間の差を計算する次の関数を追加するだけです。
time_to_go(TimeOut={{_,_,_}, {_,_,_}}) ->
Now = calendar:local_time(),
ToGo = calendar:datetime_to_gregorian_seconds(TimeOut) -
calendar:datetime_to_gregorian_seconds(Now),
Secs = if ToGo > 0 -> ToGo;
ToGo =< 0 -> 0
end,
normalize(Secs).
そうです。calendarモジュールには、かなり変わった関数名があります。上記のように、これは現在時刻とイベントが発生する予定時刻の間の秒数を計算します。イベントが過去にある場合は、代わりに0を返し、できるだけ早くサーバーに通知します。normalize/1の代わりにこれを呼び出すようにinit関数を修正します。名前をより分かりやすくするために、Delay変数をDateTimeに変更することもできます。
init(Server, EventName, DateTime) ->
loop(#state{server=Server,
name=EventName,
to_go=time_to_go(DateTime)}).
これで完了したので、休憩しましょう。新しいイベントを開始し、ミルク/ビールを1パイント(0.5リットル)飲み、イベントメッセージが届くちょうど良いタイミングで戻ってきましょう。
イベントサーバー
イベントサーバーを処理しましょう。プロトコルによると、そのためのスケルトンは次のようになります。
-module(evserv).
-compile(export_all).
loop(State) ->
receive
{Pid, MsgRef, {subscribe, Client}} ->
...
{Pid, MsgRef, {add, Name, Description, TimeOut}} ->
...
{Pid, MsgRef, {cancel, Name}} ->
...
{done, Name} ->
...
shutdown ->
...
{'DOWN', Ref, process, _Pid, _Reason} ->
...
code_change ->
...
Unknown ->
io:format("Unknown message: ~p~n",[Unknown]),
loop(State)
end.
返信が必要な呼び出しを、以前と同じ{Pid, Ref, Message}形式でラップしていることに気付くでしょう。これで、サーバーは状態に2つのものを保持する必要があります。サブスクライブしているクライアントのリストと、生成したすべてのイベントプロセスのリストです。気づいたかもしれませんが、プロトコルでは、イベントが完了すると、イベントサーバーは{done, Name}を受信しますが、{done, Name, Description}を送信する必要があります。ここでは、必要なトラフィックを最小限に抑え、イベントプロセスが厳密に必要なものだけを処理できるようにするという考え方です。つまり、クライアントのリストとイベントのリストです。
-record(state, {events, %% list of #event{} records
clients}). %% list of Pids
-record(event, {name="",
description="",
pid,
timeout={{1970,1,1},{0,0,0}}}).
そして、ループにはこれでレコード定義がヘッダーに含まれるようになりました。
loop(S = #state{}) ->
receive
...
end.
イベントとクライアントの両方がorddictである方が良いでしょう。一度に何百ものイベントやクライアントがある可能性は低いです。データ構造に関する章を思い出してください。orddictは非常に適しています。これを処理するためのinit関数を記述します。
init() ->
%% Loading events from a static file could be done here.
%% You would need to pass an argument to init telling where the
%% resource to find the events is. Then load it from here.
%% Another option is to just pass the events straight to the server
%% through this function.
loop(#state{events=orddict:new(),
clients=orddict:new()}).
スケルトンと初期化が完了したので、各メッセージを1つずつ実装します。最初のメッセージは、サブスクリプションに関するメッセージです。イベントが完了したときに通知する必要があるため、すべてのサブスクライバーのリストを保持する必要があります。また、上記のプロトコルでは、それらを監視する必要があることが記載されています。クラッシュしたクライアントを保持したり、理由もなく役に立たないメッセージを送信したくないため、これは理にかなっています。いずれにせよ、次のようになります。
{Pid, MsgRef, {subscribe, Client}} ->
Ref = erlang:monitor(process, Client),
NewClients = orddict:store(Ref, Client, S#state.clients),
Pid ! {MsgRef, ok},
loop(S#state{clients=NewClients});
このloop/1のセクションは、モニターを開始し、キーRefの下にクライアント情報をorddictに格納します。その理由は簡単です。クライアントIDを取得する必要があるのは、モニターのEXITメッセージを受信するときだけで、そのメッセージには参照が含まれており、これにより(orddictのエントリを削除できます)。
次に処理するメッセージは、イベントを追加するメッセージです。エラー状態を返すことができます。実行する検証は、受け入れるタイムスタンプを確認することだけです。{{Year,Month,Day}, {Hour,Minute,seconds}}レイアウトをサブスクライブするのは簡単ですが、閏年でないときに2月29日などの存在しない日付を受け入れたり、存在しない日付値(「5時間、マイナス1分、75秒」など)を受け入れたりしないようにする必要があります。1つの関数ですべてを検証できます。
使用する最初の構成要素は、関数calendar:valid_date/1です。これは、名前が示すとおり、日付が有効かどうかを確認します。残念ながら、calendarモジュールの奇妙さは、変わった名前だけでは終わりません。実際には、{H,M,S}に有効な値があることを確認する関数はありません。変わった命名スキームに従って、それを実装する必要があります。
valid_datetime({Date,Time}) ->
try
calendar:valid_date(Date) andalso valid_time(Time)
catch
error:function_clause -> %% not in {{Y,M,D},{H,Min,S}} format
false
end;
valid_datetime(_) ->
false.
valid_time({H,M,S}) -> valid_time(H,M,S).
valid_time(H,M,S) when H >= 0, H < 24,
M >= 0, M < 60,
S >= 0, S < 60 -> true;
valid_time(_,_,_) -> false.
valid_datetime/1関数は、メッセージの追加を試行する部分で使用できるようになりました。
{Pid, MsgRef, {add, Name, Description, TimeOut}} ->
case valid_datetime(TimeOut) of
true ->
EventPid = event:start_link(Name, TimeOut),
NewEvents = orddict:store(Name,
#event{name=Name,
description=Description,
pid=EventPid,
timeout=TimeOut},
S#state.events),
Pid ! {MsgRef, ok},
loop(S#state{events=NewEvents});
false ->
Pid ! {MsgRef, {error, bad_timeout}},
loop(S)
end;
時間が有効な場合は、新しいイベントプロセスを生成し、呼び出し元に確認を送信する前に、そのデータをイベントサーバーの状態に格納します。タイムアウトが間違っている場合は、エラーが黙って渡ったり、サーバーがクラッシュしたりするのではなく、クライアントに通知します。名前の衝突やその他の制限について追加のチェックを追加できます(プロトコルドキュメントを更新することを忘れないでください!)。
プロトコルで定義されている次のメッセージは、イベントをキャンセルするメッセージです。イベントのキャンセルはクライアント側では決して失敗しないため、コードは単純です。イベントがプロセスの状態レコードにあるかどうかを確認します。ある場合は、定義したevent:cancel/1関数を使用してイベントを強制終了し、okを送信します。見つからない場合は、とにかくすべてがうまくいったとユーザーに伝えます。イベントは実行されておらず、それがユーザーが望んでいたことです。
{Pid, MsgRef, {cancel, Name}} ->
Events = case orddict:find(Name, S#state.events) of
{ok, E} ->
event:cancel(E#event.pid),
orddict:erase(Name, S#state.events);
error ->
S#state.events
end,
Pid ! {MsgRef, ok},
loop(S#state{events=Events});
良い、良い。これで、クライアントからイベントサーバーへのすべての自発的なやり取りがカバーされました。サーバーとイベントの間で行われる処理を処理しましょう。処理するメッセージは2つあります。イベントのキャンセル(既に完了済み)と、イベントのタイムアウトです。そのメッセージは単に{done, Name}です。
{done, Name} ->
case orddict:find(Name, S#state.events) of
{ok, E} ->
send_to_clients({done, E#event.name, E#event.description},
S#state.clients),
NewEvents = orddict:erase(Name, S#state.events),
loop(S#state{events=NewEvents});
error ->
%% This may happen if we cancel an event and
%% it fires at the same time
loop(S)
end;
そして、関数send_to_clients/2は、その名前が示すとおり動作し、次のように定義されます。
send_to_clients(Msg, ClientDict) ->
orddict:map(fun(_Ref, Pid) -> Pid ! Msg end, ClientDict).
ループコードの大部分については、これで十分です。残っているのは、異なるステータスメッセージを設定することです。クライアントのダウン、シャットダウン、コードのアップグレードなどです。それらがここにあります。
shutdown ->
exit(shutdown);
{'DOWN', Ref, process, _Pid, _Reason} ->
loop(S#state{clients=orddict:erase(Ref, S#state.clients)});
code_change ->
?MODULE:loop(S);
Unknown ->
io:format("Unknown message: ~p~n",[Unknown]),
loop(S)
最初のケース(shutdown)は非常に明確です。キルメッセージを受け取り、プロセスを終了させます。状態をディスクに保存したい場合は、これがその場所になる可能性があります。より安全な保存/終了セマンティクスが必要な場合は、すべてのadd、cancel、またはdoneメッセージでこれを行うことができます。ディスクからのイベントの読み込みは、init関数で実行し、それらが到着するたびに生成することができます。
'DOWN' メッセージの処理も非常に簡単です。クライアントが死滅したことを意味し、状態のクライアントリストから削除します。
不明なメッセージは、デバッグ目的でio:format/2を使用して表示されますが、実際の運用アプリケーションでは専用のログモジュールを使用する方が適切でしょう。
そして、コード変更メッセージが登場します。これは非常に興味深いので、独自のセクションを設けます。
ホットコードローディング
ホットコードローディングを行うために、Erlangにはコードサーバーと呼ばれるものがあります。コードサーバーは基本的に、ETSテーブル(VMネイティブのインメモリデータベーステーブル)を管理するVMプロセスです。コードサーバーは、単一モジュールの2つのバージョンをメモリに保持でき、両方のバージョンを同時に実行できます。モジュールの新しいバージョンは、c(Module)、l(Module)でコンパイルしてロードするか、codeモジュールの多くの関数のいずれかを使用してロードすると、自動的にロードされます。
理解すべき概念として、Erlangにはローカル呼び出しと外部呼び出しの両方があります。ローカル呼び出しとは、エクスポートされていない可能性のある関数で行うことができる関数呼び出しのことです。形式はAtom(Args)です。一方、外部呼び出しは、エクスポートされた関数でのみ実行でき、Module:Function(Args)という形式になります。
VMにモジュールの2つのバージョンがロードされている場合、すべてのローカル呼び出しは、プロセスで現在実行されているバージョンを介して行われます。しかし、外部呼び出しは、コードサーバーで使用可能な最新バージョンのコードに対して**常に**行われます。その後、外部呼び出し内からローカル呼び出しが行われると、コードの新しいバージョンで行われます。
Erlangのすべてのプロセス/アクターは、状態を変更するために再帰呼び出しを行う必要があるため、外部再帰呼び出しを行うことで、アクターの全く新しいバージョンをロードすることができます。
注記: プロセスが最初のバージョンでまだ実行されている間にモジュールの3番目のバージョンをロードすると、そのプロセスはVMによってキルされます。VMは、それをスーパーバイザーを持たず、自身をアップグレードする方法のない孤児プロセスとみなすためです。最古のバージョンを実行しているものがいない場合、それは単純に削除され、代わりに最新バージョンが保持されます。
モジュールの新しいバージョンがロードされるたびにメッセージを送信するシステムモジュールに自身をバインドする方法はあります。これを行うことで、このようなメッセージを受信したときのみモジュールをリロードし、常にコードアップグレード関数(例えばMyModule:Upgrade(CurrentState))を使用して行うことができます。この関数は、新しいバージョンの仕様に従って状態データ構造を変換できます。この「サブスクリプション」処理は、OTPフレームワークによって自動的に行われます。すぐに学習を始めます。リマインダーアプリケーションでは、コードサーバーを使用せず、代わりにシェルからのカスタムcode_changeメッセージを使用して、非常に基本的なリロードを行います。ホットコードローディングを行うために知っておく必要があるのは、ほぼこれだけです。それでも、より一般的な例を以下に示します。
-module(hotload).
-export([server/1, upgrade/1]).
server(State) ->
receive
update ->
NewState = ?MODULE:upgrade(State),
?MODULE:server(NewState); %% loop in the new version of the module
SomeMessage ->
%% do something here
server(State) %% stay in the same version no matter what.
end.
upgrade(OldState) ->
%% transform and return the state here.
ご覧のとおり、私たちの?MODULE:loop(S)はこのパターンに適合しています。
メッセージを隠しましょう
メッセージを隠す!人々があなたのコードとプロセスを構築することを期待するなら、インターフェース関数でメッセージを隠す必要があります。evservモジュールで使用したものはこちらです。
start() ->
register(?MODULE, Pid=spawn(?MODULE, init, [])),
Pid.
start_link() ->
register(?MODULE, Pid=spawn_link(?MODULE, init, [])),
Pid.
terminate() ->
?MODULE ! shutdown.
現時点では一度に1つしか実行する必要がないため、サーバーモジュールを登録することにしました。リマインダーの使用を多くのユーザーがサポートするように拡張する場合は、代わりにglobalモジュールまたはgprocライブラリで名前を登録することをお勧めします。このサンプルアプリのために、これだけで十分です。
最初に書いたメッセージは、次に抽象化する必要があるものです。サブスクライブする方法です。上記の小さなプロトコルまたは仕様ではモニターを要求していたため、ここに追加されています。サブスクライブメッセージによって返された参照がDOWNメッセージにある場合、クライアントはサーバーがダウンしたことを認識します。
subscribe(Pid) ->
Ref = erlang:monitor(process, whereis(?MODULE)),
?MODULE ! {self(), Ref, {subscribe, Pid}},
receive
{Ref, ok} ->
{ok, Ref};
{'DOWN', Ref, process, _Pid, Reason} ->
{error, Reason}
after 5000 ->
{error, timeout}
end.
次はイベントの追加です。
add_event(Name, Description, TimeOut) ->
Ref = make_ref(),
?MODULE ! {self(), Ref, {add, Name, Description, TimeOut}},
receive
{Ref, Msg} -> Msg
after 5000 ->
{error, timeout}
end.
受信する可能性のある{error, bad_timeout}メッセージをクライアントに転送することを選択しました。erlang:error(bad_timeout)を発生させてクライアントをクラッシュさせることもできました。クライアントをクラッシュさせるか、エラーメッセージを転送するかのどちらが適切かは、コミュニティでまだ議論されています。クラッシュ関数の代替案はこちらです。
add_event2(Name, Description, TimeOut) ->
Ref = make_ref(),
?MODULE ! {self(), Ref, {add, Name, Description, TimeOut}},
receive
{Ref, {error, Reason}} -> erlang:error(Reason);
{Ref, Msg} -> Msg
after 5000 ->
{error, timeout}
end.
次に、イベントのキャンセルです。名前だけを受け取ります。
cancel(Name) ->
Ref = make_ref(),
?MODULE ! {self(), Ref, {cancel, Name}},
receive
{Ref, ok} -> ok
after 5000 ->
{error, timeout}
end.
最後に、クライアントに提供される小さな機能として、特定の期間中にすべてのメッセージを蓄積するために使用される関数があります。メッセージが見つかった場合、すべて取得され、関数はできるだけ早く戻ります。
listen(Delay) ->
receive
M = {done, _Name, _Description} ->
[M | listen(0)]
after Delay*1000 ->
[]
end.
テストドライブ
これでアプリケーションをコンパイルしてテスト実行できるようになりました。少し簡単にするために、プロジェクトをビルドするための特定のErlang makefileを作成します。Emakefileという名前のファイルを開き、プロジェクトのベースディレクトリに配置します。このファイルにはErlang項が含まれており、Erlangコンパイラに素晴らしいサクサクした.beamファイルを生成するレシピを提供します。
{'src/*', [debug_info,
{i, "src"},
{i, "include"},
{outdir, "ebin"}]}.
これは、コンパイラにファイルにdebug_infoを追加する(これはめったに放棄したくないオプションです)、src/およびinclude/ディレクトリにあるファイルを探し、ebin/に出力することを指示します。
コマンドラインでerl -makeを実行すると、すべてのファイルがコンパイルされ、ebin/ディレクトリに配置されます。erl -pa ebin/を実行してErlangシェルを起動します。-pa <directory>オプションは、Erlang VMに、モジュールを探す場所としてそのパスを追加するように指示します。
別のオプションとして、通常どおりシェルを起動し、make:all([load])を呼び出すことができます。これは、現在のディレクトリにある「Emakefile」という名前のファイルを探し、(変更されている場合)再コンパイルし、新しいファイルをロードします。
これで、何千ものイベントを追跡できるようになりました(テキストを作成するときは、DateTime変数を意味のあるものに置き換えてください)。
1> evserv:start().
<0.34.0>
2> evserv:subscribe(self()).
{ok,#Ref<0.0.0.31>}
3> evserv:add_event("Hey there", "test", FutureDateTime).
ok
4> evserv:listen(5).
[]
5> evserv:cancel("Hey there").
ok
6> evserv:add_event("Hey there2", "test", NextMinuteDateTime).
ok
7> evserv:listen(2000).
[{done,"Hey there2","test"}]
素晴らしい。作成したいくつかの基本的なインターフェース関数があれば、クライアントの作成は非常に簡単になるはずです。
スーパービジョンの追加
前の章で行ったように、「リスターター」を作成して、より安定したアプリケーションにする必要があります。sup.erlという名前のファイルを開き、スーパーバイザーを配置します。
-module(sup).
-export([start/2, start_link/2, init/1, loop/1]).
start(Mod,Args) ->
spawn(?MODULE, init, [{Mod, Args}]).
start_link(Mod,Args) ->
spawn_link(?MODULE, init, [{Mod, Args}]).
init({Mod,Args}) ->
process_flag(trap_exit, true),
loop({Mod,start_link,Args}).
loop({M,F,A}) ->
Pid = apply(M,F,A),
receive
{'EXIT', _From, shutdown} ->
exit(shutdown); % will kill the child too
{'EXIT', Pid, Reason} ->
io:format("Process ~p exited for reason ~p~n",[Pid,Reason]),
loop({M,F,A})
end.
これは「リスターター」とやや似ていますが、こちらは少し汎用的です。start_link関数がある限り、任意のモジュールを使用できます。スーパーバイザー自体がシャットダウン終了信号で終了されない限り、監視しているプロセスを無期限に再起動します。使用例はこちらです。
1> c(evserv), c(sup).
{ok,sup}
2> SupPid = sup:start(evserv, []).
<0.43.0>
3> whereis(evserv).
<0.44.0>
4> exit(whereis(evserv), die).
true
Process <0.44.0> exited for reason die
5> exit(whereis(evserv), die).
Process <0.48.0> exited for reason die
true
6> exit(SupPid, shutdown).
true
7> whereis(evserv).
undefined
ご覧のとおり、スーパーバイザーをキルすると、その子もキルされます。
注記: OTPスーパーバイザーに関する章では、さらに高度で柔軟なスーパーバイザーについて説明します。スーパービジョントリと言った場合、人々が考えているのはこれらです。ここで説明するスーパーバイザーは、最も基本的な形式であり、実際のスーパーバイザーと比較して運用環境には適していません。
名前空間(またはその欠如)
Erlangはフラットなモジュール構造(階層はありません)であるため、アプリケーションが競合することがよくあります。その一例として、ほとんどすべてのプロジェクトで少なくとも一度は定義しようとする、頻繁に使用されるuserモジュールがあります。これは、Erlangに付属するuserモジュールと競合します。code:clash/0関数を使用して、競合がないかテストできます。
そのため、一般的なパターンは、すべてのモジュール名にプロジェクト名をプレフィックスとして付けることです。この場合、リマインダーアプリケーションのモジュールは、reminder_evserv、reminder_sup、reminder_eventに名前を変更する必要があります。
その後、プログラマーによっては、アプリケーション自体にちなんで名付けられたモジュールを追加して、プログラマーが独自のアプリケーションを使用する際に使用できる共通の呼び出しをラップすることにします。例として、スーパーバイザーによるアプリケーションの開始、サーバーへのサブスクライブ、イベントの追加とキャンセルなどの関数があります。
登録済みの名前、データベーステーブルなど、他の名前空間にも注意することが重要です。
非常に基本的な並行Erlangアプリケーションは、これでおしまいです。このアプリケーションでは、スーパーバイザー、クライアント、サーバー、タイマーとして使用されるプロセス(何千ものプロセスを持つことができます)など、多くの並行プロセスをあまり考えずに持つことができることを示しました。それらを同期する必要はなく、ロックも、本当のメインループもありません。メッセージパッシングにより、アプリケーションを分離された懸念事項とタスクを持ついくつかのモジュールに簡単に区切ることができます。
evserv.erl内の基本的な呼び出しを使用して、Erlang VMの外側からイベントサーバーと対話できるクライアントを作成し、プログラムを真に役立つものにすることができます。
ただし、それを行う前に、OTPフレームワークについて学習することをお勧めします。次のいくつかの章では、その構成要素の一部について説明します。これにより、はるかに堅牢でエレガントなアプリケーションを作成できます。Erlangの力の大きな部分は、それを利用することから来ています。これは、自尊心の高いErlangプログラマーが知っておく必要がある、慎重に作成され、設計されたツールです。