Spring多层级上下文踩坑记

Louis2020-05-27Java技术JavaSpring

因为在项目中用到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;
	}
}

在多上下文的情况下,这个类只保存了一个上下文,会导致上下文获取产生问题。这里有两种解决办法:

  1. 在每个上下文里各自实现这个工具类(推荐)
  2. 如果所有上下文共用这个类(由公共类加载器加载),这里就要保存所有上下文,在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去处理。

这里有几个问题:

  1. 多模块场景下,P的listener一定会被调用多次(有几个C就调几次),如果某个listener只希望被调用一次,需要特殊处理
  2. 如果P和C1有相同的listener,假如C1发布了事件e,e会在C1的listener中处理一次,还会在P的listener中处理一次,也就是会处理2次
  3. 并不是所有event都有事件共享魔咒,例如ApplicationEnvironmentPreparedEvent就没问题,主要看事件是不是通过AbstractApplicationContext#publishEvent这个方法来发布的(例如在SpringBoot中EventPublishingRunListener也会发布事件,就没有这个问题)

综上,建议:

  1. 增加listener时分析event是否是通过publishEvent()发布的,如果是那就要小心了
  2. 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=(空字符串)

Last Updated 2024/11/24 20:50:53