背景

今天早上,测试人员突然反馈系统出现问题,经初步排查发现是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)方法的源代码,并发现了问题所在。

  1. BeanUtil中,对于booleanBoolean类型的字段,统一进行处理。
  2. 方法会依次匹配Method[]数组中的方法,只要找到匹配的gettersetter方法就返回。
  3. 对于booleanBoolean类型的字段,匹配规则是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);
}

根据上述结论,可以解释为什么出现问题存在偶发性:

因为isTimeOuttimeout 分别生成了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 实现、编译器优化、类加载顺序等。

Method[]

反馈优化

当我准备给 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 的基本步骤:

  1. 添加依赖:首先,在你的项目中添加 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 版本。

  1. 创建映射接口:在源代码中创建一个接口,用于定义对象之间的映射规则。这个接口需要被 MapStruct 识别并生成相应的映射实现类。接口上需要添加 @Mapper 注解。
@Mapper
public interface CarMapper {
    CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);

    CarDto carToCarDto(Car car);
}

在上述示例中,我们定义了一个 CarMapper 接口,并声明了一个映射方法 carToCarDto,用于将 Car 对象映射为 CarDto 对象。INSTANCE 字段用于获取映射器实例。

  1. 定义映射规则:在映射接口中,你需要定义对象之间的映射规则。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 之间的映射关系。

  1. 进行映射操作:使用映射器实例进行对象之间的映射操作。
Car car = new Car("Toyota", 4);
CarDto carDto = CarMapper.INSTANCE.carToCarDto(car);

在上述示例中,我们使用 CarMapper.INSTANCE 获取映射器实例,然后调用映射方法 carToCarDto 进行对象映射。

MapStruct 会根据你定义的映射规则和注解自动生成映射实现类。你无需手动实现映射代码。

需要注意的是,为了使 MapStruct 正确工作,你需要确保源对象和目标对象的字段名称和类型是兼容的,或者使用 @Mapping 注解进行字段的映射指定

手动赋值

结合使用vo2dto插件实现快速赋值。

vo2dto使用

Demo demo = new Demo(false, 0);

Demo target = new Demo();
target.setIsTimeOut(demo.getIsTimeOut());
target.setTimeOut(demo.getTimeOut());

改进优化

  • 在命名布尔值字段时,禁止使用 is 开头(参考阿里巴巴Java开发手册)。

阿里巴巴Java开发手册

  • 避免使用BeanUtil.CopyProperties类进行属性复制,考虑手动赋值或使用Lombok toBuilder 进行赋值。

注意: 请确保在引用和使用第三方库时,始终使用最新版本,以充分利用其修复的错误和改进的功能。