Inkstone · blog

Java 对象转换器模式最佳实践指南

2,010 words 6 min read #Java#Engineering#Converter#Best Practice

概述

在我们的项目中,存在多种对象转换器的实现方式。本文档分析各种模式的优劣,并提供推荐的最佳实践方法。

现有的转换器模式

1. 手动转换方式 ❌ 不推荐

示例:

private QueryCalendarListRo buildQueryCalendarListRo(QueryCalendarRo ro, QueryCalendarTypeEnum calendarType, Boolean finished) {
// todo 1.QueryCalendarRo 一旦新增参数,一定要在下面手动set,不然SQL里就没有参数值。 2. 以后不要用这个写法了
QueryCalendarListRo filterRo = new QueryCalendarListRo();
filterRo.setSource(ro.getSource());
filterRo.setPage(ro.getPage());
filterRo.setSize(ro.getSize());
filterRo.setDraftStatus(ro.getDraftStatus()); // 容易忘记设置
// ... 手动设置所有字段
return filterRo;
}

问题:

  • 易遗漏字段:新增字段时容易忘记在转换方法中添加
  • 维护成本高:每个字段都需要手动编写转换代码
  • 无编译时检查:遗漏字段只能在运行时发现
  • 代码冗长:大量重复的 setter 调用
  • 容易出错:已经导致了 2 次线上 bug

2. 静态工具类 + Lambda函数式接口 ⚠️ 谨慎使用

示例:

public class SettlementBillConverter {
public static final Converter<List<TalentSettlementListBo>, BatchSubmitSettlementVo> BATCH_SUBMIT_STATISTICS = input -> {
if (input == null) {
return new BatchSubmitSettlementVo();
}
Set<String> zhContractNos = new HashSet<>();
BigDecimal totalPrice = BigDecimal.ZERO;
for (TalentSettlementListBo bo : input) {
zhContractNos.add(bo.getZhContractNo());
if (bo.getSettlementPrice() != null) {
totalPrice = totalPrice.add(bo.getSettlementPrice());
}
}
return BatchSubmitSettlementVo.builder()
.contractCount(zhContractNos.size())
.totalPrice(totalPrice).build();
};
public static SignInfoVo convert2Vo(SettlementAccountInfo info, boolean needDecrypt) {
return SignInfoVo.builder()
.signStatus(info.getSignStatus())
.name(info.getName())
.phone(info.getPhone())
.build();
}
}

优点:

  • ✅ 静态常量复用性好
  • ✅ Lambda 表达式简洁

问题:

  • 仍然是手动转换:容易遗漏字段
  • 调试困难:Lambda 表达式难以调试
  • 类型安全性差:编译时无法检查字段映射是否正确

适用场景: 仅适用于复杂的聚合计算转换,不适合简单的字段映射。

3. BeanUtils.copyProperties() 方式 ❌ 不推荐

示例:

private QueryCalendarListRo buildQueryCalendarListRo(QueryCalendarRo ro, QueryCalendarTypeEnum calendarType, Boolean finished) {
QueryCalendarListRo filterRo = new QueryCalendarListRo();
BeanUtils.copyProperties(ro, filterRo);
// 处理特殊字段
filterRo.setCalendarType(calendarType);
filterRo.setFinished(finished);
return filterRo;
}

看似简单,但存在严重问题:

  • 集合字段问题List<String> 等集合只复制引用,修改会影响原对象
  • 嵌套对象问题:只做浅拷贝,嵌套对象共享引用
  • JSON字段问题:复杂对象结构无法正确复制
  • 类型不匹配静默失败:字段名相同但类型不同时会静默跳过
  • null值处理问题:可能覆盖目标对象的默认值
  • 性能问题:使用反射,性能较差
  • 调试困难:字段复制失败时难以定位问题
  • 已导致线上bug:在 draft 参数转换时出现过集合和JSON字段问题

典型失败场景:

// 原对象
QueryCalendarRo original = new QueryCalendarRo();
original.setNotificationIds(Arrays.asList("1", "2", "3"));
original.setScheduleLinkStatus(Arrays.asList("waiting", "finished"));
// 错误的复制
QueryCalendarListRo target = new QueryCalendarListRo();
BeanUtils.copyProperties(original, target);
// 问题:target.getNotificationIds() 与 original.getNotificationIds() 指向同一个 List
target.getNotificationIds().add("4"); // 会修改原对象!

适用场景: 无,不建议在任何场景下使用。

4. MapStruct + 静态工具类混合 ⚠️ 谨慎使用

示例:

public class BriefImageConverter {
public static BriefImageV2Vo toBriefImageVo(BriefImageV2 briefImageV2) {
// 先用MapStruct做基础转换
BriefImageV2Vo briefImageVo = BriefImageMapping.INSTANCE.toBriefImageVo(briefImageV2);
// 再手动处理特殊字段
briefImageVo.setId(briefImageV2.getId());
briefImageVo.setDescriptionTag(briefImageV2.getContentHighlight());
return briefImageVo;
}
}

优点:

  • ✅ 结合了 MapStruct 的便利性和手动处理的灵活性

问题:

  • 架构复杂:需要维护 MapStruct 接口 + 静态工具类两套代码
  • 容易混乱:开发者不清楚何时用 MapStruct,何时用手动转换
  • 维护成本高:修改字段映射需要在两个地方同时修改

适用场景: 仅在 MapStruct 无法处理复杂逻辑时使用。

5. MapStruct 接口 ✅ 强烈推荐

示例:

@Mapper
public interface DraftConverter {
DraftConverter INSTANCE = Mappers.getMapper(DraftConverter.class);
@Mapping(target = "draftType", source = "draftType", qualifiedByName = "convertDraftType")
@Mapping(target = "operationTags", source = "operationTags", qualifiedByName = "convertOperationTags")
DraftBaseVo convertToDraftBaseVo(ScheduleDraft scheduleDraft);
@Named("convertDraftType")
static String convertDraftType(DraftTypeEnum draftType) {
return draftType != null ? draftType.name() : null;
}
@Named("convertOperationTags")
static List<OperationTagEnum> convertOperationTags(String operationTags) {
if (StringUtils.hasText(operationTags)) {
return JSON.parseArray(operationTags, OperationTagEnum.class);
}
return null;
}
}

优点:

  • 编译时检查:缺少字段映射会在编译时报错
  • 类型安全:自动处理类型转换,编译时保证类型正确
  • 高性能:编译时生成纯 Java 代码,无反射开销
  • 维护简单:新增字段时 MapStruct 会自动提示需要映射
  • 支持复杂转换:通过 @Named 方法支持复杂的字段转换逻辑
  • 支持集合和嵌套对象:自动处理 List、Map 等复杂类型
  • 代码简洁:减少大量样板代码

问题:

  • ❌ 需要学习 MapStruct 注解语法(学习成本低)
  • ❌ 需要添加编译时依赖

推荐方案

🎯 主要推荐:MapStruct 接口模式

对于所有新的转换器和现有转换器的重构,强烈推荐使用 MapStruct 接口模式

依赖配置(项目已配置)

<dependencies>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.4.2.Final</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.8</source>
<target>1.8</target>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.4.2.Final</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

实现示例

@Mapper
public interface QueryCalendarMapper {
QueryCalendarMapper INSTANCE = Mappers.getMapper(QueryCalendarMapper.class);
/**
* 构建查询日历参数
*
* @param originalRo 原始请求参数
* @param calendarType 日历类型
* @param finished 是否完成
* @return 转换后的查询参数
*/
@Mapping(target = "calendarType", source = "calendarType")
@Mapping(target = "finished", source = "finished")
@Mapping(target = "draftStatus", source = "originalRo.draftStatus") // 确保关键字段不遗漏
@Mapping(target = "notificationIds", source = "originalRo.notificationIds")
QueryCalendarListRo buildQueryCalendarListRo(QueryCalendarRo originalRo,
QueryCalendarTypeEnum calendarType,
Boolean finished);
/**
* 处理复杂的时间和状态转换逻辑
*/
@AfterMapping
default void handleComplexLogic(@MappingTarget QueryCalendarListRo target,
QueryCalendarRo source,
QueryCalendarTypeEnum calendarType) {
// 原来的复杂时间处理逻辑
// 原来的状态处理逻辑
}
/**
* 深拷贝集合字段
*/
default List<String> copyStringList(List<String> original) {
return original == null ? null : new ArrayList<>(original);
}
}

使用方式

// 服务层代码大幅简化
private QueryCalendarListRo buildQueryCalendarListRo(QueryCalendarRo ro, QueryCalendarTypeEnum calendarType, Boolean finished) {
return QueryCalendarMapper.INSTANCE.buildQueryCalendarListRo(ro, calendarType, finished);
}

迁移策略

立即行动项

  1. 新代码强制使用 MapStruct:所有新的转换器必须使用 MapStruct 接口模式
  2. 重构高风险代码:优先重构已经出过 bug 的手动转换方法和 BeanUtils.copyProperties 方法
  3. 添加单元测试:为所有转换器添加完整的单元测试,特别是集合和嵌套对象字段
  4. 禁用 BeanUtils.copyProperties:在代码审查中严格禁止使用此方式

渐进式重构

  1. 识别危险转换方法:搜索所有使用 BeanUtils.copyProperties、手动 setter 调用的转换方法
  2. 评估风险等级:根据业务重要性、集合字段复杂度、是否涉及JSON序列化排序
  3. 逐步重构:每个迭代重构 2-3 个高风险转换方法

单元测试模板

@Test
public void testBuildQueryCalendarListRo() {
// 创建包含所有字段的测试对象
QueryCalendarRo original = createFullTestObject();
QueryCalendarListRo result = QueryCalendarMapper.INSTANCE
.buildQueryCalendarListRo(original, QueryCalendarTypeEnum.upload, true);
// 验证所有重要字段都被正确转换
assertThat(result.getDraftStatus()).isEqualTo(original.getDraftStatus());
assertThat(result.getSource()).isEqualTo(original.getSource());
assertThat(result.getNotificationIds()).isEqualTo(original.getNotificationIds());
assertThat(result.getCalendarType()).isEqualTo(QueryCalendarTypeEnum.upload);
assertThat(result.getFinished()).isTrue();
// 验证集合的深拷贝
if (original.getNotificationIds() != null) {
assertThat(result.getNotificationIds()).isNotSameAs(original.getNotificationIds());
assertThat(result.getNotificationIds()).containsExactlyElementsOf(original.getNotificationIds());
}
}
private QueryCalendarRo createFullTestObject() {
QueryCalendarRo ro = new QueryCalendarRo();
ro.setDraftStatus("waiting_upload");
ro.setSource("bowl");
ro.setNotificationIds(Arrays.asList("1", "2", "3"));
// 设置所有字段...
return ro;
}

代码审查清单

转换器相关的 PR 必须检查以下项目:

  • 新的转换器是否使用了 MapStruct 接口模式?
  • 是否严格禁止使用 BeanUtils.copyProperties()
  • 所有重要字段是否都有明确的 @Mapping 注解?
  • 集合字段是否进行了深拷贝处理?
  • 嵌套对象是否正确处理?
  • 是否添加了完整的单元测试,包括集合字段的独立性验证?
  • 复杂转换逻辑是否使用了 @Named 方法?
  • 是否避免了手动转换方式?

总结

  1. 立即停止使用手动转换和 BeanUtils.copyProperties,这是导致线上 bug 的主要原因
  2. 全面采用 MapStruct 接口模式,享受编译时检查和类型安全的优势
  3. 建立完整的单元测试体系,确保转换逻辑的正确性,特别关注集合和嵌套对象
  4. 制定代码审查标准,严格禁止危险的转换方式进入主分支

通过采用这些最佳实践,我们可以显著提高代码质量,减少线上 bug,提升开发效率。