ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Lombok @Data 编译期代码生成原理与工程避坑

Lombok @Data 编译期代码生成原理与工程避坑 1. 从一段只有字段的实体说起Data 到底替谁干了活1.1 那个编译通过却满屏红线的下午前阵子帮同事看一段代码他把一个实体类推上来长这样public class OrderVO { private Long id; private String orderNo; private BigDecimal amount; }本地mvn clean package顺利通过CI 也是绿的。但他本地的 IDE 里所有orderVO.getOrderNo()的调用点全是红色波浪线提示找不到符号。他第一反应是jar 包没下全重新 import 了三遍重启了两次 IDE还是红的。问题其实跟网络、跟依赖都没关系他的 IDEA 里没装 Lombok 插件或者装了但注解处理被关掉了。而 CI 之所以能过是因为 Maven 编译阶段的注解处理器Annotation Processor是独立于 IDE 插件工作的——编译器在生成字节码之前把方法补进去了所以class文件里确实存在getOrderNo()而 IDE 的静态分析器只认源码源码里没有这个方法它就报红。这件事很典型它把Data这个注解最容易被误解的一点暴露出来了Data不是运行时反射它是编译期代码生成。方法从来没消失过只是不在你眼前。1.2 把 Data 折算成五个注解很多人用了一年Data都没搞清它到底生成什么。其实它就是把下面这五个注解打包贴在类上组成部分生成内容影响范围Getter每个字段的读方法所有非静态字段Setter每个字段的写方法所有非静态、非 final 字段ToStringtoString()所有非静态字段含 transientEqualsAndHashCodeequals()与hashCode()所有非静态、非 transient 字段RequiredArgsConstructor只含必需参数的构造方法final 字段与NonNull字段这里有几个细节必须记住因为它们决定了你会不会踩坑第一Data不包含无参构造也不包含全参构造。它只给一个必需参数构造器。所谓必需指的是被final修饰的字段以及被NonNull标注且在声明处没有赋初值的字段。一个普通类如果没有 final 字段、没有NonNull那生成的其实是零参数的构造方法——所以你会觉得它好像给我加了个无参构造。这种看起来有、实际是靠巧合的假象是后面很多序列化问题的源头。第二equals/hashCode不参与静态字段也不参与 transient 字段而toString参与 transient不参与静态。getter/setter则完全跳过静态字段。三个注解的字段筛选规则各不相同这一点几乎没有文档会专门强调但在做缓存序列化、做审计日志时非常关键。第三如果类里已经显式写了任何一个构造方法RequiredArgsConstructor就不会再生成。这条规则同样适用于AllArgsConstructor、NoArgsConstructor它们的判断是有没有显式构造而不是有没有同签名的构造。所以在一个已经手写了全参构造的类上再挂Data你拿不到那个必需参数构造器也不会报错只会在调用处找不到构造方法。第四这个注解是lombok.Data不要和 Spring 的Data搞混——Spring 里没有这个注解org.springframework.data.annotation下的注解是给持久化映射用的跟 Lombok 完全两回事。项目里偶尔能看到有人 import 错了包编译器不会提醒你因为两个包都真实存在。1.3 源码里看不见的方法凭什么能被调用Java 的注解处理器在javac的解析parse之后、生成字节码generate之前运行。Lombok 在这里做了一件比较激进的事它直接操作 javac 的内部抽象语法树AST往类节点上挂新的方法节点同时把注解本身从最终产物里抹掉。所以你反编译OrderVO.class看到的是货真价实的getOrderNo()而Data的符号引用在 class 文件里根本不存在——这也解释了为什么 Lombok 是provided作用域就够了运行时不需要这个 jar。想亲眼确认有两个办法。一个是反编译。用javap -p -c看字节码里的方法表javap -p target/classes/com/example/OrderVO.class另一个是delombok把注解展开成实实在在的源码写代码评审时特别有用java -jar lombok.jar delombok src/main/java -d target/delombok我在团队里推过一个规矩凡是第一次引入 Lombok 的模块评审前先跑一遍 delombok把展开后的代码给 reviewer 看一眼。因为很多时候作者自己都没想到Data会生成那么长一串equals更没想到那个equals会把所有业务字段都算进去。2. 五个注解各自的脾气逐个拆开看生成规则2.1 Getter 与 Setter修饰符、is 前缀与链式调用Getter/Setter可以贴在类上也可以贴在单个字段上做覆盖。字段级的配置优先级高于类级这是做精细控制的主要手段。最常见的几个用法Data public class UserProfile { private Long id; private String nickName; // 只读不生成 setter Setter(AccessLevel.NONE) private String idCardNo; // 对外只给读包内可写 Setter(AccessLevel.PACKAGE) private Integer status; }这里要提醒一个容易忽视的点AccessLevel.NONE是不生成不是生成 private。有些人为了禁止外部修改选了PRIVATE结果同一个类里还是能改而且反射照样能改起不到预期效果。另一个坑是关于boolean字段的前缀。对于boolean activeLombok 默认生成isActive()但对于Boolean active包装类型生成的是getActive()。这个差异会直接影响两类东西一是 JSON 序列化时的字段名Jackson 对isXxx和getXxx的识别逻辑不完全一样二是 JSP/模板引擎里的属性访问。lombok.getter.noIsPrefix true可以全局关掉is前缀但如果你在同一个工程的不同模块里混用就会出现同一个 DTO 在不同接口里字段名不同的荒唐现象。我的建议是要么全项目统一要么在字段上显式写Getter加自定义方法。链式调用也是常见需求。在lombok.config里打开lombok.accessors.chain true所有 setter 会返回thisUserProfile p new UserProfile().setNickName(张三).setStatus(1);但请注意链式 setter 会让setXxx的返回类型从void变成类本身这会破坏某些依赖标准 JavaBean 签名的框架。尤其是一些老版本的 ORM、模板引擎、以及做法反射调用的通用代码它们会按void setXxx(T)去匹配方法。所以我一般只在纯 DTO、Internal API 的模型上开链式实体类上不开。2.2 ToString字段裁剪、循环引用与 includeFieldNamestoString()是排查问题时用得最多的方法也是Data里最容易闯祸的一个。默认情况下它把所有非静态字段都拼进去包括密码、身份证号、大文本、二进制内容。线上日志里出现完整的用户敏感信息很多时候就是这里漏了裁剪Data public class LoginForm { private String username; ToString.Exclude private String password; ToString.Exclude private byte[] avatar; ToString.Include(name pwdLen) private int passwordLength() { return password null ? 0 : password.length(); } }ToString.Exclude是最省事的做法。如果你更希望默认什么都不输出只输出我指定的可以改用ToString(onlyExplicitlyIncluded true)然后给每个想输出的字段加ToString.Include。这个反向配置在安全敏感的对象上非常值用因为它是默认拒绝的策略新增字段不会自动泄露。includeFieldNames默认是true输出形如UserProfile(id1, nickName张三)。有人嫌啰嗦把它关掉输出UserProfile(1, 张三)结果两个字段类型都是 String 的时候完全看不出谁是谁。这个开关我从来不动格式化多几个字符的成本远低于排查时认错字段的代价。至于循环引用它是ToString的头号事故。两个实体互相持有引用订单持有用户用户持有订单列表或者实体持有自己的父节点toString()会一直递归到栈溢出。这类问题不会在开发环境暴露往往是在打日志的那一刻突然StackOverflowError而且异常栈特别长很吓人。处理方式有两种用ToString.Exclude断掉其中一条边或者干脆在关联字段上只输出 ID。2.3 EqualsAndHashCodecallSuper 与那个不调用父类的警告这是Data里最需要动脑子的部分因为equals的语义一旦写错问题会以最隐蔽的方式爆发。Lombok 在生成equals/hashCode时默认不会调用父类的实现并且会给你一条警告Generating equals/hashCode implementation but without a call to superclass, even though this class does not extend java.lang.Object.这条警告经常被淹没在几千行构建日志里。它的含义是如果父类也有自己的状态子类比较时会完全忽略父类字段。最经典的后果就是——父类和子类实例互相比较时永远不会相等因为 Lombok 生成的equals里有一句o instanceof SubClass的判断更准确说是canEqual机制父类实例过不了这道检查。处理这个问题的开关是lombok.equalsAndHashCode.callSuper取值call、skip、warn。我个人的判断标准很简单父类是纯抽象、无状态、只提供行为的比如一个BaseService之类skip警告关掉。父类本身带业务字段比如BaseEntity里有createTime那要么call要么更彻底一点在子类上用EqualsAndHashCode(onlyExplicitlyIncluded true)加若干个EqualsAndHashCode.Include明确只按业务主键比较。顺带说一个canEqual机制。Lombok 生成equals时会在类里偷偷塞一个protected boolean canEqual(Object other)子类生成时把它覆写成只认自己的类型。这是为了在子类继承父类、父类也生成 equals的场景下避免对称性被破坏。这个设计本身没问题但它意味着只要你在继承链的两端都生成了 equals它们之间的相等关系就会变得比你想象的严格。很多人第一次遇到两个值一模一样的对象 equals 返回 false根因就在这里。2.4 RequiredArgsConstructorfinal、NonNull 与构造器冲突Data附带的这个构造器实际价值比很多人以为的大。它天然支持构造器注入风格Data public class OrderService { private final OrderRepository orderRepository; private final NotifyClient notifyClient; }上面这个类只有一个构造方法接收两个 final 依赖字段又都是 final、不可变。这比Autowired字段注入好得多依赖不可变、便于单元测试里直接 new、也不会出现循环依赖时那种启动期才报的怪问题。所以我在 Service 层其实是把Data拆开用的——不挂Data只挂RequiredArgsConstructor再加Getter给需要暴露的字段。冲突场景主要有两个。一是和Builder一起用这个放在第 4 节细讲。二是NonNull的语义容易被误解Data public class Config { NonNull private String appKey; }它会生成带判空的构造方法并在 setter 里也插入判空逻辑——传null进去会抛NullPointerException且异常消息是 Lombok 拼的不是你的业务消息。有些人以为NonNull只是个文档标记结果在容器里传了个 null 参数进来抛出的 NPE 完全看不到上下文排查时一脸茫然。我一般会在lombok.config里加lombok.nonNull.exceptionType IllegalArgumentException让语义更直白一些。还有一个细节NonNull字段如果在声明处赋了初值比如private String appKey default;那它不会被算进必需参数里也就不会出现在构造器参数列表里。这个规则跟 final 字段一样很容易被忽略。3. 哪些类不该图省事挂 Data四类危险场景3.1 数据库实体主键未赋值时的 equals 悖论这是我在真实项目里见过最多的一类事故。Data Entity Table(name t_order) public class Order { Id GeneratedValue private Long id; private String orderNo; private BigDecimal amount; }问题不在语法在语义。Data生成的equals会把id、orderNo、amount全算进去。于是两个刚new出来、还没入库的对象id都是null字段也一样它们会被判为相等。你不能在HashSet里同时放两个新订单。你new了一个Order(orderNo A)放进HashSet然后把orderNo改成B这个对象就再也找不回来了因为它的hashCode变了。这就是 3.3 要讲的哈希漂移。正确做法是给实体类明确指定相等性依据。要么只用主键且对未持久化对象做特殊处理Getter Setter ToString EqualsAndHashCode(onlyExplicitlyIncluded true) Entity public class Order { Id GeneratedValue EqualsAndHashCode.Include private Long id; private String orderNo; private BigDecimal amount; Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof Order)) return false; Order other (Order) o; if (this.id null || other.id null) return false; // 未持久化对象不与其他实例相等 return this.id.equals(other.id); } Override public int hashCode() { return id null ? System.identityHashCode(this) : id.hashCode(); } }要么更省事一点干脆不挂Data只挂Getter/Setterequals从Object继承按引用比较。对 JPA 实体来说按引用比较在很多场景下反而是最安全的默认值——因为 Hibernate 保证同一个持久化上下文里同一条记录只会有一个 Java 实例。判断标准这个实体会不会被放进Set、会不会作为Map的 key、会不会用List.contains()做去重如果会就必须认真设计 equals如果不会那就别用Data去生成一个你根本不需要、还可能出错的实现。3.2 懒加载代理instanceof 与 getClass 的差别接着上面的话题。假设你在Order上只按主键做相等性判断还写了o instanceof Order这种检查——这在 Hibernate 场景下是正确的做法因为 Hibernate 的懒加载代理类是Order的子类instanceof能通过getClass() o.getClass()则通不过。反过来如果你手写 equals 时用了getClass()比较那就会出现从session.get()拿到的对象和从关联属性里拿到的代理对象不相等这种灵异现象。表现是明明查出来是同一条订单list.contains(order)永远是false。这个坑之所以难查是因为它在单测里通常复现不了——单测用的是 new 出来的真实实例没有代理。只有到了集成环境、开启了懒加载才会冒出来。我在评审实体类代码时只要看到手写的equals里出现getClass()基本都会要求改成instanceof加canEqual的写法或者干脆去掉自定义 equals。还有一种相关的坑代理对象在toString()里触发懒加载如果此时 Session 已经关闭就会抛LazyInitializationException。表现是打个日志就报错日志本身把现场搞炸了。所以实体类上的toString一定要把关联字段排除掉或者只输出关联对象的 ID。3.3 可变字段做 Map 键hashCode 漂移现场这个场景不限于实体任何挂Data的可变对象都可能遇到Data public class Point { private int x; private int y; } MapPoint, String map new HashMap(); Point p new Point(); p.setX(1); map.put(p, 起点); p.setX(2); System.out.println(map.get(p)); // nullput的时候hashCode按x1算存进了 1 号桶改完x2get时按x2算跑到 2 号桶去找自然找不到。对象其实还在 Map 里成了幽灵键只能靠遍历entrySet才能捞出来。Data让这个坑变得特别容易踩因为它默认就把所有字段放进hashCode你一行代码都没写就中招了。防护手段有三个层次用作 Map 键的对象设计成不可变字段全 final没有 setter。必须可变时把参与hashCode的字段收窄到不可变的那些用EqualsAndHashCode(onlyExplicitlyIncluded true)。最保守的一种这类对象不要挂Data用Getter加手写的equals/hashCode只算 ID。我曾经在一个权限模块里见过更隐蔽的版本MapRole, SetPermission缓存角色对象被人中途改了roleName导致缓存再也命中不了每次都要重新查库。性能问题排查了两天最后是靠把 Map 里所有 key 打印出来跟预期对比才发现的。3.4 双向关联与集合字段递归与改集合等于改哈希双向关联Order持有ListOrderItemOrderItem持有Order挂上Data之后toString会无限递归equals也会互相调用最终都是StackOverflowError。而且这个异常栈有几千行打印出来能刷满整个终端。处理规则我总结成一句话关联字段一律排除出 equals、hashCode 和 toString只保留标量字段。Data public class Order { private Long id; private String orderNo; ToString.Exclude EqualsAndHashCode.Exclude private ListOrderItem items; }另外一个不能忽视的点Data生成的hashCode如果包含集合字段那集合内容一变hashCode就变。这不仅影响 Map 键还会影响对象存入 HashSet 后又被修改的场景。集合字段几乎永远是可变状态把它算进hashCode里等于给自己埋雷。顺带说一句Data生成的getter直接返回集合引用调用方可以随意往里面加元素破坏封装。如果确实需要对外暴露集合我会显式写public ListOrderItem getItems() { return items null ? Collections.emptyList() : Collections.unmodifiableList(items); }手写之后Lombok 就不会再生成同名方法不会冲突。4. 框架协同Jackson、MapStruct、MyBatis 眼里的 Data4.1 Jackson 反序列化时挑哪个构造函数Data和 Jackson 的冲突几乎每次都在同一个地方爆发没有无参构造。前面说过如果类里有 final 字段或者NonNull字段Data会生成一个带参数的构造方法。此时类就没有无参构造了。Jackson 在把 JSON 转成对象时如果找不到无参构造、也没有标注JsonCreator的构造方法就会抛InvalidDefinitionException: Cannot construct instance of com.example.OrderVO (no Creators, like default constructor, exist)解决方案有三种各有适用场景方案写法适用场景补无参构造加NoArgsConstructor大部分 Web 请求体最省事标注构造方法在必需参数构造上加JsonCreator参数加JsonProperty明确要做不可变对象加构造属性名lombok.anyConstructor.addConstructorProperties true多框架共用一次配置全局生效第三种值得单独说说。打开这个开关后Lombok 会在生成的构造方法上加java.beans.ConstructorProperties({fieldA, fieldB})。Jackson、MyBatis、部分映射框架都能识别这个注解从而知道参数和字段的对应关系。注意这要求构造器参数名和字段名保持一致而 Lombok 本来就是按字段名生成的所以天然匹配。这个配置我在多个项目里用过确实省掉了大量手写JsonProperty的工作。还有一个默认行为要留意Data的getter/setter让 Jackson 默认按 JavaBean 属性名做映射nickName对应 JSON 里的nickName。如果你在字段上加JsonProperty(nick_name)这个配置会被 Lombok 生成的 getter/setter 继承因为 Jackson 会从字段、getter、setter 三处合并注解信息不会丢这点可以放心。4.2 Builder 与 Data 同时上构造函数被谁占了Data配Builder是最常见的组合但很多人写完发现反序列化坏了原因在构造函数的归属上。规则是这样的Builder需要一个全参构造方法。如果类里没有显式构造Builder会自己生成一个包级可见的全参构造。而这个构造方法一旦存在Data附带的RequiredArgsConstructor就认为已经有构造了于是什么也不生成。结果就是你的类只有一个包级全参构造没有无参构造Jackson 反序列化失败。所以这个组合的标准写法其实是Getter Setter Builder NoArgsConstructor AllArgsConstructor public class OrderVO { private Long id; private String orderNo; }也就是把Data拆成GetterSetter然后显式声明两个构造方法。这样谁都满意Builder用全参构造Jackson 用无参构造。这里顺便带走一个经验只要同时用Builder和任何序列化框架就显式写全NoArgsConstructorAllArgsConstructor别指望注解之间的隐式协商。我在评审时看到Data Builder裸配基本会直接提 comment 要求补构造方法因为这种问题在本地自测时往往碰不到——本地通常只测对象转 JSON没测 JSON 转对象。4.3 MyBatis 结果映射与无参构造的硬性要求MyBatis 默认的resultType映射走的是无参构造 setter这条路。它有两条路可选一是无参构造后逐个set二是带参构造按列名匹配。默认走第一条。所以如果你给实体挂了Data并且这个实体没有无参构造比如所有字段都是 finalMyBatis 就会抛异常报找不到合适的构造方法。这一点在实体类字段全 final、追求不可变的团队里经常翻车。如果想走构造器映射也可以在 resultMap 里用constructor显式声明或者在 Maven 里配置lombok.anyConstructor.addConstructorProperties true让 MyBatis 自动识别。我一般的选择是持久层实体一律保留无参构造不管是不是追求不可变。持久层对象的生命周期本来就由框架控制谈不可变收益很低反而增加集成成本。还有一点Data生成的 setter 都返回void除非开了链式。MyBatis 通过反射找setXxx链式 setter 也能被识别它只看方法名和参数但有些老版本的 MyBatis-Plus 或者自定义 TypeHandler 在按标准签名严格匹配时可能会出问题。所以实体类上我从不开启链式访问器。5. 工程化配置让 Lombok 在 IDE 和构建流水线里稳定下来5.1 Maven 与 Gradle 的依赖声明和注解处理器路径Maven 里用provided就够dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId version1.18.30/version scopeprovided/scope /dependency但有个细节如果你在pom.xml里配置了annotationProcessorPaths那么 Lombok 就必须出现在这个列表里因为它会覆盖 classpath 上的处理器发现机制plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration annotationProcessorPaths path groupIdorg.projectlombok/groupId artifactIdlombok/artifactId version${lombok.version}/version /path path groupIdorg.mapstruct/groupId artifactIdmapstruct-processor/artifactId version${mapstruct.version}/version /path /annotationProcessorPaths /configuration /plugin我见过不止一次本地好的、打包就报错的案例根因就是引入 MapStruct 之后配了annotationProcessorPaths但漏了 Lombok于是 Lombok 完全不生效所有 getter 找不到。报错信息是找不到符号 getXxx跟依赖缺失一模一样容易带偏排查方向。Gradle 的写法dependencies { compileOnly org.projectlombok:lombok:1.18.30 annotationProcessor org.projectlombok:lombok:1.18.30 testCompileOnly org.projectlombok:lombok:1.18.30 testAnnotationProcessor org.projectlombok:lombok:1.18.30 }测试相关的两行不能省否则单测里用到的 Builder、getter 全是红的。另外一个组合问题Lombok 和 MapStruct 一起用时处理顺序有影响。MapStruct 在生成映射实现类时需要读取源类的方法签名如果 Lombok 还没处理它看到的源码里就没有 getter。解决办法是把 Lombok 放在annotationProcessorPaths的前面新版 Lombok 和 MapStruct 都做了兼容处理但显式排序更稳或者把 MapStruct 相关代码放到单独的模块里编译。这类问题在构建日志里不会明确报错只会表现为映射类里所有字段都是 null特别费时间。5.2 lombok.config 里值得打开的几项lombok.config放在项目根目录或者源码目录下逐级生效。为避免向上找到父目录意外继承配置第一行通常写config.stopBubbling true我常用的几项配置项值作用lombok.addLombokGeneratedAnnotationtrue生成的代码加lombok.Generated让 JaCoCo 等覆盖率工具忽略lombok.anyConstructor.addConstructorPropertiestrue生成构造方法参数名注解方便 Jackson/MyBatislombok.data.flagUsageERROR在指定模块里禁止使用Datalombok.equalsAndHashCode.callSuperskip或call明确继承链上的相等性策略lombok.nonNull.exceptionTypeIllegalArgumentException让NonNull抛出语义更清晰的异常第一项特别值得打开。它常被忽略但效果很直接不打开的时候JaCoCo 会把equals、hashCode、toString这些自动生成的方法都算进覆盖率分母一个 POJO 多的项目覆盖率会凭空掉十几个点。打开之后这些方法被标记为生成的覆盖率统计就干净了。我见过团队为了凑覆盖率数字去给 POJO 写测试就是在跟这个配置较劲。第三项是团队治理的利器。我们有个模块专门存放对外接口模型评审后决定全面禁用Data只允许显式Getter/Setter加自定义equals。做法就是在那个模块的lombok.config里写lombok.data.flagUsage ERROR谁不小心挂了Data编译直接失败。这种强制手段比写在文档里管用得多因为它是编译期拦截。5.3 JDK 升级后那个 IllegalAccessError 的来龙去脉JDK 版本往上升的时候老版本 Lombok 会突然炸掉报这种错java.lang.IllegalAccessError: class lombok.javac.apt.LombokProcessor (in unnamed module 0x...) cannot access class com.sun.tools.javac.processing.JavacProcessingEnvironment (in module jdk.compiler) because module jdk.compiler does not export com.sun.tools.javac.processing to unnamed module 0x...原因在于 Lombok 直接操作 javac 的内部 AST而新 JDK 的模块系统收紧了这些内部包的访问权限。这不是你的代码问题是工具和 JDK 的适配问题。两个处理方向。首选是把 Lombok 升到跟 JDK 匹配的版本——这个系列一直在跟进新版本基本能覆盖较新的 JDK具体版本号对着官方 changelog 挑一个比当前 JDK 更晚发布的即可。次选是在编译参数里打开导出--add-exports jdk.compiler/com.sun.tools.javac.processingALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.processingALL-UNNAMED但我个人更倾向升级 Lombok因为--add-exports这种方案要写进构建脚本、还得保证所有开发者的 IDE 编译参数一致维护成本更高而且升级 JDK 之后这类参数还会一直叠加上去越积越乱。这里有个团队协作上的经验Lombok 的版本应该跟 JDK 版本一起纳入统一升级计划。很多团队的 pom 里写的是${lombok.version}但继承自某个父 pom实际锁在一个很旧的值上几年不动。等到某次要升级 JDK才发现构建直接挂了然后一边排查业务代码一边猜是不是 Lombok 的问题。把这两个版本放在同一张升级清单上能省掉不少事。6. 排查实录三个报错现场的完整链路6.1 子类比较永远返回 false现象一个测试用例里两个字段完全一样的SubOrder对象equals返回false。排查链路是这样的。第一步确认是不是同一个类加载器加载的。用a.getClass() b.getClass()和a.getClass().getClassLoader() b.getClass().getClassLoader()打印一下确认是同一个类排除了热部署导致的类加载器差异——这是最容易被误判的方向尤其在 IDE 里跑测试的时候。第二步反编译看生成的equals。javap -p -c target/classes/.../SubOrder.class看到了canEqual调用也看到了instanceof SubOrder的判断逻辑本身没问题。第三步找到父类。父类BaseOrder也挂了Data并且它自己的equals里也有一句instanceof BaseOrder。而SubOrder.equals在比较完后调用了other.canEqual(this)canEqual在SubOrder里被覆写成只接受SubOrder实例。问题是这样的如果代码里有一步是把SubOrder赋给了BaseOrder类型的变量再去做比较或者列表里存的是父类类型、实际元素是子类那么对称性要求会导致两边路由不同结果就飘忽不定。真正让人困惑的是看起来完全一样却不相等。最终的根因其实是更简单的一件事BaseOrder里有个status字段两边确实不同只是打印日志时被ToString.Exclude排除掉了看日志看不出来。这个案例给我两个教训。第一排查 equals 问题时先把toString的排除项临时去掉或者直接打印所有字段的反射值别信任被裁剪过的日志。第二继承链上不要两代都挂Data父类要么不生成 equals要么明确callSuper call让语义可预测。6.2 StackOverflowError 的调用栈怎么读现象一个接口偶尔返回 500日志里是超长的StackOverflowError。这种栈其实很好读不用怕它长。看最上面那几十行找到重复出现的两三个方法名那就是递归环at Order.toString(Order.java:12) at OrderItem.toString(OrderItem.java:15) at Order.toString(Order.java:12) at OrderItem.toString(OrderItem.java:15) ...一眼就能看出是Order和OrderItem互相引用。修复就是在一侧的关联字段上加ToString.Exclude或者更彻底两侧都加各自只输出自己的标量字段。这里要提醒一个容易走偏的方向有些人第一反应是日志打太多把栈打爆了去调 JVM 的栈大小。栈大小确实能延后爆栈的时机但只是在增加递归深度问题还在而且下一次可能换成equals递归或者序列化时的递归。治标的做法是把-Xss调大治本的做法是把关联字段排除掉。另外equals/hashCode也可能形成递归环而且比toString更隐蔽——因为它不一定在日志里出现可能表现为某个接口偶尔卡死或者 CPU 飙高。判断依据同样是看栈里的重复方法名。如果确认是 equals 递归最干脆的修复是把关联字段用EqualsAndHashCode.Exclude排除。6.3 JaCoCo 覆盖率突然掉下来现象某次迭代没加多少代码覆盖率从 78% 掉到 61%。第一反应是新增业务代码没写测试。但看增量覆盖率报告新增代码的覆盖率其实是满的。问题出在某个 POJO 模块上那个模块的Instruction覆盖数几乎为零。到这里基本就能定位了引入了 Lombok 之后一批 POJO 自动生成了equals、hashCode、toString、getter、setter这些方法被计入了统计分母但没人会给它们写测试。验证方式很简单用 delombok 把源码展开数一下生成的方法行数和覆盖率分母的增量对一下数量级能对上就基本确定了。修复在lombok.config里加lombok.addLombokGeneratedAnnotation true重新跑覆盖率。JaCoCo 从 0.8.0 之后会识别lombok.Generated并跳过这些方法覆盖率立刻回到正常水平。这个坑还有个变体如果项目里同时用了别的代码生成工具比如某些 Mapper 生成器它们的产物同样会被计入分母需要各自找对应的排除配置。我的建议是引入任何代码生成工具时就同步处理覆盖率排除别等到季度考核前才发现数字难看。7. 手写还是交给注解record、显式代码与团队规范7.1 record 能替代 Data 的部分Java 14 之后有了record它天生就是不可变数据载体自动生成访问器、equals、hashCode、toStringpublic record OrderVO(Long id, String orderNo, BigDecimal amount) {}跟Data相比差异挺明确对比项Datarecord可变性默认可变有 setter天生不可变无 setter继承支持继承不能继承其他类无参构造视情况可能没有永远没有字段扩展随时加字段声明即定改构造就破坏兼容序列化框架支持成熟需要框架版本支持record在纯内部传输、方法返回值、值对象这类场景挺好用特别是它天然不可变直接从设计上避免了 3.3 那类哈希漂移问题。但它在 Web 请求体上不太合适——没有无参构造Jackson 需要额外配置或框架版本支持字段一多构造函数参数列表又长改一次就得动所有调用点。我现在的取舍是内部服务之间传值用record需要跟外部框架来回序列化的用Data或显式的Getter/Setter。混着用完全没问题关键是每个类都想清楚它的可变性需求而不是见到数据类就无脑挂Data。7.2 什么情况下我会手写 getter/setter有三种情况我宁愿多敲几行。第一种setter 里需要校验或归一化。比如金额必须setScale(2)手机号要去空格。手写之后 Lombok 就不会生成同名方法语义清楚public void setAmount(BigDecimal amount) { this.amount amount null ? null : amount.setScale(2, RoundingMode.HALF_UP); }第二种getter 需要返回防御性副本。集合、日期、byte[]这些字段直接返回引用会带来外部篡改风险。手写返回不可变副本比事后排查谁把这个 List 改了要省事得多。第三种这个类是对外契约的核心模型。API 返回体、SDK 里的模型类我倾向显式声明所有访问器。原因是这些类的字段名和签名一旦发布就很难改显式写出来能让代码评审看到全貌也避免某天有人在类上加个注解、或者换个配置导致序列化结果悄悄变化。反过来说内部用的、生命周期短的、只在一两个方法里传来传去的临时对象挂Data完全没问题。分清这两类比纠结Lombok 好不好要有意义得多。7.3 团队里怎么把这条线划清楚工具本身没有对错失控的用法才有。我在几个项目里落过一套比较简单的约定执行成本不高DTO / VO / 请求响应模型允许Data但必须显式加NoArgsConstructor和AllArgsConstructor配合 Builder 时敏感字段必须ToString.Exclude。持久层实体禁止裸挂Data。只允许Getter/Setterequals/hashCode要么不生成要么按主键显式声明。Service / 组件类不挂Data用RequiredArgsConstructor做构造注入。值对象 / 需要在 Map 里当 key 的对象优先用record或不挂Data的不可变类。约定靠什么落地靠配置不靠自觉。lombok.data.flagUsage ERROR可以按模块生效把实体模块和契约模块设成禁止使用编译期就拦住了。另外在 CI 里可以加一步delombok加 grep检查生成的实体类里有没有出现包含集合字段的hashCode。这些检查看着土但比事后开会强调有效得多。最后分享一个我自己一直在用的排查习惯遇到任何跟对象行为相关的诡异问题——不相等、找不到、打印不出来——第一步先跑 delombok 把注解展开。很多问题只要看见展开后的真实代码答案就自己浮出来了。Data的便利性和风险都来自同一件事它把你没写出来的代码替你写好了而读代码的人只能看见你没写的那部分。养成展开看一眼的习惯这个注解就从隐患变成了真正好用的工具。
返回列表