1. 问题的起点:一个看似简单却总让人头疼的Bean转换问题
做Java后端开发的同学,几乎每天都要跟Bean转换打交道。Entity转DTO、DTO转VO、请求参数转领域模型,这些操作看似简单,写起来却极其枯燥。尤其当字段数量超过20个的时候,手动写getter/setter的代码简直让人崩溃;用BeanUtils之类的反射工具又担心性能损耗,而且类型不安全,编译期任何错误都不会暴露。我自己曾经在一个订单导出项目中接过一个遗留代码,几百个字段全靠手写转换,改一个字段类型就要翻遍几十个类的getter/setter,那个维护成本我现在回忆起来还有心理阴影。
MapStruct这个编译期代码生成器,解决的正是这个问题。它通过注解处理器在编译阶段自动生成类型安全的映射代码,没有运行时反射开销,性能基本等同于手写代码。高频使用几年下来,我对它的评价是:上限很高,但坑也不少。如果你只懂得写个@Mapper接口就以为万事大吉,那么你在实际项目中十有八九会遇到一些让你排查到怀疑人生的诡异问题。这也是我写这篇文章的初衷,把项目实战中踩过的坑、排查过的疑难问题和一些提升效率的使用方式梳理出来,希望能帮你少走弯路。
这篇文章的内容既不是翻译官方文档的说明书,也不是入门级别的Hello World演示。我默认你已经对MapStruct的入门用法有所了解,接下来有关排查思路、工具选型、与Lombok的集成问题、复杂映射的处理方案、敏感数据治理、性能调优和工程化实践,才是最值得花时间研读的部分。如果你还在纠结"要不要用MapStruct"或者"用了之后会不会有坑",读完应该也会有自己的判断了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MapStruct的定位与选型逻辑
2.1 编译期生成和运行时反射的本质差别
在选型之前必须搞清楚一个核心概念,即编译期注解处理和运行时反射这两条技术路线在本质上的区别。MapStruct在编译阶段扫描被@Mapper注解标记的接口,利用注解处理器读取接口方法的返回类型、参数类型和字段信息,然后直接生成对应实现类,代码里会明确写出每一个字段的赋值语句。
code复制// 接口定义
@Mapper
public interface OrderMapper {
OrderDTO toDTO(Order order);
}
编译后生成的实现类大概长这样:
code复制@Override
public OrderDTO toDTO(Order order) {
if (order == null) {
return null;
}
OrderDTO orderDTO = new OrderDTO();
orderDTO.setId(order.getId());
orderDTO.setOrderNo(order.getOrderNo());
orderDTO.setAmount(order.getAmount());
// 更多字段赋值...
return orderDTO;
}
这意味着运行时根本不需要通过反射去解析字段,JVM直接执行普通的字节码指令即可完成赋值操作。我用JMH做过简单测试,在同一台机器上对100万条数据的列表做字段转换,MapStruct的性能在800毫秒左右,Spring的BeanUtils.copyProperties则在3秒以上,差异可以说非常明显。
2.2 同类型工具的横向对比
很多团队选型时喜欢把MapStruct和ModelMapper、Orika放在一起比较。ModelMapper通过运行时反射+类型推断实现映射,API看起来简洁,但项目复杂之后对错误信息极不友好——我曾经因为一个字段类型不匹配,排查了将近两个小时,报错信息跟实际原因完全对不上号。Orika是字节码增强方案,性能表现不错,但它的字节码操作在某些自定义类加载器环境下会碰壁,尤其是项目里同时存在热部署插件时,时不时就会出现诡异的ClassCastException。
MapStruct在这三者中相对最"笨"也最"稳",因为它没什么运行时魔法,编译期就干完了所有分析工作。当然它也有短板:学习成本稍微高一点,需要理解注解的各种组合方式;对于递归映射或循环引用,相比框架级工具需要更多人为干预。不过总体上,在微服务和领域驱动设计流行的今天,MapStruct的静态化特性让它成为最容易排查问题的一个选择。
提示:如果你的团队对性能要求不高,且字段映射关系极简,继续用BeanUtils也无可厚非。一旦映射关系复杂、字段量大、性能敏感,MapStruct几乎是唯一值得认真考虑的选择。
3. 核心配置细读:从入门到容易忽略的进阶细节
3.1 @Mapper注解的componentModel参数
@Mapper注解核心参数是componentModel,它决定了生成的实现类以什么方式被Spring容器管理。最常用的值为spring,生成类会标注@Component注解,可以直接注入使用。如果不设置componentModel,生成类是无状态的普通类,需要通过Mappers.getMapper(Class)手动获取实例。
code复制@Mapper(componentModel = "spring")
public interface OrderMapper {
OrderDTO toDTO(Order order);
}
在Spring Boot项目中,componentModel="spring"是首选。不过这里有个很容易被忽略的细节:如果Mapper接口写在common模块,而启动类不在该模块的包扫描路径下,就可能导致注入失败。排查了很多次后,我发现最佳实践是使用@MapperScan("com.example.common.mapper")显式声明扫描路径,避免依赖默认的包扫描规则。
3.2 @Mapping注解的target、source与dateFormat
@Mapping注解是日常打交道最多的元素,target对应目标字段名,source对应源字段名。字段名一致时可以不写@Mapping,不一致时必须声明。当类型不一致时,MapStruct会尝试自动类型转换;如果转换逻辑不明确,就需要指定转换策略。
code复制@Mapping(target = "createTime", source = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Mapping(target = "price", source = "price", numberFormat = "#,##0.00")
OrderDTO toDTO(Order order);
日期格式化是这里的高频出错点。源字段是LocalDateTime,目标字段是String,如果不指定dateFormat,MapStruct的编译期会尝试调用toString()方法,生成结果往往是"2025-06-12T15:30:00"这类不符合预期的格式。第一次遇到这个问题时,我以为框架有bug,查了半天才发现是缺少格式化参数。这事提醒我:在使用MapStruct之前,有必要先梳理清楚类型映射矩阵,明确哪些类型可以自动转换,哪些必须指定格式。
3.3 @MappingTarget:更新已有对象而不是新建对象
很多业务场景下需要把源对象的属性更新到已有目标对象上,而不是new一个新对象。比如在更新接口中,先从数据库查出当前实体,然后用前端传过来的字段做增量更新。
code复制void updateEntity(OrderDTO dto, @MappingTarget Order entity);
@MappingTarget这个参数标记让MapStruct直接在传入的实体对象上进行字段赋值,而不是创建新实例。这种方法在性能上还有额外优势——避免了目标对象的重复实例化。在批量更新的场景下,复用目标对象可以明显减少GC压力。
需要注意的一点是,null值的情况。默认策略会把源字段为null的值也赋到目标对象上,这在使用@MappingTarget做部分更新时经常出问题。比如前端只传了订单金额,没有传备注字段,备注字段值会直接被覆盖为null。解决办法是配合使用NullValuePropertyMappingStrategy:
code复制@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void updateEntity(OrderDTO dto, @MappingTarget Order entity);
IGNORE策略会让源对象null字段不参与赋值。这个配置在真实项目中极其实用,我几乎在每个涉及更新的Mapper上都加了它。
3.4 嵌套映射与自动深度转换
一个订单可能包含客户信息、商品列表等嵌套结构。MapStruct对嵌套映射的处理方式是自动创建子映射器。如果Order里有Customer属性,OrderDTO里有CustomerDTO属性,MapStruct会检查是否存在Customer转CustomerDTO的映射方法,如果接口中有,就自动复用。
code复制@Mapper(componentModel = "spring")
public interface OrderMapper {
OrderDTO toDTO(Order order);
CustomerDTO toCustomerDTO(Customer customer);
}
这里有个隐性规则值得注意:如果子对象的源属性名和目标属性名完全一致,MapStruct会自动完成转换,不需要任何额外注释。如果目标子对象属性名不同,则必须在根方法上通过类似@Mapping(target = "customer.name", source = "customer.customerName")的路径写法来处理。点号路径是一种"内联映射",这在大对象嵌套场景下很好用,但也容易让代码变得复杂。我的经验是,嵌套层数超过两层时就应该为每个层级单独定义映射方法,而不是依赖点号路径硬凑,否则维护起来相当痛苦。
4. 问题排查:我踩过的那些坑和定位思路
4.1 编译报错信息竖着看:实际上信息量已经很大
MapStruct的编译报错往往看着唬人,实际上它的信息量比大多数框架都要丰富。我见过太多同事看到报错第一反应是去搜索引擎复制粘贴错误信息,其实冷静下来读一下就能定位。
code复制error: Unknown property "orderNo" in result type OrderDTO. Did you mean "null"?
这种错误通常是目标类里根本没有orderNo这个属性。排查思路很简单:打开OrderDTO类检查字段名;确认是否有对应getter/setter;如果是Lombok生成,确认Lombok注解是否正常生效。有趣的是,MapStruct对Lombok的支持依赖注解处理器的执行顺序,前后顺序不同产生的错误也不同。在Maven多模块项目中,最好在maven-compiler-plugin的配置里显式声明lombok和mapstruct-processor的先后顺序。
4.2 敏感数据过度公开:一个容易被忽视的问题
最近团队在治理接口数据时发现一个问题:某些API响应里多返回了本不该暴露的字段,比如用户手机号、身份证号、内部备注信息。日志系统一查,这些敏感数据已经随着接口响应输出到了前端控制台。这类问题的根源往往就出在转换层的"懒"上——直接整个Entity转DTO,没有注意字段裁剪。
code复制// 危险写法:entity所有字段都会被公开
UserDTO userInfo = userMapper.toDTO(user);
如果User实体里有phone、idCard、internalRemark等字段,而UserDTO也有同名字段,MapStruct默认会全部映射过去。这在某些内部系统里可能没问题,但一旦接口暴露到公网,就构成了敏感数据过度公开的风险。安全评审不通过还是小事,出了数据泄露事件才是真正的麻烦。
针对这个问题,我梳理了几种可行的治理方案:
- 方案一:目标DTO中直接不定义敏感字段。MapStruct映射时自动忽略这些字段,这是最简单也最安全的方式。
- 方案二:使用@Mapping(target = "phone", ignore = true)等显式忽略。
- 方案三:使用@BeanMapping(ignoreByDefault = true),表示只用显式声明的字段映射,未声明的全部忽略。这在数据合规要求严格的场景下非常有用。
code复制@BeanMapping(ignoreByDefault = true)
@Mapping(target = "userName", source = "userName")
@Mapping(target = "email", source = "email")
UserSafeDTO toSafeDTO(User user);
注意方案三有个细节:ignoreByDefault = true之后,所有字段都必须通过@Mapping显式声明,否则不会映射。这种方法看似繁琐,但配合代码评审流程,反而最容易确保不遗漏敏感字段。我现在负责的项目里,所有对外接口的DTO映射都强制使用ignoreByDefault策略,安全审计评分因此有了明显提升。
4.3 与Lombok一起使用时的坑:annotationProcessorPaths
MapStruct与Lombok共存是很多团队采用的组合,但集成过程经常出问题。最典型的表现是:编译时MapStruct报"Unknown property",但明明实体类里有这个字段。原因在于Lombok在编译期自动生成getter/setter,而MapStruct通过读取字段所在的类信息来检测getter/setter是否存在。如果MapStruct的注解处理步骤在Lombok之前执行,MapStruct就看不到Lombok生成的getter/setter。
解决方式是使用Maven的annotationProcessorPaths显式声明处理器的执行顺序:
xml复制<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.5.5.Final</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
在这个配置中Lombok处理器排在MapStruct前面,MapStruct就能看到Lombok生成的方法。另外有些项目使用Gradle构建,同样要注意annotationProcessor的依赖顺序。这个坑的排查特征非常明显:如果单独编译实体所在模块没问题,但整体编译时报一堆"Unknown property"错误,基本可以判断是注解处理器顺序出了问题。
4.4 "深拷贝"陷阱:MapStruct生成的其实是浅拷贝
来看一个很多人都理解偏差的点。MapStruct处理对象内的List
code复制List<ItemDTO> list = orderMapper.toDTO(order).getItems();
list.get(0).setName("修改");
// 源order也会被修改
这并非框架缺陷,而是MapStruct的默认行为——它只在顶层对象上做"深拷贝",嵌套层级保持浅拷贝。如果业务要求彻底深拷贝,必须在子对象类型上定义对应的转换方法,让MapStruct逐层生成赋值语句。真正彻底的深拷贝,除了给子对象定义转换方法外,子对象内的更深层嵌套也需要同样的处理。
4.5 Java 8+日期时间类型的映射踩坑
实体类使用LocalDateTime已经非常普遍,但一些老系统或对接外部SDK时,DTO字段仍是java.util.Date。MapStruct对这两种类型的转换能力比较有限。我在一个订单同步项目中遇到的情况是:源字段是LocalDateTime,目标字段是Date,编译时MapStruct直接报无法映射的错误。
标准解法是自定义转换方法:
code复制default Date map(LocalDateTime value) {
return value == null ? null : Date.from(value.atZone(ZoneId.systemDefault()).toInstant());
}
把这个default方法放在同一个Mapper接口里,MapStruct在遇到LocalDateTime转Date时就会自动调用。这种自定义转换方法是Mapper内部代码的"内建转换器",优先级高于默认转换。我在实际项目中维护了一个CommonTypeMapper基接口,专门放一些通用的类型转换方法,比如LocalDateTime转Date、Date转LocalDateTime、BigDecimal精度处理等,各业务Mapper继承它即可复用。
4.6 多模块项目中的MapStruct配置
大型项目往往是多模块结构,比如common模块放DTO、entity模块放实体类、api模块放接口。MapStruct的注解处理器默认只会处理当前编译单元中的注解。如果你的Mapper接口在api模块中,而DTO在common模块中,编译时可能不会触发MapStruct的代码生成。
解决方案有几种:优先确保Mapper接口所在模块的pom.xml引入了mapstruct-processor;如果Mapper接口需要被其他模块引用,可以考虑把生成实现类放到单独的模块。另一个经验是尽量避免让Mapper接口跨模块直接依赖实体类,因为这会增加模块之间的耦合,还会导致编译顺序的复杂性。合理的设计是,在领域层内部完成领域对象到DTO的转换,上层接口只接收已经转换好的DTO对象。
5. 高效使用:写出清晰、可维护的映射代码
5.1 自定义类型转换方法的最佳实践
前面已经提到自定义转换方法能解决类型不一致的问题。建议把所有通用转换方法汇总到一个独立的交接口或抽象类中:
code复制public interface CommonTypeMapper {
default Date localDateTimeToDate(LocalDateTime value) {
return value == null ? null : Date.from(value.atZone(ZoneId.systemDefault()).toInstant());
}
default LocalDateTime dateToLocalDateTime(Date value) {
return value == null ? null : LocalDateTime.ofInstant(value.toInstant(), ZoneId.systemDefault());
}
default BigDecimal scale2(BigDecimal value) {
return value == null ? null : value.setScale(2, RoundingMode.HALF_UP);
}
}
业务Mapper继承或组合这个接口,就能在全工程范围内共享这些转换能力。这种方式既实现了代码复用,也保证了转换规则的一致性,好过重复在每个Mapper里写同样的方法。
5.2 使用MappingConstants与常量表达式
有时目标字段需要设置一个固定常量值或常量表达式。MapStruct支持在@Mapping的expression属性中写Java表达式,也支持通过constant设置固定值。
code复制@Mapping(target = "sourceSystem", constant = "ORDER-SERVICE")
@Mapping(target = "createdTime", expression = "java(new java.util.Date())")
OrderDTO toDTO(Order order);
expression属性能力很强,但使用频繁会降低代码可读性。建议只在特殊场景使用,比如需要调用Spring容器中的Bean方法或生成UUID。当表达式逻辑变得复杂时,更好的做法是定义default方法,然后在expression里调用它,避免编写大段内联Java代码。
5.3 Uses与依赖注入其他Mapper
当映射逻辑需要调用Spring Bean时,@Mapper注解中的uses属性或者直接注入都能实现。uses属性指定了映射时使用的辅助类:
code复制@Mapper(componentModel = "spring", uses = {IdGenerator.class})
public interface OrderMapper {
@Mapping(target = "orderId", expression = "java(idGenerator.generate())")
OrderDTO toDTO(Order order);
}
IdGenerator会被注入到生成的实现类中。注意使用uses时,辅助类必须能被Spring容器管理,比如标注@Component。这个机制对于通过Spring上下文获取业务服务、组合复杂映射逻辑的场景很有帮助。
5.4 用MapStruct实现集合映射
集合映射也是高频需求。MapStruct对List和Set有内建支持,不需要额外循环代码:
code复制List<OrderDTO> toDTOList(List<Order> orders);
MapStruct生成的实现会自动遍历源列表,逐个调用单对象映射方法。值得留意的是MapStruct对Map类型的转换支持相对有限,复杂Map转换建议自定义默认方法处理,否则编译期会报一堆类型不兼容的提示。
6. 常见问题速查与避坑笔记
| 问题场景 | 典型表现 | 排查方向与解决方案 |
|---|---|---|
| Lombok字段编译报错 | Unknown property "xxx" | 调整annotationProcessorPaths顺序,Lombok在前,MapStruct在后 |
| 字段值为null被覆盖 | @MappingTarget更新时null字段被赋到目标对象 | 使用@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) |
| 日期格式不正确 | LocalDateTime转String格式不符合预期 | 指定dateFormat参数或自定义default转换方法 |
| List元素被共享引用 | 修改DTO内部对象导致源对象被修改 | 为嵌套对象定义单独的映射方法,确保深层拷贝 |
| 敏感字段泄露 | 响应多出phone、idCard等字段 | 启用@BeanMapping(ignoreByDefault = true),显式声明可映射字段 |
| 编译正常但运行时空Mapper | 注入的Mapper实例为空 | 检查componentModel="spring"是否设置,@MapperScan扫描路径是否正确 |
| 类型无法映射 | 编译时报Cannot map property | 添加自定义default方法或使用qualifiedByName指定命名转换方法 |
| 多个源参数映射 | 一个方法需要组合多个对象 | 方法签名写多个参数,通过@Mapping(source = "param.field", target = "xxx")区分 |
这张表里的东西,全部来自我真实踩坑后的血泪总结。可以把它截图保存,出了问题对照着排查,至少能省一半时间。
7. 性能测试与调优思路
很多团队选择MapStruct就是冲着性能去的,但到底快在哪、能快多少,需要实际验证。我的建议是引入JMH做基准测试,用数据说话。测试维度至少覆盖三类:
- 单对象转换
- 批量列表转换
- 嵌套复杂对象转换
对比对象可以包括手写setter、BeanUtils.copyProperties、Jackson的ObjectMapper转换(注意,这个方式常用于POJO转Map再转目标类)等。
实测下来,MapStruct生成代码的性能通常与手写setter基本持平,而相比反射方案有数倍的优势。如果你发现MapStruct的性能没有达到预期,可以从两个方向排查:一是检查是否使用了表达式调用SpringBean,每次转换都走Spring上下文查找,这会产生额外开销;二是检查是否使用了集合工厂或延迟加载。MapStruct在List转换时默认生成new ArrayList,如果源集合本身很大,重复扩容也会带来少量性能损耗,可以配置使用带初始容量的集合工厂。
注意:性能测试不要只跑一两次就看结果,建议预热后运行多个Iteration,获得稳定的均值和误差数据。同时注意堆内存设置,防止GC干扰测试结果。
8. 工程化落地经验:从零搭建规范
8.1 代码规范与命名约束
一个团队里多个开发者都在写Mapper接口时,如果没有统一规范,很快会变得杂乱无章。我所在的团队沉淀了一组MapStruct的编码规范:
- Mapper接口统一放在xxx.infrastructure.mapper包下,便于批量扫描和代码评审。
- Mapper接口命名统一为XxxMapper,在类名前加@Mapper(componentModel = "spring")。
- 敏感字段映射的DTO一律使用@BeanMapping(ignoreByDefault = true),只能显式声明。
- 所有对外接口转换层禁止使用通用的toDTO名字,必须使用业务含义明确的方法名,如toOrderDetailDTO。
- 通用类型转换统一继承CommonTypeMapper,不允许在每个Mapper中重复定义。
这套规范本质上就是把MapStruct的灵活性收一收,让代码模式保持稳定,提高可审查性。
8.2 代码生成检查与CI集成
MapStruct在编译期生成代码,如果在持续集成流水线中配置了增量编译,可能因为部分类未编译导致注解处理器没有执行完全。解决方式是,在CI的构建步骤中强制执行全量编译,如mvn clean install。将mapstruct-processor的依赖配置为provided,最终打包时避免把处理器打进去。
另外可以在CI流水线中增加对生成代码的检查,使用MapStruct提供的注解处理日志选项:
bash复制mvn clean install -Amapstruct.suppressGeneratorTimestamp=true -Amapstruct.verbose=true
verbose模式会输出详细的映射处理信息,用于定位一些"为什么这个字段没映射?"的疑问。关闭suppressGeneratorTimestamp可以保证生成代码的确定性,避免每次构建产生差异。
8.3 常见踩坑复盘:多模块编译顺序问题
在微服务架构中,如果多个服务模块依赖了公共的entity和dto模块,而每个服务的Mapper接口定义各不相同,编译时可能因为服务模块间的时序关系导致MapStruct处理器重复执行或找不到类。我的建议是,把参与同一次部署的服务模块拆分成可独立构建的单元,在构建脚本中显式指定模块编译顺序,避免并行构建造成的偶发性编译失败。
9. 番外实践:将MapStruct应用到数据传输对象版本管理
很多团队现在推行API版本化,一个DTO可能需要向下兼容多个版本。比如订单查询接口V1没有返回预估运费,V2新增了estimateFreight字段。如果用MapStruct,可以通过定义不同的Mapper方法或使用expression动态填补版本缺省字段,让转换逻辑随着版本演进保持清晰。
code复制@Mapping(target = "estimateFreight", expression = "java(version.equals(\"V2\") ? order.calculateEstimateFreight() : null)")
OrderDTO toDTO(Order order, String version);
这种写法虽然灵活,但表达式内逻辑变多之后维护成本很高。更推荐的方式是,为每个版本定义独立的Mapper方法,例如toDTOV1、toDTOV2,并在方法中显式声明版本差异字段。这虽然让Mapper接口变得庞大,但是意图最清晰,也最容易做自动化测试覆盖。
10. 我们还能怎样更进一步:自定义CodeGen的边界
MapStruct本身能覆盖约90%的常规映射需求。剩余10%比较棘手的场景,比如字段名需要根据配置动态调整、映射策略需要在运行时才能确定等,MapStruct并不擅长。我的做法是结合Builder模式和策略模式,在Mapper接口外加一层适配器,定义统一的转换入口,内部再分发到不同的Mapper方法或自定义转换逻辑。
code复制@Component
public class OrderConvertAdapter {
private final OrderMapper mapper;
public OrderDTO convert(Order order, ConvertContext context) {
if (context.isPrivacyMode()) {
return mapper.toSafeDTO(order);
}
return mapper.toDTO(order);
}
}
这样既保留了MapStruct的编译期类型安全,也给运行时动态决策留出空间。没有银弹,但通过组合不同工具,能解决绝大多数实际业务问题。
回看我这些年的实践经验,MapStruct给我的最大感受是:它不像某些框架那样什么都能自动完成,但只要你理解了它的执行机制和边界,给予合理的配置和约束,它就能成为代码整洁性和运行性能的可靠支撑。尤其是编译期生成代码这一点,让我在排查问题时节省了海量时间——找不到运行时魔法,所有逻辑都是明明白白放在那里,报错也直接指向问题根源。
关于敏感数据过度公开问题,我特别想再强调一句:转换层往往是数据安全最容易失守的地方,看似无害的toDTO操作,背后的字段管控不能只是依赖框架自动完成,而应该从设计上就明确边界。ignoreByDefault虽然写起来麻烦,但长远来看是值得的。这也是我在团队里始终坚持让所有对外DTO映射走显式字段声明的原因。
如果这篇文章里的某一段能帮你避开一个线上事故或者摆脱一个疑难bug,那我觉得写这些字就值了。以后有时间,我可能还会再写一篇关于如何通过自定义插件扩展MapStruct生成逻辑的文章,如果你在实践中还有新的问题,欢迎一起交流。
