はじめに
業務でSpringを利用することばかりなのですが、RequestScopeの挙動を調べる機会があったので、ここで共有します。
前提
- Oracle JDK 17.0.5
- SpringFramework 6.0.2
- RequestScopeアノテーションの
proxyMode
はScopedProxyMode.TARGET_CLASS
要約
- リクエストスレッドでRequestScopeのSpringコンポーネントが最初に呼ばれたときにインスタンスを生成して、その後そのインスタンスをリクエストスレッドで使いまわしている。
- RequestScopeのSpringコンポーネントをむやみに子スレッドで利用しないように。(スレッドアンセーフになることもある)
SpringのRequestScopeとは
SpringではIoCコンテナ1によって、様々なスコープでインスタンスをDI(依存性の注入)することができます。
そのひとつがRequestScopeです。
SpringのWebアプリケーションでHTTPリクエストごとに新しいBeanインスタンスを作成し、そのインスタンスをリクエストのライフサイクルに合わせて管理します。
つまり、同一のHTTPリクエストが処理されている間は同じBeanインスタンスが使われ、リクエストの処理が終了するとそのインスタンスは破棄されます。
公式ドキュメントだと、下記に詳しく記載されています。
https://spring.pleiades.io/spring-framework/reference/core/beans/factory-scopes.html
RequestScopeを調べることになった出来事
業務で「RequestScopeのSpringコンポーネントを、リクエストのスレッドから派生した複数の子スレッドで共有したい」というQAがきました。
そもそもどういう仕組みだったっけな...と思いつつ、やり方を調べながら手元で検証することにしました。
そもそもRequestScopeをリクエストスレッド以外で扱うとどうなるか
下記のコードのように、何もせずにリクエストスレッドから子スレッドを作成してRequestScopeのSpringコンポーネントを利用すると、例外が発生します。
// これがRequestScopeのSpringコンポーネント
@Autowired
private ConcurrentMapCache mapCache;
@Autowired
private TaskExecutor taskExecutor;
@GetMapping("/err")
public void err() {
taskExecutor.execute(() -> {
mapCache.put("err", "value");
});
}
Exception in thread "task-1" org.springframework.beans.factory.support.ScopeNotActiveException: Error creating bean with name 'scopedTarget.mapCache': Scope 'request' is not active for the current thread; consider defining a scoped proxy for this bean if you intend to refer to it from a singleton
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:374)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:200)
at org.springframework.aop.target.SimpleBeanTargetSource.getTarget(SimpleBeanTargetSource.java:35)
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:687)
at org.springframework.cache.concurrent.ConcurrentMapCache$$SpringCGLIB$$0.put(<generated>)
at jp.co.nri.palette.example.grpc.greet.client.GreetingController.lambda$err$9(GreetingController.java:128)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635)
at java.base/java.lang.Thread.run(Thread.java:833)
Caused by: java.lang.IllegalStateException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing a request outside of the originally receiving thread? If you are actually operating within a web request and still receive this message, your code is probably running outside of DispatcherServlet: In this case, use RequestContextListener or RequestContextFilter to expose the current request.
at org.springframework.web.context.request.RequestContextHolder.currentRequestAttributes(RequestContextHolder.java:131)
at org.springframework.web.context.request.AbstractRequestAttributesScope.get(AbstractRequestAttributesScope.java:42)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:362)
... 8 more
スタックトレースを見てみると、RequestContextHolder#currentRequestAttributes
の実行時にスレッドにRequestAttributes
が存在しないことによって例外が発生していることがわかります。
AbstractBeanFactory#doGetBean
で上記例外がハンドリングされてScopeNotActiveException
がスローされていますが、例外もメッセージもわかりやすいですね。
リクエストスレッドの子スレッドでRequestScopeを扱うためのひとつの方法
色々調べてみたところ、RequestScopeのSpringコンポーネントをリクエストスレッドの子スレッドで扱うための方法はいくつかありそうです。
改修範囲をできるだけ減らせる方法として、RequestAttributes
を引き継ぐ方法があります。
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
Runnable runnable = () -> {
try {
RequestContextHolder.setRequestAttributes(requestAttributes);
/* ここでRequestScopeのBeanを扱える */
} finally {
RequestContextHolder.resetRequestAttributes();
};
上記のRunnableをリクエストスレッドの子スレッドで実行することで、RequestScopeのSpringコンポーネントを扱ってもエラーが発生しなくなります。
仮説
今回の改修方法において、ふわふわしながらも下記の仮説をたてて検証に進むことにしました。
- RequestScopeのSpringコンポーネントを扱うにあたって、スレッドに
RequestAttributes
が必要である。 - RequestScopeのSpringコンポーネントは
RequestAttributes
に保存されて、その後使い回される。 - リクエストを受信してからController処理開始までにRequestScopeのSpringコンポーネントが生成される。(ここがかなりふわふわしていた)
早速RequstScopeのSpringコンポーネントを複数スレッドから利用してみる
下記のように、Java ConfigとControllerの実装を用意しました。
@Configuration
public class SampleConfiguration {
// RequestScopeを指定する
@RequestScope
@Bean
// Springで用意しているスレッドセーフのキャッシュクラスをSpringコンポーネントにする
public ConcurrentMapCache mapCache() {
return new ConcurrentMapCache("cache"); /* 引数は今回関係なし */
}
}
@RestController
@RequiredArgsConstructor
public class SampleController {
private final ConcurrentMapCache mapCache;
private final TaskExecutor taskExecutor;
@GetMapping("/cache")
public void cache() {
// リクエストスレッドのRequstAttributesを取得
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
List<Runnable> runnables = new ArrayList<>();
// 10コのRunnableのListを作成する
IntStream.rangeClosed(0, 10).forEach(i -> {
runnables.add(
// mapCacheに対してキー:i, バリュー:"value"をputするだけのRunnable
() -> {
try {
RequestContextHolder.setRequestAttributes(requestAttributes, true);
mapCache.put(String.valueOf(i), "value");
} finally {
RequestContextHolder.resetRequestAttributes();
}
}
);
});
// 10コのRunnableを同時に実行して待つ
CompletableFuture.allOf(
runnables.stream()
.parallel()
.map(runnable -> CompletableFuture.runAsync(runnable))
.toArray(CompletableFuture[]::new));
// mapCacheに入った要素を確認する
IntStream.range(0, 10).forEach(i -> {
// 要素があればokを表示、なければfailed
boolean success = mapCache.evictIfPresent(String.valueOf(i));
System.out.println(String.format("%2d - check:%s", i, success ? "ok" : "failed"));
});
}
これを実行すると、すべてのRunnableが同じmapCacheに対して要素を追加できていれば、okのみが表示されるはずです。
実行してみると下記のような結果に。
0 - check:failed
1 - check:failed
2 - check:ok
3 - check:failed
4 - check:failed
5 - check:failed
6 - check:failed
7 - check:ok
8 - check:ok
9 - check:ok
...半分以上失敗していますね。
特にok/failedに規則性があるわけではなく、実行するたびに結果が変わります。
これはつまり、リクエストスレッドに対して複数のmapCache
のインスタンスが存在していることを示唆していますね。
仮説3のRequestScopeのSpringコンポーネントが事前に作成されているというのも違いそうです。
なぜこのような結果になっているのか、mapCache#put
しているところを中心にデバッグしてみます。
RequestScopeのSpringコンポーネントをデバッグ
ControllerのRunnable内のmapCache#put
にデバッグポイントを置いてみます。
また、スタックトレースに記載されていたRequestContextHolder#currentRequestAttributes
にもデバッグポイントを置きます。
mapCache
のクラスを見てみると、CGLIBで生成されたクラスであることがクラス名からわかります。
これは、RequestScopeアノテーションのproxyMode
にScopedProxyMode.TARGET_CLASSがデフォルトで指定されているためです。
RequestContextHolder#currentRequestAttributes
で止めると、上記の左下にあるような呼び出し階層になります。
これをそれぞれ見てみると、下記のようなことがわかります。
-
ConcurrentMapCache
のCGLIBで作成されたクラスが、ConcurrentMapCache
のインスタンスを取得しようとしていそう。 -
ConcurrentMapCache
のインスタンスを現在のスレッドに紐づくRequestAttributes
から取得しようとしている。
AbstractRequestAttributesScope#getの44行目を見てみると、if (scopedObject == null) {
があり、これは「RequestAttributesに対象のSpringコンポーネントがなかった(nullだった)場合」を示しています。
if文の次の行にデバッグポイントを置いて、もう一度リクエストを送信してみます。
AbstractRequestAttributesScope#get
の引数scopedTarget.mapCacheがRequestAttributes
に存在しないため、scopedObject
がnullとなっていることがわかります。
また、45行目ではobjectFactory.getObject()
によってscopedObject
を取得しています。
ここを更に深掘りすると、新しいConcurrentMapCache
を作成して返却していました。
46行目では、新しく生成されたConcurrentMapCache
をRequestAttributes
に追加していることがわかります。
RequestAttributes
のインスタンスはリクエストスレッドのすべての子スレッドで共有しているので、以降AbstractRequestAttributesScope#get
を別スレッドが実行すると、同じConcurrentMapCache
を取得できます。
デバッグしてわかったこと
RequestScopeのSpringコンポーネントをデバッグすることによって、下記が明らかになりました。
- RequestScopeのSpringコンポーネントはリクエストスレッドの最初に利用するときに生成されて、
RequestAttributes
に追加される。 - 2回目以降は
RequstAttributes
からRequestScopeのSpringコンポーネントのインスタンスを取得して利用する。
上記から、リクエストスレッド上で同時に複数子スレッドからRequestScopeのSpringコンポーネントを扱うとき、複数スレッドがAbstractRequestAttributesScope#getで新しいインスタンスを作成してRequestAttributes
に追加するため、スレッドアンセーフな動きになることがわかります。
(RequestAttributes#put
は上書きなので、最後に追加されたインスタンスがRequestScopeのSpringコンポーネントとして以降利用される)
リクエストスレッドでRequestScopeのSpringコンポーネントを事前に呼び出す
判明したことから、RequestScopeのSpringコンポーネントをリクエストスレッドで一度利用しておくと、子スレッドでスレッドアンセーフにならなそうです。
下記のように、リクエストスレッドでmapCache
を一度呼び出すコードを追加します。
@RestController
@RequiredArgsConstructor
public class SampleController {
private final ConcurrentMapCache mapCache;
private final TaskExecutor taskExecutor;
@GetMapping("/cache")
public void cache() {
// リクエストスレッドのRequstAttributesを取得
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
List<Runnable> runnables = new ArrayList<>();
// ★ここで一度RequestScopeのSpringコンポーネントを呼び出しておく★
mapCache.put("x", "x");
// 10コのRunnableのListを作成する
IntStream.rangeClosed(0, 10).forEach(i -> {
runnables.add(
// mapCacheに対してキー:i, バリュー:"value"をputするだけのRunnable
() -> {
try {
RequestContextHolder.setRequestAttributes(requestAttributes, true);
mapCache.put(String.valueOf(i), "value");
} finally {
RequestContextHolder.resetRequestAttributes();
}
}
);
});
// 10コのRunnableを同時に実行して待つ
CompletableFuture.allOf(
runnables.stream()
.parallel()
.map(runnable -> CompletableFuture.runAsync(runnable))
.toArray(CompletableFuture[]::new));
// mapCacheに入った要素を確認する
IntStream.range(0, 10).forEach(i -> {
// 要素があればokを表示、なければfailed
boolean success = mapCache.evictIfPresent(String.valueOf(i));
System.out.println(String.format("%2d - check:%s", i, success ? "ok" : "failed"));
});
}
実行してみると、想定通り何度実行してもスレッドアンセーフにはなりませんでした。
0 - check:ok
1 - check:ok
2 - check:ok
3 - check:ok
4 - check:ok
5 - check:ok
6 - check:ok
7 - check:ok
8 - check:ok
9 - check:ok
余談
ConcurrentMapCache
の要素を確認する際に、わざわざIntStream
を用いて確認するようにしています。
確認箇所の抜粋
// mapCacheに入った要素を確認する
IntStream.range(0, 10).forEach(i -> {
// 要素があればokを表示、なければfailed
boolean success = mapCache.evictIfPresent(String.valueOf(i));
System.out.println(String.format("%2d - check:%s", i, success ? "ok" : "failed"));
});
ConcurrentMapCache
にはgetNativeCacheというメソッドがあるので、これでMapを取得してまとめて確認できそうに見えます。
ただ、SpringでCGLIBを用いてクラスを作成する場合、finalのメソッドはオーバーライドされないため、CGLIBのクラスをそのまま呼び出してnullが返却されます。
ここらへんは下記の記事が詳しかったので、ご覧いただければと思います。
https://backpaper0.github.io/2018/02/22/spring_proxy.html
まとめ
周囲へのヒアリングやWeb記事を確認した感じだと、仕組みの複雑さなどから、そもそもRequestScopeのSpringコンポーネントを子スレッドで利用すること自体あまりオススメできなさそうです。
もし、そのような必要が出てきた場合、下記のような選択肢をとることもできます。
- 必要な情報を子スレッドに引き継ぐ
- 新たなカスタムスコープを作成する
RequestContextFilter#setThreadContextInheritableにtrueを指定する方法もあるようですが、未調査。
RequestScopeなどの便利な機能を利用する際は、どのような仕組みで動作しているのかを確認してから使わなければいけないな...とあらためて感じた出来事でした。
参考
- https://stackoverflow.com/questions/37540466/spring-promoting-request-scoped-bean-to-child-threads-httpservletrequest
- https://codestuff.medium.com/mdc-thread-pools-spring-request-scope-beans-2e2a2cb744fa
- https://medium.com/@pranav_maniar/spring-accessing-request-scope-beans-outside-of-web-request-faad27b5ed57