背景
今天早上,测试人员突然反馈系统出现问题,经初步排查发现是isTimeOut
字段为空导致的。我登录测试环境系统,确认了该故障的存在并能够稳定复现。
由于我最近没有修改过相关代码(对象属性如图,省略其他代码),我怀疑其他同事可能不小心修改了这部分代码的逻辑。我检查了字段赋值的相关代码,但没有发现问题。这时,我决定通过本地调试来进一步排查原因。
@Data
public class Demo {
// 是否超时
private Boolean isTimeOut;
private Boolean proTimeOut;
// 超时时长
private Integer timeOut;
public static void main(String[] args) {
Demo source = new Demo();
source.setIsTimeOut(false);
source.setTimeOut(null);
Demo target = new Demo();
// HuTool
BeanUtil.copyProperties(source, target);
// null
System.out.println("isTimeOut:" + target.getIsTimeOut());
// null
System.out.println("timeOut:" + target.getTimeOut());
}
}
排查过程
发现问题
字段赋值的地方并不多,一个是直接赋予默认值false,另一个是根据业务逻辑判断是否超时。
我先排除了直接赋值默认值的情况,然后在业务逻辑判断结果处设置了断点,发现isTimeOut
字段有值。
我继续追踪代码,发现问题出现在BeanUtil.copyProperties(source, target)
方法上,其他属性都复制成功,但isTimeOut
字段仍然为空。
但为什么会出现这种问题呢?
深入挖掘代码后,我发现了如下问题:isTimeOut
的赋值方法调用了setTimeOut()
而不是Lombok
生成的setIsTimeOut()
。
寻找原因
我研究了 Hutool5.6.2 BeanUtil.copyProperties(source, target
)方法的源代码,并发现了问题所在。
- 在
BeanUtil
中,对于boolean
和Boolean
类型的字段,统一进行处理。 - 方法会依次匹配
Method[]
数组中的方法,只要找到匹配的getter
和setter
方法就返回。 - 对于
boolean
和Boolean
类型的字段,匹配规则是isName
-》setName
或者isName
-》setIsName
。
// 判断获取 getter 和 setter 方法
private PropDesc findProp(Field field, Method[] methods, boolean ignoreCase) {
final String fieldName = field.getName();
final Class<?> fieldType = field.getType();
// 1. 对 boolean,Boolean 是统一处理的。
final boolean isBooleanField = BooleanUtil.isBoolean(fieldType);
// 省略相关逻辑
for (Method method : methods) {
// 省略相关逻辑
} else if (isMatchSetter(methodName, fieldName, isBooleanField, ignoreCase)) {
// 只有一个参数的情况下方法名与字段名对应匹配,则为Setter方法
setter = method;
}
// 2. 匹配到就直接返回
if (null != getter && null != setter) {
// 如果Getter和Setter方法都找到了,不再继续寻找
break;
}
}
return new PropDesc(field, getter, setter);
}
// 判断是否 setter 方法
private boolean isMatchSetter(String methodName, String fieldName, boolean isBooleanField,
boolean ignoreCase) {
final String handledFieldName;
if (ignoreCase) {
// 全部转为小写,忽略大小写比较
methodName = methodName.toLowerCase();
handledFieldName = fieldName.toLowerCase();
fieldName = handledFieldName;
} else {
handledFieldName = StrUtil.upperFirst(fieldName);
}
// 非标准Setter方法跳过
if (false == methodName.startsWith("set")) {
return false;
}
// 3. 针对Boolean类型特殊检查
if (isBooleanField && fieldName.startsWith("is")) {
// 字段是is开头
// isName -》 setName
if (("set" + StrUtil.removePrefix(fieldName, "is")).equals(methodName)
// isName -》 setIsName
|| ("set" + handledFieldName).equals(methodName)
) {
return true;
}
}
// 包括boolean的任何类型只有一种匹配情况:name -》 setName
return ("set" + handledFieldName).equals(methodName);
}
根据上述结论,可以解释为什么出现问题存在偶发性:
因为isTimeOut
和 timeout
分别生成了setIsTimeOut(boolean)
和setTimeOut(integer)
方法,同时Hutool 判断逻辑存在 Bug。
扩展:使用 Lombok 生成Setter方法。对于布尔类型的属性,生成的方法名是不同的:
- 对于包装类型
Boolean
,生成的方法名是setIsTimeOut()
。- 对于基本类型
boolean
,生成的方法名是setTimeOut()
。
即匹配Setter方法的结果与方法数组的顺序有关。此外,对于isTimeOut
字段,可能会匹配到setTimeOut(integer)
方法,而不是setIsTimeOut(boolean)
方法。
继续深入挖掘后,发现方法数组的来源是Class.getDeclaredMethods()
,而该方法返回的数组顺序通常是不固定的,因此该问题可能出现偶发性。 因此可能存在setTimeOut(integer)
方法排在setIsTimeOut(boolean)
之前的情况,如下图所示:
在 Java 中,
Class.getDeclaredMethods()
方法返回的Method[]
数组的 顺序通常是不固定的 ,即不保证按照特定顺序排列。方法的返回顺序可能会因多种因素而变化,例如 JVM 实现、编译器优化、类加载顺序等。
反馈优化
当我准备给 Hutool 官方提 issue,并尝试修复后提 pr,发现这个bug已经修复了,修复版本:5.7.20 (2022-01-07)。
Hutool 增加校验了 Setter方法传参,保证参数和赋值对象类型一致。
解决方案
方法 | 应用范围 |
---|---|
Lombok @Builder(toBuilder = true) | 相同类型及子类 |
MapStruct | 任何 |
手动赋值 | 任何 |
Lombok toBuilder
Lombok 的 @Builder
注解提供了一种方便的方式来生成构建器模式的代码,用于创建对象实例。而 @Builder(toBuilder = true)
则扩展了 @Builder 注解的功能,生成一个额外的 toBuilder()
方法,用于创建当前对象的副本。
我们可以通过调用 toBuilder() 方法获取一个对象的副本,然后使用链式调用的方式设置副本对象的字段值。通过 build() 方法最终构建新的对象。
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true)
public class Demo {
private Boolean isTimeOut;
private Integer timeOut;
}
Demo demo = new Demo(false, 0);
Demo target = demo.toBuilder().build();
MapStruct
以下内容由 ChatGPT-4 生成,可能存在一些错误,请注意识别。
Java MapStruct 是一个用于生成类型安全的映射器(Mapper)的代码生成器。它可以帮助简化对象之间的映射过程,减少手动编写重复的映射代码的工作量。
下面是使用 MapStruct 的基本步骤:
- 添加依赖:首先,在你的项目中添加 MapStruct 的依赖。你可以使用 Maven 或 Gradle 来管理依赖关系。
Maven:
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.4.2.Final</version>
</dependency>
Gradle:
implementation 'org.mapstruct:mapstruct:1.4.2.Final'
请确保将版本号替换为最新的 MapStruct 版本。
- 创建映射接口:在源代码中创建一个接口,用于定义对象之间的映射规则。这个接口需要被 MapStruct 识别并生成相应的映射实现类。接口上需要添加
@Mapper
注解。
@Mapper
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
CarDto carToCarDto(Car car);
}
在上述示例中,我们定义了一个 CarMapper
接口,并声明了一个映射方法 carToCarDto
,用于将 Car
对象映射为 CarDto
对象。INSTANCE
字段用于获取映射器实例。
- 定义映射规则:在映射接口中,你需要定义对象之间的映射规则。MapStruct 会根据这些规则自动生成映射实现类。
@Mapper
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
CarDto carToCarDto(Car car);
@Mapping(source = "numberOfSeats", target = "seatCount")
CarDto carToCarDtoWithSeatCount(Car car);
}
在上述示例中,我们使用 @Mapping
注解指定了源对象字段 numberOfSeats
和目标对象字段 seatCount
之间的映射关系。
- 进行映射操作:使用映射器实例进行对象之间的映射操作。
Car car = new Car("Toyota", 4);
CarDto carDto = CarMapper.INSTANCE.carToCarDto(car);
在上述示例中,我们使用 CarMapper.INSTANCE
获取映射器实例,然后调用映射方法 carToCarDto
进行对象映射。
MapStruct 会根据你定义的映射规则和注解自动生成映射实现类。你无需手动实现映射代码。
需要注意的是,为了使 MapStruct 正确工作,你需要确保源对象和目标对象的字段名称和类型是兼容的,或者使用 @Mapping
注解进行字段的映射指定
手动赋值
结合使用vo2dto插件实现快速赋值。
Demo demo = new Demo(false, 0);
Demo target = new Demo();
target.setIsTimeOut(demo.getIsTimeOut());
target.setTimeOut(demo.getTimeOut());
改进优化
- 在命名布尔值字段时,禁止使用 is 开头(参考阿里巴巴Java开发手册)。
- 避免使用
BeanUtil.CopyProperties
类进行属性复制,考虑手动赋值或使用Lombok toBuilder 进行赋值。
注意: 请确保在引用和使用第三方库时,始终使用最新版本,以充分利用其修复的错误和改进的功能。
评论