1. 从 DTO 到 Domain 这条必经之路,继承为什么总在添加麻烦
1.1 一个让我加了两个班的实际场景
前阵子做订单中心重构,碰到一个让我印象特别深的场景:订单要支持多渠道,不同渠道的订单长得还不一样,有的是实物直邮,有的是虚拟卡券,有的走线下核销。于是很自然地设计了一个父类OrderDTO,下面挂了五个子类 DTO,每种渠道一套字段。前端传 JSON 进来的时候会带一个type字段,用来区分具体是哪种订单。
改造当天一切都很顺利,DTO 建好了,Lombok 的@Data也打上了,Jackson 把 JSON 反序列化成OrderDTO也跑通了。真正的问题发生在第二天:我把其中一个子类型的 DTO 直接收进统一的List里,然后发现反序列化出来的对象,居然是个父类型实例,子类字段全部丢失,trackingNo、cardNo这种东西统统是 null。一开始以为是前端传参的问题,抓包、比对字段、查了整整一下午。直到项目老哥路过看了一眼,说了一句“你父类上是不是没加@JsonTypeInfo”,我才意识到自己踩了 Jackson 处理继承时的经典大坑。
坦白讲,这个问题对于新手来说非常不友好,因为报错时没有任何“你缺少多态注解”的提示,它只是安静地失忆,把你想要的子类对象变成一个字段全空的父类壳。这篇避坑指南,就是把我那两天踩过的坑、翻过的源码、以及后来敲定的映射方案完整复盘一遍,重点覆盖三条线:Jackson 在继承 DTO 上应该怎么配、Lombok 跟 Jackson 合作时有哪些值得注意的坑、以及 DTO 到 Domain 的映射究竟该走手动、框架还是混合路线。适合正在写 Java 后端、项目里 DTO 有继承关系、并且被序列化或转换问题卡住的朋友,特别是刚接触这一块的初中级开发。
1.2 DTO、Domain 和序列化层之间的三角关系
很多人写 DTO 的时候,脑子里其实只有一个想法:它就是拿来装参数的。这个认知在简单接口里问题不大,可一旦项目进入业务领域复杂度上升的阶段,DTO、VO、Domain 混在一起写,早晚要出事。
简单划分一下各自的职责:DTO 是网络边界上的传输对象,它只表达“客户端和服务端之间长什么样”,它关心的字段顺序、命名风格、类型结构,都与接口协议强相关,不该带任何业务方法。Domain 是业务模型,它表达的是业务规则和领域逻辑,比如订单状态流转、金额计算规则、库存扣减行为,这些跟接口协议没关系。DTO 到 Domain 的转换,本质上是在两个不同上下文之间做一次翻译,翻译的时候你会处理字段重命名、类型调整、业务默认值、嵌套对象重组这些事。
那继承在这中间扮演什么角色?很多团队在 DTO 层引入继承,理由其实很简单:多个子类型确实有公共字段,而且接口层希望用同一个统一对象去接收不同类型的数据。这个理由站得住脚,但它同时引入了两件麻烦事:一个麻烦发生在 Jackson 反序列化阶段,也就是上文说的“父类壳”问题;另一个麻烦发生在映射阶段,你需要根据具体子类型去构建不同的 Domain 对象,如果映射逻辑写得糙,就会变成满屏的 if 分支。
我倾向于把这两件事分开看待,因为它们的解决方式截然不同。反序列化问题靠 Jackson 的多态配置解决,映射问题靠转换器的设计解决。如果你试图用一个方案同时解决两者,比如在 DTO 内部塞满自定义业务方法,那 DTO 的纯粹性就没了,后面维护成本反而会上来。
1.3 继承进入 DTO 设计后的三个危险信号
什么样的信号说明你的 DTO 继承已经进入危险区域?我总结过三个:
第一个信号,代码里出现if ("PHYSICAL".equals(type))这类硬编码判断,而且出现了不止一次。每多一个分支,你就要在多个地方同步修改,漏掉一处就出线上事故。
第二个信号,反序列化回来的对象,类型永远是父类。这种问题隐蔽性最强,因为它不报错。你在 IDE 里打断点看,对象表面上构建成功,字段还能看到一部分,但子类特有字段始终是空的,这种情况九成跟多态配置缺失有关。
第三个信号,DTO 转换到 Domain 的代码变成了一堆重复的BeanUtils调用,还有大段的 getter/setter 赋值。一旦你发现自己复制粘贴的转换代码超过了十行,就该停下来思考统一方案了,而不是继续贴下一段。
这三个信号不一定同时出现,但只要中了一条,今天就值得继续看下去。接下来我们逐个拆解,先解决 Jackson 的多态配置问题,因为它是整个链路的入口。
2. Jackson 多态反序列化:继承 DTO 的正确姿势
2.1 直觉方案为什么行不通
很多人的第一反应是:既然前端传了一个type字段,那我就在反序列化之后手动判断type,然后各自转成对应的子类不就行了?这个方案在接口少、子类少的时候确实能用,但它把类型决策放到了业务代码里,等于把 Jackson 天生该干的事抢过来自己做。
这样做最直接的问题是性能与逻辑重复。每收到一个请求,先反序列化成父类,再 switch 判断,再手动 new 一个子类,把属性逐个拷贝过去,这个流程本身就多了一次对象创建和字段拷贝。其次,它破坏了统一入口的便利性,如果接口签名是List<OrderDTO>,下游拿到对象后根本不知道具体类型,只能靠instanceof去猜,猜错了就埋雷。
Jackson 本身对多态是有原生支持的,核心就是@JsonTypeInfo和@JsonSubTypes这两个注解。你完全可以把“哪个字段决定类型、哪些子类可以被识别、每个子类的类型标识是什么”这些规则定义在父类上,让 Jackson 在反序列化时自己去选择正确的目标类。这样做之后,业务代码里不再有任何 switch 判断,拿到手的对象类型就已经是对的。
2.2 @JsonTypeInfo + @JsonSubTypes 的配置过程
还是用订单的场景举例。我们定义一个父类OrderDTO,两个子类PhysicalOrderDTO和DigitalOrderDTO,前端 JSON 里用type字段表示订单类型:
@JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type" ) @JsonSubTypes({ @JsonSubTypes.Type(value = PhysicalOrderDTO.class, name = "PHYSICAL"), @JsonSubTypes.Type(value = DigitalOrderDTO.class, name = "DIGITAL") }) public class OrderDTO { private String orderNo; private BigDecimal amount; // 注意 type 字段要不要加? private String type; // getters/setters }这里有两个非常值得新手注意的地方。
第一个,use = JsonTypeInfo.Id.NAME搭配property = "type"之后,Jackson 会在反序列化时读取 JSON 里的type字段值,然后到@JsonSubTypes注册表里去匹配,匹配上了就实例化对应的子类。如果你在实体类里也声明了一个type字段,并且希望保留它供业务使用,就必须给@JsonTypeInfo加上一个visible = true属性,否则 Jackson 会在匹配完成后把这个字段吞掉。我第一次配置时就忘记加visible,结果反序列化成功之后业务代码拿不到type值,排查半天才意识到是这个开关的问题。
第二个,type字段值建议用Id.NAME这种字符串标识,而不是直接用Id.CLASS。Id.CLASS是把 Java 类的全限定名直接写进 JSON,比如com.example.dto.PhysicalOrderDTO。这样做虽然省去注册子类的麻烦,但至少有两个隐患:一来会把内部类路径暴露给前端,属于信息泄露的范畴;二来一旦类名重构,旧数据全部失效。所以我一律推荐字符串标识,字符串标识的可读性和可控性都要好得多。
2.3 三种继承形态的实际配置对比
继承在 DTO 层的形态,不只是一种。我梳理了三种常见的,它们的配置策略有细微差别:
| 继承形态 | 典型场景 | 注解配置建议 |
|---|---|---|
| 抽象父类 + 多个子类 | 订单、支付、商品等类型聚合 | 父类上加@JsonTypeInfo+@JsonSubTypes,子类无需重复标注 |
| 具体父类 + 子类扩展 | 基础 DTO 被多个接口复用 | 同上,但要注意父类本身也可能被实例化,建议把父类设为抽象类,避免误实例化 |
| 泛型包装继承 | 统一响应包装了不同类型数据 | 不能只靠注解,通常需要自定义序列化器或在反序列化时借助TypeReference控制 |
第三类相对少见,但一旦碰到就很疼。比如Response<T> { List<T> data; },如果T本身是抽象 DTO,那么 Jackson 在处理List<T>时,泛型擦除会导致类型信息丢失,即使T上有@JsonTypeInfo也可能不起作用。这种情况下我通常直接绕过泛型包装,改用显式的包装类,或者给泛型字段加@JsonTypeInfo(use = Id.DEDUCTION)从目标类的声明去推断。这不是一个适合新手的操作,所以我的建议是:能用前两种形态解决,就尽量不要设计泛型包装继承。
2.4 序列化和反序列化两侧的影响
多态配置不只是影响反序列化,序列化侧同样会被波及。当你的接口返回的是OrderDTO父类引用,实际对象却是DigitalOrderDTO时,Jackson 默认会根据对象实际运行时的类型输出 JSON,type字段也会自动带上,这样就保证了前后端对类型标识的认知一致。但有一点需要留意:如果你的下游是别的团队,他们按type字段去分支处理,那么任何一端改了类型标识值,另一端都要跟着改,这是跨团队接口最容易踩的坑。
我在实践中养成了一个习惯,就是给 DTO 写一个序列化/反序列化的单元测试,把每个子类型都覆盖一遍,断言从 JSON 到对象再回到 JSON 的过程中,type字段值不变、子类字段不丢。这个测试成本很低,但在重构的时候能救你一条命。后面讲映射方案时你还会看到,这个测试同样能用来验证 DTO 到 Domain 的转换逻辑。
3. Lombok 与 Jackson 共处时的兼容性雷区
3.1 编译期注解处理失败:没装插件不等于没配处理器
Lombok 和 Jackson 的悲剧往往发生在编译期和运行期两个阶段。先看编译期。
很多时候你刚 clone 一个新项目,打开 IDE 发现一堆类似“找不到方法 getOrderNo”的错误,第一反应是 Lombok 插件没装。但装完插件之后错误依旧挂着,那就得怀疑编译配置了。IDEA 里 Lombok 插件只是让 IDE 能看懂注解并生成代码提示,真正的编译期注解处理和构建工具息息相关。Maven 项目必须在pom.xml里把 Lombok 放进annotationProcessorPaths,否则编译时注解处理器根本不参与。
另一个高频错误是java: You aren't using a compiler supported by lombok, so lombok will not work...,这类提示多半出现在你升级 JDK 版本之后。Lombok 对 JDK 的支持是有版本窗口的,比如 JDK 21 刚出来的那段时间,就要注意手头 Lombok 的大版本是否跟上。遇到这个提示,优先去 Lombok 官方 Release 页面确认对应 JDK 的兼容版本,升级 Lombok 依赖,而不是去 IDE 里硬调配置。我见过有人为了这个问题把编译级别从 21 降到 11,那是杀鸡取卵,完全没必要。
3.2 @Builder 遇上继承:父类字段丢失的真相
如果你的 DTO 用了@Builder,同时又做了继承,那这里有个非常容易踩的坑:Lombok 的@Builder只生成当前类的 builder,继承的父类字段不会出现在 builder 里。这句话单独看没什么,一旦你在实际代码里准备new PhysicalOrderDTO()时,用了PhysicalOrderDTO.builder().orderNo("123")编译器直接报错,或者更隐蔽的,它构建出的对象里orderNo始终为 null。
Lombok 官方对这个问题提供了解法:@SuperBuilder。这个注解同样放在类上,父类和子类都必须标注,它生成的 builder 会包含父类继承下来的字段。需要注意的是,@SuperBuilder和@Builder不能混用在同一个类上,我见过有人同时打两个注解,编译直接报重复方法。如果你的项目里既有继承又有构建器需求,统一都用@SuperBuilder就好。
@Data @SuperBuilder @NoArgsConstructor @AllArgsConstructor public class OrderDTO { private String orderNo; private BigDecimal amount; } @Data @SuperBuilder @NoArgsConstructor @AllArgsConstructor public class DigitalOrderDTO extends OrderDTO { private String cardNo; }构建时就能这样用了:
DigitalOrderDTO dto = DigitalOrderDTO.builder() .orderNo("2025001") .amount(new BigDecimal("99.00")) .cardNo("CARD-8888") .build();这里我再补充一点:DTO 反序列化时,Jackson 默认的实例化策略是走无参构造器,如果你只写了@SuperBuilder而没有@NoArgsConstructor和@AllArgsConstructor,编译可能没问题,但运行时反序列化很可能会报cannot construct instance。所以我的经验是:DTO 类上,@NoArgsConstructor基本是标配,它能让 Jackson 拿到一个可以安全创建对象的入口。
3.3 Jackson 对构造器的要求:为什么“默认私有化”会坑你
有些同学为了封装性,把 DTO 的构造器改成私有或受保护,然后寄希望于静态工厂方法创建对象。这个思路在普通 Java 代码里没问题,但对 Jackson 就不友好了。Jackson 在反序列化时默认要求一个可见的无参构造器,或者是被@JsonCreator标注的构造器/静态工厂方法。如果两者都不存在,它会报JsonMappingException一类的错误,措辞通常含糊不清。
举个我自己踩过的例子。当时有一个ReportDTO,我为了强制所有创建都走of()方法,把构造器设成了 private,也没有加@JsonCreator。结果前端传参进来,Jackson 死活无法实例化。我当时第一反应是类没找到,折腾了一阵才意识到是构造函数可见性问题。
正确做法有两种路径。其一,保留 public 无参构造器,Jackson 先建空对象再调用 setter 填充字段;其二,如果你确实不想暴露无参构造器,那就用@JsonCreator搭配@JsonProperty标注参数,明确告诉 Jackson 从哪个 JSON 字段映射到构造器参数:
public class ReportDTO { private final String reportId; @JsonCreator public ReportDTO(@JsonProperty("reportId") String reportId) { this.reportId = reportId; } public String getReportId() { return reportId; } }这条建议对普通 DTO 和继承 DTO 都适用。继承场景里,如果你打算让 Jackson 用子类的全参构造器完成反序列化,记得每个参数都要有@JsonProperty,否则 Jackson 无法知道 JSON 字段和参数的对应关系。
3.4 Lombok 生成的 equals/hashCode 在继承场景的隐藏风险
@Data会自动生成equals和hashCode,这是大家经常忽略的一个角落。默认情况下,Lombok 生成的这两个方法不会调用父类的字段,也就是说两个DigitalOrderDTO,就算cardNo相同但父类的orderNo和amount完全不同,它们依然会被判定为相等。这个现象在集合判断、缓存 key、去重逻辑里都可能引发诡异的 bug。
正确的姿势是手动指定callSuper = true:
@Data @EqualsAndHashCode(callSuper = true) public class DigitalOrderDTO extends OrderDTO { private String cardNo; }这么改完之后,equals和hashCode会把父类字段也算进去。但这里又有一个逆向风险:一旦父类字段被纳入比较,两个对象的比较范围就变大了,如果你只是拿 DTO 做接口入参校验,也许根本不需要equals。所以更谨慎的方式是:对纯 DTO 对象,干脆不用@Data,改成@Getter+@Setter,显式控制有没有equals;需要比较逻辑的地方,再单独在 Domain 层实现。我后来在项目规范里就明确写了,DTO 层别挂@Data,统一定义@Getter@Setter@NoArgsConstructor@AllArgsConstructor,业务模型才允许使用@Data。这条规范看似严格,实际排查问题的效率高了很多。
4. DTO 映射到 Domain 的实践方案对比与选型
4.1 手写映射为什么仍然是一个不错的兜底方案
聊完反序列化,回到标题里最核心的需求:从 DTO 到 Domain 的映射。
市面上常见的选择有三类:手写 getter/setter 转换、反射工具类、编译期代码生成工具。手写最简单直接,也最可控,只是代码量大。当字段少、结构简单时,我反而推荐手写,因为可读性最好,而且一旦出问题,追踪链路最短。
public class OrderDomainMapper { public static OrderDomain toDomain(OrderDTO dto) { if (dto == null) { return null; } return OrderDomain.builder() .orderNo(dto.getOrderNo()) .amount(dto.getAmount()) .type(OrderType.valueOf(dto.getType())) .build(); } }这段代码谁都能看懂,谁都能改,也几乎没有框架学习成本。它的缺点在字段很多、嵌套很深的时候会暴露,比如一个 DTO 挂了十几个子对象,手写映射可能是两三百行样板代码。这时候就容易想到反射工具,比如 Apache Commons BeanUtils、Spring 的BeanUtils.copyProperties、Cglib 的BeanCopier。
这类工具的核心优势是省代码,但劣势也很明显:字段名不同就不行,类型不匹配就静默失败,继承结构复杂时行为不可预测。特别是有枚举类型转换、字段重命名这种需求时,用反射工具等于把自己的灵活性都交出去。我的结论是,单层简单映射可以用反射工具,但凡是涉及类型转换和多态,就别偷懒。
4.2 MapStruct 和 Lombok 组合的配置细节
MapStruct 是我在字段多、对象层次深的项目里比较喜欢的选择。它的原理是编译期生成实现类,运行时不走反射,性能和手写几乎一致,同时你只需要写一个接口定义,复杂的映射负担就轻了很多。
但它和 Lombok 组合使用时,存在一个很典型的坑:如果你只在pom.xml里依赖了lombok和mapstruct-processor,没有显式声明annotationProcessorPaths,那么在不同构建环境下,两个注解处理器的执行顺序可能不一致。Lombok 的处理器必须在 MapStruct 之前执行,因为 MapStruct 在读取源文件时需要调用 Lombok 已经生成好的 getter/setter,否则它会报告“找不到属性”。
<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>依赖版本</version> </path> <path> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>依赖版本</version> </path> </annotationProcessorPaths> </configuration> </plugin>还有一点,MapStruct 会自动查找同名字段,如果你的 DTO 字段名和 Domain 字段名不一致,它有@Mapping注解去指定。建议在写映射接口之前,先花一点时间统一 DTO 和 Domain 的字段命名,这样能减少大量@Mapping配置。如果遇到枚举类型不一样,可以提供一个 default 方法,在接口里手动处理:
@Mapper public interface OrderMapper { OrderMapper INSTANCE = Mappers.getMapper(OrderMapper.class); @Mapping(target = "type", expression = "java(dto.getType() != null ? OrderTypeEnum.valueOf(dto.getType()) : null)") OrderDomain toDomain(OrderDTO dto); }注意这种表达式写法在代码评审时经常会被质疑,因为可读性一般。我一般优先写 default 方法做显式映射,这样单元测试可以直接覆盖。
4.3 继承 DTO 映射 Domain 的完整案例
现在把多态和映射串起来,看一个完整的订单案例。前端传进来的 JSON 长这样:
{ "type": "DIGITAL", "orderNo": "D20250301", "amount": 99.00, "cardNo": "CARD-8888" }反序列化之后得到的是DigitalOrderDTO实例。接下来要映射到 Domain,也就是DigitalOrderDomain,这个 Domain 带有业务方法,比如activate()用来做卡券激活。
最干净的方案是让 Mapper 接口针对父类做多态转换,但 MapStruct 对多态的支持并不算友好,它需要你为每个子类写一个映射方法。我采用的方式是:用一个通用的转换入口,先拿到 DTO 的具体类型,再分派到对应的映射逻辑。有人会觉得这又回到了 if 分支,其实不一样,这里的 if 分支只有一层,而且集中在转换层内部,对上游调用方来说依然是无感知的。
public class OrderDomainFactory { public static OrderDomain toDomain(OrderDTO dto) { if (dto instanceof DigitalOrderDTO) { return DigitalOrderMapper.INSTANCE.toDomain((DigitalOrderDTO) dto); } if (dto instanceof PhysicalOrderDTO) { return PhysicalOrderMapper.INSTANCE.toDomain((PhysicalOrderDTO) dto); } throw new IllegalArgumentException("未知订单类型: " + dto.getClass().getName()); } }这样做的好处是:每个子类的映射规则独立成类,互不干扰;新增一个子类时,你只需要加一个 Mapper 和一个分支,改动范围小,且不容易碰坏已有逻辑。我在实际项目里还会给工厂方法配上对应的单测,把所有子类型遍历一遍,确保不遗漏。
4.4 fastjson 和 jackson 的选择题,为什么不建议切来切去
在讨论 DTO 序列化时,总绕不开 fastjson 和 Jackson 的对比。说实话,对于继承 DTO 这个场景,multipage 注解就是更成熟的选择。前者在多态处理上更多依赖自定义序列化器或手动开关,要写出跟@JsonTypeInfo一样简洁清晰的多态方案,配置成本是不低的。而且从依赖稳定性和社区维护角度来看,Jackson 的升级节奏和适配范围普遍更稳定,尤其在处理复杂对象图、循环引用、泛型擦除这些问题上,踩过坑的人都懂。
我并不是说 fastjson 一无是处,它在简单场景下确实上手快,但如果你要在同一套项目里混用两个序列化框架,那才叫真正的灾难。同一个对象,在接口入参用 Jackson 反序列化,又在某个内部调用用 fastjson 序列化,两边的注解机制不同,字段命名策略不同,出问题的排查链路会变得极长。我的建议是:新项目直接选一个框架定死,老项目结束迁移周期,别在中间状态里反复试探。
5. 排错链路:把来自接口层的“domain forbidden”这类报错一网打尽
5.1 报错来自映射层之外的判断方法
很多同学在排查问题时有一种惯性,一看错误信息里有domain或者forbidden,脑子里立刻联想到是不是 DTO 转换出了问题,然后一头扎进映射代码里找原因。实际上,像{"code":1004,"error":"domain forbidden"}这类错误,很多情况是接口层或者网关层的校验拦截,跟对象映射没有半毛钱关系,它说明请求根本还没有到达业务处理逻辑,就被拦在门外了。
我排查这类问题有一个固定流程:先看错误产生的调用链,再看返回错误的类名,最后才看业务代码。如果错误信息来自某个过滤器、拦截器或者网关组件,那基本确定是入口校验拦截。这时候要做的是检查请求域名、白名单配置、安全策略是否把当前请求来源拦住了,而不是去改 DTO 或 Mapper。拿代码 1004 举例,如果项目里约定这个数字代表“域名在禁止访问列表中”,那你改映射层代码一百遍,错误还是在原地等你。
5.2 一组反序列化异常排查清单
我把自己遇到过的典型反序列化和映射异常整理成了一张清单,方便对症下药:
| 典型报错 | 根因方向 | 首选排查动作 |
|---|---|---|
cannot construct instance of ... | 缺少无参构造器或无可见构造器 | 检查 DTO 是否缺失@NoArgsConstructor或@JsonCreator |
Unrecognized field "xxx" | 目标类没有对应属性,或 getter/setter 缺失 | 检查 JSON 字段和实体字段名是否一致,检查 Lombok 注解是否生效 |
| 反序列化结果是父类但字段为空 | 缺少@JsonTypeInfo或类型标识字段不匹配 | 检查父类是否配置多态注解,检查前端 type 值是否与注册名一致 |
cannot find symbol getXxx | Lombok 处理器未参与编译 | 检查annotationProcessorPaths是否配置 Lombok |
You aren't using a compiler supported by lombok | Lombok 版本与 JDK 不兼容 | 升级 Lombok 到支持当前 JDK 的版本 |
| 映射后关键字段全部为 null | 字段名不一致,或 MapStruct 未生成实现类 | 检查target字段名,检查 Mapper 接口是否有@Mapper注解 |
这张表是我和团队排查时用的基本盘,覆盖面不算全,但每一次都能帮我们在一两分钟内排除掉最常见的方向。遇到奇怪的报错,我建议先把原始 JSON 打印出来,再单独用 ObjectMapper 去做一次最小化反序列化测试,把不确定因素拆掉。这个习惯能极大降低排查成本。
5.3 我后来沉淀出的三个“先看”原则
踩坑越多,越发现很多问题跳不出几个固定的根因模式。我总结出三个原则,现在写代码时都会先过一遍。
第一个,先看 DTO 是否有多态注解。只要 DTO 存在继承关系,第一件事永远是确认父类有没有@JsonTypeInfo,以及前端是否传入正确的类型标识。这个问题是继承 DTO 场景里出现频率最高的,优先级永远排第一。
第二个,先看 Lombok 是否真的参与了编译。项目刚拉下来、IDE 刚换版本、JDK 刚升级,这三个时间点最容易出现 Lombok 不生效的情况。先看编译输出,再看依赖配置,最后才怀疑代码本身,能省很多时间。
第三个,先看错误来自哪一层。如果一个报错发生在接口层、网关层或拦截器层,它的根因往往和映射层毫无关系。把排查范围圈定在“哪一层产生的异常”,比盯着堆栈尾部找答案高效得多。我见过不少开发者在 DTO 转换代码里反复打日志,却忽略了一个简单事实:那个domain forbidden错误压根都没让代码走到映射环节。
这三个原则算不上什么高深理论,但都是真金白银换来的经验。把这三条记牢,至少能帮你少走一半的弯路。
最后再分享一个个人习惯,也是我在这类问题反复折磨之后形成的:不管项目多急,DTO 和 Domain 之间一定留一个清晰的转换层,所有序列化配置和映射规则都集中写在那个层的附近,不要让它们散落在业务代码的各个角落。这样你遇到问题的时候,知道往哪看;项目有新人接手的时候,也知道从哪读起。保持这个结构,Jackson、Lombok、继承这些看似麻烦的组合,其实完全可以稳定运行。