删除手写代码之前先保留行为契约

商品输入对象需要转换成展示对象,还需要支持更新已有对象。MapStruct 可以生成字段映射,Lombok 可以生成访问器、builder 和相等方法。代码行数减少之后,null、集合引用和 hashCode 行为仍然存在;如果不检查生成结果,就可能把原本看得见的决策变成构建过程中隐含的默认值。

本篇使用独立 Maven 模块,实际运行 MapStruct 1.6.3 与 Lombok 1.18.42,并显式加入 lombok-mapstruct-binding 0.2.0。固定源码分别为 b4e25e49deae707b50ce061172e114292b414a23 与 2031eb0880942b5f0b7281580f6e877a3e87279a。所有应用源码保持 Java 8,在 JDK 8 和 JDK 21 分别 clean verify,没有用已经生成的 class 代替处理器执行。

完整项目见 下载处理器实验工程,测试见 ProcessorContractTest.java,生成映射见 ProductMapperImpl.java,字节码观察见 javap.txt,命令见 RUN.md。

注解 API、处理器与运行代码是三层依赖

org.mapstruct:mapstruct 提供 Mapper、MappingTarget 等注解和 Mappers 工厂。mapstruct-processor 才在编译时分析模型并生成实现。Lombok 同时提供注解与编译集成,本模块将其声明为 provided,并在 annotationProcessorPaths 明确列出。

binding 放在处理器路径,不作为业务运行时接口。官方 MapStruct FAQ说明较新 Lombok 版本与 MapStruct 配合需要这个组件;它用于协调 Lombok 对类型的修改与 MapStruct 对属性的观察时机。把它误放在普通运行依赖中,并不能等价替代正确配置处理器路径。

本模块还把 mapstruct-processor 作为 test scope 依赖,原因是测试内部调用 JavaCompiler 编译一个故意错误的 mapper。这个依赖服务于编译诊断实验,不表示生产运行映射时需要加载处理器。POM 中两处用途不同,不能把它们合并解释为“MapStruct 都是运行时反射”。

构建对象 本例用途 检查位置
mapstruct API 声明映射规则与取得实现 编译/运行 classpath
mapstruct-processor 生成 ProductMapperImpl annotationProcessorPaths
lombok 生成成员与 builder provided 加处理器路径
lombok-mapstruct-binding 协调类型完成状态 处理器路径
处理器测试依赖 动态编译错误样本 test classpath

第一项可迁移模式是按构建阶段解释依赖。编译成功、IDE 能显示生成成员、运行时能够加载映射实现是不同验收点。某个编辑器没有正确启用处理器,不应通过随意添加运行依赖掩盖;某个增量构建保留旧生成文件,也不能当成 clean 构建已经通过。

MapStruct 生成了普通方法调用

ProductInput 与 ProductView 都有 name、count、tags。前者通过 Lombok 生成访问器,后者还生成 builder。ProductMapper 声明 create、overwrite 和 patch 三种方法,目标未映射字段策略设为 ERROR。

实际生成的 create 先判断 source 是否为 null,非空时调用 ProductView.builder,再调用 name、count、tags,最后 build。读取 tags 时生成 new ArrayList,说明该映射结果拥有一个新的列表容器。测试先与手写构造结果比较,再修改输入列表,验证输出列表没有增加新元素。

这个复制只是当前映射中列表容器的复制。样本元素是 String,没有验证可变元素的深复制;若换成包含可变对象的列表,需要检查生成元素映射与别名关系。不能从一次 new ArrayList 推导整个对象图互相隔离。

源码生成也让调试边界更明确。生成类可以阅读、断点和反编译,业务调用最终执行普通 Java 代码。Mappers 工厂定位实现的过程与映射方法内部如何处理字段,应分别解释;不能因为使用工厂,就声称每个字段都在运行时动态反射赋值。

源对象为 null 与字段为 null 不同

测试确认 create(null) 返回 null。更新方法收到整个 source 为 null 时,生成代码直接 return,不修改已有 target。这与 source 非空、其中字段全部为 null 的情况不同。

overwrite 接收一个新建但字段为空的 ProductInput,结果把 target 的 name、count、tags 清为 null。patch 使用 NullValuePropertyMappingStrategy.IGNORE,同一输入保留旧 name、count 与列表。两种方法都成功执行,却表达不同更新语义。

BeanMapping 固定源码及官方参考文档区分 null 处理策略。这里控制更新目标属性时如何处理 null。整个源对象为 null 时如何返回,需要查看另一层方法策略。JSON 请求中的字段缺失与显式清空,也不能由这个开关自动区分。

如果把 null 统一解释为不更新,就没有办法仅凭这个普通字段表达清空。需要清空能力的接口,应采用明确的存在性标记、三态包装或单独命令,再决定生成映射如何读取这些信息。生成工具可以执行已表达的规则,不能恢复请求模型没有保存的信息。

更新集合时还需要检查目标可变性

生成的 overwrite 在 target.tags 非空且 source.tags 非空时,调用 clear 后 addAll;source.tags 为 null 时则把目标属性设为 null。patch 在源列表为 null 时跳过更新,而非空列表同样进入清空再添加的路径。

这些语句说明目标列表需要支持修改。若调用方把不可变列表放进 target,再执行更新,可能在 clear 时失败。当前实验使用 ArrayList,因此没有把该失败路径记为已测试结果;读生成源码可以识别这个前提,并据此设计后续反例。

源列表和目标列表若恰好共享同一实例,也需要单独审查。先 clear 再 addAll 会改变源引用指向的对象,不能因为 mapper 方法名叫 update 就认定它对任意别名关系都安全。本篇创建映射的独立列表断言只约束 create,不覆盖调用方任意构造的更新别名。

第二项可迁移模式是检查生成代码的对象所有权。属性名匹配只能说明数据从哪里流向哪里,不能说明容器是否复制、目标是否可变或元素是否共享。把这些条件写成回归断言,才能在注解配置或模型改变时发现语义变化。

编译诊断必须来自真正的处理器

测试通过 ToolProvider.getSystemJavaCompiler 创建一个临时编译任务。输入中 Source 只有 name,Target 还有 extra,mapper 的 unmappedTargetPolicy 为 ERROR。任务明确指定 org.mapstruct.ap.MappingProcessor,结果编译失败,诊断包含 Unmapped target property 与 extra。

这是预期失败,整个 JUnit 测试应通过。原始诊断保存在 Maven 输出与 Surefire XML 中,证明错误来自实际编译流程,而不是手工构造一段错误文本。双 JDK 分别运行此任务,因此也验证了本机两个编译器都能加载处理器。

未映射字段错误可以防止目标模型增加属性后静默遗漏,却不能证明映射业务正确。把库存映射成价格,只要类型与属性规则允许,也可能编译通过;数值范围、单位与权限规则仍需业务测试。编译诊断减少某类结构错误,没有取代运行验收。

处理器诊断也受配置范围影响。若某 mapper 显式忽略字段,ERROR 不会把这个有意忽略重新解释为错误。忽略声明应像普通代码分支一样接受审阅,不能为了让构建通过就批量增加 ignore 而不解释业务含义。

Lombok builder 不会自动复制集合

测试创建一个 tags 列表,通过 ProductView.builder().tags(tags).build() 生成对象。assertSame 确认对象内部仍是原列表,随后外部增加元素,目标列表长度也变化。这与 MapStruct create 中生成 new ArrayList 的结果不同,原因是两个生成器承担的规则不同。

Lombok 的 builder 主要生成一组保存构建参数的字段与方法,再调用构造逻辑。没有额外注解或自定义规则时,它不会凭空决定哪些对象应复制,更不会自动深复制。构造方式看起来流畅,并不等于得到不可变值对象。

本例通过 javap 检查 ProductView 与内部 ProductViewBuilder,确认生成了访问器、构建入口、equals 和 hashCode 等成员。字节码是实际编译产物,不是根据注解名称推测出的预期方法列表。需要观察生成源时也可以使用 Lombok 的相关工具,但本次验证以 javap 和运行断言为准。

生成 equals 与 hashCode 不会冻结身份

ProductView 使用 Data,生成的相等与哈希计算涉及可变字段。测试将对象放入 HashSet,保存原 hashCode,再修改 count;哈希发生变化,contains 返回 false,但遍历集合仍能找到原对象引用。

这个结果说明集合没有自动搬迁对象所在的桶。生成方法遵守当前字段值计算,不会替调用方保证这些字段在入集合之后不变。手写相同 equals/hashCode 也会有相同问题,因此不能把它归因于“注解生成天然不可靠”,而应检查对象身份策略与可变性是否一致。

tags 的可变列表也可能影响相等和哈希。即使对象没有显式调用 setter,共享列表被外部修改仍可能改变对象的比较结果。本篇运行断言直接修改 count,集合共享由另一条测试单独证明;不把未执行的其他修改路径伪装成已有测量。

自动生成代码不提供线程安全保证。builder 构造一次对象、Data 生成访问器,与跨线程发布、同步修改和可见性没有自动等价关系。需要不可变对象时,应明确字段、容器与元素的保护范围,再选择适合的生成规则和构造方式。

组合处理器有构建维护成本

MapStruct 读取模型属性,Lombok 在编译过程中增加成员,二者需要协调。Lombok 源码中的 MapStruct 通知实现通过类型完成状态接口告诉 MapStruct 是否可以继续处理。它解释了 binding 的职责,但不应被当作应用需要调用的业务 API。

固定 Lombok 版本尤其重要,因为其编译集成需要适配 JDK 编译器变化。本篇真实运行的是 JDK 8 与 21,不宣称所有介于二者之间或更高版本都已验证。升级 JDK 或处理器时,应保留 clean 编译、生成源码比较、诊断反例和运行断言,而不只观察 IDE 没有红线。

构建可复现还要注意生成文件中的时间信息。本模块关闭 MapStruct 生成时间戳和版本注释,使主要生成代码更容易比较;这个设置减少文本噪声,不等于整个 jar 已经具备字节级可复现构建。归档时间、编译器版本与其他插件仍可能影响产物。

手写替代与验收范围

字段较少、更新规则特殊或所有权关系复杂时,手写映射更容易直接表达意图。大量稳定同构字段可以从 MapStruct 获得编译检查与样板减少;Lombok 适合团队明确接受其构建与模型规则的场景。生成和手写不是互斥选择,关键是相同业务契约能否由同一组测试验证。

四组测试在双 JDK 上通过:创建映射与手写结果一致、null 更新策略、builder 共享与可变哈希、真实编译诊断。生成实现与 javap 分别保存两套,未运行 IDE 集成测试,也未进行构建耗时基准。注解减少源码量的同时增加处理器配置与升级检查,这是需要显式承担的工程成本。

手算题:非空输入对象的所有字段为 null,overwrite 与 patch 是否都不修改目标?不是,前者清空,后者保留。改动练习是把目标 tags 换成不可变列表,运行更新并检查失败位置,再选择替换列表或要求可变目标;修改策略后需保留原有 null 断言。

判断关键词 可迁移模式 具体选择
processor path 按构建阶段解释依赖 API、生成器、binding 分开配置
null 方法输入与属性更新分开 create、overwrite、patch 各自验收
generated code 所有权仍是业务契约 检查复制、目标可变性和共享元素
equals/hashCode 身份策略与可变性一致 不把可变比较字段当稳定集合键

系列起点:可复现基线。