1. 整体设计思路为什么还要在 MyBatis-Plus 之上做一层通用化封装先说个挺常见的现象很多团队用了 MyBatis-Plus 之后BaseMapper和IService已经帮我们省掉了大量单表 CRUD 的样板代码但只要你业务模块一多还是免不了每个 Service 里重复写一堆saveOrUpdate、page、list之类的方法只是换了实体类型和条件参数。代码重复率看着不高但每次新起一个模块都要从旧代码里复制粘贴、改类型、改字段实在烦人。所以我当时就琢磨着能不能把增删改查这层再往上收一收做成一套相对通用的、无状态的 CRUD 基类和工具类让业务 Service 只需要关心自己独有的逻辑。这套封装的定位不是替代 MyBatis-Plus而是在它之上做一层业务无关的收口。说白了就是把BaseMapper提供的单表操作、IService提供的批量能力再加一些常用的查询条件组合、分页参数、字段填充逻辑统一封装成一套可复用的方法集合。这样做的好处有几点一是新模块的 Service 可以少写甚至不写增删改查方法二是统一了查询条件的组装方式团队里不同人写的查询风格不会差太多三是后续如果要从单库换成读写分离或者加多租户插件收口在一处也好改造。不过这里要提前说清楚一个关键判断不是所有项目都适合做这层封装。如果你的系统只有两三个业务表或者每个实体的查询逻辑差异极大那硬套通用封装反而是负担。通用化的本质是在约定大于配置的前提下做取舍实体命名规范、表结构设计规范、字段映射规范都得先定好否则封装出来的东西只会成为新的约束。1.1 从 BaseMapper 到通用封装拆解三层结构理解这套设计建议先从三层结构入手。最底层是 MyBatis-Plus 自带的BaseMapperT它已经提供了insert、deleteById、selectById、selectList、updateById等基础方法。这一层的特点是强类型、零 SQL但只适合单实体的简单操作。第二层是 MyBatis-Plus 的IServiceT和ServiceImplM, T它把BaseMapper包装了一下加上了save、saveOrUpdate、list、page、getOne等更语义化的方法还支持链式查询和链式更新。很多项目停在这一层就够用了但我在实际使用中还是发现了一些重复劳动每个 Service 都要继承ServiceImpl每个 Service 里都要暴露page方法每个 Controller 都要写分页参数转换。所以第三层就是我说的通用化封装。它的核心思路是把实体类型作为泛型参数把数据库访问操作收敛到一套共享的 API 里。举个例子一个通用的CrudServiceT可以设计成接收实体类型就自动具备增删改查、分页、批量操作的能力业务子类只需要声明自己对应的实体类型然后补充自定义方法即可。更轻量一点的做法是干脆不做继承而是做一个无状态的工具类类似 MyBatis-Plus 的Db工具类那样静态方法直接传入实体类和查询条件。1.2 为什么选择无状态设计无状态这个词听起来有点抽象其实核心意思是封装的方法不持有业务上下文不保存任何关于当前操作对象的状态信息所有参数都通过方法入参传递。打个比方传统 Service 就像一家餐厅里固定服务的服务员他记得你今天点了什么菜无状态工具类就像叫号取餐的柜台你递什么单子它给你什么餐不记人。我之所以倾向无状态设计主要原因是它测试方便、并发安全、扩展灵活。一个静态方法或者不带成员变量的 Service 基类天然不存在多实例状态同步问题业务调用方也不需要关心内部缓存。特别是现在很多项目会做到微服务化拆分如果 Service 内部存了一些缓存或者会话级别的状态拆成独立部署单元时就会出问题。另一个原因是无状态方法组合起来非常方便比如我可以先调用通用查询方法拿到列表再把这批数据传入批量更新方法整个过程没有中间状态残留。不过无状态设计也有一个要注意的代价一些需要跨方法维护的上下文比如当前登录用户、数据权限范围就无法隐式传递了。这种情况通常有两个解法一是把这些上下文放在线程变量或者请求作用域里二是在调用通用方法时显式传入条件对象。我在实际封装里更偏向第二种因为显式传参让调用链路清晰可见排问题时不至于靠猜。1.3 封装边界哪些内容该收进来哪些不该收这是整个设计里最值得琢磨的部分。我在最开始设计时犯过一个错误想把所有能想到的操作都收进通用封装里结果类越来越大方法越来越多参数也越来越复杂最后用起来反而比直接用BaseMapper还累。后来我总结出一条经验通用封装只收业务无关、结构一致、跨实体复用的操作其余一律留给业务 Service 自己实现。按这个标准下面这些内容应该收进来单条新增、批量新增、按主键删除、按条件删除、按主键更新其中更新时实体里为 null 的字段是否更新需要做成可配置、按主键查询、按条件查询列表、按条件查询分页、按条件统计数量。下面这些内容不应该收进来涉及复杂表关联的查询、涉及业务权限校验的操作、多实体事务协调操作、与外部系统交互的数据同步逻辑。收进来的内容是通用能力不收进来的内容是业务个性边界画清楚以后封装类才不会变成一个什么都装的大杂烩。另外还有一个取舍问题返回值结构。我见过不少封装喜欢返回一个统一的ResultT包装类把成功失败、错误码、数据都包一起。但我个人不太建议在 Service 层的通用 CRUD 方法里返回这种结构因为Result更偏向接口协议层放到 Service 层会让上层被迫解包而且异常处理体系会被削弱。正确做法是 Service 层返回原始数据或者布尔值由 Controller 层统一转换为接口响应结构这样通用封装、业务 Service、Controller 三层职责分离才清晰。2. 核心方法设计与实际操作要点2.1 通用方法集合的三种形态具体到落地通用化封装常见的形态有三种各有各的使用场景我在项目里其实是继承基类 静态工具类两种都用了。第一种是抽象基类形态比如定义一个CrudServiceImplM, T里面实现了大部分 CRUD 方法业务 Service 继承它以后自动获得全套能力。这种形态的好处是子类可以很方便地重写某个方法比如在save方法里追加业务校验或者在delete时加一道软删除逻辑。缺点是 Java 单继承会限制子类的扩展空间如果业务 Service 还需要继承别的基类就得调整设计。第二种是静态工具类形态类似 MyBatis-Plus 新版本提供的Db工具类。它接受实体类型和实体对象作为参数直接静态调用操作数据库。优点是特别灵活任何类里都能直接使用不需要注入任何 Service缺点是无法通过继承机制做个性化扩展所有调用方拿到的是同一套行为想要差异就得自己再包一层。第三种是组合式 Service 形态就是把通用 CRUD 能力封装到一个独立的GenericCrudService组件里业务 Service 通过组合方式持有它而不是继承它。这样既解决了继承占用问题又能通过依赖注入切换实现类适合对组件间耦合敏感的团队。从我的实践经验来看如果是中小型项目、代码规范统一用抽象基类形态最省事团队成员心智负担最低。如果项目里已经有大一统的 Service 基类体系那静态工具类形态可以作为补充专门处理临时查询、数据修复、运维脚本一类场景。组合式 Service 形态最优雅但初期设计成本也最高团队没形成习惯之前很容易用得七零八落。// 抽象基类形态的核心骨架 public abstract class CrudServiceImplM extends BaseMapperT, T extends ServiceImplM, T { // 子类可以通过重写该方法追加业务校验 Override public boolean save(T entity) { // 通用前置处理比如主键生成、创建时间填充 fillCreateMeta(entity); return super.save(entity); } protected void fillCreateMeta(T entity) { // 反射或实现接口方式填充 createTime } }2.2 方法签名设计参数怎么传返回值怎么定方法签名设计直接决定封装好不好用。我见过最糟糕的设计是通用 CRUD 方法的参数里塞进一堆可选条件pageNum、pageSize、keyword、status、startTime、endTime泛化得看起来功能强大实则每个调用方都要传一堆用不到的东西。我在设计时的原则是单条操作传实体批量操作传集合查询操作传Wrapper或者专用的查询参数对象。分页查询单独设计一个方法接受页码、每页大小和Wrapper返回 MyBatis-Plus 的Page对象即可。之所以不搞一个万能参数对象是因为 Java 的泛型擦除和重载机制会带来各种坑而且参数对象一旦加了字段所有调用方都要跟着编译这违背了接口稳定的初衷。返回值方面我的习惯是增删改操作返回布尔值表示是否成功查询单条返回实体或 null列表返回ListT分页返回PageT统计返回long。布尔值虽然信息量有限但在大多数业务场景下够用了而且语义清晰。如果你需要更精确的影响行数完全可以单独留一个返回int的方法不冲突。public interface GenericCrudServiceT { boolean save(T entity); boolean saveBatch(CollectionT entityList); boolean updateById(T entity); boolean deleteById(Serializable id); boolean deleteByWrapper(WrapperT queryWrapper); T getById(Serializable id); ListT list(WrapperT queryWrapper); PageT page(long current, long size, WrapperT queryWrapper); long count(WrapperT queryWrapper); }2.3 泛型擦除问题与实体类识别写通用 CRUD 封装避不开一个 Java 的老问题泛型擦除。你这个类声明了T到了运行时T到底是什么类型不能靠T.class拿到。在 Spring 容器里子类继承CrudServiceImplUser时通过反射是可以从泛型签名里解析出User.class的但这个解析逻辑有点绕不是直接T.class就能取到。我自己常用的解法有两种。一种是在基类构造器里解析泛型参数Spring 的ResolvableType工具类可以帮上忙另一种更简单粗暴就是由子类显式提供一个ClassT entityClass给基类。显式提供的做法虽然看起来多一点代码但最可靠也方便 IDE 做类型推断我倾向于在基类构造器里做一次泛型解析失败时再要求子类传入。实际项目中泛型解析一般是能成功的因为在 Spring 管理的 Bean 里子类的generic superclass信息是完整保留的。SuppressWarnings(unchecked) protected ClassT resolveEntityClass() { ResolvableType resolvableType ResolvableType.forClass(getClass()); return (ClassT) resolvableType.getSuperType().getGeneric(1).resolve(); }识别出实体类之后很多通用能力就能自动做了比如根据实体类获取对应的TableInfoMyBatis-Plus 内部缓存了实体的表名、主键、字段列表进而实现主键生成、公共字段自动填充、字段值拷贝等操作。我建议在封装内部对所有实体统一走TableInfoHelper来获取元数据不要自己硬编码表名字段名这样后续实体变动时无需改动封装代码。2.4 批量操作与性能取舍批量操作是最值得聊的部分因为这里有一个非常经典的坑MyBatis-Plus 的saveBatch底层并不是真正的 JDBC 批量提交而是循环执行单条插入。很多人在性能测试时发现批量插入一万条数据要好几秒还以为是自己代码有问题其实是因为没有开启 JDBC 的批量重写参数。MySQL 的 JDBC 驱动有一个参数叫rewriteBatchedStatementstrue不开启的话驱动会逐条解析执行 SQL开启之后驱动会把多条插入语句合并成一条多值插入语句性能提升非常明显。所以在使用批量操作时我第一件事就是检查数据库连接串有没有把这个参数加上加了之后saveBatch的性能才能体现出来。jdbc:mysql://localhost:3306/demo?rewriteBatchedStatementstrueuseSSLfalseserverTimezoneAsia/Shanghai但这里还得提醒一句saveBatch的默认批次大小是 1000如果你一次性传入两万条数据它内部会分批次提交。每批次之间它不是在一个事务里的所以中途失败时会有一部分数据已经落库。如果你要求批量操作要么全成功要么全失败需要自己做外层事务控制比如在 Service 方法上加上Transactional让整个批量操作进入同一个事务管理范围。批量更新同样存在这个陷阱。MyBatis-Plus 的updateBatchById默认是逐条 update性能瓶颈主要在单条 SQL 的行数上。我在实际项目中做过对比一万条数据的逐条更新在未开启批处理重写时大约要 3-4 秒开启后大约能降到 1 秒左右看具体字段数量和索引情况。如果你的接口对写入实时性要求高可以考虑自己写 XML 里的foreach批量更新或者用 case when 方式一次性更新多条记录后者甚至能进一步减少 SQL 条数。2.5 条件构造器的安全边界通用 CRUD 封装里查询和更新、删除操作经常需要接收Wrapper参数。这个Wrapper是 MyBatis-Plus 里非常灵活的条件构造器但也正因为灵活容易写出越权操作。最典型的是deleteByWrapper和updateByWrapper这类接口如果调用方传入一个空Wrapper即没有任何条件产生的 SQL 就成了全表删除或全表更新。这个风险一定要在封装层就拦住。我的做法是通用删除和更新方法里对Wrapper判空并且用TableInfo的字段列表校验条件里的字段是否存在防止传入不存在的列导致 SQL 注入。更进一步可以在封装里约定凡是按条件更新或删除的方法必须显式传入非空的Wrapper空条件直接抛异常。业务层如果确实需要全表更新那应该单独写一个语义明确的方法而不是走通用接口。public boolean updateByWrapper(T entity, WrapperT updateWrapper) { Assert.notNull(updateWrapper, updateWrapper must not be null); Assert.hasText(updateWrapper.getSqlSegment(), updateWrapper must have condition); return baseMapper.update(entity, updateWrapper) 0; }LambdaQueryWrapper比普通QueryWrapper更安全因为它是通过方法引用获取实体字段名的编译时就能发现字段名拼写错误。在实际项目里我应该要求所有查询操作都使用LambdaQueryWrapper或LambdaUpdateWrapper普通字符串式的QueryWrapper只允许在动态表名这种特殊场景下使用。这一点可以从代码规范上约束也可以在封装方法里做接口约束比如只接受WrapperT且内部通过condition片段判断。3. 实操过程手写一套可落地的通用 CRUD 封装3.1 定义通用接口和基类实现前面的设计说得比较抽象现在我把一套我实际用过的方案完整写出来供你直接抄作业或者按需裁剪。我会以一个crud-service-starter的思路来组织代码这样后续用到新项目里只要引入依赖并继承基类就能干活。首先定义一个通用的 CRUD 接口业务 Service 接口继承它。这里我稍微加了一点扩展把page方法的返回改成自定义的PageResultT这样能统一分页响应结构Controller 层不需要处理 MyBatis-Plus 的Page对象细节。public interface BaseCrudServiceT { boolean save(T entity); boolean saveBatch(CollectionT entityList); boolean saveOrUpdate(T entity); boolean removeById(Serializable id); boolean remove(WrapperT queryWrapper); boolean updateById(T entity); boolean update(T entity, WrapperT updateWrapper); T getById(Serializable id); ListT list(WrapperT queryWrapper); PageResultT page(long current, long size, WrapperT queryWrapper); long count(WrapperT queryWrapper); }对应的基类实现核心就是继承ServiceImpl因为ServiceImpl已经把BaseMapper注入和基础方法实现封装好了我们只需要补充条件校验和公共字段处理。public abstract class BaseCrudServiceImplM extends BaseMapperT, T extends ServiceImplM, T implements BaseCrudServiceT { Override public boolean remove(WrapperT queryWrapper) { // 核心安全校验条件不允许为空 Assert.notNull(queryWrapper, queryWrapper must not be null); Assert.hasText(queryWrapper.getSqlSegment(), queryWrapper must have condition); return remove(queryWrapper); } Override public boolean removeById(Serializable id) { Assert.notNull(id, id must not be null); return super.removeById(id); } Override public PageResultT page(long current, long size, WrapperT queryWrapper) { PageT page super.page(new Page(current, size), queryWrapper); return PageResult.of(page.getRecords(), page.getTotal()); } }这里有一个容易踩坑的细节重写remove(Wrapper)时如果你调用的是super.remove(queryWrapper)注意别自己递归调用自己因为remove已经被你重写了里面再调同名方法会栈溢出。我用的是super.remove(queryWrapper)或者直接用baseMapper.delete(queryWrapper)要分清这个区别。类似的问题在page方法重写时也存在super.page(...)才是正确姿势。3.2 公共字段自动填充公共字段填充几乎是所有业务表都有的需求比如创建时间、更新时间、创建人、更新人。MyBatis-Plus 提供了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()); this.strictInsertFill(metaObject, createBy, String.class, getCurrentUserId()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); this.strictUpdateFill(metaObject, updateBy, String.class, getCurrentUserId()); } private String getCurrentUserId() { // 从请求上下文或 Spring Security 上下文中获取 return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()) .map(Authentication::getName) .orElse(system); } }要注意的一点是strictInsertFill只在实体字段为空时才会填充如果业务代码已经给createTime设置了值填充器不会覆盖它。这个行为在很多场景下是合理的但如果你希望强制覆盖就得用setFieldValByName方法。我在项目里遇到过测试环境需要固定时间戳的场景后来统一约定希望填充的字段就让填充器处理不希望被填充的就在实体字段上不配置填充策略避免两套逻辑混用导致线上数据异常。另外MetaObjectHandler是全局生效的不管你的通用封装还是普通 Service 都能拿到。所以严格来说这块不能算通用 CRUD 封装的一部分而是基础建设。但我在实际项目里发现很多团队忽略了这个机制每个 Service 的 save 方法里都手动 set 一遍 createTime这其实违背了通用化封装的初衷。建议把它和 CRUD 封装一起部署统一落地。3.3 从实体元数据生成简化的默认查询条件有时候业务方就想要一个最简单的按名称模糊查询 状态过滤 时间倒序的列表如果每次都在 Controller 里组装LambdaQueryWrapper代码会显得很重复。我封装了一套基于实体字段注解的默认查询条件生成器简单来说就是在实体里给某些字段加上自定义注解标记它们参与列表查询、排序、模糊匹配等规则然后通用封装读取这些注解自动生成QueryWrapper。Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface QueryField { QueryType type() default QueryType.EQ; boolean fuzzy() default false; } public enum QueryType { EQ, LIKE, IN, RANGE }实体类上的用法大致是这样的public class User { private Long id; QueryField(type QueryType.EQ) private Integer status; QueryField(type QueryType.LIKE) private String userName; QueryField(type QueryType.RANGE) private LocalDateTime createTime; }然后封装一个方法接收实体对象作为查询参数载体读取非空字段自动生成条件。这样一个简单列表接口的查询条件就完全不需要手写 Wrapper 了。public WrapperT buildQueryWrapper(T query) { LambdaQueryWrapperT wrapper Wrappers.lambdaQuery(); Field[] fields query.getClass().getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); QueryField qf field.getAnnotation(QueryField.class); if (qf null) { continue; } Object value ReflectionUtils.getField(field, query); if (value null || value.toString().isEmpty()) { continue; } String column camelToUnderline(field.getName()); switch (qf.type()) { case EQ - wrapper.eq(column, value); case LIKE - wrapper.like(column, value); case IN - wrapper.in(column, (Collection?) value); case RANGE - wrapper.apply(column {0} and column {1}, value, field.getAnnotation(RangeEnd.class)); } } return wrapper; }这里有个反射性能的隐患但考虑到列表查询频率不算极端加上 Spring 的反射工具类做了缓存实际影响可控。真正要注意的是field.setAccessible(true)在 Java 模块化环境下的潜在问题不过传统 Spring Boot 应用里基本不会踩到。这个设计我个人建议控制在中等规模项目里使用超大项目直接上专业的查询对象配合QueryWrapper通用封装也一样能跑。3.4 Controller 层怎么配合通用 Service 封装好之后Controller 层也可以跟着做一套通用的 BaseController思路和 Service 类似把常见的增删改查接口收敛起来。BaseController 里定义泛型T对应的 Service 引用通过 Spring 依赖注入拿到具体的处理类然后提供标准 REST 接口。public abstract class BaseControllerT { protected abstract BaseCrudServiceT getCrudService(); PostMapping public ResultBoolean save(RequestBody T entity) { return Result.success(getCrudService().save(entity)); } PutMapping public ResultBoolean update(RequestBody T entity) { return Result.success(getCrudService().updateById(entity)); } DeleteMapping(/{id}) public ResultBoolean delete(PathVariable Long id) { return Result.success(getCrudService().removeById(id)); } GetMapping(/{id}) public ResultT get(PathVariable Long id) { return Result.success(getCrudService().getById(id)); } }这种 BaseController 能极大减少新模块的接口样板代码但它对 REST 风格有一些假设比如新增用 POST、更新用 PUT、删除用 DELETE如果你的项目里有些接口并不是这种风格那 BaseController 就不适合全局套用。我的实践是标准 CRUD 接口走 BaseController业务型接口比如上线审核发布这种有状态流转语义的绝不套用单独写。3.5 一个完整的小 Demo从零配置到调用为了让你串起整个链路我写一个最小可运行示例。假设现在要对Product表做通用 CRUD一共五步。第一步建表。一张极简商品表包含主键、名称、价格、状态、创建时间、更新时间。第二步写实体类加 MyBatis-Plus 注解。Data TableName(product) public class Product { TableId(type IdType.ASSIGN_ID) private Long id; private String name; private BigDecimal price; private Integer status; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }第三步写 Mapper继承BaseMapperProduct不需要写任何方法。第四步写 Service 接口和实现类接口继承BaseCrudServiceProduct实现类继承BaseCrudServiceImplProductMapper, Product。第五步Controller 继承BaseControllerProduct实现抽象方法返回 Service。到这里一个商品模块的增删改查分页全部可用了。Service public class ProductServiceImpl extends BaseCrudServiceImplProductMapper, Product implements ProductService { }实际跑起来以后你能直接调用的接口有POST 新增、PUT 更新、DELETE 删除、GET 单查再加上我们扩展的 POST/page分页查询。统计数量、条件列表等能力也都在 Service 层暴露着Controller 里按需映射即可。这套东西对于一个简单后台管理系统的开发效率提升是非常明显的一个新表从建表到 CRUD 接口可用半小时以内能搞定。4. 常见问题与排查技巧实录4.1 批量插入性能差加参数后仍然慢先说批量插入。如果你已经加了rewriteBatchedStatementstrue但性能提升不明显那多半是主键生成策略的问题。MyBatis-Plus 默认的ASSIGN_ID会使用雪花算法生成分布式 ID这个流程本身很快但在批量插入场景下每条记录都要走一次 ID 生成这个开销不可忽视。我实际测试过两万条商品数据批量插入使用ASSIGN_ID大约耗时 1.2 秒改成AUTO自增主键后大约能降到 0.8 秒左右。当然分布式环境里自增主键有跨库冲突问题不能为了这点性能牺牲全局唯一性。更好的思路是看批量操作是不是真的需要一次插入这么多条如果单次超过五千条建议业务侧做分片提交避免长时间占用数据库连接。还有一个容易被忽略的点saveBatch内部是循环执行baseMapper.insert(entity)如果实体里某些字段定义为TEXT类型大字段批量插入很容易触达单条 SQL 的 packet 上限。MySQL 默认max_allowed_packet一般是 4M 或 16M大字段数据多了以后会直接报错。这种情况需要在数据库层调大参数或者在业务层限制单批次的条数。4.2 逻辑删除与通用删除方法的冲突MyBatis-Plus 的逻辑删除是通过全局配置和实体字段注解实现的删除时自动把deleted字段置为 1查询时自动追加deleted0。这个机制对普通用户是透明的但自己写通用 CRUD 封装时容易被坑到。最常见的坑是removeByWrapper接口。因为逻辑删除生效时MyBatis-Plus 会把delete语句转换为update语句看起来没问题但如果你在封装里校验条件时使用了getSqlSegment()它返回的是拼接好的条件片段逻辑删除追加的条件并不在里面所以校验逻辑没问题。真正的坑在于如果业务表没有deleted字段但全局逻辑删除配置是开启的MyBatis-Plus 会在执行查询时尝试追加deleted0结果 SQL 报错列不存在。这个错误通常在运行期才暴露而且容易误判成通用封装的问题。我的排查建议是实体类上统一使用TableLogic注解没有逻辑删除字段的表就不要全局开启配置按表维度控制。这样通用封装跑不同实体时可以明确知道哪些表有逻辑删除、哪些没有不至于条件错乱。4.3 自定义 SQL 与通用封装混用时的数据权限问题另一个我在实际项目中反复踩的坑是数据权限。通用封装因为面向所有实体如果直接暴露出去所有调用方都能查询任意数据这在内部工具类场景问题不大但在面向多租户或者多部门数据隔离的系统里风险非常大。最典型的案例一个后台用户如果通过通用查询接口传入status1条件本来只能看自己部门的数据结果能查到所有部门的数据。我在这里的建议是通用封装的查询方法只面向 Service 层开放Controller 层绝对不允许直接调用底层的list(Wrapper)。每个 Controller 接口都要经过业务 Service 做数据权限过滤后再把受限的 Wrapper 传给通用方法。具体做法有两种一种是在业务 Service 里传入的 Wrapper 基础上强制追加dept_id 当前部门条件另一种是使用 MyBatis-Plus 的拦截器做数据权限统一处理。后者更根治但配置复杂度高而且和通用封装叠加时要小心重复追加条件。分享一个我记得很清楚的教训我之前在某个项目里图方便直接在 Controller 里调了getCrudService().list(queryWrapper)结果 QA 拿一个普通账号测出一个越权漏洞能从列表接口里拉取其他部门的数据。后来我立了一条铁律所有 Controller 层的查询必须走业务 Service 的自定义方法通用 CRUD 接口只允许在内部调用不允许直接暴露成 HTTP API。4.4 字段映射不对导致插入失败通用封装里最容易让新手上头的就是实体字段和数据库字段的映射问题。MyBatis-Plus 默认开启驼峰转下划线userName能自动映射到user_name但如果你有一些特殊命名字段比如数据库字段带前缀或者缩写就需要在实体上用TableField明确指定。举一个真实的案例我有一次接手同事的项目他的实体里有个字段叫batchNo数据库列名是batch_no但同事在图省事的情况下没有加注解第一版全表查询没问题可以自动映射后来这个表做了一次字段改名数据库列变成了batch_number线上突然报字段找不到。排查时发现就是少了TableField(batch_number)注解。为了避免这类问题我建议在实体类里对所有非同名映射字段统一加注解不要依赖全局驼峰配置兜底。这个规范听着琐碎但在通用封装的场景下实体字段映射一旦出错影响面是所有用到该实体的通用接口。4.5 分页查询里 count 慢的优化技巧分页查询本质是两条 SQL一条查总数、一条查数据。MyBatis-Plus 分页插件默认生成的 count SQL 会把原来的查询语句包一层SELECT COUNT(*) FROM ( ... )当主查询很复杂时count 效率非常低。通用封装里因为条件是通过 Wrapper 拼出来的情况还好但一旦业务 Service 在 Wrapper 里加了select指定某些列count 的 SQL 也会带着这些列虽然 MySQL 优化器大概率会忽略但极端场景下还是会慢。我在通用封装里做了一点点优化分页查询时可以允许调用方自定义 count 查询的条件和是否跳过 count。如果业务对 total 值不敏感比如纯下拉滚动加载可以直接传searchCountfalse跳过 count 查询。这个开关在 MyBatis-Plus 的Page对象上有对应属性非常方便。另外对复杂查询的 count 语句我会在数据库端用EXPLAIN检查执行计划确认是否有大字段参与 count 导致扫描行数过高。5. 从通用封装到进一步演进5.1 如何与数据权限注解整合前面提到了数据权限问题这里聊聊更系统的整合方式。我在后来的项目里把通用 CRUD 封装和一套轻量数据权限注解做了结合。思路是在设计通用查询方法时预留一个权限条件追加器的扩展点业务 Service 通过重写appendDataScope方法把自己需要追加的数据权限条件返回。这样既保持了通用方法的结构统一又允许业务按需定制。protected void appendDataScope(LambdaQueryWrapperT wrapper) { // 默认无操作子类按需重写 } Override public PageResultT page(long current, long size, WrapperT queryWrapper) { LambdaQueryWrapperT lambdaWrapper (LambdaQueryWrapperT) queryWrapper; appendDataScope(lambdaWrapper); return super.page(current, size, lambdaWrapper); }这种设计比全局拦截器好在一点数据权限逻辑是显式可见的不同业务可以有不同的权限维度而不是被一个全局规则卡死。缺点是每个业务 Service 都要记得重写这个方法。对于全员默认无权限个别业务放开的场景很合适反过来如果你希望默认全权限个别业务收缩那这个设计要调整默认实现。5.2 多数据源下的通用封装适配现在很多项目会同时连多个库比如主库负责写入、读库负责查询。通用 CRUD 封装如果只绑定单一数据源切换起来会有点麻烦。MyBatis-Plus 的动态数据源插件是常规解法核心思路是在配置里为不同数据源定义DataSourceBean然后通过注解或者自定义路由规则在方法执行前切换数据源。通用封装适配多数据源时我建议把写操作和读操作的默认数据源分开配置。举个例子save、update、delete系列方法强制走主数据源list、page、get系列方法默认走读数据源让调用方可以在必要时指定强制主库读防止主从延迟导致的数据不一致。但这样也会带来一个新的复杂度主从延迟场景下刚写入的数据马上查询可能查不到。解决办法就是在通用的getById和getOne方法上提供参数控制是否走主库。public T getById(Serializable id, boolean forceMaster) { if (forceMaster) { DynamicRoutingDataSource.setMaster(); } try { return super.getById(id); } finally { DynamicRoutingDataSource.clear(); } }当然多数据源这套东西水很深涉及事务、XA、分布式 ID 生成策略不是一篇文章能讲完的。如果你是刚开始做通用封装我的建议是先把单数据源下的 CRUD 封装做扎实多数据源作为后续演进方向不要一上来就背上复杂度。5.3 缓存与通用查询的结合通用 C 端总有热门列表查询要快的诉求。通用 CRUD 封装方法天然适合做缓存因为接口统一缓存切面可以很规整地加上。我试过两种做法第一种是直接基于 Spring Cache 在通用 Service 接口上加注解第二种是封装内部做一层本地缓存但手动处理失效逻辑。第一种做法简单直接但有个坑泛型接口上的Cacheable注解默认 key 会包含参数对象而Wrapper对象没有稳定的 toString直接用它做缓存 key 会导致缓存命中率极低。正确的做法是给 Wrapper 做一个内容摘要 key或者要求调用方显式传入 cacheKey。我在封装里加了一个queryCacheKey方法对 Wrapper 的 SQL 片段做 hash配合实体类型名生成一个相对稳定的 key。protected String buildCacheKey(WrapperT wrapper) { String sqlSegment Optional.ofNullable(wrapper) .map(Wrapper::getSqlSegment) .orElse(); String entityName resolveEntityClass().getSimpleName(); return entityName : DigestUtils.md5DigestAsHex(sqlSegment.getBytes(StandardCharsets.UTF_8)); }第二种做法适合高频热点数据但对缓存失效策略要求高万一业务数据变更但缓存没更新会产生脏读。我个人的建议是通用 CRUD 封装初期不要内置缓存等明确性能瓶颈后再在业务层针对具体查询加缓存。通用缓存看起来美但一把梭的后果就是各种数据不一致问题。5.4 测试策略通用封装的单测怎么写通用 CRUD 封装的测试我认为重点不是测 MyBatis-Plus 已经保证过的功能而是测你新增的校验逻辑、公共字段填充、权限追加、条件构造器转换这些自己写的逻辑。如果封装里引入了安全校验比如空条件拦截那至少要覆盖正常传入、空条件、只有ORDER BY没有条件、条件里带非法字段等场景。我在项目里的做法是引入 H2 内存数据库配合 MyBatis-Plus 的自动建表能力做集成测试。测试方法很简单在测试配置里用 H2 数据源启动 Spring 上下文然后直接调用通用 Service 的方法验证数据库里的最终状态。这种测试方式跑得很快而且不依赖外部 MySQL 实例团队里任何人拉下代码都能直接执行。SpringBootTest AutoConfigureMockMvc class ProductServiceImplTest { Autowired private ProductService productService; Test void saveThenGet_shouldSuccess() { Product product new Product(); product.setName(测试商品); product.setPrice(new BigDecimal(9.90)); productService.save(product); Product saved productService.getById(product.getId()); Assertions.assertEquals(测试商品, saved.getName()); Assertions.assertNotNull(saved.getCreateTime()); } Test void removeWithEmptyWrapper_shouldThrow() { Assertions.assertThrows(IllegalArgumentException.class, () - productService.remove(null)); } }这种测试在通用封装里价值很高因为你每改一次封装的底层逻辑所有业务的测试都能跟着跑一遍能及早发现公共行为变更引发的连锁问题。我自己经历过一次公共字段填充策略调整结果十个模块的插入行为全变了如果没有这层集成测试兜底上线必炸。5.5 演进路线图从单表封装到业务能力沉淀最后聊聊演进思路。通用 CRUD 封装只是起步做到后面你会发现项目真正需要沉淀的不是增删改查而是业务能力。比如各种复杂查询的组装策略、状态变更的核心规则、数据权限的管控逻辑这些才是业务的核心竞争力。通用 CRUD 封装的意义恰恰是把低层次的数据库操作收拢起来让团队有余力把精力放到更高层次的能力抽象上。我见过一些团队把通用封装做得过于深入把业务规则也往里面塞最后封装类变成了神类几千行代码改动一下影响一片。所以我的建议是通用封装始终保持在数据库访问操作这一层不要往上越界。业务规则该放 Service 就放 Service该抽独立组件就抽独立组件通用封装始终是那条最底层的捷径。如果你刚开始做这套东西可以从最轻量的方式入手先用 MyBatis-Plus 原生的IService和ServiceImpl撑住大部分 CRUD再按项目实际痛点逐步封装通用方法。不要一上来就搞一套大而全的框架那大概率会在后续迭代里不断返工。我自己这套封装也是从简单的BaseCrudService开始经历了几个项目、砍掉不少冗余方法之后才沉淀成现在这个版本。实用永远是第一位的。