Spring多层级上下文踩坑记
因为在项目中用到Spring上下文隔离,且还有父上下文,踩了一些坑,在此做个记录。
为方便叙述,把父上下文称为P,子上下文称为C1、C2。
类加载器的连锁反应
本来想把上下文完全隔离,但因业务需要引入了父上下文,一旦引入父上下文就要求部分公用类必须使用同一个类加载器。
下面是AbstractApplicationContext的部分代码,第2行做了一个赋值操作,在Java中只有相同类型才能赋值,所以C1、C2和P的ApplicationContext这个类必须使用相同的类加载器加载,这个类在spring-context中,同时又依赖了spring-core/spring-bean等,这些全部都要使用公共类加载器来加载。
public void setParent(ApplicationContext parent) {
this.parent = parent;
if (parent != null) {
Environment parentEnvironment = parent.getEnvironment();
if (parentEnvironment instanceof ConfigurableEnvironment) {
getEnvironment().merge((ConfigurableEnvironment) parentEnvironment);
}
}
}如果项目使用了SpringBoot,则扩展到几乎整个SpringBoot都要用公共类加载器,换句话说P、C1、C2的Spring版本必须一致(在不重写类加载器的前提下)。
ApplicationContextAware工具类问题
有些代码不方便获取Spring上下文,通常这时候会写一个ApplicationContextAware工具类来实现上下文的获取,这个实现类大致如下:
public class SpringContextUtil implements ApplicationContextAware {
private static ApplicationContext context;
@Override
public void setApplicationContext(ApplicationContext applicationContext) {
SpringContextUtil.context = applicationContext;
}
public static getApplicationContext() {
return SpringContextUtil.context;
}
}在多上下文的情况下,这个类只保存了一个上下文,会导致上下文获取产生问题。这里有两种解决办法:
- 在每个上下文里各自实现这个工具类(推荐)
- 如果所有上下文共用这个类(由公共类加载器加载),这里就要保存所有上下文,在getApplicationContext()中通过线程或类加载器来推断当前上下文是哪个,但这并不是一个好办法。线程有可能是共用线程,类加载器有可能是公共类加载器,这都会导致获取不到真实的上下文。
如果只是为了通过上下文调用getBean(),那么上下文就变成了工厂模式,丢掉了Spring的依赖注入特性,官方也不推荐这么使用。还是尽量@AutoWired吧!
配置加载
部分老代码加载properties文件(其实不仅是配置文件,也包括各种资源)是通过ClassPathResource来加载的,这种加载方式跟类加载器强相关。如果公共代码(技术框架等)使用公共类加载器去加载,就读不到其他类加载器的资源。
建议资源加载都通过FileSystemResource,也就是文件路径去加载,但这个需要注意Windows和Linux的文件系统差异。
另,如果只是为了加载配置文件(properties、yml等),SpringBoot已经帮我们加载完放在Environment里了,建议不需要再手动loadProperties。
自动化配置
强制配置
如果用到了SpringBoot和公共类加载器,需注意用公共类加载器加载的jar包是在P/C1/C2共享的,SpringBoot的有些自动化配置只要满足存在某些类就会配置,例如:
@Configuration
@ConditionalOnClass({ JedisConnection.class, RedisOperations.class, Jedis.class })
@EnableConfigurationProperties(RedisProperties.class)
public class RedisAutoConfiguration {
// ...
}上面这个类来自spring-boot-autoconfigure,如果C1用到了redis,必须把redis相关的jar包放在公共类加载器来加载(因为上述的连锁反应),因为redis放在公共类加载器加载,那么C2也会被自动化配置redis,即便C2不想用。
这个问题可以通过增加AutoConfigurationImportFilter来解决,也就是给redis自动化配置再增加一个条件判断,例如当properties中存在redis.enabled=true时才触发。
但在这个问题出现之前,得先解决另一个问题。
自动化配置继承
如果公共类加载器中包含了redis,最先触发自动化配置的是P,P把redis相关的bean全部配置好了,并且C1、C2还继承了这些bean(Spring父上下文的bean自动继承)。在C1再次进行自动化配置时就会报xxx bean已经存在!
因此涉及父子上下文的,一定要在自动化配置bean上加@ConditionalOnMissingBean注解,这样C1再次配置时就不会报错。
Environment继承
还是上面的代码,在设置父上下文时有一个Environment的merge操作,这里merge的是PropertySource、Active Profiles、Default Profiles。
如果P的PropertySource在C1中不存在,就会merge到C1,并且放在最后。这样P就可以通过配置文件影响C1的行为,当然C1可以重写配置,前提是你知道P提供了哪些配置。
public void merge(ConfigurableEnvironment parent) {
for (PropertySource<?> ps : parent.getPropertySources()) {
if (!this.propertySources.contains(ps.getName())) {
this.propertySources.addLast(ps);
}
}
String[] parentActiveProfiles = parent.getActiveProfiles();
if (!ObjectUtils.isEmpty(parentActiveProfiles)) {
synchronized (this.activeProfiles) {
for (String profile : parentActiveProfiles) {
this.activeProfiles.add(profile);
}
}
}
String[] parentDefaultProfiles = parent.getDefaultProfiles();
if (!ObjectUtils.isEmpty(parentDefaultProfiles)) {
synchronized (this.defaultProfiles) {
this.defaultProfiles.remove(RESERVED_DEFAULT_PROFILE_NAME);
for (String profile : parentDefaultProfiles) {
this.defaultProfiles.add(profile);
}
}
}
}事件共享魔咒
看如下高亮代码,C1首先发布event,进行事件广播,处理完后又让P发布了同一个event,P又处理了一次这个事件。
这意味着P的listener(用于处理event的监听器)是共享的,不论是C1还是C2发布的事件,都会通知到P,并调用P的listener去处理。
这里有几个问题:
- 多模块场景下,P的listener一定会被调用多次(有几个C就调几次),如果某个listener只希望被调用一次,需要特殊处理
- 如果P和C1有相同的listener,假如C1发布了事件e,e会在C1的listener中处理一次,还会在P的listener中处理一次,也就是会处理2次
- 并不是所有event都有事件共享魔咒,例如
ApplicationEnvironmentPreparedEvent就没问题,主要看事件是不是通过AbstractApplicationContext#publishEvent这个方法来发布的(例如在SpringBoot中EventPublishingRunListener也会发布事件,就没有这个问题)
综上,建议:
- 增加listener时分析event是否是通过publishEvent()发布的,如果是那就要小心了
- P和C1尽量不用相同的listener,以避免C1的事件被重复触发
protected void publishEvent(Object event, ResolvableType eventType) {
// 省略无关代码...
// Multicast right now if possible - or lazily once the multicaster is initialized
if (this.earlyApplicationEvents != null) {
this.earlyApplicationEvents.add(applicationEvent);
}
else {
getApplicationEventMulticaster().multicastEvent(applicationEvent, eventType);
}
// Publish event via parent context as well...
if (this.parent != null) {
if (this.parent instanceof AbstractApplicationContext) {
((AbstractApplicationContext) this.parent).publishEvent(event, eventType);
}
else {
this.parent.publishEvent(event);
}
}
}MBean Domain
如果有启用Live Beans View(主要作用是通过JMX查看Spring上下文中有哪些bean以及bean的依赖关系),父子模块的spring.liveBeansView.mbeanDomain必须设置成一样,否则在应用关闭时会抛出InstanceNotFoundException导致系统无法优雅停机。
详见:
- AbstractApplicationContext.doClose()
- LiveBeansView.registerApplicationContext()
- LiveBeansView.unregisterApplicationContext()
注:IDEA在启动时会自动设置spring.liveBeansView.mbeanDomain=(空字符串)