16
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NRI OpenStandiaAdvent Calendar 2023

Day 16

今更SpringFrameworkのRequestScopeの挙動を確認してみる

Last updated at Posted at 2023-12-16

はじめに

業務で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を子スレッドで呼び出すサンプル
  // これがRequestScopeのSpringコンポーネント
  @Autowired
  private ConcurrentMapCache mapCache;
  @Autowired
  private TaskExecutor taskExecutor;

  @GetMapping("/err")
  public void err() {
    taskExecutor.execute(() -> {
      mapCache.put("err", "value");
    });
  }
REST呼び出し結果
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コンポーネントを扱ってもエラーが発生しなくなります。

仮説

今回の改修方法において、ふわふわしながらも下記の仮説をたてて検証に進むことにしました。

  1. RequestScopeのSpringコンポーネントを扱うにあたって、スレッドにRequestAttributesが必要である。
  2. RequestScopeのSpringコンポーネントはRequestAttributesに保存されて、その後使い回される。
  3. リクエストを受信してからController処理開始までにRequestScopeのSpringコンポーネントが生成される。(ここがかなりふわふわしていた)

早速RequstScopeのSpringコンポーネントを複数スレッドから利用してみる

下記のように、Java ConfigとControllerの実装を用意しました。

RequestScopeのSpringコンポーネントをJava Configで用意
@Configuration
public class SampleConfiguration {
  // RequestScopeを指定する
  @RequestScope  
  @Bean
  // Springで用意しているスレッドセーフのキャッシュクラスをSpringコンポーネントにする
  public ConcurrentMapCache mapCache() {  
    return new ConcurrentMapCache("cache");  /* 引数は今回関係なし */
  }
}
Controllerを用意
@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にもデバッグポイントを置きます。

image.png

mapCacheのクラスを見てみると、CGLIBで生成されたクラスであることがクラス名からわかります。
これは、RequestScopeアノテーションのproxyModeScopedProxyMode.TARGET_CLASSがデフォルトで指定されているためです。

さらに、デバッグポイントを進めます。
68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f34343438322f37313234633961622d363365342d643132612d383334322d3738613339616364653139612e70.png

RequestContextHolder#currentRequestAttributesで止めると、上記の左下にあるような呼び出し階層になります。
これをそれぞれ見てみると、下記のようなことがわかります。

AbstractRequestAttributesScope#getの44行目を見てみると、if (scopedObject == null) {があり、これは「RequestAttributesに対象のSpringコンポーネントがなかった(nullだった)場合」を示しています。
if文の次の行にデバッグポイントを置いて、もう一度リクエストを送信してみます。

image.png

AbstractRequestAttributesScope#getの引数scopedTarget.mapCacheRequestAttributesに存在しないため、scopedObjectがnullとなっていることがわかります。

また、45行目ではobjectFactory.getObject()によってscopedObjectを取得しています。
ここを更に深掘りすると、新しいConcurrentMapCacheを作成して返却していました。

46行目では、新しく生成されたConcurrentMapCacheRequestAttributesに追加していることがわかります。
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を一度呼び出すコードを追加します。

リクエストスレッドで一度RequestScopeのSpringコンポーネントを呼び出す
@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#setThreadContextInheritabletrueを指定する方法もあるようですが、未調査。

RequestScopeなどの便利な機能を利用する際は、どのような仕組みで動作しているのかを確認してから使わなければいけないな...とあらためて感じた出来事でした。

参考

  1. IoC コンテナー

16
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?