SpringBoot(四) 集成apollo遇到的事儿

Posted by ZhouJ000 on October 12, 2019

SpringBoot(一) 启动与自动配置
SpringBoot(二) starter与servlet容器
SpringBoot(三) Environment
SpringBoot(四) 集成apollo遇到的事儿
SpringBoot(五) 健康检查(上)
SpringBoot(六) 健康检查(下)

问题

公司新写项目,使用公司封装的SpringBoot-Starter,其中配置中心使用的是Apollo。看了下Apollo配置中心发现可以配置环境和集群,查看了starter源码发现已经配置好了Apollo地址和环境,由于老项目使用的disconf在相同环境下是按机器有多个配置的,因此切换到Apollo相应就是使用多套集群配置

从封装代码中得知,是使用继承ApplicationContextInitializer来初始化的,在initialize方法中配置System的Property信息,得以将application.properties中配置的信息从ConfigurableEnvironment中获取并设置到System。封装的代码中会设置env、app.id、apollo.bootstrap.enabled等配置,但是并没有设置集群信息,这样会使用默认default集群

解决

从网上查询得知Apollo集群配置是使用apollo.cluster的,那么在application.properties中加入这个配置,启动一下果然没有读取到集群配置信息,那么按照封装的初始化思路,写入apollo.cluster属性:

public class InitApolloCluster implements ApplicationContextInitializer<ConfigurableApplicationContext>, Ordered {

    @Override
    public void initialize(ConfigurableApplicationContext applicationContext) {
        ConfigurableEnvironment environment = applicationContext.getEnvironment();
        String cluster = System.getProperty("apollo.cluster", environment.getProperty("apollo.cluster"));
        if(StringUtils.isNotBlank(cluster)){
            // 指定了cluster,不然使用default
            System.setProperty("apollo.cluster", cluster);
        }
    }

    @Override
    public int getOrder() {
		// 在封装的初始化类之后运行,其实没关系
        return Ordered.HIGHEST_PRECEDENCE + 2;
    }
}

然后在WEB-INFO下配置spring.factories(在spring.factories中要设置好apollo.cluster值):

org.springframework.context.ApplicationContextInitializer=XXXX...

现在启动SpringBoot项目,果然使用了对应集群的Apollo配置

探究

在成功配置后,debug了一下,发现虽然进了初始化方法,但是前后执行了2次,并且第一次还没有从Environment取到配置,有点奇怪,从调用链可以看到是从SpringApplication的applyInitializers入口进去的:

protected void applyInitializers(ConfigurableApplicationContext context) {
	// 获取spring.factories配置的所有ApplicationContextInitializer实现类
	for (ApplicationContextInitializer initializer : getInitializers()) {
		Class<?> requiredType = GenericTypeResolver.resolveTypeArgument(
				initializer.getClass(), ApplicationContextInitializer.class);
		Assert.isInstanceOf(requiredType, context, "Unable to call initializer.");
		initializer.initialize(context);
	}
}


// 路径从SpringApplication的run方法到prepareContext方法
public ConfigurableApplicationContext run(String... args) {
	// ...
	ConfigurableEnvironment environment = prepareEnvironment(listeners,
					applicationArguments);
	// ...
	// 准备上下文
	prepareContext(context, environment, listeners, applicationArguments,
					printedBanner);
	// ...
}

private void prepareContext(ConfigurableApplicationContext context,
			ConfigurableEnvironment environment, SpringApplicationRunListeners listeners,
			ApplicationArguments applicationArguments, Banner printedBanner) {
	context.setEnvironment(environment);
	postProcessApplicationContext(context);
	// 调用所有的初始化类
	applyInitializers(context);
	listeners.contextPrepared(context);
	// ...
}

到这里发现就是正常的启动步骤,但是继续往下看,发现怎么这个run方法的调用并不是我们的启动类,而是从新build的SpringApplication去执行的

上一篇SpringBoot Environment中写到,SpringApplication启动后准备环境,并且会调用listeners.environmentPrepared(environment)向监听器发送事件,其中SpringApplicationRunListen类型的监听器(借鉴了Spring的ApplicationListener)是SpringApplication的run()方法执行的过程中贯穿始终的事件监听器。在这个debug链路中可以看到,最终invokeListener方法执行了BootstrapApplicationListener的onApplicationEvent方法,而这个BootstrapApplicationListener类是springframework.cloud的:

public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
	ConfigurableEnvironment environment = event.getEnvironment();
	if (!environment.getProperty("spring.cloud.bootstrap.enabled", Boolean.class,
			true)) {
		return;
	}
	// don't listen to events in a bootstrap context
	if (environment.getPropertySources().contains(BOOTSTRAP_PROPERTY_SOURCE_NAME)) {
		return;
	}
	// 创建bootstrapServiceContext
	ConfigurableApplicationContext context = bootstrapServiceContext(environment,
			event.getSpringApplication());
	apply(context, event.getSpringApplication(), environment);
}

创建过程中会调用SpringApplicationBuilder的run方法:

private ConfigurableApplicationContext bootstrapServiceContext(
		ConfigurableEnvironment environment, final SpringApplication application) {
	StandardEnvironment bootstrapEnvironment = new StandardEnvironment();
	MutablePropertySources bootstrapProperties = bootstrapEnvironment
			.getPropertySources();
	for (PropertySource<?> source : bootstrapProperties) {
		bootstrapProperties.remove(source.getName());
	}
	String configName = environment
			.resolvePlaceholders("${spring.cloud.bootstrap.name:bootstrap}");
	String configLocation = environment
			.resolvePlaceholders("${spring.cloud.bootstrap.location:}");
	Map<String, Object> bootstrapMap = new HashMap<>();
	bootstrapMap.put("spring.config.name", configName);
	if (StringUtils.hasText(configLocation)) {
		bootstrapMap.put("spring.config.location", configLocation);
	}
	bootstrapProperties.addFirst(
			new MapPropertySource(BOOTSTRAP_PROPERTY_SOURCE_NAME, bootstrapMap));
	for (PropertySource<?> source : environment.getPropertySources()) {
		bootstrapProperties.addLast(source);
	}
	ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
	// Use names and ensure unique to protect against duplicates
	List<String> names = SpringFactoriesLoader
			.loadFactoryNames(BootstrapConfiguration.class, classLoader);
	for (String name : StringUtils.commaDelimitedListToStringArray(
			environment.getProperty("spring.cloud.bootstrap.sources", ""))) {
		names.add(name);
	}
	// TODO: is it possible or sensible to share a ResourceLoader?
	// 这里创建了SpringApplicationBuilder
	SpringApplicationBuilder builder = new SpringApplicationBuilder()
			.profiles(environment.getActiveProfiles()).bannerMode(Mode.OFF)
			.environment(bootstrapEnvironment)
			.properties("spring.application.name:" + configName)
			.registerShutdownHook(false).logStartupInfo(false).web(false);
	List<Class<?>> sources = new ArrayList<>();
	for (String name : names) {
		Class<?> cls = ClassUtils.resolveClassName(name, null);
		try {
			cls.getDeclaredAnnotations();
		}
		catch (Exception e) {
			continue;
		}
		sources.add(cls);
	}
	builder.sources(sources.toArray(new Class[sources.size()]));
	AnnotationAwareOrderComparator.sort(sources);
	// 这里又调用了builder的run方法
	final ConfigurableApplicationContext context = builder.run();
	// Make the bootstrap context a parent of the app context
	addAncestorInitializer(application, context);
	// It only has properties in it now that we don't want in the parent so remove
	// it (and it will be added back later)
	bootstrapProperties.remove(BOOTSTRAP_PROPERTY_SOURCE_NAME);
	mergeDefaultProperties(environment.getPropertySources(), bootstrapProperties);
	return context;
}

从SpringApplicationBuilder的run方法可以看到,重新创建了SpringApplication并调用其run方法

public ConfigurableApplicationContext run(String... args) {
	if (this.running.get()) {
		// If already created we just return the existing context
		return this.context;
	}
	configureAsChildIfNecessary(args);
	if (this.running.compareAndSet(false, true)) {
		synchronized (this.running) {
			// If not already running copy the sources over and then run.
			this.context = build().run(args);
		}
	}
	return this.context;
}

public SpringApplication build() {
	return build(new String[0]);
}
public SpringApplication build(String... args) {
	configureAsChildIfNecessary(args);
	// 这里的application是在SpringApplicationBuilder创建时的构造函数中创建的
	// new SpringApplication(sources)
	this.application.setSources(this.sources);
	return this.application;
}

那么结果就出来了,第一次执行的是由Spring Cloud创建的SpringApplication,这个时候由于是新的SpringApplication,因此从Environment中是没有取到我们配置的apollo.cluster属性的。然后第二次,在prepareEnvironment执行后的prepareContext中又做了一遍相同的事情,而这次是我们一开始启动的那个SpringApplication,因此会取到apollo.cluster属性并成功放入System

答案很明显了,是spring-cloud-context包的原因导致的,然后我尝试从pom将封装的starter中exclusions这个引用,然后发现启动报错了,查看错误原因发现是创建类失败,点进去发现是封装的配置刷新类中依赖了Spring Cloud的RefreshScope类,导致了ClassNofFound

逻辑是在刷新类的postProcessAfterInitialization方法中,当查询到ConfigurationProperties与RefreshScope这2个注解修饰的配置类时,从apollo.ConfigService.getAppConfig()中添加改变监听器addChangeListener,在发生改变时刷新这个配置bean:

// 写在postProcessAfterInitialization中,即实现了BeanPostProcessor
ConfigService.getAppConfig().addChangeListener(new ConfigChangeListener() {
	@Override
	public void onChange(ConfigChangeEvent changeEvent) {
		Object oldSettings = springFactory.getBean(beanName);
		logger.info("before refresh {}", oldSettings.toString());
		refreshScope.refresh(beanName);
		Object newSettings = springFactory.getBean(beanName);
		logger.info("after refresh {}", newSettings.toString());
	}
});

我在网上搜了一下,貌似Apollo的配置修改更新都是这么写的,区别是onChange方法使用@ApolloConfigChangeListener注解触发也行。那么看来如果要实现热加载是需要依赖spring-cloud-context包的。然后点进去看一下refresh方法:

@ManagedOperation(description = "Dispose of the current instance of bean name provided and force a refresh on next method execution.")
public boolean refresh(String name) {
	// 不以scopedTarget.开头,加上
	if (!name.startsWith(SCOPED_TARGET_PREFIX)) {
		// User wants to refresh the bean with this name but that isn't the one in the
		// cache...
		name = SCOPED_TARGET_PREFIX + name;
	}
	// Ensure lifecycle is finished if bean was disposable
	// 卸载类
	if (super.destroy(name)) {
		this.context.publishEvent(new RefreshScopeRefreshedEvent(name));
		return true;
	}
	return false;
}

那么看起来这个刷新就是卸载类后重新加载,得以读取到最新的配置,实现了”热加载”。从一开始的判断上可以得知,卸载的这个类可能是个代理类,那么@RefreshScope这个注解应该会生成代理类,所以要在需要热加载的配置类上加上注解(需要注意,@RefreshScope作用的类,不能是final类,否则启动时会报错)

扩展:
1、Apollo可以通过namespace(命名空间)关联引入重复的配置,这个还是比较方便的
2、Apollo的配置由:application(应用) + environment(环境) + cluster(集群) + namespace(命名空间,配置使用@EnableApolloConfig({“xxx”}))决定

@RefreshScope那些事
Spring作用域 (Scope:Request,Session,Thread,Refresh) 的代理机制源码解析
spring cloud的RefreshScope注解进行热部署