OTP とは?

それは Open Telecom Platform です!

A telephone with someone on the other end saying 'Hullo'

OTP は Open Telecom Platform の略ですが、もはやそれほど通信とは関係ありません(通信アプリケーションの特性を持つソフトウェアに関するものですが)。Erlang の素晴らしさの半分が並行性と分散処理から来ていて、もう半分がエラー処理能力から来ているとしたら、OTP フレームワークはその3分の1にあたります。

前の章では、言語の組み込み機能(リンク、モニター、サーバー、タイムアウト、終了トラップなど)を使用して、並行アプリケーションを記述する方法に関する一般的な実践例をいくつか見てきました。物事を行う順序、競合状態を回避する方法、プロセスがいつでも死ぬ可能性を常に覚えておく方法など、いくつかの「落とし穴」がありました。ホットコードローディング、プロセスの命名、スーパーバイザーの追加などもありました。

これらすべてを手動で行うのは時間のかかる作業であり、エラーが発生しやすい場合があります。忘れられるコーナーケースや陥る落とし穴があります。OTP フレームワークは、これらの重要な実践を長年にわたって綿密に設計され、実戦で鍛えられた一連のライブラリにまとめることで、これに対応します。すべての Erlang プログラマーはこれらを使用する必要があります。

OTP フレームワークは、アプリケーションの構築を支援するために設計されたモジュールと標準のセットでもあります。ほとんどの Erlang プログラマーが OTP を使用するようになるため、実際に遭遇するほとんどの Erlang アプリケーションはこれらの標準に従う傾向があります。

共通のプロセス、抽象化

前のプロセスの例で何度も行ったことの1つは、非常に具体的なタスクに従ってすべてを分割することです。ほとんどのプロセスで、新しいプロセスを生成する関数、初期値を与える関数、メインループなどがありました。

これらの部分は、実際には、プロセスが何に使用されるかに関係なく、記述するすべての並行プログラムに通常存在します。

common process pattern: spawn -> init -> loop -> exit

OTP フレームワークの背後にあるエンジニアとコンピューター科学者はこれらのパターンを発見し、一般的なライブラリのセットにそれらを含めました。これらのライブラリは、私たちが使用したほとんどの抽象化と同等のコード(参照を使用してメッセージにタグを付けるなど)を使用して構築されており、長年にわたって現場で使用され、私たちのインプリメンテーションよりもはるかに注意深く構築されているという利点があります。これらは、プロセスを安全に生成して初期化し、フォールトトレラントな方法でメッセージを送信するなど、多くの機能が含まれています。面白いことに、これらのライブラリを自分で使用する必要はほとんどありません。それらが含む抽象化は非常に基本的で普遍的であるため、それらの上にさらに多くの興味深いものが構築されました。それらのライブラリこそ、私たちが使用するものです。

graph of Erlang/OTP abstraction layers: Erlang -> Basic Abstraction Libraries (gen, sys, proc_lib) -> Behaviours (gen_*, supervisors)

以降の章では、プロセスの一般的な使用方法のいくつか、それらの抽象化方法、そして汎用化する方法を説明します。次に、それぞれについて、OTP フレームワークのビヘイビアを使用した対応する実装と、それぞれの使用方法を示します。

基本的なサーバー

最初に説明する一般的なパターンは、既に使用したものです。イベントサーバー を記述したとき、クライアントサーバーモデル と呼ぶことができるものがありました。イベントサーバーはクライアントからの呼び出しを受け取り、それらに基づいて処理を行い、プロトコルが指定している場合はそれに応答します。

この章では、非常にシンプルなサーバーを使用して、その本質的な特性に焦点を当てます。これがkitty_serverです。

%%%%% Naive version
-module(kitty_server).

-export([start_link/0, order_cat/4, return_cat/2, close_shop/1]).

-record(cat, {name, color=green, description}).

%%% Client API
start_link() -> spawn_link(fun init/0).

%% Synchronous call
order_cat(Pid, Name, Color, Description) ->
    Ref = erlang:monitor(process, Pid),
    Pid ! {self(), Ref, {order, Name, Color, Description}},
    receive
        {Ref, Cat} ->
            erlang:demonitor(Ref, [flush]),
            Cat;
        {'DOWN', Ref, process, Pid, Reason} ->
            erlang:error(Reason)
    after 5000 ->
        erlang:error(timeout)
    end.

%% This call is asynchronous
return_cat(Pid, Cat = #cat{}) ->
    Pid ! {return, Cat},
    ok.

%% Synchronous call
close_shop(Pid) ->
    Ref = erlang:monitor(process, Pid),
    Pid ! {self(), Ref, terminate},
    receive
        {Ref, ok} ->
            erlang:demonitor(Ref, [flush]),
            ok;
        {'DOWN', Ref, process, Pid, Reason} ->
            erlang:error(Reason)
    after 5000 ->
        erlang:error(timeout)
    end.
    
%%% Server functions
init() -> loop([]).

loop(Cats) ->
    receive
        {Pid, Ref, {order, Name, Color, Description}} ->
            if Cats =:= [] ->
                Pid ! {Ref, make_cat(Name, Color, Description)},
                loop(Cats); 
               Cats =/= [] -> % got to empty the stock
                Pid ! {Ref, hd(Cats)},
                loop(tl(Cats))
            end;
        {return, Cat = #cat{}} ->
            loop([Cat|Cats]);
        {Pid, Ref, terminate} ->
            Pid ! {Ref, ok},
            terminate(Cats);
        Unknown ->
            %% do some logging here too
            io:format("Unknown message: ~p~n", [Unknown]),
            loop(Cats)
    end.

%%% Private functions
make_cat(Name, Col, Desc) ->
    #cat{name=Name, color=Col, description=Desc}.

terminate(Cats) ->
    [io:format("~p was set free.~n",[C#cat.name]) || C <- Cats],
    ok.

これは子猫サーバー/ストアです。動作は非常に簡単です。猫を記述すると、その猫が得られます。誰かが猫を返品した場合、その猫はリストに追加され、クライアントが実際に要求したものとは別に、次の注文として自動的に送信されます(私たちは笑顔ではなく、この子猫ストアでお金を稼ぐためです)。

1> c(kitty_server).
{ok,kitty_server}
2> rr(kitty_server).
[cat]
3> Pid = kitty_server:start_link().
<0.57.0>
4> Cat1 = kitty_server:order_cat(Pid, carl, brown, "loves to burn bridges").
#cat{name = carl,color = brown,
     description = "loves to burn bridges"}
5> kitty_server:return_cat(Pid, Cat1).
ok
6> kitty_server:order_cat(Pid, jimmy, orange, "cuddly").
#cat{name = carl,color = brown,
     description = "loves to burn bridges"}
7> kitty_server:order_cat(Pid, jimmy, orange, "cuddly").
#cat{name = jimmy,color = orange,description = "cuddly"}
8> kitty_server:return_cat(Pid, Cat1).
ok
9> kitty_server:close_shop(Pid).
carl was set free.
ok
10> kitty_server:close_shop(Pid).
** exception error: no such process or port
     in function  kitty_server:close_shop/1

モジュールのソースコードを振り返ると、以前に適用したパターンを見ることができます。モニターのセットアップと解除、タイマーの適用、データの受信、メインループの使用、init 関数の処理などのセクションは、すべて馴染みがあるはずです。常に繰り返すこれらのことを抽象化することは可能であるはずです。

まず、クライアントAPIを見てみましょう。最初に気付くことは、同期呼び出しの両方が非常に似ていることです。これらは、前述のセクションで言及されている抽象化ライブラリに含まれる可能性の高い呼び出しです。ここでは、kitty サーバーのすべての一般的な部分を保持する新しいモジュールで単一の関数としてこれらを抽象化します。

-module(my_server).
-compile(export_all).

call(Pid, Msg) ->
    Ref = erlang:monitor(process, Pid),
    Pid ! {self(), Ref, Msg},
    receive
        {Ref, Reply} ->
            erlang:demonitor(Ref, [flush]),
            Reply;
        {'DOWN', Ref, process, Pid, Reason} ->
            erlang:error(Reason)
    after 5000 ->
        erlang:error(timeout)
    end.

これはメッセージとPIDを受け取り、関数に挿入し、安全な方法でメッセージを転送します。これからは、この関数の呼び出しでメッセージの送信を置き換えることができます。したがって、抽象化されたmy_serverとペアになる新しいkittyサーバーを書き直すと、次のように始めることができます。

-module(kitty_server2).
-export([start_link/0, order_cat/4, return_cat/2, close_shop/1]).

-record(cat, {name, color=green, description}).

%%% Client API
start_link() -> spawn_link(fun init/0).

%% Synchronous call
order_cat(Pid, Name, Color, Description) ->
    my_server:call(Pid, {order, Name, Color, Description}).

%% This call is asynchronous
return_cat(Pid, Cat = #cat{}) ->
    Pid ! {return, Cat},
    ok.

%% Synchronous call
close_shop(Pid) ->
    my_server:call(Pid, terminate).

私たちが持っている次の大きな一般的なコードチャンクは、call/2関数ほど明白ではありません。これまで記述してきたすべてのプロセスには、すべてのメッセージがパターンマッチされるループがあることに注意してください。これは少しデリケートな部分ですが、ここではパターンマッチングとループ自体を分離する必要があります。それをすばやく行う1つの方法は、次を追加することです。

loop(Module, State) ->
    receive
        Message -> Module:handle(Message, State)
    end.

そして、特定のモジュールは次のようになります。

handle(Message1, State) -> NewState1;
handle(Message2, State) -> NewState2;
...
handle(MessageN, State) -> NewStateN.

これは改善されています。さらにクリーンにする方法はまだあります。kitty_serverモジュールを読む際に注意を払っていたなら(そして私はそう願っています!)、同期的に呼び出す特定の方法と非同期的に呼び出す別の方法があることに気づいたでしょう。私たちの汎用サーバー実装が、どちらの種類の呼び出しであるかを明確に知るための明確な方法を提供できれば非常に役立ちます。

これを行うには、my_server:loop/2で異なる種類のメッセージを一致させる必要があります。つまり、関数の2行目にアトムsyncを追加して、同期呼び出しが明らかになるようにcall/2関数を少し変更する必要があります。

call(Pid, Msg) ->
    Ref = erlang:monitor(process, Pid),
    Pid ! {sync, self(), Ref, Msg},
    receive
        {Ref, Reply} ->
            erlang:demonitor(Ref, [flush]),
            Reply;
        {'DOWN', Ref, process, Pid, Reason} ->
            erlang:error(Reason)
    after 5000 ->
        erlang:error(timeout)
    end.

これで、非同期呼び出しのための新しい関数を提供できます。関数cast/2がこれを処理します。

cast(Pid, Msg) ->
    Pid ! {async, Msg},
    ok.

これで、ループは次のようになります。

loop(Module, State) ->
    receive
        {async, Msg} ->
             loop(Module, Module:handle_cast(Msg, State));
        {sync, Pid, Ref, Msg} ->
             loop(Module, Module:handle_call(Msg, Pid, Ref, State))
    end.
A kitchen sink

そして、sync/asyncの概念に合わないメッセージを処理するための特定のスロットを追加したり、デバッグ関数やホットコードのリロードなどの他のものを追加することもできます。

上記のループで残念な点は、抽象化が漏れていることです。my_serverを使用するプログラマーは、同期メッセージの送信とそれらへの応答時に参照についてまだ知る必要があります。それは抽象化を無意味にします。それを使用するには、退屈な詳細をすべて理解する必要があります。これがそのための簡単な修正です。

loop(Module, State) ->
    receive
        {async, Msg} ->
             loop(Module, Module:handle_cast(Msg, State));
        {sync, Pid, Ref, Msg} ->
             loop(Module, Module:handle_call(Msg, {Pid, Ref}, State))
    end.

変数PidRefをタプルに入れることで、Fromのような名前の変数として単一の引数として他の関数に渡すことができます。そうすると、ユーザーは変数の内部について何も知る必要がなくなります。代わりに、Fromの内容を理解する必要がある応答を送信する関数を用意します。

reply({Pid, Ref}, Reply) ->
    Pid ! {Ref, Reply}.

残りの作業は、モジュール名などを渡すスターター関数(startstart_linkinit)を指定することです。それらを追加すると、モジュールは次のようになります。

-module(my_server).
-export([start/2, start_link/2, call/2, cast/2, reply/2]).

%%% Public API
start(Module, InitialState) ->
    spawn(fun() -> init(Module, InitialState) end).

start_link(Module, InitialState) ->
    spawn_link(fun() -> init(Module, InitialState) end).

call(Pid, Msg) ->
    Ref = erlang:monitor(process, Pid),
    Pid ! {sync, self(), Ref, Msg},
    receive
        {Ref, Reply} ->
            erlang:demonitor(Ref, [flush]),
            Reply;
        {'DOWN', Ref, process, Pid, Reason} ->
            erlang:error(Reason)
    after 5000 ->
        erlang:error(timeout)
    end.

cast(Pid, Msg) ->
    Pid ! {async, Msg},
    ok.

reply({Pid, Ref}, Reply) ->
    Pid ! {Ref, Reply}.

%%% Private stuff
init(Module, InitialState) ->
    loop(Module, Module:init(InitialState)).

loop(Module, State) ->
    receive
        {async, Msg} ->
             loop(Module, Module:handle_cast(Msg, State));
        {sync, Pid, Ref, Msg} ->
             loop(Module, Module:handle_call(Msg, {Pid, Ref}, State))
    end.

次の作業は、my_serverで定義したインターフェースを尊重するコールバックモジュールとして、kittyサーバーを再実装することです(kitty_server2)。前の実装と同じインターフェースを維持しますが、すべての呼び出しはmy_serverを通過するようにリダイレクトされます。

-module(kitty_server2).

-export([start_link/0, order_cat/4, return_cat/2, close_shop/1]).
-export([init/1, handle_call/3, handle_cast/2]).

-record(cat, {name, color=green, description}).

%%% Client API
start_link() -> my_server:start_link(?MODULE, []).

%% Synchronous call
order_cat(Pid, Name, Color, Description) ->
    my_server:call(Pid, {order, Name, Color, Description}).

%% This call is asynchronous
return_cat(Pid, Cat = #cat{}) ->
    my_server:cast(Pid, {return, Cat}).

%% Synchronous call
close_shop(Pid) ->
    my_server:call(Pid, terminate).

モジュールの先頭に2番目の-export()を追加したことに注意してください。これらは、すべてを機能させるためにmy_serverが呼び出す必要がある関数です。

%%% Server functions
init([]) -> []. %% no treatment of info here!

handle_call({order, Name, Color, Description}, From, Cats) ->
    if Cats =:= [] ->
        my_server:reply(From, make_cat(Name, Color, Description)),
        Cats;
       Cats =/= [] ->
        my_server:reply(From, hd(Cats)),
        tl(Cats)
    end;

handle_call(terminate, From, Cats) ->
    my_server:reply(From, ok),
    terminate(Cats).

handle_cast({return, Cat = #cat{}}, Cats) ->
    [Cat|Cats].

そして、プライベート関数を再追加する必要があります。

%%% Private functions
make_cat(Name, Col, Desc) ->
    #cat{name=Name, color=Col, description=Desc}.

terminate(Cats) ->
    [io:format("~p was set free.~n",[C#cat.name]) || C <- Cats],
    exit(normal).

terminate/1で以前のokexit(normal)に置き換えることを忘れないでください。そうしないと、サーバーは継続して動作し続けます。

コードはコンパイル可能でテスト可能であり、以前と同じ方法で実行する必要があります。コードは非常に似ていますが、変更点を見てみましょう。

個別と汎用

私たちが行ってきたことは、OTP のコア(概念的に言えば)を理解することです。これが OTP の本質です。すべての汎用コンポーネントを取り込み、ライブラリに抽出し、それらが正しく機能することを確認し、可能であればそのコードを再利用します。その後、残りの作業は具体的なもの、アプリケーションごとに常に変化するものに焦点を当てるだけです。

明らかに、kittyサーバーだけでそのようなことを行っても、あまり節約できません。それは抽象化のための抽象化のように見えます。顧客に出荷する必要があるアプリがkittyサーバーだけだった場合、最初のバージョンで十分かもしれません。より大きなアプリケーションを持つ場合は、コードの汎用部分と特定のセクションを分離する価値があるかもしれません。

サーバーで実行されているErlangソフトウェアがあると想像してみましょう。私たちのソフトウェアは、いくつかの子猫サーバー、獣医プロセス(壊れた子猫を送信すると、修正されて返されます)、子猫美容院、ペットフード、用品などのサーバーを実行しています。これらのほとんどは、クライアントサーバーパターンで実装できます。時間が経つにつれて、複雑なシステムは周囲で実行されているさまざまなサーバーでいっぱいになります。

サーバーを追加すると、コードの観点からだけでなく、テスト、メンテナンス、理解の観点からも複雑さが増します。各実装は異なり、異なる人によって異なるスタイルでプログラムされるなどです。しかし、これらのサーバーがすべて同じ共通のmy_server抽象化を共有している場合、その複雑さを大幅に削減できます。モジュールの基本的な概念をすぐに理解できます(「ああ、サーバーだ!」)、テスト、ドキュメント化などを行うための単一の汎用実装があります。残りの作業は、それぞれの実装に費やすことができます。

A dung beetle pushing its crap

これは、多くの時間追跡とバグ修正を削減することを意味します(すべてのサーバーで一度に実行するだけです)。また、導入するバグの数を減らすことも意味します。my_server:call/3やプロセスのメインループを毎回書き直すと、時間がかかるだけでなく、手順を忘れたり、バグが発生する可能性が大幅に高まります。バグが少なくなれば、夜中に何かを修正するために電話をかける回数が減り、間違いなく私たち全員にとって良いことです。結果は異なる場合がありますが、休日出勤してバグを修正するのも好ましくないと確信しています。

汎用的なものと具体的なものを分離した際に起こったもう一つの興味深いことは、個々のモジュールのテストがはるかに容易になったことです。古いkittyサーバーの実装を単体テストする場合、テストごとに1つのプロセスを生成し、適切な状態を与え、メッセージを送信し、期待した応答を待つ必要がありました。一方、2番目のkittyサーバーでは、'handle_call/3'と'handle_cast/2'関数を介して関数呼び出しを実行し、新しい状態として出力されるものを確認するだけで済みます。サーバーをセットアップしたり、状態を操作する必要はありません。関数パラメーターとして渡すだけです。これは、サーバーの汎用的な側面も、何もせずに観察したい動作に集中できる非常に単純な関数を実装できるため、はるかに簡単にテストできることを意味します。

このように共通の抽象化を使用することの、より「隠れた」利点は、すべての人が同じバックエンドをプロセスに使用する場合、その単一のバックエンドを少し高速化するために誰かが最適化すると、それを使用するすべてのプロセスも少し高速化されることです。この原則を実践で機能させるには、通常、多くの人が同じ抽象化を使用し、それに努力を注ぐ必要があります。幸いなことに、Erlangコミュニティでは、OTPフレームワークでそれが起こっています。

私たちのモジュールに戻りましょう。まだ対処していないことがたくさんあります。名前付きプロセス、タイムアウトの構成、デバッグ情報の追加、予期しないメッセージの処理方法、ホットコードローディングの統合方法、特定のエラーの処理、ほとんどの応答を記述する必要性の抽象化、サーバーのシャットダウン方法のほとんどの処理、サーバーがスーパーバイザーと適切に動作するようにすることなどです。これらすべてを説明することは、このテキストでは冗長ですが、出荷する必要がある実際の製品では必要になります。繰り返しますが、これらすべてを自分で行うことが少し危険な作業である理由がわかるかもしれません。幸いなことに、あなた(そしてあなたのアプリケーションをサポートする人々)のために、Erlang/OTPチームが`gen_server`ビヘイビアでそれらすべてを処理してくれました。gen_serverは、ステロイドを注入したmy_serverのようなもので、長年にわたるテストと本番使用の実績があります。