
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/setterBuilder提供链式构建既简洁又“优雅”。我甚至还在心里夸了一句这实体类写得真干净。然后就是正常写 Mapper继承BaseMapperUser在 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(ClassT type) { return create(type, null, null); } public T T create(ClassT type, ListClass? constructorArgTypes, ListObject constructorArgs) { // 如果指定了构造参数就找对应的构造器否则调用无参构造 ... return instantiateClass(type, constructorArgTypes, constructorArgs); }当 MyBatis 做普通的结果映射时既没有指定 constructorArgTypes 也没有指定 constructorArgs就会走到无参构造这条路private T T instantiateClass(ClassT type, ListClass? constructorArgTypes, ListObject constructorArgs) { ConstructorT 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 编译报错或者行为诡异。原因在于如果不加AllArgsConstructorBuilder内部仍然会生成它自己的全参构造器但这个构造器是包私有的如果项目里其他类不在同一个包下直接通过 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子类不再用BuilderData 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.24Builder生成的构造器是包私有的升级到 1.18.30 之后Lombok 对Builder的生成逻辑有过调整Builder默认不再生成全参构造器了不对这个说法不准确。实际上Builder一直会生成一个名为UserBuilder的静态内部类并在宿主类中生成一个全参构造器但不同版本对这个构造器的访问修饰符、是否需要显式AllArgsConstructor的处理有细微差异。为了避免团队里“我本地能跑你本地跑不了”的问题最好把 Lombok 版本固定在父 POM 或 Gradle 的版本目录里不要让人随便升。而且建议开启编译期的 annotation processor 检查这样在 IDE 里就能看到 Lombok 生成的方法。5.3 在编译期就把这类问题拦下的几种手段等运行时报错再排查成本已经高了。团队里更合理的做法是把“实体类必须有 NoArgsConstructor”这件事做成硬约束。最轻量的办法写一个简单的单元测试用反射扫描所有继承了Model或标注了TableName的类断言它们存在无参构造。比如Test void allTableEntityShouldHaveNoArgsConstructor() { ListClass? 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 类名看一眼构造器列表比翻半天文档都快。