スレッドベース 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
には気を付けよう - ソースコード