资讯详情

资讯详情

MyBatis-Plus实战指南:BaseMapper、Wrapper、分页与注入器

用了不少年后端开发在数据库访问层打交道最多的还是 MyBatis 和它的衍生品。早期用 MyBatis 基本都是 XML 起步Mapper 里全是手写的 SQL 和 resultMap后来 MyBatis-Plus 出来以后开发效率提升了一个档次。尤其当项目里全是常规单表操作时BaseMapper 的通用能力直接省掉了一大半模板代码。这篇内容我不打算按官方文档翻译一遍而是按我自己在实际项目里的使用路径来梳理从 BaseMapper 的基础操作讲到 Wrapper、插件、注入器这些扩展点顺便把踩过的坑一并讲清楚。先说适合谁看。如果你是刚接触 MyBatis-Plus 的新手这篇内容能帮你把 Mapper 层的使用逻辑理顺如果你已经用过一阵子但只是会 CTRLC/V 那几种写法这篇内容也能帮你找到更省事的模式。文章里的代码都是能直接跑的真代码不是伪代码我尽量把每个关键点都讲透。1. 先搞清楚BaseMapper 到底替你做了什么MyBatis-Plus 的 Mapper 层和原生 MyBatis 最大的区别在于通用 CRUD。你定义一个接口继承 BaseMapperMyBatis-Plus 会自动帮你生成一批常用的 SQL 方法不需要写 XML也不需要写注解。这是第一个要理解的核心这不是简单的代码生成器帮你生成一堆 XML 文件而是在运行期通过动态 SQL 拼接出对应语句。换句话说你的 Mapper 接口里的方法名和泛型类型决定了它能用哪些通用方法。1.1 从最简单的一条 selectById 说起假设有一张用户表实体类叫 UserMapper 接口写成这样public interface UserMapper extends BaseMapperUser { }就这样一个 Mapper 接口就成立可以用了。你没有写任何 SQL也没有配置 XML直接注入 UserMapper 就能调用User user userMapper.selectById(1L);这条语句会生成类似SELECT id,name,email,age FROM user WHERE id?的 SQL。注意MyBatis-Plus 默认会按实体类上的字段映射来生成列如果你在实体类里用了TableField指定列名它也会遵守。有个容易忽略的点selectById 查出来的结果不会执行联表查询也不会自动加载关联对象。很多新手以为它是某种 ORM 的“关联查询”其实不是它就是很纯粹的单表查询。想关联查询要么手写 XML要么用注解写自定义 SQL这些后面讲。1.2 Mapper 接口与 XML 的边界在哪里MyBatis-Plus 并不会禁用 XML 映射你可以混用。定义通用方法用 BaseMapper 提供的就够但一旦涉及多表 JOIN、子查询、批量动态更新成绩通用 Mapper 就不好使了得回到 XML 或注解方式。边界怎么划我的实践经验是所有单表、不带复杂业务条件的读写优先使用 BaseMapper 通用方法。所有返回结构不是实体本身的比如 VO、DTO、统计结果单独写成自定义方法。所有涉及多表关联的明确放到 XML 里用明确命名和返回类型而不是硬塞进通用方法里。这样做的好处很好代码的可读性和维护性都有保障。如果你的项目里全是selectById、selectList、insert这种调用那核心逻辑都在 Service 层Mapper 层非常薄。2. 基础用法CRUD 的新手变形记这一节我按“增删改查”四个维度把常用写法过一遍顺便把 Wrapper 的三种层次讲透。CRUD 本身不难难的是你知道什么时候该用哪种写法以及某些隐藏行为为什么和你预期的不一样。2.1 单表 CRUD 的标准写法先说增。新增主要用 insert 方法User user new User(); user.setName(张三); user.setEmail(zhangsanexample.com); user.setAge(28); int rows userMapper.insert(user);插入成功后如果主键是自增MyBatis-Plus 会自动回填到实体对象的主键字段里。这一点很实用你不需要在插入后再查一次获取自增 ID。如果你用默认的 ASSIGN_ID 策略它会生成一个雪花 ID 并赋值给实体的主键字段同样不需要手动设置。如果你之前使用原生 MyBatis可能已经习惯在 insert 里使用useGeneratedKeystrue并显式指定 keyProperty。MyBatis-Plus 的简单之处在于它把这个过程全部默认处理了。再说删。常见的有三种// 根据主键删除 int rows userMapper.deleteById(1L); // 根据条件删除 LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getId, 1L); userMapper.delete(wrapper); // 批量删除 userMapper.deleteBatchIds(Arrays.asList(1L, 2L, 3L));这里要提醒一下千万注意 delete 和 deleteById 的区别。delete 是允许你传一个 Wrapper 条件里面可能是姓名、邮箱、甚至扫描现有业务条件条件不同结果完全不一样。在实际项目里delete 这类危险操作我都习惯先做一次 count 或 selectList 验证条件再执行删除。改的写法分两种。更新整个实体User user userMapper.selectById(1L); user.setAge(29); userMapper.updateById(user);更新部分字段使用 UpdateWrapperLambdaUpdateWrapperUser updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(User::getId, 1L) .set(User::getAge, 30) .set(User::getEmail, newemailexample.com); userMapper.update(null, updateWrapper);注意 update(null, wrapper) 的第一个参数是实体对象如果你传 nullSQL 里只会用到 UpdateWrapper 中的 set 字段。如果你同时传实体和 UpdateWrapper实体中的非空字段也会被拼接进 SET 子句有时候这不是你想看到的。所以我在项目中有一个约定要么用实体方式更新要么用 UpdateWrapper 方式更新不混用。即使混用也一定是实体内有少量业务公共字段比如更新时间、操作者等再靠 Wrapper 限定 update 的范围。查的操作最常用看下面的综合示例LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1); wrapper.like(StringUtils.isNotBlank(name), User::getName, name); wrapper.orderByDesc(User::getCreateTime); wrapper.last(limit 10); ListUser userList userMapper.selectList(wrapper);这一套写法基本能覆盖绝大多数查询场景了。eq、ne、gt、lt、ge、le、like、likeLeft、likeRight、in、notIn、between、isNull、isNotNull、orderBy、groupBy 都有对应方法不需要自己拼 SQL。2.2 Wrapper 查询的三个层次Wrapper 是 MyBatis-Plus 条件构造器分三层理解你会轻松很多第一个层次是普通的 QueryWrapper使用字符串列名。比如QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(name, 张三); ListUser users userMapper.selectList(wrapper);这种写法有个缺点列名写错的话要等到运行期才知道重构时字段改名也没有编译期检查。所以除非你在写动态表名、或者在特定场景下必须用字符串拼接否则我建议少用它。第二个层次是 LambdaQueryWrapper使用实体方法引用。上面刚刚的例子就是它LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getName, 张三);它不仅能避免列名写错用了 Lambda 表达式还能利用 Java 的编译期类型检查。这个是平时用得最多的。第三个层次是链式查询方式使用 lambdaQuery() 和 lambdaUpdate()。Service 层的IService默认提供了lambdaQuery()方法ListUser users userService.lambdaQuery() .eq(User::getStatus, 1) .like(User::getName, 张) .orderByDesc(User::getCreateTime) .list();这写法和 LambdaQueryWrapper 其实是同一套运算不过链式风格更紧凑。我在简单场景下喜欢用链式复杂场景下还是单独构造 Wrapper因为单测更好写也更清晰。2.3 条件构造器的那些坑Wrapper 用多了有几个坑你大概率会遇到。第一个坑某些查询结果等于 null排查了老半天发现是参数没生效。比如wrapper.eq(StringUtils.isBlank(name), User::getName, name);第二个参数是 boolean 类型的 condition如果为 false那么这整个条件会被忽略。这是 MyBatis-Plus 故意设计的目的是让我们做动态条件。但新手经常忘记这个机制或者前端传了一个空字符串结果条件直接被吃掉了。你要明确一点就算你传了空字符串给 eq如果条件判断是StringUtils.isNotBlank它照样会把name 拼上去。第二个坑selectOne并不总是“一条”。如果你调selectOne时实际满足条件的数据有多条MyBatis-Plus 会直接抛异常异常信息大概是“Expected one result or null but was ...”。有些项目里确实存在业务上只查一条数据库却因为数据脏或并发插入了多条数据的情况。更稳妥的做法是把这类查询改为selectList后判断list.size() 1或者分组后取第一条。如果再讲究一点可以在 Wrapper 里last(limit 1)强行限制查询条数但要明确这是拼接 SQL只能配固定字符串不能拼接外部参数。第三个坑last() 方法可以拼接任意 SQL 片段同时也带来了注入风险。如果你在代码里写成wrapper.last(orderClause)这个 orderClause 是从前端传进来的那就等于把一个 SQL 注入漏洞留给攻击者。我见过几个项目里的排序字段直接由前端传列名然后拼进 last() 里这很危险。建议对所有排序字段做白名单映射前端传 sortuserName后端只映射到固定的列名。3. 分页、逻辑删除、填充与乐观锁日常命中的几个增强插件MyBatis-Plus 之所以好用不只是因为 CRUD 方便更重要的是它的插件机制帮我们解决了一系列数据库层面的横向问题。这一节我把分页、逻辑删除、自动填充、乐观锁四个常用功能串起来讲因为它们几乎在每个业务系统里都会出现。3.1 数据库分页的落地方式MyBatis-Plus 分页依赖拦截器你需要在配置类里注册插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }注意 DbType 要根据你的数据库类型来配MySQL、PostgreSQL、达梦等各不相同。配好之后分页查询变成PageUser page new Page(1, 10); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1); PageUser result userMapper.selectPage(page, wrapper); ListUser records result.getRecords(); long total result.getTotal();核心原理是拦截器在运行时把原始 SQL 拦截下来如果是分页方法就自动生成 count 查询和带 limit 的查询。这里有两点要特别注意一是 count 查询生成。默认情况下它会生成类似SELECT COUNT(*) FROM user WHERE status ?的语句。如果你的表数据量巨大count 会扫描很多行这时候要考虑在 SQL 层面做优化或者用last(limit 1000)限制最大扫描行数不过这样做返回的 total 就不准确了。所以业务上要权衡一下。二是Page对象的 current 是当前页size 是每页大小。如果不设置 current默认是 1但如果是前端传页码一定要做参数校验避免current 0导致异常。3.2 逻辑删除字段的特殊处理逻辑删除现在几乎成了标配不用物理删除好处是便于数据恢复坏处是每次查询都必须带deleted 0条件很容易漏。MyBatis-Plus 的TableLogic注解帮你解决了这个问题。实体字段TableLogic private Integer deleted;然后在配置里指定全局逻辑删除值mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0配置完成后调用deleteById时生成的 SQL 会变成UPDATE user SET deleted 1 WHERE id ? AND deleted 0而 select 查询会自动追加AND deleted 0。这个功能在日常开发中确实省心但有一个问题我特别想强调如果你在 XML 里写自定义 SQLMyBatis-Plus 是不会自动拼接逻辑删除条件的。比如你在 Mapper XML 里写了select idselectCustomUser resultTypeuser select * from user where name #{name} /select那这里的 deleted 条件需要你手动加否则你可能会把已经“删除”的数据也查出来。这是最容易踩的坑之一特别是在老项目里同时存在通用方法和自定义 XML 方法时特别容易“时灵时不灵”。还有一个反向问题有时候业务上确实想查被删除的记录比如后台数据列表要展示所有记录包括已删除的。这个时候你必须在自定义方法里手动写 SQL绕过通用方法。记住通用方法的逻辑删除是强制的想查全量不要抱着侥幸心理去拼接条件绕过那只会让代码更乱。3.3 自动填充与乐观锁实战自动填充解决的是创建时间、更新时间、操作人等字段每次都要手动 set 的问题。实现步骤分两步。第一步实体字段标记填充策略TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;第二步实现 MetaObjectHandlerComponent 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()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }这里有个细节strictInsertFill 要求实体对象对应的字段不能为 null。如果你自己在业务代码里给 createTime 赋值了那么 MyBatis-Plus 的自动填充不会覆盖。这个行为有时候是优点有时候是陷阱。建议团队内部约定自动填充字段一律不手动赋值包括批量插入场景也要检查是否有填充策略生效。乐观锁用于解决并发更新中的覆盖问题。用法也简单实体类里加Version private Integer version;注册插件interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());这样执行 updateById 时MyBatis-Plus 会生成类似UPDATE user SET name?, versionversion1 WHERE id? AND version?如果 update 影响行数为 0说明 version 已经变了需要业务层重试或报错。要注意一点乐观锁只对updateById和update(entity, wrapper)有效对 UpdateWrapper 方式的部分更新需要手动在实体上或 wrapper 条件里把 version 带上否则它不会自动帮你加版本条件。我在项目里看过不少人在 update 操作前查询实体然后又手动对 updateWrapper 拼上一个eq(version, version)其实不用这么麻烦直接用通用 updateById只要实体里 version 值是从 DB 查出来的插件会自动处理版本条件。4. 高级扩展从自定义方法到 SQL 注入器如果项目只用 BaseMapper那 MyBatis-Plus 的使用还停留在“模板”层面。真正的价值在于它的扩展能力。这一节讲两个方向一是在 Mapper 里自定义方法包括注解和 XML 方式二是通过 SQL 注入器扩展通用方法让通用方法变成一个内部脚手架。4.1 自定义方法Select 注解与 XML 的取舍自定义方法最简单的方式是直接在 Mapper 接口上写注解public interface UserMapper extends BaseMapperUser { Select(SELECT id, name, email, age FROM user WHERE email #{email}) User selectByEmail(String email); Update(UPDATE user SET status #{status} WHERE id #{id}) int updateStatusById(Long id, Integer status); }优点是快可读性好适合简单 SQL。缺点是复杂 SQL 写起来难看比如动态条件一多字符串里拼script标签就非常难受。而且注解 SQL 里的 resultMap 处理也不方便。我再强调一次逻辑删除的问题注解 SQL 同样不会自动带deleted 0你必须自己处理或者在 SQL 里手动加AND deleted 0。XML 方式的好处是 SQL 和 Java 代码分离适合复杂查询、动态条件、JOIN 等场景。项目里规范是凡是超过三行或带动态where的 SQL都必须写进 XML。注意 namespace 务必与 Mapper 接口全限定名一致否则会绑定失败。mapper namespacecom.example.mapper.UserMapper select idselectUserWithAddress resultTypecom.example.dto.UserAddressDTO SELECT u.id, u.name, u.email, a.city, a.street FROM user u LEFT JOIN address a ON a.user_id u.id WHERE u.id #{id} /select /mapper这里 resultType 可以直接用 DTO前提是列名能映射上。如果列名和 DTO 字段不一致要么给 SQL 列名取别名要么在 resultMap 里配置映射。别以为 resultType 和 resultMap 可以乱用实际场景中遇到联表查询推荐用 resultMap 显式映射避免列名相同导致取值错误。4.2 通用 SQL 注入器把重复方法收敛到框架层当你发现多个实体 Mapper 都有“查询某字段是否唯一”“逻辑批量恢复”等相同方法时就可以考虑把公共逻辑下沉到 SQL 注入器让每个 Mapper 都自动具备这些方法。实现逻辑不复杂。第一步定义 AbstractMethodpublic class SelectOneByEmail extends AbstractMethod { Override public MappedStatement injectMappedStatement(Class? mapperClass, Class? modelClass, TableInfo tableInfo) { String methodName selectOneByEmail; String sql SELECT tableInfo.getAllSqlSelect() FROM tableInfo.getTableName() WHERE email #{email} LIMIT 1; SqlSource sqlSource languageDriver.createSqlSource(configuration, sql, modelClass); return addSelectMappedStatementForTable(mapperClass, methodName, sqlSource, tableInfo); } }第二步继承 DefaultSqlInjectorpublic class MySqlInjector extends DefaultSqlInjector { Override public ListAbstractMethod getMethodList(Class? mapperClass) { ListAbstractMethod methods super.getMethodList(mapperClass); methods.add(new SelectOneByEmail()); return methods; } }第三步注册到配置mybatis-plus: global-config: sql-injector: com.example.injector.MySqlInjector然后在 Mapper 接口里声明对应方法User selectOneByEmail(Param(email) String email);所有继承 BaseMapper 的 Mapper 接口就自动具备了这个方法。这块属于进阶玩法适合团队里封装通用能力。不建议新手一开始就玩这个先把 BaseMapper 和自定义 XML 用利索再去抽象。4.3 多租户与动态表名的插件思路MyBatis-Plus 拦截器体系里还有一些比较高级的插件比如多租户插件和动态表名插件。多租户的场景是 SaaS 系统每次查询都要自动拼上 tenant_id 条件。注册TenantLineInnerInterceptor后SQL 会被自动改写给涉及的表加上租户条件。这个确实能避免业务代码里到处写tenant_id ?但要注意某些跨表查询、或者子查询里的表如果没参与租户过滤就会出现漏数据。我在实际项目中用过一版后来发现需要一个大字段列表来排除某些表维护成本有点高。建议小团队谨慎评估如果租户只要一个 id还是自己写条件更可控。动态表名更适合按月份分表、按租户分表的场景注册DynamicTableNameInnerInterceptor在运行时根据某种上下文更换表名。这个插件的风险是缓存和多线程下的表名错乱需要确保线程上下文隔离。总而言之一句话拦截器体系很强大但复杂业务的成本不低不是非用不可就尽量别用。5. 常见问题与排查实录最后这一节我把这些年实际遇到的高频问题集中写出来每条都附上排查思路。如果你在使用 MyBatis-Plus 时遇到类似情况可以直接对照着处理。5.1 查询不到数据原来是逻辑删除过滤在作怪现象执行 selectList 查某条件的数据明明数据库里有记录返回却是空列表。排查先确认实体类的字段是否有TableLogic。有的话通用方法的 SQL 会自带deleted 0你查的数据如果 deleted 字段是 1自然查不到。再看日志MyBatis-Plus 默认会打印 SQL检查 SQL 里是否带上了条件。如果 SQL 没带说明逻辑删除没生效大概率是配置没写对或实体字段标注错了。最后再看 XML 或注解自定义方法这类方法不会自动过滤 deleted这时候查不到数据说明是你手动加了条件但加错了位置。5.2 insert 后又打印了 selectById自增主键回填的问题现象调用 insert 后日志打印了两条 SQL先是 INSERT后面跟着一条 select 语句看起来像在查询刚才插入的数据。排查MyBatis-Plus 在 insert 之后对于自动生成的主键会执行回填有的数据库驱动需要SELECT LAST_INSERT_ID()才能拿到主键。这是正常的。如果你的实体主键字段用的是默认的IdType.ASSIGN_IDMyBatis-Plus 会在应用层生成雪花 ID根本不需要回查。如果你的主键是数据库自增驱动层面会返回主键。如果整个 insert 后还打印了一条 select 查询大概率是你业务代码里主动调用了 selectById或者使用的某些持久层扩展在插入后执行了缓存刷新。查一下代码调用栈就能定位。5.3 LambbdaQueryWrapper 字段找不到或泛型问题现象编译不通过或者运行时异常说can not find lambda。排查Lambda 方式依赖 Java 的SerializedLambda如果实体类的 getter 方法被私有化、改名、或者被其他框架代理可能导致拿到 Lambda 表达式失败。还有一种情况是 Lombok 生成的 getter 没在类路径上。解决办法是先确保实体类能正常序列化最好别用内部类做 lambda 谓词复杂场景下直接用 LambdaQueryWrapper 构造即可。5.4 分页查询 total 不对劲count 语句被 join 拖慢了现象分页查询功能正常但 count 查询特别慢或者返回总数不正确。排查PaginationInnerInterceptor 会自动生成 count 语句但对复杂 SQL 的执行性能不如人意。如果是单表分页一般没问题如果是带 JOIN 的分页它生成的 count SQL 可能会把大表的关联都算一遍导致慢查询。对策是手动指定一个优化的 count SQL 方法在自定义方法里用Select或 XML 单独写一个计数查询。如果总数不准确看看 Page 的构造参数是否传混了 current 和 size以及有没有在 Wrapper 里误用 last 改变了 SQL 结构。5.5 更新操作不生效或影响行数为 0 的数据被忽略现象updateById 返回 0但数据库里确实查到该记录。排查先看实体主键字段是不是 null主键为空则 MyBatis-Plus 无法生成WHERE id ?。再看是否受逻辑删除影响如果实体有TableLogic字段更新语句会在末尾拼上AND deleted 0一旦该记录已经被标为删除更新自然失败。这两个原因占绝大多数。5.6 表字段类型是 JSON、枚举、大文本时处理异常有些字段类型在 MySQL 里是 JSON实体类用 String 映射。这在 MyBatis-Plus 里默认可以兼容。如果是枚举类型MyBatis-Plus 默认按枚举的 name 存储如果你希望按一个自定义值存储可以实现枚举的 IEnum 接口或配置 typehandler。这个建议阅读官方文档但实操中建议尽量少在实体里使用数据库不直接支持的复杂类型保持 ORM 层干净。最后再分享几个省心的小习惯我在项目里长期保持几个使用习惯算是踩过几次坑后的总结。第一个习惯实体类字段上加 TableField 注解显式指明数据库列名不管类属性和列名是否同名。这样在重构代码时即使属性名改了你也能一眼看到数据库列的对应关系不至于 SQL 生成出来查不到列。第二个习惯任何涉及 delete 的通用方法在调用之前都先构造一个条件明确的对象并打印日志。我在生产环境见过一次 delete 操作把所有未删除记录都清空的惨状起因就是有人用了一个空 Wrapper 调 delete。如果需要清表不如直接显式执行删表 SQL至少有 DBA 审核环节。第三个习惯不要在任何 Wrapper 表达式里直接拼接前端传参。所有参数值都用 column 上的 set 方法比如 eq、like 这些方法内部会用#{}预编译参数。如果你坚持要自己拼条件务必用params或手动绑定参数。排序字段、动态表名这类无法预编译的只能做白名单。第四个习惯分页插件、乐观锁插件、逻辑删除这类全局配置是团队级约定最好沉淀到基建库里统一初始化。如果每个业务模块自己配置经常出现配置翻车、环境依赖不一致的问题。现在写业务代码我更多是先把业务对象和表结构梳理清楚再确定哪些用 BaseMapper哪些要自定义 XML。MyBatis-Plus 的本质是帮你把繁琐的通用重复代码减掉但它不是银弹复杂查询和性能优化仍然要求你理解 SQL 本身。明白这一点很多东西就顺了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →