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

# はじめに

業務で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です。

[^1]: [IoC コンテナー](https://spring.pleiades.io/spring-framework/reference/core/beans.html)  

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コンポーネントを利用すると、例外が発生します。   

```java:RequestScopeを子スレッドで呼び出すサンプル
  // これがRequestScopeのSpringコンポーネント
  @Autowired
  private ConcurrentMapCache mapCache;
  @Autowired
  private TaskExecutor taskExecutor;

  @GetMapping("/err")
  public void err() {
    taskExecutor.execute(() -> {
      mapCache.put("err", "value");
    });
  }
```

```plaintext: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`を引き継ぐ方法があります。  

```java
    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の実装を用意しました。

```java:RequestScopeのSpringコンポーネントをJava Configで用意
@Configuration
public class SampleConfiguration {
  // RequestScopeを指定する
  @RequestScope  
  @Bean
  // Springで用意しているスレッドセーフのキャッシュクラスをSpringコンポーネントにする
  public ConcurrentMapCache mapCache() {  
    return new ConcurrentMapCache("cache");  /* 引数は今回関係なし */
  }
}
```

```java: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のみが表示されるはずです。   

実行してみると下記のような結果に。   

```plaintext:実行結果
 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`にデバッグポイントを置いてみます。   
また、[スタックトレース](#そもそもrequestscopeをリクエストスレッド以外で扱うとどうなるか)に記載されていた`RequestContextHolder#currentRequestAttributes`にもデバッグポイントを置きます。  

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44482/97c0ba4d-2204-717e-bb21-7860eb2dbf61.png)

`mapCache`のクラスを見てみると、CGLIBで生成されたクラスであることがクラス名からわかります。   
これは、RequestScopeアノテーションの`proxyMode`に[ScopedProxyMode.TARGET_CLASS](https://spring.pleiades.io/spring-framework/docs/current/javadoc-api/org/springframework/context/annotation/ScopedProxyMode.html#TARGET_CLASS)がデフォルトで指定されているためです。   

さらに、デバッグポイントを進めます。  
![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f34343438322f37313234633961622d363365342d643132612d383334322d3738613339616364653139612e70.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44482/2b1701e2-8113-f93c-8026-697cdf815ec0.png)

`RequestContextHolder#currentRequestAttributes`で止めると、上記の左下にあるような呼び出し階層になります。   
これをそれぞれ見てみると、下記のようなことがわかります。  
- `ConcurrentMapCache`のCGLIBで作成されたクラスが、`ConcurrentMapCache`のインスタンスを取得しようとしていそう。
  - [AbstractBeanFactory#getBeanの200行目](https://github.com/spring-projects/spring-framework/blob/v6.0.0/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanFactory.java#L200)
- `ConcurrentMapCache`のインスタンスを現在のスレッドに紐づく`RequestAttributes`から取得しようとしている。
  - [AbstractRequestAttributesScope#getの42-43行目](https://github.com/spring-projects/spring-framework/blob/v6.0.0/spring-web/src/main/java/org/springframework/web/context/request/AbstractRequestAttributesScope.java#L42-L43)

[AbstractRequestAttributesScope#getの44行目](https://github.com/spring-projects/spring-framework/blob/v6.0.0/spring-web/src/main/java/org/springframework/web/context/request/AbstractRequestAttributesScope.java#L44)を見てみると、`if (scopedObject == null) {`があり、これは「RequestAttributesに対象のSpringコンポーネントがなかった（nullだった）場合」を示しています。  
if文の次の行にデバッグポイントを置いて、もう一度リクエストを送信してみます。   

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44482/ee4c4fa6-6481-c41d-094c-861e2a76b097.png)

`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](https://github.com/spring-projects/spring-framework/blob/v6.0.0/spring-web/src/main/java/org/springframework/web/context/request/AbstractRequestAttributesScope.java#L44-L46)で新しいインスタンスを作成して`RequestAttributes`に追加するため、スレッドアンセーフな動きになることがわかります。   
（`RequestAttributes#put`は上書きなので、最後に追加されたインスタンスがRequestScopeのSpringコンポーネントとして以降利用される）

## リクエストスレッドでRequestScopeのSpringコンポーネントを事前に呼び出す

判明したことから、RequestScopeのSpringコンポーネントをリクエストスレッドで一度利用しておくと、子スレッドでスレッドアンセーフにならなそうです。  
下記のように、リクエストスレッドで`mapCache`を一度呼び出すコードを追加します。

```java:リクエストスレッドで一度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"));
    });
}
```

実行してみると、想定通り何度実行してもスレッドアンセーフにはなりませんでした。

```plaintext:実行結果
 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`を用いて確認するようにしています。   

<details><summary>確認箇所の抜粋</summary><div>

```java
    // 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"));
    });
```

</div></details>

`ConcurrentMapCache`には[getNativeCache](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/concurrent/ConcurrentMapCache.html#getNativeCache())というメソッドがあるので、これでMapを取得してまとめて確認できそうに見えます。   

ただ、SpringでCGLIBを用いてクラスを作成する場合、finalのメソッドはオーバーライドされないため、CGLIBのクラスをそのまま呼び出してnullが返却されます。   
ここらへんは下記の記事が詳しかったので、ご覧いただければと思います。   
https://backpaper0.github.io/2018/02/22/spring_proxy.html

# まとめ

周囲へのヒアリングやWeb記事を確認した感じだと、仕組みの複雑さなどから、そもそもRequestScopeのSpringコンポーネントを子スレッドで利用すること自体あまりオススメできなさそうです。   
もし、そのような必要が出てきた場合、下記のような選択肢をとることもできます。   
- 必要な情報を子スレッドに引き継ぐ
- 新たな[カスタムスコープを作成](https://spring.pleiades.io/spring-framework/reference/core/beans/factory-scopes.html#beans-factory-scopes-custom)する

:::note warn
[RequestContextFilter#setThreadContextInheritable](https://spring.pleiades.io/spring-framework/docs/current/javadoc-api/org/springframework/web/filter/RequestContextFilter.html#setThreadContextInheritable(boolean))に*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