このブログの更新は Twitterアカウント @m_hiyama で通知されます。
Follow @m_hiyama

メールでのご連絡は hiyama{at}chimaira{dot}org まで。

はじめてのメールはスパムと判定されることがあります。最初は、信頼されているドメインから差し障りのない文面を送っていただけると、スパムと判定されにくいと思います。

参照用 記事

Erlang実験室:多少は実用的なサンプル

Erlangで実用的なプログラムを書こうとすると、次のことが問題になったりします。

  1. 関数ライブラリをサーバープロセスに仕立てる方法
  2. OSの環境変数コマンドライン引数にアクセスする方法

以下では、これらのやり方をサンプルを使って示します。

内容:

  1. 例題の説明
  2. ソースファイルとドキュメンテーション
  3. 関数ライブラリの作成
  4. サーバープロセスの作成
  5. 環境変数コマンドライン引数を参照する
  6. より実用的にするには

だいぶ長いし、読む人も少ないと思うので、途中で「続きを読む」にします。

●例題の説明

例題として、ファイル名の一部から実際のファイルを探し出す仕組みを作りましょう。このメカニズムは、OSのコマンド実行のときに使われています、説明しましょう。OSの環境変数PATHには、いくつかのディレクトリが保持されています。例えば、/bin:/usr/bin:/home/hiyama/bin だとします。コマンドラインで単にfooと打つと、/bin/foo, /usr/bin/foo, /home/hiyama/bin/foo の順で探します*1Windowsではさらに、拡張子を付加します。どんな拡張子を付加するかは、Windows環境変数PATHEXTで指定されます。

ここでは、Windowsと同じように、ディレクトリのリストと拡張子(より正確には接尾辞)のリストをもとにファイルを探すことにします。ディレクトリのリストが [".", "../include", "src"]、拡張子のリストが [".erl", ".hrl", ""] だとすると、与えられたfooに対して、次の順でファイルを確認します。

  1. ./foo.erl
  2. ./foo.hrl
  3. ./foo
  4. ../include/foo.erl
  5. ../include/foo.hrl
  6. ../include/foo
  7. src/foo.erl
  8. src/foo.hrl
  9. src/foo

なお、標準ライブラリのfile:path_open/3, file:path_consult/2 などが似た機能を提供しています。

●ソースファイルとドキュメンテーション

http://www.chimaira.org/tools/findf-20081203.tar.gz というファイル(2つのtipsを使って作成しました)に、ソースファイル一式とEDocによるドキュメンテーションが入ってます。Erlangソースは:


findf/src/findf.erl
findf/src/findf_lib.erl
findf/src/findf_server.erl
findf/src/findf_util.erl

findf/doc内のHTMLドキュメントからは、ハイライトされたErlangソースが参照可能です。あと、オマケにgen_serverスケルトンを2種類入れてあります。

[追記]findf_util.erlのなかに、-compile(export_all). が残ってました。ごめんなさい、これは好ましくありません。以下にパッチ、patchコマンド使うより、人手のほうが早いでしょうが。


--- findf_util.erl.orig Tue Dec 2 16:15:29 2008
+++ findf_util.erl Wed Dec 3 18:35:12 2008
@@ -6,7 +6,7 @@
-export([add_to_list/2, remove_from_list/2]).
-export([get_list_from_env/1, get_list_from_arg/2]).
-export([split/2]).
--compile(export_all).
+% -compile(export_all).

%%% ============================================================
%%% リストへの、要素の追加と削除
[/追記]

●関数ライブラリの作成

まずは、例題の機能を関数として実装します。モジュール名はfindf_lib(ソースファイルfindf_lib.erl)です。


%% @doc ファイル名の一部から、
%% ディレクトリ・リストと拡張子リストをもとに、
%% 実在するファイルを探す.
%%
%% @spec (Name::string(), Dirs::[string()], Exts::[string()])
%% -> string()
%% @throws file_error()
find_file(_Name, [], _Exts) ->
% 探すべき場所がない
throw(enoent); % POSIXエラーコード
find_file(Name, [Dir | RestDirs], Exts) ->
case find_file_within(Name, Dir, Exts) of
not_found ->
find_file(Name, RestDirs, Exts);
FilePath ->
FilePath
end.

%% @doc 単一のディレクトリ内で、
%% 拡張子リストをもとに、
%% 実在するファイルを探す.
%% (内部的関数なので、戻り値not_foundを使用).
%%
%% @spec (string(), string(), Exts::[string()])
%% -> not_found | string()
find_file_within(_Name, _Dir, []) ->
not_found;
find_file_within(Name, Dir, [Ext |RestExts]) ->
FilePath = filename:join(Dir, string:concat(Name, Ext)),
case filelib:is_regular(FilePath) of
true ->
FilePath;
false ->
find_file_within(Name, Dir, RestExts)
end.

リストに関する再帰を使って素直に書けますね。

ついでに、find_file/3で見つけたファイルのオープンと一括リードの関数も入れておきましょう。


%% @type file_function() = function().
%% ファイル名文字列をただ1つの引数とするファイル操作関数.
%% 実際には、file:open/1 と file:read_file/1 .

%% @spec (string(), [string()], [string()], file_function())
%% -> any()
%% @throws file_error()
find_file_and_do(Name, Dirs, Exts, Fun) ->
FilePath = find_file(Name, Dirs, Exts),
case Fun(FilePath) of
{ok, Value} ->
Value;
{error, Reason} ->
throw(Reason)
end.

%% @doc find_file/3 で見つかったファイルをオープンする.
%% @spec (string(), [string()], [string()]) -> pid()
%% @throws file_error()
open_file(Name, Dirs, Exts) ->
find_file_and_do(Name, Dirs, Exts,
fun(X) -> file:open(X, [read]) end).

%% @doc find_file/3 で見つかったファイルを一括リードして、
%% 内容をバイナリとして返す.
%% @spec (string(), [string()], [string()]) -> binary()
%% @throws file_error()
read_file(Name, Dirs, Exts) ->
find_file_and_do(Name, Dirs, Exts,
fun(X) -> file:read_file(X) end).

例えば、


findf_lib:find_file("foo.html", [".", "templates"], ["", ".templ"]).
と呼び出すと、./foo.html → ./foo.html.templ → templates/foo.html → templates/foo.html.templ の順でフィイルを探して、なければ enoent(no such file or directory)例外を発行します。


> findf_lib:find_file("foo.html", [".", "templates"], ["", ".templ"]).
"templates/foo.html.templ"
> findf_lib:find_file("bar.html", [".", "templates"], ["", ".templ"]).
** exception throw: enoent
in function findf_lib:find_file/3
> file:format_error(
> catch findf_lib:find_file("bar.html", [".", "templates"], ["", ".templ"])).
"no such file or directory"
>

●サーバープロセスの作成

このモジュールfindf_lib.erlがエクスポートしている関数 find_file/3, open_file/3, read_file/3 は、これだけでも他のモジュールで使えます。が、サーバープロセスにしておくとより便利に使えます。

OTPのgen_severフレームワーク(ビヘイビア)を使います。EmacsErlangモードにgen_serverのスケルトン(テンプレート)があるので、これを埋めていけばよいでしょう。Emacsのgen_serverスケルトンと、それをEDoc向けに改変したものがアーカイブのオマケに付いてます。

さて、ディレクトリのリストと拡張子のリストは、サーバー内の状態として保持しておくことにします。


-record(state, {
dirs = [], % ディレクトリのリスト
exts = [] % 拡張子のリスト
}).

Erlangでプロセスの内部状態と言ったら、ループ関数の引数のことです*2

外部からのRPCリクエス*3では、find_file, opne_file, read_file の引数は1個だけになります。リクエストメッセージは次の形式にしましょう(これが標準です!)。

  1. {find_file, Name}
  2. {open_file, Name}
  3. {read_file, Name}

RPCなので、戻り値はok/error方式(「Erlang実験室:武士道と云ふは死ぬ事と見付けたり」を参照)にします。すると、gen_serverコールバックのhandle_callは次のようになります。


%% 公開APIの処理
handle_call({find_file, Name}, _From, State) ->
Reply = try
{ok,
findf_lib:find_file(Name, State#state.dirs, State#state.exts)}
catch
Reason ->
{error, Reason}
end,
{reply, Reply, State};
handle_call({open_file, Name}, _From, State) ->
Reply = try
{ok,
findf_lib:open_file(Name, State#state.dirs, State#state.exts)}
catch
Reason ->
{error, Reason}
end,
{reply, Reply, State};
handle_call({read_file, Name}, _From, State) ->
Reply = try
{ok,
findf_lib:read_file(Name, State#state.dirs, State#state.exts)}
catch
Reason ->
{error, Reason}
end,
{reply, Reply, State}.

決まり文句なので、コピー&モディファイの作業をするだけです。findf_libの各関数の例外をキャッチして再び{error, Reason}形式に戻しているのはバカバカしい感じですが、それでも通常の関数では例外の使用をお勧めします。今後は、RPCのメッセージ内に限ってok/error方式を使い、通信の両端で例外への変換を行うべきだと思います。

ディレクトリ・リストと拡張子リストはプロセス内部で保持するので、外からそれらを見たり変更するためのインターフェースが必要です。次のようにしましょう。

  1. get_dirs
  2. {set_dirs, Dirs}
  3. {add_to_dirs, Dir}
  4. {remove_from_dirs, Dir}
  1. get_exts
  2. {set_exts, Exts}
  3. {add_to_exts, Ext}
  4. {remove_from_exts, Ext}

非常に退屈なコーディングになりますが:


%% 状態のdirsに関する操作群
handle_call(get_dirs, _From, State) ->
Reply = State#state.dirs,
{reply, Reply, State};
handle_call({set_dirs, Dirs}, _From, State) ->
NewState =
#state{dirs = Dirs,
exts = State#state.exts},
Reply = ok,
{reply, Reply, NewState};
handle_call({add_to_dirs, Dir}, _From, State) ->
NewState =
#state{dirs = findf_util:add_to_list(Dir, State#state.dirs),
exts = State#state.exts},
Reply = ok,
{reply, Reply, NewState};
handle_call({remove_from_dirs, Dir}, _From, State) ->
NewState =
#state{dirs = findf_util:remove_from_list(Dir, State#state.dirs),
exts = State#state.exts},
Reply = ok,
{reply, Reply, NewState};

%% 状態のextsに関する操作群
handle_call(get_exts, _From, State) ->
Reply = State#state.exts,
{reply, Reply, State};
handle_call({set_exts, Exts}, _From, State) ->
NewState =
#state{exts = Exts,
dirs = State#state.dirs},
Reply = ok,
{reply, Reply, NewState};
handle_call({add_to_exts, Ext}, _From, State) ->
NewState =
#state{exts = findf_util:add_to_list(Ext, State#state.exts),
dirs = State#state.dirs},
Reply = ok,
{reply, Reply, NewState};
handle_call({remove_from_exts, Ext}, _From, State) ->
NewState =
#state{exts = findf_util:remove_from_list(Ext, State#state.exts),
dirs = State#state.dirs},
Reply = ok,
{reply, Reply, NewState};

add_to_list/2 と remove_from_list/2 は、ちょっとしたユーティリティ関数です。findf_util.erlで次のように定義しています。


%% @doc リストの先頭に要素を追加する.
%% ただし、すでに同じ要素があれば何もしない.
%% @spec(term(), list()) -> list()
add_to_list(Elm, List) ->
case lists:member(Elm, List) of
true ->
List;
false ->
[Elm |List]
end.

%% @doc リストから要素を取り除く.
%% 指定した要素がなければ何もしない.
%% @spec(term(), list()) -> list()
remove_from_list(Elm, List) ->
lists:delete(Elm, List).

get_dirs, get_exts 以外は戻り値を必要としないので、handle_callではなくてhandle_castに書いて、キャスト(cast, oneway call)で通信する方法もあります。停止命令などはキャストで十分ですね*4


handle_cast(stop, State) ->
{stop, normal, State};
handle_cast(_Msg, State) ->
% その他は無視
{noreply, State}.

環境変数コマンドライン引数を参照する

さて、findfサーバーのスタートを次のように書いたとしましょう。


start() ->
gen_server:start({local, findf_server}, ?MODULE, no_arg, []).

init(no_arg) ->
{ok, #state{}}.

すると、サーバー起動時はディレクトリ・リストも拡張子リストも空っぽです。set_dirsなどで設定しないとうまく動きません。これはどうも面倒です。OSの環境変数とerlコマンドへの引数から情報を取って初期化することにします。

ある名前のOS環境変数に、ディレクトリ並びが設定されているとき、それをErlangのリストに直すには、例えば次のようにします。


-define(WIN_DELM_CH, $;). % Windowsの区切り記号
-define(STD_DELM_CH, $:). % その他の標準的区切り記号

%% @doc 並びを単一文字列で表現するときの区切り記号、OSにより異なる.
%% @spec () -> string()
list_delm_ch() ->
case os:type() of
{win32, _} ->
?WIN_DELM_CH;
_ ->
?STD_DELM_CH
end.

%% @doc 引数で指定された名前の環境変数から
%% 文字列のリストを得る.
%% @spec (string()) -> [string()]
get_list_from_env(EnvName) ->
case os:getenv(EnvName) of
false ->
[];
EnvStr ->
split(EnvStr, list_delm_ch())
end.

ここで split/2 は、string:tokens/2 とほとんど同じなんですが、string:tokens/2 が長さ0のトークンを無視してしまうので致し方なく書いたものです。なんか不格好ですが:

%% @doc 文字列を、指定された区切り文字で分割する.
%% @spec (string(), char()) -> [string()]
split(Str, Ch) ->
lists:reverse(split_rev(Str, Ch, "", [])).

%% @spec (string(), char(), string(), [string()]) -> [string()]
split_rev("", _Ch, Acc, Out) ->
[lists:reverse(Acc)|Out];
split_rev([Ch|Rest], Ch, Acc, Out) ->
split_rev(Rest, Ch, "", [lists:reverse(Acc)|Out]);
split_rev([OtherCh|Rest], Ch, Acc, Out) ->
split_rev(Rest, Ch, [OtherCh|Acc], Out).

Erlangでは、コマンドライン引数からの情報を init:get_argument(Name) で取れます。ここでNameは通常はアプリケーション名です。コマンドライン引数に、-myapp param1, param2 のようなオプションがあると、init:get_argument(myapp) で、[[param1, param2]] が返ってきます。リストの入れ子が深すぎるようですが、-myappオプションを複数指定できるので、リストのリストなのです。

init:get_argument(Name) で取ってきた「文字列のリストのリスト」の解釈は自由なので、なんか約束をする必要があります。ここでは、-myapp Key Value1 Value2 ... のように、オプションの先頭パラメータだけがキー文字列だと解釈します。-myapp Key1 Value1 Key2 Value2 ... のような解釈もありますし、その他の解釈もあるでしょう。「Key Value1 Value2 ... 」をパーズするために次の関数を用意します。


%% @doc 引数で指定されたアプリケーション名、キー名をもとに、
%% コマンドライン引数から文字列のリストを得る.
%% @spec (string(), string()) -> [string()]
get_list_from_arg(AppName, KeyName) ->
case init:get_argument(AppName) of
error ->
[];
{ok, ListOfList} ->
lists:foldl(fun(List, Acc) ->
gather_list(KeyName, List, Acc) end,
[],
ListOfList)
end.

%% @spec (KeyName::string(), KeyValues::[string()], [string()])
%% -> [string()]
gather_list(KeyName, [KeyName |Values], Acc) ->
Acc ++ split_and_merge_string_list(Values);
gather_list(_KeyName, _NoMatch, Acc) ->
Acc.

%% @spec (StrList::[string()]) -> [string()]
split_and_merge_string_list([]) ->
[];
split_and_merge_string_list([ValueStr |Rest]) ->
List = split(ValueStr, list_delm_ch()),
List ++ split_and_merge_string_list(Rest).

そして、サーバープロセスの初期化のとき、環境変数コマンドライン引数、initへの引数をもとに状態(2つのリスト)をセットアップすることにします。


init(no_arg) ->
init(#state{});
init(InitState) ->
if
not is_record(InitState, state) ->
{stop, badarg};
true ->
State = #state{
dirs = InitState#state.dirs
++ findf_util:get_list_from_arg(?APP_NAME, ?DIRS_KEY)
++ findf_util:get_list_from_env(?DIRS_ENV),

exts = InitState#state.exts
++ findf_util:get_list_from_arg(?APP_NAME, ?EXTS_KEY)
++ findf_util:get_list_from_env(?EXTS_ENV)
},
{ok, State}
end.

●より実用的にするには

これで、gen_serverコールバックモジュールは出来上がりです(詳細はfindf_server.erlを参照)。後は、findfサーバーを使いやすいようにインターフェース関数を書いて、それをfindf.erlにまとめます。findfユーザーは、findfモジュールだけを知っていればfindf機能を使えるようになります。

このfindfアプリケーションは、ある程度は実用的に使えると思いますが、ちょっと問題があります。それは、findfサーバーが名前を登録(register)してしまうので、シングルトンプロセスになってしまうことです。findfのようなサーバーは1つのERTS内で何個も使えたほうが便利なので、名前の登録はしないほうがいいでしょう。また、名前付きシングルトンプロセスとして使うならば、スーパーバイザにぶら下げて使えばより安心です。

スーパーバイザの使い方とかは、またいつか。

*1:実行パーミションがあるファイルだけを探します。

*2:プロセスディクショナリもありますが、その使用は推奨されていません。

*3:RPCのRはリモートですが、同じメモリ空間内での呼び出しのときもRPCと呼びます。

*4:確実に停止したことを確認したいなら、コールのほうが安心です。