案件回顾
前天,日常上线了个小迭代。内容是:将接口A切换成了接口B,需求很小,QA也没想着测,就让我自测后走免测上线了。开发完成后,赶紧部署到测试环境验证了下,没啥问题,perfect!可以上线了。
我兴奋地在线上一通构建,程序很快上线了。没一会,发现系统疯狂报错。瞅着错误栈里调用的接口url我一看,惊讶地大喊:“怎么线上请求到测试环境了!”。赶紧回滚代码。所幸,系统在代码回退后报错停止了。但是光回退代码还不行呀,还得找出原因上线呀。我仔细端详我的代码,业务逻辑上无懈可击,只有调用下游方式的写法有些差异。
@Value("${rpc.url}")
private String host;
.......
public Boolean customerAuth(Object... objects) {
URIBuilder uriBuilder = new URIBuilder();
uriBuilder.setHost(host);
......
String content;
HttpGet httpget;
URI uri = uriBuilder.build();
httpget = new HttpGet(uri);
LOGGER.info("request:\n {} {} \n", httpget.getMethod(), httpget.getURI());
HttpResponse response = httpClient.execute(httpget);
......
return hasAuth;
}
原本调用下游,我是采用 @Value的方式,将请求下游服务的url注入进来的。为了更优雅的实现功能(默默拿出了《代码整洁之道》),我改成了采用 @FeignClient注解的方式实现,同时将路径配置到了Apollo里面,从而减少代码量。
@FeignClient(name = "Rpc", contextId = "Rpc", url = "${rpc.url}")
public interface Rpc {
@GetMapping(value = "xxx/xxx/query")
Result<List<Object>> getContractDiscounts(@RequestParam("number") String number);
}
紧接着又仔细检查了apollo里自己配置的url路径,确认是线上的无疑。那么此时我就更晕了,“测试环境不是运行的好好的么,怎么一到生产就拉胯了呢?”,直到我看到了applicaiton.yml里的配置:
rpc:
url: http://xxx.test.com
显然,Apollo里配置没生效吧,而application.yml内的配置生效了。为了证实我的猜想,我将applicaiton.yml里的代码删掉了,然后重新启动了下服务,调用了下接口,结果报出了这个错误:
Caused by: java.lang.IllegalArgumentException: Illegal character in authority at index 7: http://${rpc.url}
at java.net.URI.create(URI.java:852)
at feign.RequestTemplate.target(RequestTemplate.java:465)
... 162 common frames omitted
果然我的猜测是没错的,为了优先解决问题,我在applicaiton-test.yml中配置了新的接口路径,重新上线后,系统没有报错,且正常运行起来了。尽管代码正常运行起来了,但是我的脑海不仅有了个疑问:“为什么在切换写法前,Apollo配置能够正常覆盖,但是在切换了写法之后,就不行了呢?”
Spring配置机制简介
为了找到问题发生的原因,首先需要了解配置是如何在SpringBoot项目中生效的。查阅资料后,我知道了在SpringBoot中,存在一个名为Application的变量,其中保存着Spring中启动的所有信息。在这所有的变量中,配置信息主要同变量Environment相关,诸如JVM参数、环境变量、Apollo配置等配置用PropertySource封装后,存放在Environment里的。
除了存储配置以外,SpringBoot还设计了propertyResolver用于管控当前的配置信息,并负责对配置进行填充。
至于PropertyResolver和PropertySource的关系,形象点来说,PropertyResolver就是一位翻译官,他会根据现有的词典PropertySource对我们的语言${xxx.url}做翻译,并最终得到所配置的信息。倘若字典中没有对应的信息,那么很自然"翻译官"是无法做出翻译的。
因此,不难分析问题的原因应该是切换写法后,配置发生了加载顺序上的变化,使得配置解析先于apollo里配置加载,从而出现解析失败的情况。
配置加载顺序梳理
认识到问题原因可能是由于配置加载顺序导致的,我们需要对Apollo、@Value、@FeignClient三者的配置加载顺序进行了解。
Apollo加载顺序梳理
首先我们来了解Apollo的配置加载顺序,结合Apollo的文档中的内容,不难得到apollo配置的加载顺序会有三种情况:
apollo.bootstrap.enabled | apollo.bootstrap.eagerLoad.enabled | 对应SpringBoot的运行阶段 |
---|---|---|
True | True | prepareEnvironment |
True | False | prepareContext |
False | False | refreshContext |
这里简单介绍下这三种情况对应的Springboot运行阶段分别负责的功能是:
- prepareEnvironment,是最早加载配置的地方,bootstrap.yml配置、系统启动参数中的环境变量都会在这个阶段被加载。
- prepareContext,主要对上下文做初始化,如设置bean名字命名器、设置加载.class文件加载器等。
- refreshContext,该阶段主要负责对bean容器进行加载,包括扫描文件得到BeanDefinition和BeanFactory工厂、Bean工厂生产Bean对象、对Bean对象再进行属性注入等工作。
这三个阶段在现有SpringBoot启动过程中顺序如下所示:
prepareEnviroment
在preparenEnvironment阶段,Spring会发出异步消息ApplicationEnvironmentPreparedEvent,同时名为ConfigFileApplicationListener对象会监听该消息,并对实现了EnvironmentPostProcessor
接口的对象进行调用。
在Apollo源码中,ApolloApplicationContextInitializer类也实现了EnvironmentPostProcessor
的接口。其实现方法中进行apollo配置的加载。
prepareContext
在prepareContext的阶段,主要依赖于方法applyInitializers。该方法会对所有实现了ApplicationContextInitializer
接口的对象进行调用。在Apollo中,ApolloApplicationContextInitializer类也实现了该接口,并在方法中进行配置加载。
refreshContext
refreshContext为Apollo的默认加载阶段。在refreshContext中,会调用invokeBeanFactoryPostProcessors方法对实现了BeanFactoryPostProcessor
接口的对象进行调用。在apollo源码中,对象PropertySourcesProcessor就实现了该接口。且该对象在postProcessBeanFactory方法中,进行了对配置信息的加载。
小结
由此梳理下来,Apollo三个阶段的加载顺序及配置控制逻辑,如下图所示:
@Value 加载顺序梳理
了解了apollo的加载顺序后。我们要了解下@Value的加载顺序,@Value的实现思想很纯粹,当你的Bean对象创建好后,我再把属性通过getter、setter方法注入进去,就实现注入的功能。
因此@Value的实现主要在Bean生成后。在refreshContext阶段,会调用finishBeanFactoryInitialization方法对所有单例bean对象做初始化逻辑。其中在AbstractAutowireCapableBeanFactory会有一个方法populateBean,其会对bean属性做填充。同上述类似,这里也会对所有继承了BeanPostProcessor
接口的对象进行调用。其中包含一个特殊的对象AutowiredAnnotationBeanPostProcessor
AutowiredAnnotationBeanPostProcessor会将用@Value注解修饰的对象扫描出来,并从配置中找到对应的配置信息,注入到对象中。结合上述apollo配置加载顺序图,我们可以得到@Value和Apollo的配置优先级大概如下所示:
可以看到,@Value的配置晚于apollo的配置,因此在切换写法前,apollo的配置可以被正常注入。
@FeignClient 加载顺序梳理
了解完@Value的加载顺序后,我们还需要了解下@FeignClient的配置加载顺序。对于FeignClient来说,它通常采用接口做实现,因此需要根据@FeignClient生成新的Bean对象,并注册到容器中。因此,其配置的加载顺序在Bean对象生成之前。
类ConfigurationClassPostProcessor继承自接口AutowiredAnnotationBeanPostProcessor
,其postProcessBeanDefinitionRegistry方法会对BeanDefinition做注入处理。
(BeanDefinition,简写为BeanDef,是Bean容器未生成的形态,如果将Bean比作一辆汽车,那么BeanDefinition就是汽车的图纸。)
同时,类ConfigurationClassBeanDefinitionReader会调用loadBeanDefinitionsFromRegistrars方法,该方法会将实现了ImportBeanDefinitionRegistrar
接口的对象逐一进行调用。这其中包含一个FeignClientsRegistrar对象,其实现的registerFeignClients方法会扫描所有被@FeignClient注解的对象。
同时,对单个BeanDef对象,还会调用FeignClientsRegistrar下的registerFeignClient方法做处理,将我们其中的url、path等属性都用propertyResolver做翻译处理,倘若此时,配置中不存在相应的属性,就不会更新。这就是造成本次问题的关键点。
关注到加载顺序上,@FeignClient注解所依赖的接口为BeanDefinitionRegistryPostProcessor
,而Apollo中默认加载的情况则依赖于BeanFactoryPostProcessor
接口。两者几乎在同一处方法调用内,但BeanDefinitionRegistryPostProcessor
接口执行稍微先于BeanFactoryPostProcessor
。因此在加载顺序上,@FeignClient会先于默认情况下的Apollo加载。
至此也就不难理解为什么Apollo注解没法生效了。因为在@FeignClient注解的情况下,beanDef注入时,apollo的配置还没有加载,PropertyResolver找不到对应的配置,自然也就无法进行注入了。
总结
在了解了上述配置的作用机制后,我在原本代码中添加了apollo.bootstrap.enabled=true,将Apollo的配置加载提前到了FeignClient加载前,然后重新运行代码,项目果然如想象中的正常运转起来。
我抱着的《代码整洁之道》,放声大笑。
参考文章
增加EnvironmentPostProcessor处理,将Apollo配置加载提到初始化日志系统之前
Spring Boot 2.2.6 源码之旅四十七@Value原理详解
[注解 @EnableFeignClients 工作原理](