问题背景
今天又遇到一个问题,给大家分享一下。有个微服务 B 通过 Feign 调用微服务 A 的接口,A 的接口处理时间超过了 5s,从而导致 B 服务客户端报了超时错误。但是在印象中,默认配置的超时时间应该是 60s 才对,不应该到了 5s 就报超时了。然后查看了环境的 apollo 配置,发现 Feign 有个配置参数被配置了两遍,一种是中划线的方式,一种是驼峰的方式,从现象上看中划线的方式配置是生效了的。如下所示:
json
feign.client.config.default.read-timeout=5000
# 中间隔了很多行之后又有一个配置
feign.client.config.default.readTimeout=60000
那这个问题就转换成了对于中划线和驼峰两种配置,在 Spring Boot 中哪种配置会生效?
下面是一个测试该现象的 demo 项目,项目中的 ConfigController
对外提供一个 /timeout
的接口,用于查询 timeout
的配置,然后这个配置是从 Config
配置类的 connectionTimeout
属性获取的,这个属性的值又是通过 properties 文件配置的。
ConfigController
:
java
@RestController
public class ConfigController {
@Autowired
private Config config;
@GetMapping("/timeout")
public String hello() {
return String.valueOf(config.getConnectionTimeout());
}
}
Config
:
java
@ConfigurationProperties(prefix = "test")
@Component
public class Config {
private long connectionTimeout;
public long getConnectionTimeout() {
return connectionTimeout;
}
public void setConnectionTimeout(long connectionTimeout) {
this.connectionTimeout = connectionTimeout;
}
}
当在 application.properties
配置中划线格式的时候,调用接口是可以正常获取到值的:
json
test.connection-timeout=30000
当在 application.properties
配置驼峰格式的时候,调用接口也是可以正常获取到值的:
json
test.connectionTimeout=30000
当在 application.properties
同时配置有中划线和驼峰格式的时候,调用接口获取到的值是中划线配置的值:
json
test.connectionTimeout=30000
test.connection-timeout=5000
咨询了 AI,AI 告诉我这是 Spring Boot 支持的宽松适配 规则:
问题原理
Spring 会将要注入配置的类的属性变成属性名到属性 的一个 Map,这里的属性是 JavaBeanBinder
的内部类 BeanProperty
的实例。这个过程是在 JavaBeanBinder
的 BeanProperties
类中实现的。代码如下:
java
static class BeanProperties {
private final Map<String, BeanProperty> properties = new LinkedHashMap<>();
BeanProperties(ResolvableType type, Class<?> resolvedType) {
this.type = type;
this.resolvedType = resolvedType;
addProperties(resolvedType);
}
private void addMethodIfPossible(Method method, String prefix, int parameterCount,
BiConsumer<BeanProperty, Method> consumer) {
if (method != null && method.getParameterCount() == parameterCount && method.getName().startsWith(prefix)
&& method.getName().length() > prefix.length()) {
String propertyName = Introspector.decapitalize(method.getName().substring(prefix.length()));
consumer.accept(this.properties.computeIfAbsent(propertyName, this::getBeanProperty), method);
}
}
private BeanProperty getBeanProperty(String name) {
return new BeanProperty(name, this.type);
}
}
而在 BeanProperty
的构造函数中会将它的名称标准化为 Dashed-Form 格式的,即使用连字符分隔。
java
static class BeanProperty {
private final String name;
private final ResolvableType declaringClassType;
BeanProperty(String name, ResolvableType declaringClassType) {
this.name = DataObjectPropertyName.toDashedForm(name);
this.declaringClassType = declaringClassType;
}
}
比如上面的配置类中的属性是 connectionTimeout
,这里生成的 BeanProperty
对象的名称就变成了 connection-timeout
这种格式。
真正的绑定结果是在 JavaBeanBinder
类中的 bind()
方法实现的,在它的方法中会循环每一个 BeanProperty
,然后对其属性进行绑定。代码如下:
java
private <T> boolean bind(DataObjectPropertyBinder propertyBinder, Bean<T> bean, BeanSupplier<T> beanSupplier,
Context context) {
boolean bound = false;
// 这里循环每个属性,然后对属性值进行绑定
for (BeanProperty beanProperty : bean.getProperties().values()) {
bound |= bind(beanSupplier, propertyBinder, beanProperty);
context.clearConfigurationProperty();
}
return bound;
}
private <T> boolean bind(BeanSupplier<T> beanSupplier, DataObjectPropertyBinder propertyBinder,
BeanProperty property) {
// 这里会拿到属性的名称connection-timeout
String propertyName = determinePropertyName(property);
ResolvableType type = property.getType();
Supplier<Object> value = property.getValue(beanSupplier);
Annotation[] annotations = property.getAnnotations();
Object bound = propertyBinder.bindProperty(propertyName,
Bindable.of(type).withSuppliedValue(value).withAnnotations(annotations));
if (bound == null) {
return false;
}
if (property.isSettable()) {
property.setValue(beanSupplier, bound);
}
else if (value == null || !bound.equals(value.get())) {
throw new IllegalStateException("No setter found for property: " + property.getName());
}
return true;
}
然后调用到 Binder
的 bindObject()
方法中,这个时候会根据 ConfigurationPropertyName
这个对象作为 key 去查找属性,这个时候它的名称应该是 test.connection-timeout。查找的过程就是遍历每个ConfigurationPropertySource对象进行查找。代码如下:
java
private <T> Object bindObject(ConfigurationPropertyName name, Bindable<T> target, BindHandler handler,
Context context, boolean allowRecursiveBinding) {
//这里根据 ConfigurationPropertyName 这个对象作为 key 去查找属性
ConfigurationProperty property = findProperty(name, target, context);
if (property != null) {
try {
return bindProperty(target, context, property);
}
catch (ConverterNotFoundException ex) {
}
}
}
private <T> ConfigurationProperty findProperty(ConfigurationPropertyName name, Bindable<T> target,
Context context) {
for (ConfigurationPropertySource source : context.getSources()) {
//遍历每个ConfigurationPropertySource对象进行查找
ConfigurationProperty property = source.getConfigurationProperty(name);
if (property != null) {
return property;
}
}
return null;
}

最终会进入到 SpringIterableConfigurationPropertySource
的 getConfigurationProperty()
方法中。在该方法中,先会调用父类 SpringConfigurationPropertySource
的 getConfigurationProperty()
方法查找,找不到再自己查找。代码如下:
java
@Override
public ConfigurationProperty getConfigurationProperty(ConfigurationPropertyName name) {
if (name == null) {
return null;
}
//先调用父类的方法查找
ConfigurationProperty configurationProperty = super.getConfigurationProperty(name);
if (configurationProperty != null) {
return configurationProperty;
}
for (String candidate : getCache().getMapped(name)) {
Object value = getPropertySourceProperty(candidate);
if (value != null) {
Origin origin = PropertySourceOrigin.get(getPropertySource(), candidate);
return ConfigurationProperty.of(this, name, value, origin);
}
}
return null;
}
Set<String> getMapped(ConfigurationPropertyName configurationPropertyName) {
return this.data.mappings().getOrDefault(configurationPropertyName, Collections.emptySet());
}
在父类 SpringConfigurationPropertySource
的 getConfigurationProperty()
方法中,首先会根据属性的名称,在这里就是 test.connection-timeout 去属性中查找,如果有则返回,这里就是为什么同时存在驼峰和中划线配置的时候,中划线会优先的情况,因为代码里面就是先找的中划线的配置。
java
@Override
public ConfigurationProperty getConfigurationProperty(ConfigurationPropertyName name) {
if (name == null) {
return null;
}
//这里使用了DefaultPropertyMapper,它什么都没有干
for (PropertyMapper mapper : this.mappers) {
try {
for (String candidate : mapper.map(name)) {
Object value = getPropertySourceProperty(candidate);
if (value != null) {
Origin origin = PropertySourceOrigin.get(this.propertySource, candidate);
return ConfigurationProperty.of(this, name, value, origin);
}
}
}
catch (Exception ex) {
// Ignore
}
}
return null;
}
protected final Object getPropertySourceProperty(String name) {
return (!this.systemEnvironmentSource) ? propertySource.getProperty(name)
: getSystemEnvironmentProperty(((SystemEnvironmentPropertySource) propertySource).getSource(), name);
}
当父类中找不到时,再从自己的方法中查找。先通过调用 getMapped()
方法转换属性的名称,然后再根据属性的名称去属性中查找。代码如下:
java
@Override
public ConfigurationProperty getConfigurationProperty(ConfigurationPropertyName name) {
if (name == null) {
return null;
}
ConfigurationProperty configurationProperty = super.getConfigurationProperty(name);
if (configurationProperty != null) {
return configurationProperty;
}
// 这里通过调用getMapped()方法转换属性的名称
for (String candidate : getCache().getMapped(name)) {
Object value = getPropertySourceProperty(candidate);
if (value != null) {
Origin origin = PropertySourceOrigin.get(getPropertySource(), candidate);
return ConfigurationProperty.of(this, name, value, origin);
}
}
return null;
}
对于属性配置文件中的每一个属性配置都会转为 ConfigurationPropertyName
对象和它实际名称的一个映射,且 ConfigurationPropertyName
,如下图所示。在 getMapped()
方法中会根据传入的 ConfigurationPropertyName
对象去 Map 中拿到实际的属性名称。实际的配置解析出来的 ConfigurationPropertyName
对象名称为 test.connectiontimeout ,传入的 ConfigurationPropertyName
对象名称为 test.connection-timeout,那么是为什么 Map 里面还能够返回值呢?
java
Set<String> getMapped(ConfigurationPropertyName configurationPropertyName) {
return this.data.mappings().getOrDefault(configurationPropertyName, Collections.emptySet());
}

这个是因为 ConfigurationPropertyName
重写了它 euqals()
方法,在它的方法里面比较的时候,只会比较数字、字母字符,且字母都会统一大小写来比较,所以传入名称为 test.connection-timeout 的对象,也能够获取到名称为 test.connectiontimeout 对象对应的 value 值。
java
ConfigurationPropertyName {
@Override
public boolean equals(Object obj) {
//省略前面的代码
return endsWithElementsEqualTo(other);
}
private boolean endsWithElementsEqualTo(ConfigurationPropertyName name) {
for (int i = this.elements.getSize() - 1; i >= 0; i--) {
if (elementDiffers(this.elements, name.elements, i)) {
return false;
}
}
return true;
}
private boolean elementDiffers(Elements e1, Elements e2, int i) {
ElementType type1 = e1.getType(i);
ElementType type2 = e2.getType(i);
if (type1.allowsFastEqualityCheck() && type2.allowsFastEqualityCheck()) {
return !fastElementEquals(e1, e2, i);
}
if (type1.allowsDashIgnoringEqualityCheck() && type2.allowsDashIgnoringEqualityCheck()) {
return !dashIgnoringElementEquals(e1, e2, i);
}
return !defaultElementEquals(e1, e2, i);
}
private boolean defaultElementEquals(Elements e1, Elements e2, int i) {
int l1 = e1.getLength(i);
int l2 = e2.getLength(i);
boolean indexed1 = e1.getType(i).isIndexed();
boolean indexed2 = e2.getType(i).isIndexed();
int i1 = 0;
int i2 = 0;
while (i1 < l1) {
if (i2 >= l2) {
return remainderIsNotAlphanumeric(e1, i, i1);
}
//这里统一了大小写
char ch1 = indexed1 ? e1.charAt(i, i1) : Character.toLowerCase(e1.charAt(i, i1));
char ch2 = indexed2 ? e2.charAt(i, i2) : Character.toLowerCase(e2.charAt(i, i2));
//这里只选择了数字、字母字符
if (!indexed1 && !ElementsParser.isAlphaNumeric(ch1)) {
i1++;
}
else if (!indexed2 && !ElementsParser.isAlphaNumeric(ch2)) {
i2++;
}
else if (ch1 != ch2) {
return false;
}
else {
i1++;
i2++;
}
}
if (i2 < l2) {
return remainderIsNotAlphanumeric(e2, i, i2);
}
return true;
}
}