MyBatis-Plus lambda cache 报错与 Mockito 单测
改一个 Service 的分支逻辑顺手补个纯 Mockito 单测结果测试还没跑到断言就红了can not find lambda cache for this entity [com.xxx.order.entity.Order]。第一反应是缓存没清于是清缓存、重启 IDE、mvn clean全套动作做了一遍问题纹丝不动。后来把堆栈从头读到尾才发现这个异常从字面上就在误导人——它压根不是缓存失效而是根本没人往缓存里写过东西。MyBatis-Plus 的 lambda 列名解析依赖一张启动期注册的静态映射表这张表由 Mapper 扫描过程填充而纯 Mockito 单测里 Mapper 是动态代理假对象扫描链路整条被绕开了表是空的于是包装器一构造就炸。这篇就把这条链路从头拆一遍异常到底在哪一行抛出、lambda cache 是谁在什么时候写进去的、Mock 之后断在哪一环、三种修法各自适合什么场景以及我在真机上踩过的那几个坑。如果你也卡在 MyBatisPlus 的 lambda 包装器和单元测试之间尤其是团队里同时在推 Mockito 单测和 MyBatis-Plus 重构的这篇应该能直接省你半天。1. 报错现场还原为什么单测里 Wrapper 一构造就炸1.1 一段最小复现代码先给一个能百分之百复现的最小样例实体、Service、测试三件套。实体用最常见的驼峰字段加下划线表名Data TableName(t_order) public class Order { TableId(type IdType.AUTO) private Long id; private String orderNo; private Integer status; private LocalDateTime createTime; }Service 里用 lambda 包装器拼条件这也是现在绝大多数项目的标准写法Service RequiredArgsConstructor public class OrderService { private final OrderMapper orderMapper; public ListOrder listPaid(String orderNo) { LambdaQueryWrapperOrder wrapper Wrappers.OrderlambdaQuery() .eq(Order::getOrderNo, orderNo) .eq(Order::getStatus, 1); return orderMapper.selectList(wrapper); } }测试就是一个干净的 Mockito 单测不启 Spring不连库不加载任何 XMLExtendWith(MockitoExtension.class) class OrderServiceTest { Mock private OrderMapper orderMapper; InjectMocks private OrderService orderService; Test void listPaid_shouldFilterByStatus() { when(orderMapper.selectList(any())).thenReturn(Collections.emptyList()); orderService.listPaid(NO-20240101); } }跑起来的结果是when(...)打桩正常orderService.listPaid(...)一进去就抛异常。注意一个关键细节——异常不是在selectList里抛的而是在拼包装器的那一行抛的。也就是说代码连 Mapper 都没碰到就挂了。这一点非常容易被忽略很多人在 Mapper 上找半天方向从一开始就错了。1.2 那一长串堆栈里真正值得看的三行把堆栈往下翻会发现真正有信息量的就三行调用at com.baomidou.mybatisplus.core.toolkit.LambdaUtils.getColumnMap(LambdaUtils.java:xxx) at com.baomidou.mybatisplus.core.conditions.AbstractLambdaWrapper.getColumn(AbstractLambdaWrapper.java:xxx) at com.baomidou.mybatisplus.core.conditions.AbstractWrapper.eq(AbstractWrapper.java:xxx) at com.xxx.order.service.OrderService.listPaid(OrderService.java:xx)第一行是被抛出的位置第二行说明我要把 lambda 转成列名但拿不到这个实体的列缓存第三行是我们调用的eq。而整个堆栈里没有org.apache.ibatis没有SqlSession没有DataSource——这恰好是最有价值的线索问题出在 MyBatis-Plus 的条件构造层而不是真正执行 SQL 的那一层。另外提一句异常类型在不同版本里可能是MybatisPlusException也可能是IllegalArgumentException或者IllegalStateException取决于这个版本里Assert.notNull的实现抛的是什么。但 message 完全一致都是can not find lambda cache for this entity [...]。所以别按异常类型去搜直接搜 message 更靠谱。1.3 线上跑得好好的为什么单测就翻车同一个 Service扔进SpringBootTest里跑得好好的扔进纯 Mockito 单测就炸。这个反差才是问题的核心。原因在于 MyBatis-Plus 的 lambda 列名映射表是在应用启动阶段扫描 Mapper 的时候一次性写入的。启动流程走完表里已经有Order - {orderNo: order_no, status: status, ...}这样的映射之后你无论在哪儿Wrappers.lambdaQuery().eq(Order::getOrderNo, ...)都只是查表取值稳定得很。而纯 Mockito 单测里OrderMapper是 Mockito 用字节码生成的一个假实现它没有被注册进任何Configuration也就没有触发扫描 Mapper 顺带注册实体元数据这一步。Spring 没起来Mapper 没被扫表就是空的。你在一个空表上查列名自然会拿到那个异常。提示判断标准很简单——只要你的单测没有启动 Spring 上下文也没有手动初始化过 MyBatis-Plus 的Configuration那么任何Wrappers.lambdaQuery()、Wrappers.lambdaUpdate()对实体字段的引用都会踩这个坑。2. 顺着 LambdaUtils 往下挖lambda cache 到底是谁写的2.1 从 SFunction 到列名中间隔着两次查表要讲清楚这个报错得先知道Order::getOrderNo这个方法引用是怎么变成order_no的。MyBatis-Plus 的SFunction接口继承了Serializable编译器会为 lambda 生成一个writeReplace方法运行时通过它拿到java.lang.invoke.SerializedLambda从中读取getImplMethodName()——也就是getOrderNo。剥掉get前缀再首字母小写得到属性名orderNo。但这只是第一步。属性名到列名之间还有一次查表因为一个实体的列名可能被TableField(order_no)显式改写也可能受全局下划线策略影响。所以AbstractLambdaWrapper#getColumn的实际逻辑是拿到实体 Class先查这个 Class 的列缓存 Map再从 Map 里按属性名取列名。这两步中任何一步取不到值都会抛出异常。这里就产生了两种表现完全不同的报错报错 message卡在哪一步常见原因can not find lambda cache for this entity [xxx]第一步整个 Class 的缓存 Map 都没有实体从未注册也就是本文的场景can not find lambda cache for this property [xxx] of entity [yyy]第二步Map 有但属性不在里面lambda 指向了TableField(exist false)字段、手写非标准 getter、Kotlin 属性名解析异常等看出区别了吧。第一种是这个类我压根不认识第二种是这个类我认识但你说的这个字段我不认识。我遇到的大部分线上问题都是第一种而且九成都发生在测试环境。分清这两句话排查方向能省一大半时间。2.2 TableInfo 的写入时机全在启动阶段列缓存的源头是TableInfo。每个实体类在 MyBatis-Plus 里都对应一个TableInfo对象记录表名、主键、字段列表、字段与列的对应关系、逻辑删除配置等等。这个对象一旦构建完成就会被TableInfoHelper放进一张以类全限定名为 key 的静态缓存里供后续所有包装器查询。那它是什么时候构建的答案是 MyBatis-Plus 接管了 MyBatis 的 Mapper 解析流程。当Configuration.addMapper(...)被调用时MyBatis-Plus 用自己的MybatisMapperAnnotationBuilder解析这个 Mapper 接口解析过程中会解析接口上的泛型参数取出它操作的那个实体类然后调用TableInfoHelper.initTableInfo(builderAssistant, entityClass)完成注册。整个过程发生在应用启动、Mapper 被扫描的那一刻。这条链路带来两个很重要的推论注册是声明式、顺带完成的。你从来没写过一行注册代码是因为启动流程替你做了。注册是有前提的。得有人调用addMapper还得有一个Configuration和一个MapperBuilderAssistant。少了任何一环注册就不会发生。顺带说正因为它是静态缓存所以在同一个 JVM 里跑多个测试类时缓存会跨测试类残留。这个特性后面会变成一个隐蔽的坑。2.3 Mock 之后链路到底断在了哪一环把上面两条串起来看就很清楚了。正常启动时链路是MybatisSqlSessionFactoryBean构建MybatisConfiguration→ 扫描MapperScan指定的包 → 对每个 Mapper 接口调用configuration.addMapper(...)→MybatisMapperAnnotationBuilder.parse()→TableInfoHelper.initTableInfo(assistant, Order.class)→ 缓存写入。而纯 Mockito 单测里OrderMapper由 Mockito 动态生成MybatisConfiguration根本不存在addMapper没有被调用initTableInfo没有被执行。断点就在最开头——MybatisConfiguration压根没建。还有一种更容易让人误判的情况有同事说我也用了SpringBootTest啊但我把 Mapper 用MockBean替换掉了为什么没事因为在MockBean的场景下Spring 上下文依然完整启动了一遍MybatisConfiguration照样构建Mapper 照样被扫描注册MockBean只是把容器里最终的 Bean 换成了假对象。注册这一步已经完成了所以 lambda cache 是满的。注意区分上下文是否启动和Bean 是否是真对象是判断这类问题的分水岭。Mock配合ExtendWith(MockitoExtension.class)不启动上下文MockBean启动上下文。这一字之差决定了你有没有 lambda cache。3. 三种修法的取舍从手工灌缓存到真启动一次3.1 方案 A手工调用 initTableInfo最轻量既然是注册这一步没做那就手动把它补上。这是改动面最小、执行最快的方案适合只想让单测跑起来的场景import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.config.GlobalConfig; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils; import org.apache.ibatis.builder.MapperBuilderAssistant; public final class MpTableInfoInitializer { private MpTableInfoInitializer() { } public static void init(Class?... entityClasses) { MybatisConfiguration configuration new MybatisConfiguration(); GlobalConfig globalConfig new GlobalConfig(); globalConfig.setBanner(false); GlobalConfigUtils.setGlobalConfig(configuration, globalConfig); MapperBuilderAssistant assistant new MapperBuilderAssistant(configuration, ); assistant.setCurrentNamespace(mp.test.initializer); for (Class? entityClass : entityClasses) { TableInfoHelper.initTableInfo(assistant, entityClass); } } }然后在测试里BeforeAll static void initMpMetadata() { MpTableInfoInitializer.init(Order.class); }为什么这样能修好因为initTableInfo干的事情就是构建TableInfo并写入缓存和启动时扫描 Mapper 触发的那一步完全等价。差别只在于启动时是顺带触发的这里是我们主动调用的。有两点必须留意。第一MapperBuilderAssistant的currentNamespace最好显式设一下留空在某些版本里会让builderAssistant.getConfiguration()相关的默认值拼装出现意外行为。第二initTableInfo的重载签名在不同版本里不一样3.5.x 之后多了带existTable参数的重载如果你用的是较新版本且编译不通过去看一眼自己依赖里的源码别照抄博客。稍微绕一点但更省事的变体是直接让 MyBatis 去扫一次 Mapper 接口MybatisConfiguration configuration new MybatisConfiguration(); GlobalConfigUtils.setGlobalConfig(configuration, new GlobalConfig()); configuration.addMapper(OrderMapper.class);addMapper内部会走完整的解析流程顺带把实体注册了。好处是不用一个个列实体类坏处是如果 Mapper 上写了Select这类注解 SQL解析过程会一并处理遇到语法或者占位符问题可能抛出别的异常反而干扰判断。纯 Mockito 场景下两种都行看你好维护哪一种。3.2 方案 B测试里搭一个真的 SqlSessionFactory如果你希望测试环境下的元数据和线上完全一致最稳妥的做法是别手工拼GlobalConfig而是复用生产的那份配置。核心思路是在测试里构建一个MybatisSqlSessionFactoryBean指向同一套mapper-locations和configuration配置项数据源可以用内存库顶上也可以只做元数据注册而完全不执行 SQL。这么做最大的价值是消除配置漂移。手工new GlobalConfig()用的是默认值如果你的生产配置里改过db-config下的下划线开关、主键生成策略、逻辑删除字段名这些测试环境下的列名映射就会和生产不一致。平时看不出问题一旦你的单测里有断言 SQL 片段或者参数值的代码立刻就会对不上。我通常的取舍是只想验证 Service 分支逻辑的用方案 A快需要验证包装器拼出来的条件确实正确的用方案 B 或者干脆走集成测试稳。3.3 方案 C接受它是个集成测试用内存库跑还有一种思路是承认包装器到 SQL 的转换本来就不是单元测试该覆盖的范围。它在语义上依赖框架的元数据装配你把它硬塞进不启动上下文的单测里等于是在测框架而不是测自己的代码。如果项目里引入了mybatis-plus-boot-starter-test它提供的MybatisPlusTest注解可以直接拉起一个只加载 MyBatis-Plus 相关 Bean 的轻量上下文配合内存库就能跑。相比完整的SpringBootTest它启动更快而且顺带把分页插件之类的拦截器也装上了。说到分页这里必须提醒一句网上很热的MyBatis-Plus 分页失效话题有一部分探针就指向测试环境。纯 Mockito 单测里分页拦截器根本没被注册Page对象返回的total、pages永远是默认值你在这种测试里断言分页结果等于什么都没测。要验证分页就必须走带拦截器的集成测试路线。3.4 三种方案怎么选方案改动成本覆盖范围适合场景主要风险A 手工 initTableInfo最低加一个工具类仅让 lambda 能解析出列名Service 分支逻辑单测配置漂移列名可能和生产不一致B 测试内建 SqlSessionFactory中等元数据与生产对齐需要断言条件拼装结果配置复杂度上升需要维护测试配置C 轻量上下文 内存库中等偏高含拦截器、SQL 执行全链路分页、复杂条件、自定义 SQL启动变慢不再是纯单元测试我的实际选择通常是这样日常的 Service 单测固定用方案 A但会把init调用收进一个基类里遇到包装器相关的断言需求切到方案 C不去为难方案 A。4. 我在实际修这个问题时踩过的坑4.1 GlobalConfig 没设列名和生产悄悄错开了第一次写工具类时我图省事直接new MapperBuilderAssistant(new MybatisConfiguration(), )就调initTableInfo跑通了很高兴。结果两天后有个测试开始莫名其妙地失败断言 SQL 片段里期待的是order_no实际拿到的是orderNo。排查半天才想起来我们的生产配置里关掉了下划线转换字段名和列名靠TableField显式声明来对应。手工 new 出来的GlobalConfig用的是框架默认值下划线转换是开的于是同一个实体在测试环境解析出来的列名和生产环境不是一回事。教训很直接只要你的测试里出现对列名、SQL 片段的断言就必须让测试环境的GlobalConfig.DbConfig和生产保持一致。要么显式设置要么直接复用生产的配置对象。4.2 静态缓存跨测试类污染出现了顺序依赖这张缓存是静态的同一个 JVM 内跨测试类共享。于是就出现了这样的现象OrderServiceTest单独跑必挂mvn test全量跑却全绿或者反过来调整一下测试类执行顺序原本绿的测试开始红。本质上这是测试之间的隐式耦合——A 测试类初始化了Order的元数据B 测试类蹭到了这份缓存于是 B 看起来不需要初始化。哪天 A 被删掉或者被跳过B 立刻暴雷。处理办法是给每个需要元数据的测试类都显式初始化别指望别人给你铺路。如果依赖的版本里提供了清理缓存的方法不同版本 API 不一样用之前先翻源码确认可以在BeforeEach里清一次再初始化保证每个用例都从干净状态出发——注意清完必须重新注册否则你会看到一个更诡异的报错。4.3 多数据源场景下只注册了一半项目用了多数据源主库和从库各有一套Configuration。我一开始只在主配置里补了注册从库那套的实体启动时报同样的错但只在从库相关的测试里出现。根本原因是元数据是按Configuration维度各自维护的你给哪个Configuration注册了哪个才有。多数据源场景下每个数据源对应的那套配置都得单独初始化不能用一把梭的写法。4.4 属性级报错不是缓存没写是字段本来就不该在里面讲一个容易混淆的变体。有一次报的是can not find lambda cache for this property [extInfo] of entity [Order]看到 entity 后面带了方括号里的类名我就以为是同一个问题继续按没注册的思路查查了两小时毫无进展。后来才反应过来这句话的结构不一样它说的是 property 找不到不是 entity 找不到。也就是说Order的缓存是有的只是extInfo这个属性不在里面。翻了一下实体定义这个字段标了TableField(exist false)——它本来就不是数据库字段天然不会进入列名映射。这种字段在包装器里被引用就是设计层面的错误用法。所以记住报 entity 找不到查注册报 property 找不到查这个字段是不是真的持久化字段或者 getter 是不是手写的非标准命名。4.5 版本差异别照抄任何一篇博客最后说一个元坑。MyBatis-Plus 在 3.4.x 到 3.5.x 之间重构了 lambda 解析部分早期LambdaUtils.extract直接返回SerializedLambda后期引入了LambdaMeta抽象还区分了反射实现和影子实现TableInfoHelper.initTableInfo的参数列表也变过。再加上mybatis-plus和mybatis-plus-boot-starter两个坐标混用时可能出现版本不一致你复制网上的修复代码编译不过或者编译过了行为不对都很正常。提示遇到这类问题最省时间的动作是打开 IDE按住 Ctrl 点进LambdaUtils和TableInfoHelper的源码看清楚你这个版本的方法签名和抛异常的判断条件。这一步花三分钟能省掉后面三小时的试错。5. 更稳的写法让单测不再依赖 lambda cache5.1 把包装器构造从被测逻辑里剥出来从设计角度讲让纯 Mockito 单测去构造 lambda 包装器本身就是在给测试引入框架依赖。更干净的思路是把这个构造动作抽出去public interface OrderWrapperFactory { LambdaQueryWrapperOrder paidBy(String orderNo); }Service 依赖这个接口单测里 Mock 掉工厂返回一个预置的包装器Service 里剩下的就是纯粹的分支逻辑跟 MyBatis-Plus 一点关系都没有跑得飞快也永远不会因为这个错挂掉。工厂本身用集成测试去覆盖各管一段。另一种极端做法是单测里改用字符串列的QueryWrapperQueryWrapperOrder wrapper new QueryWrapperOrder() .eq(order_no, orderNo) .eq(status, 1);这种写法不需要查列名映射所以纯单测里完全能用。代价是丢掉了编译期检查列名拼错要等到集成测试甚至上线才发现我不太推荐在业务代码里这么写但在测试辅助代码里偶尔用一下是可以接受的。5.2 用 ArgumentCaptor 断言条件内容而不是断言条件类型既然测试已经跑起来了顺手把断言写扎实一点。常见的坏写法是verify(orderMapper).selectList(any(LambdaQueryWrapper.class));这只验证了调过一次、参数类型对条件对不对完全没覆盖。改成抓取参数再检查内容ArgumentCaptorWrapperOrder captor ArgumentCaptor.forClass(Wrapper.class); verify(orderMapper).selectList(captor.capture()); WrapperOrder wrapper captor.getValue(); assertThat(wrapper.getSqlSegment()).contains(order_no); assertThat(wrapper.getParamNameValuePairs()).containsValue(NO-20240101); assertThat(wrapper.getParamNameValuePairs()).containsValue(1);这里有个细节值得注意getSqlSegment()出来的不是最终 SQL参数位置是占位符类似#{ew.paramNameValuePairs.MPGENVAL1}真正的值在getParamNameValuePairs()里。所以断言要分两处做一个查列名片段一个查参数值。只查前者条件写错了值你也发现不了只查后者列名拼错了你也发现不了。参数类型上建议用Wrapper接口而不是具体实现类避免哪天内部换个包装器实现就把测试搞挂。5.3 用 JUnit 5 扩展统一初始化如果决定走方案 A最好别在每个测试类里写BeforeAll而是收进一个扩展通过ExtendWith挂上去public class MpMetadataExtension implements BeforeAllCallback { Override public void beforeAll(ExtensionContext context) { MpTableInfoInitializer.init( Order.class, OrderItem.class, Payment.class ); } }用的时候ExtendWith({MockitoExtension.class, MpMetadataExtension.class}) class OrderServiceTest { // ... }这么做的好处是初始化清单集中在一处新加实体只需要改一个地方。缺点是这份清单会随着项目膨胀需要有人维护如果实体数量很多可以考虑按测试类声明需要的实体而不是全局一把梭。5.4 别忘了断言之外的正确性我见过不少修复案例测试绿了但其实是假的绿。典型的两种一是只跑了listPaid的返回值为空集合的分支压根没走到条件拼装那一步那么你补的初始化到底奏没奏效根本没被验证二是把when(orderMapper.selectList(any()))的桩打得太宽任何参数都返回同一个结果条件写错了测试照样过。验证修复是否真的生效最直接的办法是先故意不调初始化确认测试确实报can not find lambda cache加上初始化后再跑确认异常消失且断言全部通过。一红一绿才能证明你改的是对的地方。6. 修完之后怎么确认真的修好了6.1 三个必须验证的点第一单独执行这个测试类能过而不是依赖全量跑时其他测试类留下的缓存。在 IDE 里右键当前类单独 Run 一次这是最容易被忽略也最容易暴露问题的验证方式。第二干净构建下能过。mvn clean test或者等价命令跑一遍确认不是增量编译残留的 class 文件在起作用。第三配置一致性。如果你的断言里出现了具体列名或者参数值确认测试环境解析出来的列名和生产一致尤其是改过下划线策略、用了TableField显式声明列名、配置了表前缀的项目。6.2 一张速查表现象大概率原因处理方向单测报 entity 找不到缓存未注册元数据补initTableInfo或启动轻量上下文单测报 property 找不到缓存字段非持久化或 getter 非标准检查TableField(exist false)与 getter 命名单独跑挂、全量跑过静态缓存跨类残留每个测试类显式初始化断言到的列名与生产不符GlobalConfig默认值覆盖了生产配置复用生产配置对象多数据源下部分测试挂只有部分Configuration被注册每个数据源配置分别初始化分页断言永远为默认值拦截器未注册改用带上下文的集成测试这套排查逻辑我用了一年多基本上看到 message 就能定位到类别再按表往下走很少超过十分钟。真正花时间的从来不是修而是被那句can not find lambda cache带着往缓存方向跑偏。我现在遇到类似报错的第一个动作是先把谁负责写这份数据、什么时候写问清楚再去怀疑数据本身——这个习惯在我处理过的框架类问题里命中率高得吓人。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →