記錄一次因爲 FactoryBean 導致組件提前加載的問題

概述

在前段時間,筆者的開源項目的用戶反映項目在配置某個功能後,會在啓動時候出現 "No servlet set" 的錯誤,這個問題具體可以參見 Crane4j isse#268
問題的原因其實在標題已經劇透了,是因爲 FactoryBean 被提前加載,進而間接造成 SpringMVC 組件被提前加載導致的。
雖然最後解決方案沒什麼好說的,不過整個排查的過程很好加深了筆者對 Bean 生命週期,以及 FactoryBean 使用方式的理解,總的來說還是蠻有意思的,故寫此文章用於記錄。

1.問題定位

1.1.出現 No ServletContext set

跳過前情提要,簡而言之,當筆者在本地啓動項目以後,出現 “No ServletContext set” 錯誤:
image.png
根據堆棧,我們可以直接確認是 WebMvcAutoConfiguration#EnableWebMvcConfiguration
中創建 HandleMapping 的時候,因爲找不到 ServletContext 而導致的:
image.png
ServletContext 又來自於 WebMvcAutoConfiguration 實現的 ServletContextAware 回調接口:

public WebMvcAutoConfiguration implements ServletContextAware {

    @Nullable
    private ServletContext servletContext;
    
    public void setServletContext(@Nullable ServletContext servletContext) {
        this.servletContext = servletContext;
    }

    @Nullable
    public final ServletContext getServletContext() {
        return this.servletContext;
    }
}

1.2.ApplicationContextAware 爲什麼沒生效?

顯然,這個 setServletContext 方法要麼沒調用,要麼調用了但是 set 了一個空值。那麼,setServletContext 又是誰調用的?
看過 Spring 處理各種 Aware 接口源碼的同學可能立刻會敏感的意識到,這個接口的處理,要麼是基於 AbstractApplicationContext 的回調接口完成,要麼和 ApplicationContextAware 等接口一樣,是基於某個特定的後處理器完成。
如果下載了 Spring 源碼,你可以直接在 idea 中尋找 ServletContextAware#setServletContext 方法在源碼中的引用,或者你可以直接雙擊 shift 在源碼中尋找與其同名或部分同名的組件,如此我們便找到了 ServletContextAwareProcessor 這個後處理器 —— 顯然ApplicationContextAware 等接口一樣, ServletContextAware 接口是通過 ServletContextAwareProcessor 這個特定的後處理器調用的

public class ServletContextAwareProcessor implements BeanPostProcessor {

	@Nullable
	private ServletContext servletContext;

	@Nullable
	private ServletConfig servletConfig;
    
	@Nullable
	protected ServletContext getServletContext() {
		if (this.servletContext == null && getServletConfig() != null) {
			return getServletConfig().getServletContext();
		}
		return this.servletContext;
	}
    
	@Nullable
	protected ServletConfig getServletConfig() {
		return this.servletConfig;
	}

	@Override
	public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
		if (getServletContext() != null && bean instanceof ServletContextAware) {
			((ServletContextAware) bean).setServletContext(getServletContext());
		}
		if (getServletConfig() != null && bean instanceof ServletConfigAware) {
			((ServletConfigAware) bean).setServletConfig(getServletConfig());
		}
		return bean;
	}
}

非常明顯,ServletContextAwareProcessor 是一個 BeanPostProcessor,這個後處理器在 Bean 實例化後、初始化前調用,結合簡單的代碼,我們可以推測原因無外乎兩種:

  1. WebMvcAutoConfiguration 根本沒被它處理,所以沒有設置上 ServletContext
  2. WebMvcAutoConfiguration 被後處理器時,後處理器並沒有持有一個可用的 ServletContext,同時從 ServletConfig 中也無法獲得一個可用的 ServletContext

不過靜態的代碼分析到這邊基本就到頭了,具體什麼情況,還得要跑起來才知道。

2.問題復現

2.1.梳理依賴鏈

我們在 ServletContextAware#setServletContext 方法上打上斷點,然後重新啓動項目,看看到底是怎麼一回事:
image.png
好吧,啓動時根本沒有進入 ServletContextAware#setServletContext 方法,說明是我們之前推遲的第一種情況,即 WebMvcAutoConfiguration 根本沒有被 ServletContextAwareProcessor 進行處理
那麼,這種情況唯一的解釋,就是該配置類 WebMvcAutoConfiguration#EnableWebMvcConfiguration本身因爲某種原因被過早的實例化,此時 ServletContextAwareProcessor 可能都還沒有生效。
爲了驗證我們的猜想,我們可以直接在 WebMvcAutoConfiguration#EnableWebMvcConfiguration 的構造函數裏面打上斷點:
image.png
進入斷點後,我們檢查 doCreateBean 方法調用時,BeanFactory 中是否有 ServletContextAwareProcessor:
image.png
雖然沒有 ServletContextAwareProcessor,不過有一個 WebApplicationContextServletContextAwareProcessor,它是 ServletContextAwareProcessor 的子類,我們可以注意到,裏面 ServletContext 與 ServletConfig 確實都是空的。

2.2.爲什麼會提前加載?

到現在問題基本明確了,就是 WebMvcAutoConfiguration#EnableWebMvcConfiguration 過早加載的問題,那麼,爲什麼它會提前加載?
根據上面的堆棧信息, Idea 已經告訴了我們,截止調用 WebMvcAutoConfiguration#EnableWebMvcConfiguration 構造函數時,整個上下文中 doGetBean 調用了 8 次,這意味着在 WebMvcAutoConfiguration#EnableWebMvcConfiguration 之前,整條依賴鏈上還有 7 個創建中的 Bean,順着這個堆棧信息我們向上溯源,整條依賴鏈大概是這樣的:

  1. org.springframework.context.annotation.internalAsyncAnnotationProcessor
  2. org.springframework.scheduling.annotation.ProxyAsyncConfiguration
  3. operatorBeanDefinitionRegistrar.OperatorProxyFactoryBean#cn.example.ExampleOperator
  4. operatorProxyFactory
  5. operationAnnotationProxyMethodFactory
  6. springConverterManager
  7. mvcConversionService
  8. org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration$EnableWebMvcConfiguration

分析這個依賴鏈,筆者可以意識到了問題所在,因爲 3、4、5 都是 Crane4j 提供的組件,按理說這種用戶自己的組件不應該這麼早的進行初始化,因此說明創建 org.springframework.scheduling.annotation.ProxyAsyncConfiguration 這一步肯定有問題。
筆者在這一步糾結了很久,因爲 ProxyAsyncConfiguration 看起來就是一個普通的配置類,直到在裏面看到了這一段代碼:

@Autowired(required = false)
void setConfigurers(Collection<AsyncConfigurer> configurers) {
    if (CollectionUtils.isEmpty(configurers)) {
        return;
    }
    if (configurers.size() > 1) {
        throw new IllegalStateException("Only one AsyncConfigurer may exist");
    }
    AsyncConfigurer configurer = configurers.iterator().next();
    this.executor = configurer::getAsyncExecutor;
    this.exceptionHandler = configurer::getAsyncUncaughtExceptionHandler;
}

Spring 在此處進行了一次 setter 方法注入。熟悉 Spring 的同學應該都知道,這種批量注入最終會調用 ListableBeanFactory 的 getXXXForType 或者 getXXXOfType 去批量從容器中獲取 Bean。有意思的是,如果容器中存在 FactoryBean,由於 Spring 只能通過 FactoryBean#getObjectType 方法去推斷類型,因此會提前創建 FactoryBean 以便獲取其類型。
根據這個思路,我們沿着堆棧從 ProxyAsyncConfiguration 的創建向下找,找到進行依賴注入的地方,接着就發現確實是這個問題導致的:
image.png
簡單的來說,ProxyAsyncConfiguration 進行依賴注入時,調用了 getBeanNamesForType 方法,而這個方法會去檢查容器中所有的 Bean 的類型。此時由於我們的 operatorBeanDefinitionRegistrar.OperatorProxyFactoryBean#cn.example.ExampleOperator 是一個 FactoryBean,因此 Spring 直接 FactoryBean 創建了出來以獲取其類型,而 FactoryBean 的創建又觸發了其他組件的創建,最終導致了整條依賴鏈上所有組件的提前加載!

3.解決方案

現在我們已經明確的知道了問題所在,那麼該怎麼解決?
首先,getTypeForFactoryBean 這個地方提供了一個參數 allowInit 用於決定是否要初始化 FactoryBean,然而順着堆棧一路找上去,會發現這個參數最初已經在 getBeanNamesForType 就定死了是 true,這意味着我們不可能通過輕易的改變 Spring 的初始化流程來避免這個問題。
既然如此,那就只能從這個 FactoryBean 下手了,以下是其代碼:

@Setter
public static class OperatorProxyFactoryBean<T> implements FactoryBean<T> {

    private OperatorProxyFactory operatorProxyFactory;
    private Class<T> operatorType;

    @Override
    public T getObject() {
        return operatorProxyFactory.get(operatorType);
    }

    @Override
    public Class<?> getObjectType() {
        return operatorType;
    }
}

後續的問題其實就是由於它依賴的 OperatorProxyFactory 被初始化導致的,因此我們需要想辦法在把它的加載延遲到調用 getObject 的時候。
要延遲一個屬性的注入,第一種辦法是直接在屬性或者 setter 方法上添加 @Lazy 註解,此後當進行依賴注入時,Spring 將會生成一個代理對象,等到使用代理對象時纔會真正的從 Spring 容器獲取對應的 Bean。不過由於這個 Bean 是在代碼中通過手動構造 BeanDefinition 的方式創建的,依賴注入的參數在一開始就已經指定,因此無法通過加註解的方式實現。
因此我們只能採用第二種,即使用 ObjectProvider 對其進行包裹,改成這樣:

public static class OperatorProxyFactoryBean<T> implements FactoryBean<T> {

    private ObjectProvider<OperatorProxyFactory> operatorProxyFactory;
    private Class<T> operatorType;

    @Override
    public T getObject() {
        return operatorProxyFactory.getObject().get(operatorType);
    }

    @Override
    public Class<?> getObjectType() {
        return operatorType;
    }
}

重新編譯打包順利啓動,至此,這個問題徹底解決了。

總結

總的來說,這種因爲 Spring 內置組件的初始化時機被打亂,導致出現各種奇奇怪怪的問題倒是蠻常見的。
當已經出現此類問題的時候,可以考慮直接在構造函數或者某些回調接口上打斷點,通過堆棧倒推 Bean 的依賴關係來排查問題。
不過,最理想的肯定還是從一開始就避免出現這類問題。因此,在基於 Spring 生命週期開發一些組件時,我們需要特別注意它們的初始化時機。比如 BeanPostProcessor、Advice/Advisor 或者 FactoryBean 這些組件,要特別注意不要依賴到了正常的業務組件,否則它們可能就會因爲被過早初始化而無法正常使用。如果一定要使用的話,最好使用懶加載的方式去獲取依賴。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章