记SpringBoot读取配置文件的坑(自定义元数据)
使用的注解@ConfigurationProperties,@ConditionalOnProperty
在一次日常写代码中,按着度娘写的代码去读取配置文件中的数据,发现 @ConditionalOnProperty
这个注解总是验证不通过(导致bean注册不上去)。仔细查找一下资料,发现写法并无错误,但就是验证不通过,发现配置文件中不为on就能通过,即使是false也能通过(这里是指字符串)。于是开始一下午的排坑之旅。
先说结论
SpringBoot中读取配置文件,会先给字段打上标识符Tag来识别他是某一个类型,例如有str、seq、map、时间戳等。不同的类型会有不同的默认值,而value为on时,会识别成bool类型,默认值是true,这样和注解
@ConditionalOnProperty(havingValue="on")
中的on自然不相等,也就会一直校验失败。
- 那么SpringBoot是根据什么来给字段打上标签的呢?
经过一下午的查询找到在SpringBoot中有一个叫Resolver
的类,他的一个字段yamlImplicitResolvers
里面就存放了字段的校验规则。
首先先看一下校验的源码:
public Tag resolve(NodeId kind, String value, boolean implicit) {
if (kind == NodeId.scalar && implicit) {
final List<ResolverTuple> resolvers;
/*
这里我们可以看到,yamlImplicitResolvers会去取字段的第一个字母做校验
*/
if (value.length() == 0) {
resolvers = yamlImplicitResolvers.get('\0');
} else {
resolvers = yamlImplicitResolvers.get(value.charAt(0));
}
// yamlImplicitResolvers中分null和非null,这里是非null的情况
if (resolvers != null) {
for (ResolverTuple v : resolvers) {
Tag tag = v.getTag();
Pattern regexp = v.getRegexp();
// 这里会进行一个正则匹配,只有匹配上了才能返回
if (regexp.matcher(value).matches()) {
return tag;
}
}
}
if (yamlImplicitResolvers.containsKey(null)) {
for (ResolverTuple v : yamlImplicitResolvers.get(null)) {
Tag tag = v.getTag();
Pattern regexp = v.getRegexp();
// 同上
if (regexp.matcher(value).matches()) {
return tag;
}
}
}
}
// 如果在yamlImplicitResolvers没有查询到结果,那么就返回传进来的kind(一般是str)
switch (kind) {
case scalar:
return Tag.STR;
case sequence:
return Tag.SEQ;
default:
return Tag.MAP;
}
}
根据上面的代码,我们不难得出 yamlImplicitResolvers就是一个Map,里面的key为一个字符,用于匹配字段名,value为正则表达式,只有同时匹配key和value才返回完整的Tag。
- 注意这里key和value指的是yamlImplicitResolvers对象的key和value,而不是配置文件中的key和value。
列举一些yamlImplicitResolvers中的值:
null->[^(?:~|null|Null|NULL| )$,^$]
F->[^(?:yes|Yes|YES|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF)$]
N->[^(?:yes|Yes|YES|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF)$, ^(?:~|null|Null|NULL| )$]
- (万恶之源)
o->[^(?:yes|Yes|YES|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF)$]
注意,配置文件中只有value需要注意写法,如果在
@ConfigurationProperties
标识的类中定义了字段类型,那么就不会自行转换了(可能)。
痛苦记忆
- 最开始以为是写法问题,属性值写错了之类的。点进去
@ConditionalOnProperty
里看了很多遍,很多方法都试过了,发现没有问题。然后试了下官方文档的实例,发现都可以,甚至false都可以,说明这个字段应该是字符串类型的,然后发现只有用on不行,于是猜想是不是SpringBoot自带什么奇怪的转化问题。 其实在一开始是报的找不到bean这个错,一顿排查找到了带@ConditionalOnProperty
的这个方法,猜想打了断点发现根本没创建bean,猜想是不是没通过校验。😂- 然后准备去找,是不是
@ConditionalOnProperty
里面做了什么奇怪的操作把属性havingValue的值给改掉了,但自己对Spring不算很熟练,只知道SpringAop会对方法代理,执行一个代理方法,这么快想到是因为之前自己写过AOP日志。于是想着去找Spring的方法增强里面找,看看是不是把方法注解给我改东西了。 - 首先找到打印日志的类
NoSuchBeanDefinitionFailureAnalyzer
通过debug 观察到抛出的异常,但是这个异常仅仅是找不到 bean 所抛出的异常,对我们要找的问题没有什么帮助。在搜索ConditionalOnProperty
观察后,在Spring的上下文的ConditionContext
中找到了创建 bean 的方法,观察发现在该context中注解的值是正常的为**“on”**。(在OnPropertyCondition
的校验类中的getMatchOutcome
方法里面可以找到,并且debug发现把匹配结果加入了noMatch
集合中,也就是为匹配,说明配置文件读取的有问题。) - 继续观察上下文对象,发现了配置文件中的值为true,这就很神奇了,明明配置文件中写的是on,为什么会变成true呢?(这里的value点进去是Boolean类型)
在context对象中有一个environment对象,里面的propertySources属性里记录了外部数据的详细信息,其中的
OriginTrackedMapPropertySource {name='Config resource 'class path resource [application.yaml]' via location 'optional:classpath:/''}
对象就是读取我们自己写的配置文件的对象,再去里面找到source属性就可以找到我们自己定义的所有字段包括值
根本原因
到这里,问题似乎一目了然了,显然是SpringBoot在读取配置文件时,根据字段或者值在转成java对应的类型时转出了问题。
那么SpringBoot是怎么读取配置文件的呢,度娘了一下,找到了这个类YamlPropertySourceLoader
是用于读取yaml文件的,(顺带提一句,这里可以看到yml和yaml是一个文件类型)。随后在核心方法里可以找到调用的OriginTrackedYamlLoader
这货的load方法
public List<PropertySource<?>> load(String name, Resource resource) throws IOException {
if (!ClassUtils.isPresent("org.yaml.snakeyaml.Yaml", getClass().getClassLoader())) {
throw new IllegalStateException(
"Attempted to load " + name + " but snakeyaml was not found on the classpath");
}
// 就在这里
List<Map<String, Object>> loaded = new OriginTrackedYamlLoader(resource).load();
if (loaded.isEmpty()) {
return Collections.emptyList();
}
List<PropertySource<?>> propertySources = new ArrayList<>(loaded.size());
for (int i = 0; i < loaded.size(); i++) {
String documentNumber = (loaded.size() != 1) ? " (document #" + i + ")" : "";
propertySources.add(new OriginTrackedMapPropertySource(name + documentNumber,
Collections.unmodifiableMap(loaded.get(i)), true));
}
return propertySources;
}
在load里面继续调用了YamlProcessor
类的process
方法
private boolean process(MatchCallback callback, Yaml yaml, Resource resource) {
int count = 0;
try {
if (logger.isDebugEnabled()) {
logger.debug("Loading from YAML: " + resource);
}
try (Reader reader = new UnicodeReader(resource.getInputStream())) {
// 在这里才会加载yaml
for (Object object : yaml.loadAll(reader)) {
if (object != null && process(asMap(object), callback)) {
count++;
if (this.resolutionMethod == ResolutionMethod.FIRST_FOUND) {
break;
}
}
}
if (logger.isDebugEnabled()) {
logger.debug("Loaded " + count + " document" + (count > 1 ? "s" : "") +
" from YAML resource: " + resource);
}
}
}
catch (IOException ex) {
handleProcessError(resource, ex);
}
return (count > 0);
}
点进加载方法loadAll
看一下,发现这是一个迭代器(还发现配置文件用文件流读取),里面有一个
全局对象constructor
,暂时不去关心怎么来的,去重写的迭代器里面的next方法发现调用的是constructor
对象的getData()
方法。在点进去就是本文开头的内容了,这里就不再复述了。
总结
到此,我们明白了SpringBoot读取配置文件的机制,并且了解了配置文件有一些特殊的语义需要注意。这次观察SpringBoot源码告一段落,其实在一开始就大概猜到了是哪里出了问题,但是不清楚为什么会这样,阅读完源码想明白了很多,感觉又有成长了。