springboot应用在内置tomcat和在独立tomcat里Listener加载顺序不同的问题

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/Allen_jinjie/article/details/102544663

我们的一个语言国际化的实现思路是:

通过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 通过 jar 启动,控制台的日志为:

...
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 server, 它将自己新建一个:

由于我直接使用 jar 方式运行应用的,所以它走了 if 块,使用内嵌的 tomcat 创建了 web 容器, 而 tomcat 启动后会按序实例化应用注册的 listener (当然包括实现了 ContextLoaderListener 的类), filter, servlet。对于我这个应用,它读取了资源文件,并把对象放到了 servletContext 中备用。

最后,Spring 实例化 Bean 继续,这里就是 finishBeanFactoryInitialization() 方法:

扫描二维码关注公众号,回复: 7601121 查看本文章

可以看到我的应用的国际化工具类 PostContruct 注解的方法可以取到 servletContext 里的消息集合属性。

所以,springboot 中关键的方法 createWebServer() 会视情况实例化 web 容器,web 容器就绪后,springboot 继续 bean 的初始化和 Spring 容器的初始化。

如果 Springboot 应用打成 war 包,放到独立的 tomcat,那么 tomcat 启动后,就会并解压 springboot 应用,根据 Servlet 3.0 规范,查找实现了 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 就可以看到 createRootApplicationContext() 方法的最后和 springboot 的 main 方法一样调用 run() 方法,到了 createWebServer() 这里,此时 web 容器和 servletContext 都有了,所以 springboot 会完成 bean 的实例化和 IoC容器实例化工作。结束后控制权才会交给 tomcat,其再按序实例化其他的 listener (包括实现了 ContextLoaderListener 的类), filter, servlet。由于 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. webapplicationclassloader 将扫描当前应用下的 web.xml, 并实例化 webapplicationcontext
4. spring 应用通过继承 ContextLoaderListener 并注册在 web.xml,该监听器在容器启动时,调用 contextInitialized, spring 应用通过 initWebApplicationContext 方法初始化 spring 容器
5. 结束后,如果还有其他的 listener, filter 等,容器启动时会一并初始化,servlet 会延迟初始化

# springboot 应用打成 war 包放到 tomcat 里:
1.2 步同上
3. springboot 应用通过遵守 servlet 3.0+ 规范,以编程的方式实现 WebApplicationInitializer.onStartup,容器启动时会调用实现了该接口的类作为容器启动入口
4. SpringBootServletInitializer 实现将先实例化 webapplicationcontext, 然后完成 spring 容器初始化
5. webapplicationclassloader 加载其他的 listener, filter

# springboot 应用以内置 web 容器启动:
1. springboot 应用通过遵守 servlet 3.0+ 规范,以编程的方式实现 WebApplicationInitializer.onStartup,容器启动时会调用实现了该接口的类作为容器启动入口
2. 实现将先实例化 webapplicationcontext, 然后进行 IoC 容器初始化,包括提前注册 bean 到 beanfactory 以解决单例模式下循环依赖的问题,注册 aware, beanpostprocessor 等接口
3. 启动内置 web 容器,实例化并加载应用里的 listener, filter 等
4. web 容器初始化完毕,才会继续完成 IoC 容器的初始化,也就是完成所有单例模式的 bean 的初始化,从而完成整个 spring 容器的初始化

猜你喜欢

转载自blog.csdn.net/Allen_jinjie/article/details/102544663