スレッドベース vs イベント駆動
Ratpackはノンブロッキング・イベント駆動モデルで動作します。
データベースアクセスのようなブロッキング操作は、別スレッドで行わねばなりません。手書きでそれをやるのは大変ですから、RatpackにはPromiseによるコールバックの仕組みと、Blockingユーティリティーによるマルチスレッディングのサポートがついています。
これだけなら「ああ、JavaScriptみたいな感じね」で済む話なのですが、他のフレームワークを併用するとき、少々問題が発生します。
Javaの標準的なWebフレームワークであるJavaサーブレットは、1リクエスト-1スレッドのモデルで動作します。Spring Frameworkもサーブレットベースなので、このモデルに準拠します。サーブレットはHTTPリクエストを受けるとき、新しくスレッドを作成し(もしくはプールから取得し)、ハンドラーメソッドを実行します。この仕組みのおかげで各リクエストはそれぞれ別のスレッド内で実行されるため、プログラマーは(基本的には)スレッド間のリソースの共有などスレッドセーフティーの心配することなく、プログラムを書くことができます。
ところがRatpackはノンブロッキングなフレームワークなので、全てのリクエストが同じイベントループ、同じスレッドの中で実行されます(正確にはちょっと違いますが大体のイメージはそんな感じです)。ブロッキング処理は別スレッドで実行されるので、一つのリクエストが一つのスレッド内で完結するとは限りません。大抵のWebアプリケーションはデータベースに接続したり、外部のAPIを呼び出したりすると思います。
まとめると、サーブレットとRatpackでは、実行の「コンテキスト」とスレッドの関係が対応しないということです。
それ自体は別に設計思想の違いでありどちらが良い悪いという話ではないのですが、Javaの事実上の標準であるサーブレットが1リクエスト-1スレッドモデルなので、多くのJavaフレームワークはそれを前提に作られています。そのため、Ratpackとそのままでは一緒に使うことができないという事態が発生します。
ThreadLocal
(ThreadLocalなんて常識だという人は次のパラグラフへ!)
代表的な例がThreadLocalです。スレッドローカルは各スレッドに固有のインスタンスを保持するためのクラスです。例としてThreadLocalRandomを考えてみましょう。
実はJavaのRandomクラスはスレッドセーフです。複数のスレッドで共有しても安全です。ただし同期されるため、競合する場合パフォーマンスが悪くなります。
ここでThreadLocalRandomが活躍します。スレッドAとBが同じThreadLocalRandomに同時にアクセスしたとき、スレッドAはスレッドAに固有の内部的なRandomを、スレッドBはそれとは別の、スレッドB専用のRandomを使用します。同期が発生しないので、パフォーマンスは悪化しません。また、その場で毎回新しいRandomインスタンスを作るよりも効率的です。
ThreadLocalを使えば、同期の心配をすることなく、マルチスレッドに対応していないクラスを使用できるのです。
コンテキストの危機
最初に述べたように、サーブレットは1リクエスト-1スレッドで動作します。ThreadLocalは各スレッドに固有ですから、実際には「ThreadLocalはリクエストごとに一つのインスタンスを持つ」とみなすことができます。これはWebアプリケーションの実行コンテキストを管理するのに、とても便利な設計でした。例えばユーザー情報をスレッドローカルに保存すれば、プログラムはそのスレッドローカルなユーザー情報を、現在のリクエストの情報とみなすことができるのです。
代表例がlogbackのMDCです。最初にリクエストを受け付けたときにMDCにユーザー情報を保存しておけば、以後のログ出力で、そのユーザー情報を一緒に利用できるようになります。ログ出力側は、自分を呼び出しているのがどのリクエストなのか意識しないでも、その情報を利用できるのです。便利ですね。
お気づきでしょうか、Ratpackはノンブロッキングです。一つのリクエストが、一つのスレッドの中で実行されるわけではありません。ThreadLocalはRatpack上では使えません。
具体例でみてみましょう。
MDC.put("user", "John Smith");
logger.info("[1]")
Blocking.op(() -> {
logger.info("[2]");
}).then(() -> {
logger.info("[3]")
ctx.render( "OK" );
});
サーブレットであれば、3つのログ出力はどれも同一のMDCを参照します。すべてのログ出力で、MDCのuserの値はJohn Smithになります。
Ratpackでは、Blocking.op()内の処理は別スレッドです。MDC.put()を呼び出したスレッドとは別スレッドです。[2]のuserはnullになってしまいます。
Execution
では、サーブレットで行っていたようなコンテキストの管理を、Ratpackではどのように行えばよいのでしょうか?
実は、Ratpackには処理の実行をExecutionという単位で行います。RatpackのPromiseがRatpackアプリケーションの内部でしか使えないというルールは、実際には「Executionの中でしか使えない」ということだったのです。
Executionは処理のコンテキストでもあります。スレッドがサーブレットにおける実行コンテキストであるように、RatpackアプリケーションはExecutionにおいてコンテキストを管理します。Executionにはその内部を通して利用できる辞書機能を持っており、ある型のインスタンスを保存したり取り出したりすることができます。これをThreadLocalの代わりに活用できます。
ThreadLocalはRatpackでは使えないと述べましたが、RatpackにはMDCを利用するためのMDCInterceptorがあらかじめ用意されています。使い方は簡単で、Registryにこのクラスを登録するだけです。
MDCInterceptorは、ExecInterceptorインターフェースの実装です。このインターフェースは名前の通り、Executionに対するインターセプターとして動作します。すなわち、あるExecutionの実行前後に、何らかの処理を挟むために使用するものです。
MDCInterceptorの実装をのぞいてみると、処理はおおまかに次のような流れになっていることがわかります。
-
Executionからコンテキストの辞書を取り出す。ない場合、空のコンテキストの辞書を作成する。 -
MDC.setContextMap()を呼び、取り出した辞書を設定する。 -
Executionを実行する。 - MDCからコンテキストの辞書を取り出し(
MDC.getCopyOfContextMap())、Executionに設定
要するに、実際の処理の前にSLF4JのMDCを操作して、RatpackのExecutionとMDCが一致するように操作しています。
Sentryを使う
ようやく本題に入ります。ログ監視サービスSentryのJavaクライアントも、Ratpackにおいては若干の操作が必要になります。
SentryにはTagやBreadcrumbのような、実行のコンテキストを記録して出力する機能があります。しかし、Sentryのドキュメントにも書かれているように、「コンテキスト」が何を意味するかはアプリケーションによって異なります。Sentryは、このコンテキストの管理をContextManagerというクラスで抽象化しています。デフォルトでは二つの実装が用意されており、Android用JavaクライアントはSingletonContextManager、通常版ではThreadLocalContextManagerが使用されます。名前の通りシングルトンとスレッドローカルです。そして、今まで議論してきたように、スレッドローカルはRatpackでは使えません。シングルトン実装ではユーザー情報がおかしくなってしまいます。
そこで、Executionを利用した独自の実装のContextManagerを書くことにします。
@Override
public Context getContext() {
Optional<Execution> currentExecOpt = Execution.currentOpt();
if ( currentExecOpt.isPresent() ) {
Execution currentExec = currentExecOpt.get();
Optional<Context> context = currentExec.maybeGet( Context.class );
if ( context.isPresent() ) {
return context.get();
} else {
Context newContext = new Context();
currentExec.add( newContext );
return newContext;
}
} else {
return singletonContext;
}
}
まず、現在のプログラムがExecution内で実行されていることを確かめます。Executionの外にいる場合、シングルトンのContextオブジェクトを返します。これは別にスレッドローカルなどでもいいのですが、Executionの外で実行されるコードは、自前でスレッドを作成したりしない限り、サーバーの起動処理くらいだと思います。Context自体はsynchronizedされておりスレッドセーフなので、このような実装にすることにしました。
Execution内にいた場合、現在のExecutionにSentryのContextオブジェクトが保存されているかを確かめます。保存されていない場合は新しくContextを作成し、Executionに登録します。これで以後のExecutionにもContextが引き継がれるようになります。Blockingなどで別のスレッドに移動したとしても、同じContextが取得できるようになりました。
あとはこのContextManager実装を使用するようにすればいいのですが、これは、SentryClientFactoryをオーバーライドすることで実現できます。
public final class RatpackSentryClientFactory extends DefaultSentryClientFactory {
@Override
protected ContextManager getContextManager( Dsn dsn ) {
return new RatpackSentryContextManager();
}
}
これだけです。
最後に、Sentryクライアントを作成するデフォルトの方法として、このクラスを指定してあげる必要があります。やり方としては、Sentry.init()メソッドを使用する方法と、プロパティーを使う方法があります。
Sentry.init( new RatpackSentryClientFactory() );
ただ、sentry-logbackのような他のロギングフレームワークと統合されたクライアントを使う場合、Sentryの初期化はグローバルなSentryクラスに依存していると思います。実際、sentry-logbackは個別のSentryClientを指定するような作りにはなっていません。そのため、プロパティーファイル経由で設定するほうが好ましいと思います。
factory=factory=rip.deadcode.ratpack.sentry.RatpackSentryClientFactory
factoryキーの値に、作成したSentryClientFactoryのFQCNを指定します。
まとめ
- Ratpackで
ThreadLocalには気を付けよう - ソースコード