这就是宽松的适配规则!

问题背景

今天又遇到一个问题,给大家分享一下。有个微服务 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 的实例。这个过程是在 JavaBeanBinderBeanProperties 类中实现的。代码如下:

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;  
}

然后调用到 BinderbindObject() 方法中,这个时候会根据 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;  
}

最终会进入到 SpringIterableConfigurationPropertySourcegetConfigurationProperty() 方法中。在该方法中,先会调用父类 SpringConfigurationPropertySourcegetConfigurationProperty() 方法查找,找不到再自己查找。代码如下:

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());  
}

在父类 SpringConfigurationPropertySourcegetConfigurationProperty() 方法中,首先会根据属性的名称,在这里就是 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;  
	}
}
相关推荐
陈随易4 分钟前
一段时间没写文章了,花了10天放了个屁
前端·后端·程序员
星星电灯猴8 分钟前
抓包工具分析接口跳转异常:安全校验误判 Bug 全记录
后端
调试人生的显微镜9 分钟前
后台发热、掉电严重?iOS 应用性能问题实战分析全过程
后端
用户307429716715817 分钟前
Spring AI 评估-优化器模式完整指南
java·spring boot
深栈解码17 分钟前
OpenIM 源码深度解析系列(十八):附录二数据库结构
后端
前端付豪24 分钟前
Google Ads 广告系统排序与实时竞价架构揭秘
前端·后端·架构
代码or搬砖33 分钟前
Spring AOP全面详讲
java·spring
努力的小郑33 分钟前
MySQL DATETIME类型存储空间详解:从8字节到5字节的演变
后端
stein_java1 小时前
springMVC-15 异常处理
java·spring
哪吒编程2 小时前
我的第一个AI编程助手,IDEA最新插件“飞算JavaAI”,太爽了
java·后端·ai编程