springboot應用在內置tomcat和在獨立tomcat裏Listener加載順序不同的問題

我們的一個語言國際化的實現思路是:

通過Listener在應用被加載的時候讀取properties 資源文件,然後把對象放入 ServletContext 中,I18NUtils 工具類通過注入 ServletContext,實例化時從上下文獲取對象,簡化 API(讀文件的時間放到應用啓動而不是業務初次調用時)。

@WebListener
public class VinciContextLoaderListener extends ContextLoaderListener {
	private Logger logger = LoggerFactory.getLogger(VinciContextLoaderListener.class);
	
	@Override
	public void contextInitialized(ServletContextEvent event) {
		ServletContext servletContext = event.getServletContext();				
		Map<String, String> resourceMap = doMessagesInit();
		servletContext.setAttribute(VinciConstants.I18N, resourceMap);
	}
	
	private Map<String, String> doMessagesInit(){
		logger.info("進入初始化國際化資源文件信息的方法 doMessagesInit()");
		...
	}
}
// I18NUtils 工具類:
@Component
public class InternationalizationUtils {
	private Logger logger = LoggerFactory.getLogger(InternationalizationUtils.class);
	static Map<String, String> i18nMap = null;
	
	@Autowired
    private ServletContext ctx;
	
	@PostConstruct
	public void init(){
		logger.info("-------------開始初始化國際化工具類---------------");
		i18nMap = (Map<String, String>) ctx.getAttribute(VinciConstants.I18N);
		logger.info("(i18nMap == null) ------------- " + (i18nMap == null));
	}

	public static String getString(String key){
		return i18nMap.get(key);
	}
}

直接在 Eclipse 通過引導類啓動,控制檯的日誌爲:

...
11:02:14 INFO  [o.a.c.c.C.[Tomcat].[localhost].[/vinci-web]] - Initializing Spring embedded WebApplicationContext
11:02:14 INFO  [org.springframework.web.context.ContextLoader] - Root WebApplicationContext: initialization completed in 9346 ms
11:02:14 INFO  [c.h.vinci.interceptor.VinciContextLoaderListener] - 進入初始化國際化資源文件信息的方法 doMessagesInit()
11:02:14 INFO  [com.hebta.vinci.interceptor.SessionFilter] - Start session manager。。。
11:02:14 INFO  [c.h.vinci.common.util.InternationalizationUtils] - -------------開始初始化國際化工具類---------------
11:02:18 INFO  [c.h.vinci.common.util.InternationalizationUtils] - (i18nMap == null) ------------- false
log4j:WARN No appenders could be found for logger (com.alibaba.druid.pool.DruidDataSource).
...

可以看到,初始化國際化資源和將其暴露給應用都是期望的執行順序,但是如果應用打包成 war 並放到外部的 tomcat 裏:

10:56:18 INFO  [org.springframework.web.context.ContextLoader] - Root WebApplicationContext: initialization completed in 2397 ms
10:56:18 INFO  [o.s.boot.web.servlet.RegistrationBean] - Filter errorPageFilter was not registered (possibly already registered?)
10:56:18 INFO  [c.h.vinci.common.util.InternationalizationUtils] - -------------開始初始化國際化工具類---------------
10:56:18 INFO  [c.h.vinci.common.util.InternationalizationUtils] - (i18nMap == null) ------------- true
Load model sucess
...
10:56:24 INFO  [com.hebta.vinci.VinciApplication] - Started VinciApplication in 7.983 seconds (JVM running for 49.806)
10:56:24 INFO  [c.h.vinci.interceptor.VinciContextLoaderListener] - 添加一個全局的Map變量,用來防止一個用戶賬號多處登錄
10:56:24 INFO  [c.h.vinci.interceptor.VinciContextLoaderListener] - 進入初始化國際化資源文件信息的方法 doMessagesInit()
...

順序反了,導致應用無法獲取任一資源消息。通過 debug Spring 的啓動方法,就可以知道原因了,springboot 的 run 方法會調用 refresh 方法,它是 Spring 的核心方法,裏面有個方法就是 createWebServer(), 從實現可知,如果沒有已經運行的 web 容器,那麼 webServer 變量爲 null, 它將使用默認的 TomcatWebServer 新建一個 web 容器:

 

默認的 TomcatWebServer 啓動後會按序實例化應用註冊的 listener (當然包括實現了 ContextLoaderListener 的類), filter, servlet。對於我這個應用,它讀取了資源文件,並把對象放到了 servletContext 中備用。

最後,Spring 進行 IoC 容器的初始化,這裏就是 finishBeanFactoryInitialization() 方法:

可以看到我的應用的國際化工具類 @PostContruct 註解的方法(也就是 BeanPostProcessor 在完成實例化後的 post 操作)可以取到 servletContext 裏的消息集合屬性。

所以,springboot 中關鍵的方法 createWebServer() 會視情況創建 web 容器。

如果 Springboot 應用打成 war 包,放到獨立的 tomcat,那麼 tomcat 啓動後,就會並解壓 springboot 應用,創建 ServletContext,(根據 Servlet 3.0 規範)webappclassloader 會查找實現了 ServletContextInitializer 接口的類,作爲加載應用的入口。我們知道 springboot 應用要打包成 war 則必須繼承 SpringBootServletInitializer,我這裏直接讓啓動類繼承:

@SpringBootApplication(scanBasePackages = {"com.hebta.data.processor", "com.hebta.vinci"})
@ServletComponentScan
@MapperScan("com.hebta.vinci.dao")
@EnableTransactionManagement
public class VinciApplication extends SpringBootServletInitializer {
	@Override
	protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {		
		return application.sources(VinciApplication.class);
	}
	public static void main(String[] args) {
	
		SpringApplication.run(VinciApplication.class, args);
	}
}

繼續看 SpringBootServletInitializer.onStartup() 就可以看到 createRootApplicationContext() 方法的最後和 springboot 的 main 方法一樣調用 run() 方法,到了 createWebServer() 這裏,此時獨立的 tomcat 完成了 servletContext 的實例化,springboot 會繼續 bean 的實例化和 IoC容器的創建,完了後將控制權交給 tomcat,tomcat 再按序實例化應用裏的 listeners (包括實現了 ContextLoaderListener 的類), filters。由於 I18NUtils bean 的初始化先於  VinciContextLoaderListener 運行,所以無法從 servletContext 裏拿到消息集合屬性。

問題根源找到了,我的問題也就好解決了,考慮該工具類只會在業務代碼調用的時候纔會用到,可以使用 static 式的單例實現:

public class InternationalizationUtils {	
	static Map<String, String> i18nMap = null;
	
	static {
		i18nMap = (Map<String, String>)SessionUtil.getRequest().getServletContext().getAttribute(VinciConstants.I18N);
	}

	public static String getString(String key){
		return i18nMap.get(key);
	}
}

其實,我們這個應用是從 springmvc 改造到 springboot 的,原來的 VinciContextLoaderListener.java :

原來的應用是 tomcat 通過 web.xml 找到此 listener, 先讀取資源文件,然後創建 spring 容器。而 springboot 則是遵從了 Servlet 3.0 消除 web.xml 的規範(紅框裏的代碼不可出現在 springboot 的 contextInitialized() 方法裏),入口變了,是先構建 IoC 容器,然後執行其他 listener 邏輯。

總結:
# 普通的 spring 應用放到 tomcat 裏:
1. tomcat 啓動後,使用 webapplicationclassloader 加載應用
2. 加載後,將創建一個 servletConext 作爲該 web 應用的全局上下文,相當於一個 HashMap,對 web 應用下的各容器可見
3. spring 應用通過繼承 ContextLoaderListener 並註冊在 web.xml,該監聽器監聽到 servletConext 初始化時,調用 contextInitialized() 方法, spring 應用通過 initWebApplicationContext 方法初始化 spring 應用上下文
5. 結束後,如果還有其他的 listener, filter 等,會一併加載並初始化,servlet 會延遲初始化

# springboot 應用打成 war 包放到 tomcat 裏:
1, 2 步同上
3. springboot 應用通過遵守 servlet 3.0+ 規範,以編程的方式實現 WebApplicationInitializer.onStartup,容器啓動時會調用實現了該接口的類作爲容器啓動入口
4. SpringBootServletInitializer 實現將先實例化 webapplicationcontext, 然後 refresh context 完成 spring IoC 容器初始化
5. webapplicationclassloader 加載其他的 listener, filter

# springboot 應用以內置 web 容器啓動:
1. springboot 直接走 SpringApplication.run()
2. 先實例化 webapplicationcontext, 然後刷新 context, 刷新 context 過程中,使用內置 web 容器創建 webServer,關聯 webapplicationcontext 和全局上下文 servletContext,最後實例化應用裏的 listener, filter 等
3. 完成 IoC 容器初始化,啓動 webServer 對外提供服務

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