Erlangを業務で書き始めて1年半ほど経ちましたが, その間に自分自身で嵌まったこと.
そして, 身近な人が嵌まっていたポイントを纏めてご紹介します.
(ついでによく見る誤りも...)
バイナリ
バイナリパターンマッチで済むものは正規表現を使うべきではない
Url = <<"http://test.net/hoge">>,
re:run(Url, <<"http://.*">>).
のようなコードはerlangらしくないので, バイナリパターンマッチを使おう. (物によると思います)
case Url of
<<"http://", _/binary>> -> true;
_ -> false
end.
iolistではなくiodataを使おう
binaryを扱う上でよく出てくる型として, iolistとiodataがあるが, これらは以下のように定義されている
-type iodata() :: iolist() | binary().
-type iolist() :: maybe_improper_list(byte() | binary() | iolist(), binary() | []).
あまり使う機会はないが, erlangはimproper list([<<"hoge">> | <<"fugo">>]
)を使うことができるので, 以下のようなことが可能である.
%% iodataにbinaryをくっつけるような関数
%%
%% (<<"hoge">>, <<"fugo">>) -> [<<"hoge">> | <<"fugo">>]
%% (<<"hoge">>, [<<"fugo">>) -> [<<"hoge">>, <<"fugo">>]
-spec hoge(binary(), iodata()) -> iodata().
hoge(A, B) -> [A | B].
ちなみに, iolist_to_binary はiodata_to_binary
であり, list_to_binaryがiolist_to_binary
である.
な…何を言っているのかわからねーと思うが(ry
バイナリーパターンマッチのサイズは困ったら変数を使う
1> H = <<"hoge">>.
<<"hoge">>
2> <<H:(byte_size(H))/binary, _/binary>> = <<"hogefugo">>. % これはダメ
* 1: illegal bit size
3> Size = byte_size(H).
4
4> <<H:Size/binary, _/binary>> = <<"hogefugo">>. % こちらは大丈夫
<<"hogefugo">>
gen_server, supervisor周り
テキトウな再起動戦略は無限ループを生む
再起動戦略をテキトウに実装すると, 無限に再起動するプロセスが出来たりする.
以下の例がこれに該当する.
- supervisor A
|- gen_server A (one_for_one + permanent)
- supervisor B
|- gen_server B (simple_one_for_one + temporary)
init(_) ->
gen_server_b:start_link(...).
init(_) ->
erlang:send(self(), init), % 初期化処理を遅延実行
{ok, #?MODULE{}}.
handle_info(init, State) ->
.... % ここで例外で死ぬ
gen_server_b自体はtemporaryなので死ぬが, 引きずられてgen_server_aが死ぬことで, 再度gen_server_bが立ち上がる....という現象が発生する.
人を救うための再起動戦略が人を不幸にし始めるので依存関係まで考慮して適切な再起動戦略とプロセス監視ツリーを最初に構成した方が良いと思う.
simple_one_for_one + permanent / transientには要注意
supervisor以下に配置したgen_serverの終了の仕方には, およそ下の2つが考えられると思う.
supervisor:terminate_child(?MODULE, Pid)
gen_server:stop(Pid)
このどちらを取っても, そのプロセスが終了することは保証されるが, 再起動していないことは保証されない.
上記を呼び出す前に異常終了していた場合, supervisorの再起動戦略によって再起動されるので, 想定していない挙動をする場合がある.
つまり, 止めたつもりのプロセスが別のpidになって起動している... なんてことが起きる.
これを防ぐ為には,
- simple_one_for_one + temporary にする
- one_for_oneにする
といった手法をとらざるを得ない. (後者の場合は, pidではなくchild_idを使うため, terminate_childを用いれば想定した動作が保証される)
自分はtemporary以外ではsimple_one_for_oneを使わないようにしている.
gen_server などの init / terminateでは重い処理をしてはならない
理由は二つある.
- initが重いとtimeoutになる場合がある. (gen_server:call -> start_link など)
- supervisorのメッセージキューが溜まる (参考)
前者はすぐに気がつくが, 後者は負荷を上げないと気がつかないので, 要注意である. (特にterminateは気づきにくい)
gen_serverなどのterminateは必ず呼ばれる訳ではない
その為, terminateが呼ばれなくても大丈夫なようにコードを書くべきである.
自分はterminateに処理を書かないようにしている.
terminateが呼ばれる条件はKOU_CHANG氏のリンクされたプロセスの終了時の挙動がよく纏まっている.
eunit / ct 関係
非同期 (gen_server:cast) の実行が終わるのを待ちたい
meckという素晴らしいライブラリが存在するので, これを使うと以下のようなコードを書くことができる.
sleepを書くとCIを回し辛くなって人にストレスを与えるが, あまりこのコードを量産すると何したいのか分かりにくくなるのでご利用は計画的に.
%% 簡便化の為に, one_for_one + {local, ?MODULE}で名前付けをしているプロセスを例に取る.
ok = meck:new(Mod, [passthrough]),
ok = gen_server:cast(Mod, run),
%% handle_cast(run, _) が1回実行されるのを待つ
?assertEqual(ok, meck:wait(1, Mod, handle_cast, [run, '_'], 3000))
meckの使い方はここを参考にすると良い.
meck:expectしたはずが本来のコードが呼ばれる
大抵呼び出し元のコードがfully qualified function (Module:functionで呼び出された物) でないことが多い.
meckはfully qualified functionの関数しかmockできないので要注意.
なので、自分は意図的に?MODULE:function
な書き方をしてデバッグしやすくしたりする時もしばしば.
ctがいつまでも終わらない
ctはデフォルトでtimetrapがないので, 無限ループがあったりすると終わらない.
ここらへんを参考にwatchdogを入れると良い.
add_pathaでパスを通したが, 変なバージョンのmeckが呼び出される
erlangのinstallの仕方次第では, meckなどのライブラリがインストールされ, そちらを参照してしまう場合がある.
add_pathzを使ってパスを通すと解決する.
stickのモジュール (stdlibなど) をmeckしようとするが上手くいかない
meckの中で呼ばれているモジュールはmeckできないことがほとんどである. (listsなどがこれに該当する)
erlang shell上でmeckできるか実際にやってみると良い.
rebarで特定のSUITE, testだけ実行することができない
rebar2は特定の条件でしか正しく動作しないので, rebar3を使うことで解決されるケースが多い.
複数Nodeで発生する問題
is_process_aliveは自ノードのプロセスに対してしか使えない
気軽に呼ぶなってことなのだろうか.
あまりis_process_aliveをコード中に書くことはないのだが, テストや調査では使うので不便極まりない.
Pid must refer to a process at the local node.
とあるので, どうしても使いたい場合は
case rpc:call(node(Pid), erlang, is_process_alive, [Pid], infinity) of
{badrpc, _} -> false;
Other -> Other
end
とでもしよう.
process_infoなども同様の制限を受ける.
バージョン差異に注意
ホットコードアップデートとかやっていると, ノード間のバージョン差異は必ず起こる問題である.
特に問題になるのはrecordだろう.
update(Pid, Data) -> % Data is a record.
gen_server:call(Pid, {update, Data}).
こういったコードは危険ということになる.
OTP18で劇的に性能が上がったmap (参考)を使うか, ノード間通信をしないようにするか……工夫が必要になる.
自分は複数ノード間の呼び出しが発生する箇所を制限し (極一部のmoduleに閉じる) その箇所ではmapを使うようにしている.
on_finishのcallbackを定義してはならない
on_complete (成功した場合にcallbackが呼ばれる) は定義しても良いが, on_finish (成功しても失敗してもcallbackが呼ばれる) は定義してはならない.
例えば, 以下のケースを想定する.
処理が終わったら, 成否に関わらず Process A (Node A) に {Ref, finish} というメッセージを送りたい.
処理を行うのは Process B (Node B) である.
この場合, Node Bが落ちる (疎通が取れなくなるだけでも同様) と処理の成否に関わらずメッセージを送ることができなくなる.
よって, monitorを使うべきであり, on_finishは定義するべきではないと言える.
想定した挙動 | 現実 (実行された場合) | 現実 (実行されなかった場合) | |
---|---|---|---|
on_complete | 成功した場合にcallbackが呼ばれる | 成功していることが保証される | 失敗している可能性も, 成功している可能性もある |
on_finish | 成功しても失敗してもcallbackが呼ばれる | 実行が終了したことが保証される | 実行が終了していない可能性も, 実行が終了している可能性もある |
纏めると, 上記のようになる.
それを踏まえた上でmonitorと併用しつつ定義するのであれば良いだろう.
monitorを使えるところでは積極的にmonitorを採用すべきだと思う.
rpc:callで関数を呼び出した場合, selfは一時プロセスになる
fugo() ->
self().
(a@ubuntu)1> net_kernel:connect('b@ubuntu').
true
(a@ubuntu)2> self().
<0.47.0>
(a@ubuntu)3> rpc:call('b@ubuntu', hoge, fugo, []). % self()を呼ぶだけの関数
<6256.58.0>
その為, rpc:call先でlink/monitorをする場合は, Pidを別途渡す必要がある.
その他
システム間の循環参照を作らないようにする
サブシステムを使っていると起きがちだが, システムの循環参照が発生すると, application:startやdialyzerが正しい動作をしなくなる.
stacktraceを握りつぶさないようにする
以下のコードはstacktraceが失われる. (例外発生箇所がcatchの中になる)
try
hoge()
catch
Class:Reason ->
error_logger:format("failed : ~p ~p~n", [Class, Reason]),
erlang:Class(Reason)
end
また, 以下のコードでもstacktraceが失われる場合がある.
try
hoge()
after
fugo()
end
catchしたらstacktraceをログに吐くべきだし, 万全を期すならafterでは例外が飛ばない関数か, catch fugo()
のような書き方をするべきだろう.
error/2の第二引数は関数の引数である
Args is expected to be the list of arguments for the current function
適当な値を渡すとエラーログの表記が嘘になるので適切な引数を設定しなければならない.
間違えたまま使ってると、昔のコード見たときに泣きたくなる.
非常に興味深い記事が多い中恐縮ですが, 少しでも参考になれば幸いです.
あと一枠頑張ります.