ThriftのプロキシをErlang/OTPで作成する


本記事はErlang Advent Calendar 2015の8日目の記事です。

最初に

本エントリでは、弊社で開発中のオンラインゲームサーバーエンジン「KiQ」の開発過程で得られた経験をもとに、Thriftの通信をErlang/OTPで仲介するといろいろなことができそうですよ、ということをご紹介します。

Thriftについて

Thriftは、多言語対応のRPCで、現在 0.9.3 が配布されています(Apache Thrift公式サイト)。Thriftでは、サーバーとクライアントの間はTCPソケットやファイルなどが使用でき、その中を流れるプロトコルとしてバイナリやJSONなどが選択できるようになっています。

KiQでは、クライアントとKiQの間、およびKiQとプラグインの間の通信にThriftを採用しており、KiQの多言語対応は、Thriftによって実現されています。

公式のチュートリアルページ( https://thrift.apache.org/tutorial/ )では、以下の言語が紹介されています。

Actionscript 3.0、c_glib、C++、CSharp、
D、Dart、Delphi、Go、Graphviz、Haxe Framework、
Haskell、Java、Javascript、Node.js、
OCaml、Perl、PHP、Python、Ruby

いろいろありますね。

上記のチュートリアルには含まれていませんが、ThriftはErlang/OTPにも対応しています。

ちなみに、多言語対応が売りのThriftですが、当然、同じ言語同士のRPCとしても利用可能です。

Thriftでの開発は、.thriftファイル(IDL)を作成してthriftコマンドに食わせ、待ち受けるサーバー側コードのスタブと、そこに接続するクライアントコードを生成させ、それらに実処理を追加していく、という流れになります。

RPCとしては標準的ですね。

プロキシほしいよね

thriftコマンドが生成したスタブでは、サーバー側コードはTCPで待ち受けるように書かれています。クライアントはサーバーがlistenしているポートにつなぎに行きます。サーバーはクライアントから直接接続されるわけです。

ですが、場合によってはThriftの通信に介入したり、もしくはサーバー側コードの実体を別のサーバーで動作させ、接続をそこに転送したり、といったことがしたくなります。

KiQの場合も、Vert.xに負荷分散をしてもらいたい、というニーズがありました。

どうするか。

KiQのプロトタイプを作成する際に採用したのはプロキシです。

* Thriftの通信をいったんプロキシの入り口に設置したThriftサーバーで終端し、
* プロキシの中でリクエストの内容を転送し、
* プロキシの出口であらたなThriftクライアントを生成し、
* 転送されてきたリクエストの内容で本来のThriftサーバーを呼び出す

という動きをするプロキシがあれば、Vert.x経由でリクエストを分散できそうです。

Thriftネイティブのプロトコルをそのまま扱うのはダメなの?

TCPソケットでリクエストが飛んでくるのですから、それを仲介するのは難しくなさそうな感じもします。

しかし、Thriftは.thriftファイルで構造体をサポートしており、C++やJavaなどではThrift構造体をクラスにバインディングしています。つまり、.thriftファイルで構造体まわりの変更があると、このバインディング用クラスも変更されることになります。

そしてVert.xはJavaベースの分散基盤です。このプロキシをVert.x上に作成するとなると、ThriftのJava実装に基づくことになりますが、.thriftファイルの変更で発生するバインディング用クラスの変更から基盤側が影響されないようにしなければなりません(もちろんプロトタイプではある程度固定してしまって構わなかったのですが…)。

こうしたところも考慮に入れ、そのうえで短期間に汎用の中継ツールを作るのは、プロトタイプ作成としては面倒そうでした。

Erlang/OTPでThriftプロキシを

ここで活躍したのが、Erlang版のThriftです。

Erlangでは、サーバー側が受け取る呼出情報(関数名とパラメータ)がErlang termで届くので、それをそのまま固めて中継にまわすことができます。Thrift構造体をクラスにバインディングする他の言語よりも非常に扱いやすくなっています。

クライアント向けインターフェース

以下は、Thriftのチュートリアルのhandle_function実装です。Thriftのサーバー側関数呼び出しはhandle_functionにRPCの呼出情報を渡し、apply/3で実行するという、わかりやすい構造ですね。これが改造のベースです。

handle_function(Function, Args) when is_atom(Function), is_tuple(Args) ->
case apply(?MODULE, Function, tuple_to_list(Args)) of
ok -> ok;
Reply -> {reply, Reply}
end.

そして、プロキシ化したhandle_function/2はこちら。関数名と引数をまとめてBase64でエンコードし、caller:call/1に送っています。これだけです。

handle_function(Function, Args) when is_atom(Function), is_tuple(Args) ->
CallData = base64:encode(term_to_binary({Function, Args})),
RepliedData = caller:call(CallData),
{ok, Term} = binary_to_term(base64:decode(RepliedData)),
case Term of
ok -> ok;
Reply -> {reply, Reply}
end.

(tproxy/src/gateway.erlより 全体はこちら)

サーバー向けインターフェース

また、Thrift RPCサーバーとの通信も、Erlang版であれば実装が容易です。呼び出し情報をErlang termで受け取ったら、thrift_client:call/3を呼び出すだけです。

上の呼び出し側に対応するコード(caller:callの実体であるhandle_call/3の中身)はこちら。Base64でデコードして関数名と引数を取り出し、「thrift_client:call/3」を呼んでいます。

handle_call({call, Packed}, _From, State) ->
{ok, Client0} = thrift_client_util:new("127.0.0.1", 10002, tproxy_thrift, []),
{Function, Args} = binary_to_term(base64:decode(Packed)),
ArgsList = tuple_to_list(Args),

{_Client1, Result} = thrift_client:call(Client0, Function, ArgsList),

EncodedResult = binary_to_list(base64:encode(term_to_binary(Result))),
thrift_client:close(Client0),
{reply, EncodedResult, State}.

(tproxy/src/caller.erlより 全体はこちら)

このように、サーバー、クライアントとも、.thriftファイルの変更に強い(というか中継するだけならまったく影響されない)のでした。

まとめ

KiQのプロトタイピングで使ったものそのものではありませんが、Thriftをプロキシするコードを

https://github.com/masatoshiitoh/thrift_proxy

に置きました。

プロキシを起動してC++ベースのサーバーとクライアントを実行すると、TCP/10001に接続したクライアントからのリクエストを、TCP/10002で待ち受けているサーバーが処理しているのが見ていただけると思います。

gRPCの登場でやや影の薄くなった感のあるThriftですが(失礼!)、まだまだ有用であると思います。そして、Thriftでややこしいことをするときに、Erlangでの実装はとても便利なように作られているのでした。ありがたいですね。

※ちなみに、その後Vert.x上にThriftリクエストの中継機能を直接実装できました。