ejabbered の gen_mod/ejabberd_hooks の仕組み

  • 書き殴りです、ご容赦ください
  • さらに嘘を書いている可能性大です
  • 自分の頭の整理をするために書き出しただけです
  • 日本語が大いに間違っていますが許してください

Erlang で書かれた XMPP サーバ実装の ejabberd ですが、かなり勉強になる実装になっています。中でも一番勉強になるのが gen_mod をつかった動的モジュール拡張でしょうか。ejabberd では mod_なんちゃらを気軽に実装する事が出来るようにプラガブルな実装になっています。Erlang でサーバを書いているときは、プラガブルな実装にしたくなります。

プラガブルにすればメンテナンスも拡張も思いのままだからです(言い過ぎ)。

ejabberd は gen_mod.erl と ejabberd_hooks.erl 二つのファイルで受けたリクエストに対して処理を行います。

gen_mod.erl はモジュールのベースとなる物です。これ自体を直接呼び出すことはほとんどありません。

behaviour(gen_mod) とするために -export([behabiour_info/1]) を使っています。これはおきまりで、behaviour_info(callbacks) に gen_mod を behaviour(振る舞い?) を指定した際、実装しなければならない API です。

gen_mod.erl

-export([behaviour_info/1]).

behaviour_info(callbacks) ->
    [{start, 2},
     {stop, 1}];
behaviour_info(_Other) ->
    undefined.

ejabberd では start/2 と stop/1 を実装しなければなりません。gen_mod は他にはどんなことをしているのでしょう。start/0 では ets でテーブルを作成しています。これはモジュールを登録しておくテーブルです。

次に gen_mod:star_module/3 では指定されたモジュールを先ほど作成したテーブルに追加しています。ここでポイントは case catch ... of です。

case catch Module:start(Host, Opts) of
    {'EXIT', Reason} ->
        del_module_mnesia(Host, Module),
        ets:delete(ejabberd_modules, {Module, Host}),
        ?ERROR_MSG("~p", [Reason]);
    _ ->
        ok
end.

この書き方はもう古く、try/catch を使うのがスマートです。
実際 RabbtMQ は全て try/catch に切り替えています。

try Module:start(Host, Opts) of
    _ ->
        ok
catch
    {'EXIT', Reason} ->
        del_module_mnesia(Host, Module),
        ets:delete(ejabberd_modules, {Module, Host}),
        ?ERROR_MSG("~p", [Reason])
end.

... なんか以外に読みづらいですね。case catch(...) of の方が読みやすいような。まぁ気にしないで次へ。

登録されたモジュールを実行します。実行に失敗したらテーブルから削除します。成功したらそのままです。

基本はこれらのモジュールを登録するだけの機能を持っています。ejabberd_hooks は登録されたモジュールをグループ分けにして処理を行います。

gen_ip_blacklist.erl というモジュールがあります。これはブラックリストを processone からダウンロードするモジュールです。behaviour(gen_mod) ですので start/2 と stop/1 を実装しています。start/1 は自分自身のモジュールの init を使って spawn してますね。複数登録されるモジュールじゃないので register してしまっています。

init では http:request を使うために inets:start(). しています。そして c2s の blacklist を登録するデータベースを作成しています。というかこれなんで gen_server じゃないんでしょうね。

loop(_State) ... があるということはプロセスはずーっと起動しているものの用です。gen_mod_sup.erl みたいなのを作ってそこにモジュールぶら下げるのではまずいのでしょうか。理由が色々有りそうですが ... (面倒とか複雑になるとかだと思いますが)

ets:new を作ったら update_bl_c2s で c2s のブラックリストを ?BLC2S ... "http://xaai.process-one.net/bl_c2s.txt" に http:request で取りに行っています。

取得したデータを元に bl_c2s テーブルを一度初期化して(delete_all_objects)取得したデータの IP をデータベースに入れています。その後、ejabberd_hooks:add で hooks へ優先度 50 で登録しています。登録される関数は is_ip_in_c2s_blacklist です。これは check_bl_c2s の際与えられる IP が blacklist かどうかをチェックし、blacklist の場合は hooks:run_fold だろうが run だろうがそこで処理を止めてしまいます。
さらに指定した一定間隔事に update_bl_c2s を実行するように timer:apply_interval を使っています。

mod_ip_blacklist.erl

init(State)->
    inets:start(),
    ets:new(bl_c2s, [named_table, public, {keypos, #bl_c2s.ip}]),
    update_bl_c2s(),
    %% Register hooks for blacklist
    ejabberd_hooks:add(check_bl_c2s, ?MODULE, is_ip_in_c2s_blacklist, 50),
    %% Set timer: Download the blacklist file every 6 hours
    timer:apply_interval(timer:hours(?UPDATE_INTERVAL), ?MODULE, update_bl_c2s, []),
    loop(State).

loop(_State) ->
    receive
	stop ->
	    ok
    end.

update_bl_c2s() ->
    ?INFO_MSG("Updating C2S Blacklist", []),
    case http:request(?BLC2S) of
	{ok, {{_Version, 200, _Reason}, _Headers, Body}} ->
	    IPs = string:tokens(Body,"\n"),
	    ets:delete_all_objects(bl_c2s),
	    lists:foreach(
	      fun(IP) ->
		      ets:insert(bl_c2s, #bl_c2s{ip=list_to_binary(IP)})
	      end, IPs);
	{error, Reason} ->
	    ?ERROR_MSG("Cannot download C2S blacklist file. Reason: ~p",
		       [Reason])
    end.

ちなみに ejabberd_c2s.erl で ejabberd_hooks:run_fold(check_bl_c2s, ...) が呼ばれて居ます。

gen_mod の仕組みは単純で start/2 で ejabberd_hooks.erl へモジュールを登録するということです。hooks 側で特定の hook atom で処理を行うような仕組みになっています。

hooks の仕組みは至って簡単ですが、かなり素敵なコードになっています。

add(...) では指定したモジュール、関数、優先度を指定します。優先度は後ほど説明します。
hooks 自体は gen_server で書かれていますので hook 管理プロセスとして起動しっぱなしになります。

hooks:add が呼ばれると gen_server:call で handle_call({add, ... }) が呼ばれます。ここで、まず hooks という ets テーブルに hook グループが登録されていないかどうかを確認します。 特定の hook 名で hook はグループ化されています。これは一度に同様の処理を行うためです。先ほどのブラックリストは1種類しかないプラグインですが、似たような処理を行う場合はひとまとめに処理をし、チェーンのように処理を連続させます。

hook グループが登録されていない場合は {優先度、モジュール、関数} を一つのリストとして hooks テーブルへ追加します。もしグループが登録されている場合はまず、グループのリストを取り出し、 lists:member で、今回の hook が登録されているかどうかを確認します。登録されていない場合は、 lists:merge を使い、優先度が高い(0 が一番高い) 順から並ぶよう二つのリストをマージします。insert で同様の hooks グループは上書きされます。

これで登録は終わりです。

delete は逆で、 hook グループから値を取り出し、削除したリストを上書き保存します。

さて、最後は hooks の実行です。これは run と run_fold があります。
run はとにかく同じ値を受け渡していきます。run_fold は与えられたチェーンの中で渡していく値が変化していく可能性があります。

ejabberd_hooks.erl

run(Hook, Args) ->
    run(Hook, global, Args).

run(Hook, Host, Args) ->
    case ets:lookup(hooks, {Hook, Host}) of
	[{_, Ls}] ->
	    run1(Ls, Hook, Args);
	[] ->
	    ok
    end.

run_fold(Hook, Val, Args) ->
    run_fold(Hook, global, Val, Args).

run_fold(Hook, Host, Val, Args) ->
    case ets:lookup(hooks, {Hook, Host}) of
	[{_, Ls}] ->
	    run_fold1(Ls, Hook, Val, Args);
	[] ->
	    Val
    end.

run は与えられた hooks グループ名と Host(今回はスルーしています)と引数を元に hooks テーブルを検索し、見つかったら淡々と処理をしていきます。その際値の変更は行いません。

run_fold も同様ですが、処理の最中に値が変更されることもあります。

run も run_fold も処理を中断しそこで値を返すということも出来るようになっています。A という hook が成功したら B という hook を処理する必要が無いといった場合に使われます。

ejabberd ではこれらのモジュールを登録し、プラグインを実行するといった処理を実装することで複雑になりがちな処理を mod_なんちゃらで分割するようにしています。
そこで erjabberd_hooks を使い hook をグループ化し、キレイに使い分けています。また、実装のコアのリクエストハンドラー部分では所定の処理で hooks を呼び出すだけといった簡単な実装が出来ています。

ejabberd 作った人たちは頭がいいなぁという話しです。

ライセンス

%%% ejabberd, Copyright (C) 2002-2009 ProcessOne

後書き

というかプラガブルに実装する方法を学ぶ本とか無いんでしょうか ... 。ejabberd の書き方も実は基礎の基だったり ... ?