Spring BootでHttpServletRequestをAutowireしようとしてサーバが起動しなくなった話とか

Spring BootでWebアプリケーションを開発していてちょっとハマってしまった。

覚書がてら書いておく。

 

Javaの基本とかSpringの基本とかにはあまり細かく触れない。

 

 

[やりたかったこと:HttpServletRequestをAutowireする]

    @Autowired
    private HttpServletRequest req;

    public void doSomething() {
        // reqオブジェクトにアクセスして、
// リクエストヘッダをごにょごにょしたり、 // セッションをごにょごにょしたり。 }

 

こういう感じのことがやりたかったのに、後述のエラーにつまづいてしまったお話。

 

[Springの基本:BeanはSingleton]

https://docs.spring.io/spring-framework/docs/5.0.0.RELEASE/spring-framework-reference/core.html#beans-factory-scopes

    singleton
    (Default) Scopes a single bean definition to a single object instance per Spring IoC container.

 

リファレンスにも記載されている通り、SpringコンテナはデフォルトでオブジェクトをSingletonとして扱う。つまり、Springコンテナの中で、各Classについてただ1つのBean(インスタンス)が生成されるのが基本。

 

もちろんSingleton以外にも、Prototypeスコープ、RequestスコープそしてSessionスコープなどがある。最近はWebSocketスコープなんてあるのね。自分が最初にSpring使い始めた時は2.X系だったので、WebSocketなんて考えもしなかったな……。


Springの基本について詳細は以下の書籍や本家のリファレンスに譲るとして。

Spring Framework 5 プログラミング入門

Spring Framework 5 プログラミング入門

 
SpringBootプログラミング入門

SpringBootプログラミング入門

 

 

 

[賢いSpring:実際のインスタンスなしでもAutowireできる]

上で書いた通り、SpringではオブジェクトはSingletonとして扱われる。

ControllerクラスとかServiceクラスはロジックを記述するだけだから、Springの世界にインスタンスが1つだけでも特に不都合はない。

 

しかし、普通のWebアプリケーションだったら、多くのリクエストを処理する必要があるので、リクエストのインスタンスが1個だけでは立ち行かなくなってしまいそう。 

そういえば、この部分どうやってSpringが動かしてるんだろう?

 

ということで、調べてみた。

 

WebApplicationContextUtilsクラスの内部クラスとして定義されているRequestObjectFactoryという、いわゆるプロキシオブジェクトを利用することで実現しているらしい。

 

実装はすごくシンプル。

	/**
	 * Factory that exposes the current request object on demand.
	 */
	@SuppressWarnings("serial")
	private static class RequestObjectFactory implements ObjectFactory<ServletRequest>, Serializable {

		@Override
		public ServletRequest getObject() {
			return currentRequestAttributes().getRequest();
		}

		@Override
		public String toString() {
			return "Current HttpServletRequest";
		}
	}

 

getObject()を呼ぶと、リクエストの実体が返ってくると言うことらしい。

currentRequestAttributes()の中を見ると、RequestContextHolderが呼ばれていて、よくある感じでThreadLocalを使ってリクエストの実体を保持していた。なるほど。

 

ちなみに、アプリの起動からAutowireされるまでのざっくりした流れはこんな感じ。

  1. SpringApplication#runで対象のアプリを起動する
  2. AnnotationConfigServletWebServerApplicationContext#refreshの中で、TomcatStarter#onStartupが呼ばれる
  3. WebApplicationContextUtils#registerWebApplicationScopesが実行される
  4. ServletRequestインターフェースのAutowire対象BeanとしてRequestObjectFactoryが登録される
  5. DefaultListableBeanFactory#preInstantiateSingletonsでSingletonがインスタンス化される際に、3で登録されたBeanがAutowireされる
  6. AutowireされたBeanを使ってごにょごにょ

 

 

[つまづいたこと:BeanPostProcessorと普通のBeanの初期化タイミングの違い]

冒頭のコードを書き終わり、意気揚々とSpring Bootを起動したら、エラーが出た。

2018-05-26 14:51:24.046 ERROR 4304 --- [ main] o.s.b.d.LoggingFailureAnalysisReporter :
***************************
APPLICATION FAILED TO START
***************************
Description:
Field req in com.kdnakt.spring.bean_sandbox.MyBeanInstantiatedByConfig required a bean of type 'javax.servlet.http.HttpServletRequest' that could not be found.
Action:
Consider defining a bean of type 'javax.servlet.http.HttpServletRequest' in your configuration.

Springは賢いからこれで原因が分かるのかもしれないが、最初見たとき自分にはさっぱり分からなかった。せめてスタックトレースくらい出して欲しい。

 

実コードは晒せないので、同じエラーの発生条件を満たしたサンプルコードを書いてある。

github.com

 

問題の原因となっていたのは次の部分。

    @Bean
    public BeanPostProcessor object() {
        myBeanInstantiatedByConfig();// throws BeanCreationException
        return null;
    }

 

上で書いた通り、 

2. AnnotationConfigServletWebServerApplicationContext#refreshの中で、TomcatStarter#onStartupが呼ばれる

のだが、refreshメソッド(正確には、AbstractApplicationContextクラスのrefreshメソッド)の中身の詳細はリンク先を参照していただくとして、メソッドの中の関連する部分を抜き出してみる。

spring-framework/AbstractApplicationContext.java at master · spring-projects/spring-framework · GitHub

	@Override
	public void refresh() throws BeansException, IllegalStateException {
		// 省略
		// Register bean processors that intercept bean creation.
		registerBeanPostProcessors(beanFactory);
		// 省略
		// Initialize other special beans in specific context subclasses.
		onRefresh();
		// 省略
		// Instantiate all remaining (non-lazy-init) singletons.
		finishBeanFactoryInitialization(beanFactory);
		// 省略 
	}

 

RequestObjectFactoryがBeanとして登録されるのは、onRefreshのタイミングで、通常のBeanはfinishBeanFactoryInitializationでインスタンスが生成される。従って、BeanFactoryにRequestObjectFactoryインスタンスが登録されており、問題なくAutowireが可能だ。

しかし、BeanPostProcessorインターフェースを実装したクラスを利用する場合は話が異なる。BeanPostProcessorはBeanを生成したのちに、後処理を行うためのクラスであるから、当然Beanの生成より先にインスタンスが生成される。それがregisterBeanPostProcessorsメソッドである。

 

つまり、自分のコードは、RequestObjectFactoryがBeanとして登録される前に、BeanPostProcessorを生成する時点で、HttpServletRequestをAutowireするクラスをBeanとして生成し、BeanPostProcessorインスタンスと関連づけようとしていたため、AutowireすべきBeanが見つからず、上述のエラーに繋がった、と言うことになる。

 

とまあなんとか言語化できたけど、解決には一日近くかかってしまった。 適当にSpring使ってきたツケですな。

 

肝心の解決策としては、BeanPostProcessorで必要なロジックにはHttpServletRequestのインスタンスへのアクセスが不要だったので、該当のBeanを2つに分割して、1つはこれまで通りBeanPostProcessor用に、もう1つのクラスでHttpServletRequestをAutowireすることでBean生成タイミングをずらすことで、無事にSpring Bootを起動できるようになった。

 

[まとめ]

* Spring賢い

* 問題発生時にはフレームワークの正確な理解が不可欠

* フレームワークの基礎だけでも少し知っておくと、問題解決の役に立つ

 

今日のブログ執筆BGMはこちら。

www.youtube.com