☰
MyBatis-Plus通用化封装:单表CRUD代码量压缩70%的实践
2026/10/3 23:33:08 网站建设 项目流程

从接触 MyBatis-Plus 的第一天起,我就有一个强烈的感觉:这个框架已经把单表 CRUD 逼到了“写代码都算浪费时间”的地步——BaseMapper、IService、LambdaQueryWrapper 这三板斧下来,绝大多数简单增删改查根本不需要手写 SQL。但真正把项目做大之后你会发现,光有框架能力还不够,如果每个业务 Service 都在重复写同样的 save、update、page 方法,本质上还是在造重复轮子,只是把 SQL 换成了 Java 代码而已。今天聊的这套“增删改查通用化封装”,就是我在几个中大型项目里沉淀下来的一套写法,核心目标只有一个:把单表 CRUD 的代码量再压缩掉 70%,同时保留足够的扩展空间。

1. 为什么要做通用化封装:先搞清楚痛点再动手

1.1 项目里的“重复代码病”

很多团队用上 MyBatis-Plus 之后,代码大概是这样的:每个实体对应一个 Mapper 接口,继承 BaseMapper 之后啥都不用写;每个业务类再继承一个 ServiceImpl,然后开始堆方法。

问题出在“堆方法”这一步。比如一个标准的用户模块,你可能会写这些:

  • saveUser(User user):其实就是调save,但为了规范还是包了一层
  • updateUser(User user):又是updateById的套壳
  • deleteUser(Long id):继续套壳
  • getUserById(Long id):继续套壳
  • pageUser(PageVO vo):开始用 LambdaQueryWrapper 拼条件,然后调page

一个模块如此没什么,但项目里有几十个模块呢?每个模块都来一套这种“套壳方法”,代码量就变得非常可观。更麻烦的是,这种代码没有任何业务含量,后期维护的人还得逐个打开看,想确认一下到底是不是只是单纯调了框架方法。我用一个词形容这种状态:无效忙碌。

所以做通用化封装的第一动机不是炫技,而是把这些“不需要动脑子的方法”全部收拢到公共代码里,让业务 Service 只保留真正有业务逻辑的方法。

1.2 通用化封装到底封装什么

做这件事之前先想清楚边界。MyBatis-Plus 官方提供的IService和ServiceImpl已经非常强了,它们本身就实现了save、saveBatch、updateById、list、page等通用方法。所以我们要做的不是重复造一个更底层的轮子,而是做三件事:

第一,把“基类”做得更好用。官方ServiceImpl的方法参数通常是实体对象,但在很多场景下,我们需要更灵活的条件删除、条件更新,或者基于字段的查询。基类里应该补充这一类方法。

第二,提供一个无状态的静态调用入口。新版的 MyBatis-Plus 其实已经提供了Db工具类,我们可以顺着这个思路,把常用的增删改查进一步封装成静态方法,让没有 Service 的轻量场景也能优雅地操作数据库。

第三,把“查询条件构造”这条最烦人的线梳理清楚。条件构造器虽然好用,但每个业务类里都在拼类似的非空判断、时间区间、排序规则,这些都是重复劳动。封装之后,我们应该能用一个模型对象 + 一个泛型方法搞定大部分查询。

1.3 使用到的 MyBatis-Plus 底层能力

在开始写代码之前,先把我们要依赖的框架能力列一下,方便后面理解:

  • BaseMapper:提供了单表的insert、deleteById、updateById、selectById等基础能力,是 Mapper 层的根。
  • IService / ServiceImpl:Service 层的通用 CRUD 骨架,内置了批量操作和链式查询。
  • LambdaQueryWrapper / LambdaUpdateWrapper:类型安全的条件构造器,编译期就能校验字段名,避免硬编码字段名的低级错误。
  • Wrapper 子类:针对复杂查询的 QueryWrapper,以及支持 set 语句的 UpdateWrapper。
  • MybatisPlusInterceptor:分页插件、乐观锁插件、防全表更新与删除插件等,都通过这个拦截器机制生效。
  • MetaObjectHandler:字段自动填充,比如创建时间、更新时间自动写入。
  • TableLogic:逻辑删除支持,删除操作自动变成update。

理解这些底层能力之后,封装思路就很清晰了:框架给了我零件,我要做的只是把它们组装成一套好用的工具,而不是从零开始造发动机。

2. Service 层基类设计:一行代码继承全套 CRUD

2.1 基础抽象类的泛型设计

先给出一套我在多个项目中验证过的抽象类设计,它是对官方ServiceImpl的增强,解决了几类高频问题。

public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> extends ServiceImpl<M, T> { /** * 按 ID 查询实体,找不到返回 null,不抛异常 */ public T getByIdQuietly(Serializable id) { if (id == null) { return null; } return baseMapper.selectById(id); } /** * 按条件查询单条记录,存在多条时只取第一条 */ public T getOne(Wrapper<T> queryWrapper) { return baseMapper.selectOne(queryWrapper); } /** * 条件删除:直接传 Wrapper */ public boolean remove(Wrapper<T> queryWrapper) { return baseMapper.delete(queryWrapper) > 0; } /** * 条件更新:传 UpdateWrapper */ public boolean update(Wrapper<T> updateWrapper) { return baseMapper.update(null, updateWrapper) > 0; } /** * 判断某个条件下是否存在记录 */ public boolean exists(Wrapper<T> queryWrapper) { return baseMapper.selectCount(queryWrapper) > 0; } }

这个基类的核心思路是:把 Wrapper 的用法提升到 Service 层。官方IService其实也有getOne(Wrapper)、remove(Wrapper)这些方法,但很多团队用不起来,原因在于继承链路太长,而且官方方法的某些行为(比如getOne默认在多条记录时会抛异常)不符合实际操作习惯。我在基类里重新封装的方法,行为更贴近实际开发,尤其适合那种“查一下有没有,没有就拉倒”的场景。

使用的时候,业务 Service 的写法就变成了:

public interface UserService extends IService<User> { // 业务方法再单独声明 } @Serivce public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implements UserService { // 只写真正的业务逻辑,CRUD 全在基类里 }

这里有一个细节值得注意:泛型T必须是实体类型,M是 Mapper 类型。官方ServiceImpl的泛型定义是ServiceImpl<M extends BaseMapper<T>, T>,我们保持同样的顺序,这样所有继承者都能自动获得 Spring 注入的baseMapper。

2.2 无状态与多数据源问题

“无状态”这个词在热词里出现过,也正是Db工具类的核心思想——查询方法不依赖某个 Service 实例的状态,你随时传一个条件进去,就能拿结果出来。这非常适合在工具类、监听器、定时任务这类没有 Service 注入的场合使用。

MyBatis-Plus 的Db工具类内部通过SqlHelper获取当前请求对应的 SqlSession,并自动识别IService的泛型类型来定位 Mapper。它的好处是无侵入,但使用上有个限制:如果你的项目配置了多数据源,静态工具类是没法直接感知“当前应该用哪个数据源”的。这种情况我更推荐在项目里维护一个“数据源路由”的上下文,或者在调用Db方法之前通过DynamicDataSourceContextHolder切换数据源。

public class MyDbUtils { /** * 指定数据源执行查询 */ public static <T> T queryOn(String dsName, Supplier<T> supplier) { DynamicDataSourceContextHolder.push(dsName); try { return supplier.get(); } finally { DynamicDataSourceContextHolder.poll(); } } }

类似的封装很轻量,但能规避静态方法在多数据源场景下带来的隐患。实际项目里我见过因为直接用Db.list()查到了默认库,排查了大半天才发现是数据源没切换的问题,这类坑提前预防很有必要。

2.3 Db 工具类的静态调用方式

如果你是新项目,或者不想为每个模块都维护一个 Service 基类,我更推荐直接用构造工具类的思路来提供静态 CRUD。MyBatis-Plus 3.5.x 之后官方提供了比较完整的Db工具类,我们自己也可以在它的基础上做一层薄封装:

public class CrudKit { public static <T> T getById(Class<T> clazz, Serializable id) { return Db.getById(id, clazz); } public static <T> boolean save(T entity) { return Db.save(entity); } public static <T> boolean updateById(T entity) { return Db.updateById(entity); } public static <T> boolean saveOrUpdate(T entity) { return Db.saveOrUpdate(entity); } public static <T> boolean removeById(Class<T> clazz, Serializable id) { return Db.removeById(id, clazz); } public static <T> List<T> list(Class<T> clazz, Wrapper<T> queryWrapper) { return Db.list(queryWrapper, clazz); } public static <T> Page<T> page(Class<T> clazz, Page<T> page, Wrapper<T> queryWrapper) { return Db.page(page, clazz, queryWrapper); } }

写完之后,你的业务代码可以完全绕开 Service,直接在 Controller 或者 Application Service 里调用,例如:

@PostMapping("/users") public Result<Long> createUser(@RequestBody User user) { CrudKit.save(user); return Result.ok(user.getId()); } @GetMapping("/users/{id}") public Result<User> detail(@PathVariable Long id) { return Result.ok(CrudKit.getById(User.class, id)); }

这里的调用非常直观,但它不适合承载复杂事务。如果你的操作需要多个步骤并且必须保证原子性,还是老老实实走 Service 层并加上@Transactional,静态工具类只管简单的单表单操作。这一点后面会重点展开。

3. 条件构造与查询封装:再也不用手写几十个 Mapper

3.1 LambdaQueryWrapper 的链式条件

把增删改查方法收敛之后,剩下最影响代码量的就是条件构造。我见过不少同事写查询条件时还在手动拼接字符串,或者写一长串的if判断往 QueryWrapper 里塞条件。这种写法不仅丑,而且容易漏条件。

LambdaQueryWrapper 的核心价值在于:字段名是强类型引用的,不是字符串。只要实体字段改了名,编译期就能发现错误,比运行期 SQL 报错强太多。

LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(User.class) .eq(StringUtils.isNotBlank(user.getUserName()), User::getUserName, user.getUserName()) .ge(user.getCreateTime() != null, User::getCreateTime, user.getCreateTime()) .le(user.getEndTime() != null, User::getCreateTime, user.getEndTime()) .orderByDesc(User::getId);

但这样写还是有问题:每个查询都要来一遍非空判断。如果能把“查询参数对象”统一处理,代码会清爽很多。我的做法是定义一个通用的查询基类和配套的 Wrapper 构建器。

3.2 分页查询的封装与排序注入

分页是后端 CRUD 里最高频的场景。MyBatis-Plus 的分页逻辑靠PaginationInnerInterceptor完成,它会把Page对象里的current和size解析成LIMIT语句,同时自动生成COUNT查询。使用前必须确保已经添加了分页插件:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }

setMaxLimit(500L)这一步很重要,它能防止有人一不小心查全表把数据库打爆。我建议每个项目都加上这个上限。

分页查询的封装建议做一个通用的请求模型:

public class PageQuery { private long pageNum = 1; private long pageSize = 10; private String orderByColumn; private String orderByDirection = "asc"; // getter/setter 略 public <T> Page<T> toPage() { Page<T> page = new Page<>(pageNum, pageSize); if (StringUtils.isNotBlank(orderByColumn)) { // 这里必须防止 SQL 注入,不要直接拼接字段名 String column = SqlKeywordUtils.camelToUnderline(orderByColumn); if ("desc".equalsIgnoreCase(orderByDirection)) { page.addOrder(OrderItem.desc(column)); } else { page.addOrder(OrderItem.asc(column)); } } return page; } }

注意排序字段这块有两个坑:

  • 防止 SQL 注入:排序字段不能直接拼进 SQL,更不能让前端传任意字段名进来。推荐的做法是把前端传的字段名映射到一个白名单集合里,然后转换成数据库列名,否则有人传个id; drop table user这种值就麻烦了。
  • 字段映射:前端通常传驼峰字段名(如createTime),底层排序要用下划线列名(如create_time)。如果你不显式指定,MyBatis-Plus 在部分场景下会原样拼接,导致排序不生效。

分页之后,返回给前端的结果建议也统一封装一下:

public class PageResult<T> { private List<T> records; private long total; private long pageNum; private long pageSize; }

这样前端拿到的是一个结构固定的对象,而不是直接裸用 MyBatis-Plus 的Page。Page里带了很多 MyBatis-Plus 的内部字段,直接序列化给前端不仅冗余,有时还会暴露不需要的信息。

3.3 批量操作的性能优化

通用化封装很容易忽略的一个点是批量操作。很多项目里,业务代码直接循环调save或者updateById,一条一条地发 SQL,性能极差。MyBatis-Plus 的saveBatch正是解决这个问题的,但它也有一点小坑:默认情况下saveBatch并不是真正的 JDBC 批量提交。

ServiceImpl的saveBatch底层走的是SqlHelper.executeBatch,虽然会攒一批再执行,但如果连接 URL 上没有开启rewriteBatchedStatements=true,MySQL 驱动并不会真正把多条 SQL 合并成一条批量执行。我踩过这个坑之后,统一在数据源配置里加了参数:

spring.datasource.url=jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true

加了之后,插 10 万条数据的时间能下降一个量级。如果是批量更新,同样的参数也有效。

另一个提升批量性能的思路是“分段批量插入”,即把一个大列表切成若干小批,每批 500 或 1000 条,而不是一次性把所有数据交给saveBatch。原因是单次批处理太多会导致 SQL 语句过长,数据库解析压力大,反而影响性能。封装之后大致这样:

public static <T> void batchSaveSafely(IService<T> service, List<T> list, int batchSize) { if (CollectionUtils.isEmpty(list)) { return; } int size = 100; for (int i = 0; i < list.size(); i += size) { List<T> subList = list.subList(i, Math.min(i + size, list.size())); service.saveBatch(subList); } }

如果对实时性要求不高,批量操作放在异步线程池里执行也是一个常见的优化方向,但要注意线程池的隔离策略和数据库连接数上限,避免异步任务压垮数据库连接池。

4. 踩坑实录:这些细节不过处理,上线就出事

4.1 saveBatch 不起作用的真相

有一个高频问题:saveBatch用了,但查看日志发现 SQL 还是一条条 insert。可能的原因除了上面说的rewriteBatchedStatements没开,还可能是MyBatis-Plus 的 SQL 注入器没有正确识别批量方法。

如果你的项目自定义了 SQL 注入器或者继承了DefaultSqlInjector,一定要记得把批量插入对应的Method也继承下来。很多人自定义注入器只为了加一两个自定义方法,结果覆盖了默认方法列表,导致saveBatch退化成了逐条插入。

排查方法很简单:把 MyBatis 的日志级别调到 TRACE,看Preparing语句是不是带VALUES (?, ?), (?, ?)这种多组占位符。如果没有,说明批量没有生效,逐个检查 URL 参数和注入器配置。

4.2 this 调用方法导致事务失效

这是 Spring 事务里最常见的坑,但在 MyBatis-Plus 的 Service 封装中更容易出现。因为封装之后,业务方法往往直接写在BaseServiceImpl里,然后在子类里调this.save()、this.remove()。

@Transactional的生效原理是基于 Spring AOP 动态代理。当你通过this调用同一个类内部的另一个方法时,调用的是 this 对象的原始方法,根本没经过代理对象,事务注解自然不生效。很多人以为事务切的是整个类,其实切面只对“从外部进入代理对象”的调用生效。

所以我的封装建议是:

  • 把事务边界尽量控制在 Controller 调用的那一个 Service 方法上。
  • 如果同类内部要互相调用,拆到不同的 Service 类中,通过注入的方式互相调用,让调用链经过代理。
  • 或者使用@Resource注入自身代理对象(@Lazy解决循环依赖),但这种方式不推荐,代码可读性差。
@Service public class OrderServiceImpl extends BaseServiceImpl<OrderMapper, Order> implements OrderService { @Resource private OrderService self; @Override @Transactional(rollbackFor = Exception.class) public void createOrderWithItems(Order order, List<OrderItem> items) { self.save(order); itemService.saveBatch(items); // 这里如果抛异常,事务会回滚 } }

rollbackFor = Exception.class也很重要。Spring 事务默认只在遇到RuntimeException或者Error时回滚,如果业务方法抛的是受检异常(比如IOException),事务不会自动回滚。强烈建议所有@Transactional都显式声明rollbackFor,避免这类隐性风险。

4.3 逻辑删除和字段映射的坑

很多项目用逻辑删除。MyBatis-Plus 的做法是在实体字段上加@TableLogic,这样删除变成update set deleted = 1 where id = ?,查询自动加上deleted = 0条件。

这里有两个坑:

  • 唯一索引冲突:逻辑删除后记录还在表里,如果业务上要求某个字段唯一(比如用户手机号),第二次插入相同手机号会触发唯一索引冲突。解决方案是唯一索引里包含deleted字段,但deleted通常只有 0 和 1,删除多条后再次插入还是会冲突。更稳妥的方案是用deleted字段存删除时间戳(如deleted = 0表示未删除,删除时写入当前时间戳),但这样需要自定义实现,不能直接用@TableLogic默认逻辑。
  • 字段映射问题:@TableLogic逻辑删除字段要求数据库列名和实体属性名严格映射。如果数据库列名是is_deleted,实体属性是deleted,必须显式配置@TableField("is_deleted"),否则 MyBatis-Plus 默认会按驼峰转下划线去匹配,匹配不上就可能导致 SQL 错误或查出来的数据不对。

此外,逻辑删除字段不建议用Boolean类型映射,因为 MySQL 的tinyint转 Boolean 在某些驱动版本下会有兼容问题,直接用Integer或者Long更稳妥。

4.4 快速排查速查表

为了节省大家时间,我把上面提到的坑和方案汇总成了一张速查表:

问题现象可能原因解决方案
saveBatch 速度没提升缺少 rewriteBatchedStatements 参数数据源 URL 拼接该参数
saveBatch 退化逐条插入自定义 SQL 注入器覆盖了默认方法继承 DefaultSqlInjector 并保留默认方法
Controller 调用 Service 方法事务不生效this 调用内部方法绕过代理拆 Service 或注入自身代理对象
分页排序不生效字段名未映射为下划线列名显式指定 OrderItem 列名
逻辑删除后唯一索引冲突deleted 字段只有 0/1用 delete 时间戳方案,或业务层加校验
大列表内存溢出一次查全表且未分页强制分页或限制最大条数

这些坑大多数只在数据量变大、并发变高之后才会暴露。写代码的时候图快,上线之后就会付出排查的代价,所以从第一天开始就应该把这些规范固化进基类封装里。

5. 扩展思路与实际体会

5.1 还能封装什么:自动填充、乐观锁、租户隔离

通用化 CRUD 封装做到这一步,其实已经覆盖了 80% 的单表操作需求。剩下 20% 是一些横切能力,也建议一并在基类层统一处理。

字段自动填充。创建时间、更新时间、创建人、更新人这些字段,几乎每张业务表都有。与其每个 Service 手动 set,不如实现MetaObjectHandler:

@Component 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()); } }

实体对应字段加上@TableField(fill = FieldFill.INSERT)和@TableField(fill = FieldFill.INSERT_UPDATE)。这样所有继承基类的实体都能自动填充,完全从业务代码中剥离。

乐观锁。做更新操作时并发控制很关键。MyBatis-Plus 的乐观锁插件原理是:更新时自动带上version = 旧值的条件,并且set version = version + 1。如果更新影响行数为 0,说明并发冲突了。我建议所有核心业务表都加上version字段,并在配置类中注册乐观锁插件:

interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());

多租户隔离。如果你是 SaaS 项目,MyBatis-Plus 的TenantLineInnerInterceptor可以在 SQL 层面自动追加租户条件,避免每个查询都手动拼租户 ID。但这个拦截器一定要谨慎配置“忽略表”,比如一些全局字典表就不能加租户条件,否则会出现查不到数据的问题。

5.2 我的几条实际操作建议

聊到最后,分享几个实践中沉淀下来的原则:

第一,封装要用,但不要滥。通用化封装解决的是 80% 的重复劳动,千万不要试图覆盖所有场景。遇到特殊业务,比如多表关联查询、复杂子查询、存储过程,果断使用自定义 Mapper 方法,没必要为了“统一”而强行把代码往基类里塞。

第二,参数校验不能丢。很多人封装完之后,Controller 里直接丢一个实体进来就 save 了,连基本的非空校验都没有。结果数据库各种异常,排查得头疼。建议在 Controller 层引入 Bean Validation,在 Service 层也保留必要的业务校验,封装只是帮你省重复方法,不是省掉该做的防御。

第三,统一返回结构。增删改查的入参和出参,项目里一定要定好固定的返回包装类。比如Result<T>,里面包含 code、message、data 三个字段就行。不要今天这个接口返回Map,明天那个接口返回JSONObject,后面对前端和调用方都是灾难。

第四,多看看框架更新。MyBatis-Plus 的版本迭代很快,比如Db工具类是 3.5.x 才补全的,之前大家只能自己封装。我每隔一段时间会看一眼官方更新日志,很多新特性能直接简化掉旧代码。

如果让我用一句话总结这套封装的价值,那就是:把该留的灵活都留下,把能省的重复都省掉。它在实际项目中帮我减少了大量机械编码,也让新成员接手时的认知负担小了很多。你完全可以按自己的业务习惯调整基类的覆盖面,核心思路其实是相通的——先用框架能力把地基打好,再在基类层统一发力,让业务代码真正做到“只写业务”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询