1. 一个“教科书级”的实体类,是如何在分页查询时突然崩掉的
1.1 我那个干净漂亮的实体类
先交代背景。项目是 Spring Boot 3 + MyBatis Plus 3.5.x + Lombok,老组合了,平时写实体类基本都是复制粘贴。当时我建了一个用户表对应的实体类,长这样:
@Data @Builder public class User { private Long id; private String userName; private Integer age; private String email; private LocalDateTime createTime; }代码看起来没有任何问题,@Data提供 getter/setter,@Builder提供链式构建,既简洁又“优雅”。我甚至还在心里夸了一句:这实体类写得真干净。
然后就是正常写 Mapper,继承BaseMapper<User>,在 Service 里调userMapper.selectPage(page, queryWrapper)做分页查询。结果程序一跑起来,页面直接 500。控制台刷了一大串异常,最刺眼的是这一行:
org.apache.ibatis.executor.ExecutorException: Error instantiating class com.example.demo.entity.User with invalid types () or values (). Cause: java.lang.NoSuchMethodException: com.example.demo.entity.User.<init>()当时我第一反应是:又是哪个字段类型写错了?还是 MyBatis Plus 和 Java 17 的反射权限出问题了?排查了半天,把@Data删掉、手动手写 getter/setter,居然就好了;再加上@Builder,又崩了。问题定位到@Builder头上,但为什么会这样,当时根本想不通。
1.2 崩溃现场的完整异常链
如果只看最外层的错误,很容易被带偏。完整异常栈其实已经给出了答案,但需要耐心往下翻。我这里整理一下核心链路:
org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.executor.ExecutorException: Error instantiating class com.example.demo.entity.User with invalid types () or values (). Cause: java.lang.NoSuchMethodException: com.example.demo.entity.User.<init>()这里的关键词是NoSuchMethodException: User.<init>(),翻译成人话就是:MyBatis 尝试调用 User 类的无参构造方法,但这个方法根本不存在。
要知道,MyBatis 在做结果集映射时,默认会先通过无参构造函数创建一个实体对象,然后挨个给属性赋值。这个过程发生在数据库查询返回之后、把ResultSet转成 Java 对象的那一步。如果没有无参构造器,后面所有赋值逻辑都没办法开始,只能直接抛异常。
我当时还试了selectById、selectList,只要涉及把查询结果映射成实体,全部会挂。不是分页特有的问题,是@Builder给实体类带来的“通病”。
2. 追到 MyBatis 源码里:它为什么死磕无参构造
2.1 ObjectFactory 与 instantiateClass 的隐式约定
为了搞清楚 MyBatis 到底是怎么创建对象的,我翻了一下 MyBatis 的源码。核心在org.apache.ibatis.reflection.factory.DefaultObjectFactory,这个类负责所有结果对象的实例化,它有两个关键方法:
public <T> T create(Class<T> type) { return create(type, null, null); } public <T> T create(Class<T> type, List<Class<?>> constructorArgTypes, List<Object> constructorArgs) { // 如果指定了构造参数,就找对应的构造器;否则调用无参构造 ... return instantiateClass(type, constructorArgTypes, constructorArgs); }当 MyBatis 做普通的结果映射时,既没有指定 constructorArgTypes 也没有指定 constructorArgs,就会走到无参构造这条路:
private <T> T instantiateClass(Class<T> type, List<Class<?>> constructorArgTypes, List<Object> constructorArgs) { Constructor<T> constructor; if (constructorArgTypes == null || constructorArgTypes.isEmpty()) { constructor = type.getDeclaredConstructor(); ... } ... }type.getDeclaredConstructor()本质就是获取无参构造函数。Java 反射里,如果你没有显式声明任何构造器,编译器会自动给你生成一个默认无参构造器;但一旦你声明了其他构造器,默认的无参构造器就没了。而@Builder会强制给类生成一个包私有的全参构造器,导致原来的默认无参构造器被覆盖掉,所以反射时自然找不到。
2.2 MyBatis Plus 对实体映射的额外要求
MyBatis Plus 本身是基于 MyBatis 做的增强,所以底层实例化机制完全继承了 MyBatis 的这一约束。但 MyBatis Plus 里还有不少场景会额外依赖这个隐式约定,比如:
selectPage分页之后,对每一条记录做POJO映射;selectMaps虽然返回 Map,但如果有resultType指向某个实体,依然需要实例化;updateById传入实体时,虽然不需要实例化,但在SqlHelper里可能会用到实体的 class 做反射校验;- 自动填充
MetaObjectHandler要拿到实体对象做字段赋值,也依赖对象已经成功创建。
所以 MyBatis Plus 对无参构造的要求,比纯 MyBatis 更普遍。一旦实体类没有无参构造,基本宣告这个实体和 MyBatis Plus 绝缘了。
2.3 @Builder 到底生成了什么代码:javap 看真相
与其猜,不如直接看编译后的字节码。在 IDE 的 target/classes 目录下找到User.class,执行:
javap -p com.example.demo.entity.User如果只加了@Data和@Builder,输出会是这样:
public class com.example.demo.entity.User { private java.lang.Long id; private java.lang.String userName; private java.lang.Integer age; private java.lang.String email; private java.time.LocalDateTime createTime; public com.example.demo.entity.User(); // @Data 默认生成的,不,这个确实有! ... }等等,这里有个关键细节:当@Data和@Builder同时存在时,Lombok 会怎么处理构造函数?
实际上,@Builder生成的构造器是包私有全参构造器,但它不会主动移除默认无参构造器。如果类里没有显式写任何构造器,Java 编译器会生成默认无参构造器,@Data也不会管这个。按道理来说,应该存在无参构造才对。可为什么实际运行时说没有?
这里我最初也被绕晕了。后来仔细看了 Lombok 的文档和源码才发现:@Builder在类上标注时,会生成一个包私有全参构造器,这个构造器会让 Java 编译器不再生成默认无参构造器。也就是说,虽然默认无参构造器不是 Lombok“删除”的,而是 Java 语言本身的规则:只要类里出现了任何显式构造器,默认无参构造器就消失。@Builder在编译阶段往类里注入了一个全参构造器,所以最终字节码里只有那个包私有全参构造器,没有无参构造器。
用javap再看一眼,会发现构造器列表只有:
com.example.demo.entity.User(java.lang.Long, java.lang.String, java.lang.Integer, java.lang.String, java.time.LocalDateTime);没有public User()。于是 MyBatis 反射调用getDeclaredConstructor()时直接NoSuchMethodException。
3. 修复方案:两个注解补上,世界立刻清净
3.1 标准四件套:@Data + @Builder + @NoArgsConstructor + @AllArgsConstructor
修复非常简单,在原来的实体类上补两个注解:
@Data @Builder @NoArgsConstructor @AllArgsConstructor public class User { private Long id; private String userName; private Integer age; private String email; private LocalDateTime createTime; }@NoArgsConstructor让 Lombok 生成一个 public 无参构造器,解决 MyBatis 反射实例化的问题;@AllArgsConstructor生成 public 全参构造器,保存@Builder生成全参构造器的“能力”,两者配合形成闭环。
改完重启服务,分页查询立刻恢复正常。当时我一度怀疑是不是 Lombok 版本的问题,后来发现就是差这两个注解,真的无语。
3.2 为什么不能只加 @NoArgsConstructor
有同学可能会问:既然 MyBatis 只需要无参构造,那我只加@NoArgsConstructor不就行了?
其实单独加@NoArgsConstructor,程序确实能跑。但接下来会踩另一个坑:当你在其他地方用new User(...)或User.builder().build()时,会发现 Lombok 编译报错或者行为诡异。原因在于,如果不加@AllArgsConstructor,@Builder内部仍然会生成它自己的全参构造器,但这个构造器是包私有的;如果项目里其他类不在同一个包下,直接通过 builder 构建是没问题的,因为 builder 内部调用的是同一个包内的构造器。可是如果哪天你想显式调用全参构造器,或者在继承场景下使用super(...),就可能出现“构造器不可见”的问题。
更常见的坑是:你只加@NoArgsConstructor,但漏了@AllArgsConstructor,然后在某个需要全参构造的地方用了@RequiredArgsConstructor或者手写了构造器,导致构造器数量混乱。所以保守的团队约定是:用了@Builder,就同时加上@NoArgsConstructor和@AllArgsConstructor,不要省。
顺便提供一个验证方法,写完注解后,再执行一次javap -p,能看到这三个构造器:
public com.example.demo.entity.User(); public com.example.demo.entity.User(java.lang.Long, java.lang.String, java.lang.Integer, java.lang.String, java.time.LocalDateTime);才是真正安全的状态。
3.3 参数顺序与 @AllArgsConstructor 的连带隐患
补上@AllArgsConstructor之后,还有一个很容易被忽略的细节:全参构造器的参数顺序完全取决于实体类里字段的声明顺序。
假设你有两个字段,声明顺序是:
private Long id; private String userName;那么@AllArgsConstructor生成的构造器就是(Long, String)。如果哪天你为了排版,把字段顺序调整成:
private String userName; private Long id;那全参构造器就变成(String, Long)。如果项目里有代码直接new User(userId, name),编译不会报错,因为Long和String类型不同,编译器能帮你发现;但如果两个相邻字段类型一样,比如Long id和Long parentId,顺序一换,编译器根本发现不了,运行时会拿到完全错乱的字段值。
这类问题在加了@Builder之后反而少了,因为大家都是用userName("xxx").id(1L)这种链式赋值,不用关心构造器参数顺序。但万一项目里有人用了new User(...)或反射调用全参构造器,就很容易翻车。所以我的建议是:实体类的字段顺序一旦定了,就不要随意调整;如果非要调整,全局搜一下new 实体类(的调用点。
4. 继承场景下的另一个深坑:Builder 不认父类字段
4.1 我的 BaseEntity 居然被 Builder 无视了
无参构造的问题解决后,本以为万事大吉,结果没过多久又踩了一个更隐蔽的坑。
项目里有个公共基类BaseEntity,放着所有表都有的 id、createTime、updateTime、deleted 这些审计字段:
@Data public class BaseEntity { private Long id; private LocalDateTime createTime; private LocalDateTime updateTime; private Integer deleted; }然后具体实体继承它:
@Data @Builder @NoArgsConstructor @AllArgsConstructor public class User extends BaseEntity { private String userName; private Integer age; }看到问题了吗?User.builder().id(1L).userName("张三").build()这行代码,id(1L)是编译不过的。因为@Builder在生成 Builder 类时,只会扫描当前类声明的字段,不会扫描父类的字段。也就是说,User.builder()里根本没有id()方法、createTime()方法。
你只能写:
User user = User.builder().userName("张三").age(20).build(); user.setId(1L);这种代码看起来就很割裂:一部分字段靠 Builder,一部分字段靠 Setter。而且如果团队里有人误以为id也能通过 builder 设置,代码就会直接编译失败,也算个好事,起码能立刻发现。
4.2 @SuperBuilder 的正确打开方式
Lombok 从 1.18.2 开始提供了@SuperBuilder,专门解决继承场景下 builder 无法操作父类字段的问题。用法是:父类和子类都标上@SuperBuilder,子类不再用@Builder:
@Data @SuperBuilder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class BaseEntity { private Long id; private LocalDateTime createTime; private LocalDateTime updateTime; private Integer deleted; }@Data @SuperBuilder @NoArgsConstructor @AllArgsConstructor public class User extends BaseEntity { private String userName; private Integer age; }这样User.builder().id(1L).userName("张三").build()就完全合法,父子类字段都能链式赋值。
不过要注意,@SuperBuilder和@Builder不能混用。如果父类用了@SuperBuilder、子类用了@Builder,编译时会报错,提示 Builder 方法冲突。团队里最好统一约定:只要实体类有继承关系,一律用@SuperBuilder,不要用@Builder。
另外,@SuperBuilder的生成逻辑比@Builder复杂,生成的是泛型 Builder 类,所以当你用javap检查时,会看到更多内部类。遇到复杂泛型时,IDE 的自动补全偶尔会有点傻,但整体不影响使用。
4.3 自动填充字段在 Builder 场景下还生效吗
还有朋友问过:用了@Builder或@SuperBuilder创建对象,MyBatis Plus 的自动填充(@TableField(fill = FieldFill.INSERT))还生效吗?
答案是生效的。因为自动填充的机制是在 SQL 执行前,通过MetaObjectHandler拿到实体对象,再直接调用字段的 setter 或通过反射赋值。它不关心这个对象是用new创建的还是用 builder 创建的。
本地测试过一段代码:
public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }用User.builder().build()创建对象,不手动设置 createTime,直接userMapper.insert(user),数据库里的 createTime 照样有值。这个坑可以排除。
但如果你的实体类没有显式提供 setter 方法,自动填充就可能失败。这一点和@Builder无关,和@Data是否生效有关。只要@Data在,setter 就会生成,自动填充就没问题。
5. 团队协作时最容易踩的同类坑清单
5.1 DTO 和服务层里乱用 @Builder
这个问题其实不仅出现在实体类上。很多同事习惯在 Controller 接收参数的 VO / DTO 上也写@Builder。如果只是做参数接收,框架(比如 Spring MVC)通常不需要无参构造;但一旦这个 DTO 被 MyBatis 当查询条件对象用,或者被 JSON 反序列化(Jackson 一般需要无参构造或@JsonCreator),就会出幺蛾子。
Jackson 反序列化时,如果只有@Builder没有无参构造,会抛:
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of ... (no Creators, like default constructor, exist)和 MyBatis 的报错长得完全不一样,但根子是一样的:没有无参构造。所以统一规范:凡是会作为实体、查询参数、JSON 请求体出现的 POJO,都加上@NoArgsConstructor和@AllArgsConstructor,别管当前用不用得到。
5.2 Lombok 版本不一致导致的行为差异
Lombok 版本对@Builder和@Data的行为有影响吗?我遇到过。项目最初用的 Lombok 1.18.24,@Builder生成的构造器是包私有的;升级到 1.18.30 之后,Lombok 对@Builder的生成逻辑有过调整,@Builder默认不再生成全参构造器了?不对,这个说法不准确。实际上@Builder一直会生成一个名为UserBuilder的静态内部类,并在宿主类中生成一个全参构造器,但不同版本对这个构造器的访问修饰符、是否需要显式@AllArgsConstructor的处理有细微差异。
为了避免团队里“我本地能跑,你本地跑不了”的问题,最好把 Lombok 版本固定在父 POM 或 Gradle 的版本目录里,不要让人随便升。而且建议开启编译期的 annotation processor 检查,这样在 IDE 里就能看到 Lombok 生成的方法。
5.3 在编译期就把这类问题拦下的几种手段
等运行时报错再排查,成本已经高了。团队里更合理的做法是把“实体类必须有 @NoArgsConstructor”这件事做成硬约束。
最轻量的办法:写一个简单的单元测试,用反射扫描所有继承了Model或标注了@TableName的类,断言它们存在无参构造。比如:
@Test void allTableEntityShouldHaveNoArgsConstructor() { List<Class<?>> entities = scanEntityClasses(); for (Class<?> clazz : entities) { boolean hasNoArgsConstructor = Arrays.stream(clazz.getDeclaredConstructors()) .anyMatch(c -> c.getParameterCount() == 0); Assert.verify(hasNoArgsConstructor, clazz.getName()); } }更重的办法:用 ArchUnit 写架构规则,禁止实体类只加@Builder不加@NoArgsConstructor。ArchUnit 用起来也不难:
@AnalyzeClasses(packages = "com.example.demo.entity") public class EntityRuleTest { @ArchTest static final ArchRule entities_should_have_no_args_constructor = classes().that().areAnnotatedWith(TableName.class) .should(haveNoArgsConstructor()); }这类测试跑在 CI 上,团队里谁写了不合规的实体类,提交代码时直接红。别看这是小事,能省下来来回回扯皮的功夫。
6. 我踩完坑后留下的几条操作习惯
那次折腾之后,我给自己定了几条硬规矩,现在基本不会再被同类问题坑到。
第一,只要 POJO 上写@Builder,无脑补上@NoArgsConstructor和@AllArgsConstructor。不管它未来会不会被 MyBatis、Jackson、Fastjson 或者其他框架反射实例化,先补上再说。成本只有两行注解,收益是少踩一堆雷。
第二,优先用@SuperBuilder而不是@Builder。尤其是实体类有继承关系的场景,@Builder天生残缺。就算当前实体没有父类,未来加父类时也容易踩坑,不如一开始就统一。
第三,写完实体类之后,习惯性执行mvn compile或让 IDE 的 Lombok 插件跑一遍,再用 javap 看一眼构造器。这一步看着笨,但特别管用,能在本地就把问题挡掉,而不是等测试环境报NoSuchMethodException才来查。
第四,团队里加新人也一定会遇到这个问题。我在项目的 README 里专门写了一段“实体类注解规范”,把@Builder和@NoArgsConstructor的搭配要求、为什么这么配的说明放进去。新人照着写,基本不会再犯。
最后再送一个排查这类问题的小技巧:遇到Error instantiating class或NoSuchMethodException时,先不要急着查 MyBatis 配置,直接跳到异常的 Cause,看它说缺的是哪个方法的<init>()。如果缺的是无参构造,99% 是实体类构造器被 Lombok 或其他注解“动过手脚”了。用javap -p 类名看一眼构造器列表,比翻半天文档都快。