概述
在我们的项目中,存在多种对象转换器的实现方式。本文档分析各种模式的优劣,并提供推荐的最佳实践方法。
现有的转换器模式
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() 指向同一个 Listtarget.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 接口 ✅ 强烈推荐
示例:
@Mapperpublic 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>实现示例
@Mapperpublic 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);}迁移策略
立即行动项
- 新代码强制使用 MapStruct:所有新的转换器必须使用 MapStruct 接口模式
- 重构高风险代码:优先重构已经出过 bug 的手动转换方法和 BeanUtils.copyProperties 方法
- 添加单元测试:为所有转换器添加完整的单元测试,特别是集合和嵌套对象字段
- 禁用 BeanUtils.copyProperties:在代码审查中严格禁止使用此方式
渐进式重构
- 识别危险转换方法:搜索所有使用
BeanUtils.copyProperties、手动 setter 调用的转换方法 - 评估风险等级:根据业务重要性、集合字段复杂度、是否涉及JSON序列化排序
- 逐步重构:每个迭代重构 2-3 个高风险转换方法
单元测试模板
@Testpublic 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方法? - 是否避免了手动转换方式?
总结
- 立即停止使用手动转换和 BeanUtils.copyProperties,这是导致线上 bug 的主要原因
- 全面采用 MapStruct 接口模式,享受编译时检查和类型安全的优势
- 建立完整的单元测试体系,确保转换逻辑的正确性,特别关注集合和嵌套对象
- 制定代码审查标准,严格禁止危险的转换方式进入主分支
通过采用这些最佳实践,我们可以显著提高代码质量,减少线上 bug,提升开发效率。