MyBatis-Plus自定义BaseMapper实战:从逻辑删除到多租户的架构升级

MyBatis-Plus自定义BaseMapper实战:从逻辑删除到多租户的架构升级 1. 从“能用”到“好用”为什么我们需要自定义 BaseMapper如果你用过 MyBatis-Plus那你肯定对BaseMapperT不陌生。它就像官方发的一个“全家桶”里面塞满了insert、selectById、updateById、delete这些基础 CRUD 方法。刚上手的时候你会觉得真香不用写 XML 了简单几行代码就能操作数据库开发效率肉眼可见地提升。但用久了特别是项目上了规模你就会发现这个“全家桶”开始有点不够吃了。我遇到过最典型的一个场景就是逻辑删除。MP 的逻辑删除默认是把deleted字段从 0 改成 1。这没问题但业务上经常有这种需求领导要查历史数据包括那些已经被“删除”的。按照默认的BaseMapper你调用selectList它自动给你加上deleted 0的条件根本查不到已删除的数据。这时候怎么办难道要为了这个特殊场景去单独写一个原生的 MyBatis XML 映射文件或者用Select注解写一个全新的方法这感觉就像为了喝杯牛奶得去养头牛背离了使用 MP 提升效率的初衷。另一个高频痛点就是字段的自动填充。比如create_time、update_time这种字段我们希望插入或更新时数据库自动填上当前时间。MP 提供了MetaObjectHandler这个处理器配合TableField(fill FieldFill.INSERT)这样的注解确实能自动填充。但是如果你有一个方法它不是标准的insert或updateById而是你自定义的一个批量更新状态的方法比如updateStatusByIds那这个处理器就失效了update_time字段不会被自动更新。你不得不在业务代码里手动去set这个时间既繁琐又容易遗漏。还有更细的比如你想统一给所有查询加上某个固定的过滤条件多租户场景下根据tenant_id过滤是最典型的或者你想对某些敏感字段如密码的更新操作做统一的加密处理。这些横切关注点如果每个方法都去手动处理代码会变得冗余且难以维护。所以自定义BaseMapper的核心价值就在于把 MP 从一个“开箱即用”的工具升级为一个“量身定制”的架构组件。它允许我们在不破坏 MP 优雅接口的前提下将那些散落在业务代码里的通用逻辑收拢起来实现真正的 DRYDon‘t Repeat Yourself。这不是简单的功能扩展而是一种架构层面的提效和规范。接下来我们就一步步拆解如何从零开始玩转这个自定义过程。2. 庖丁解牛理解 BaseMapper 的骨架与扩展点在动手改造之前我们得先搞清楚BaseMapper到底是个什么结构MP 又是如何让它工作的。这就像修车你得先看懂发动机的图纸。BaseMapperT本身是一个泛型接口它继承自 MP 的com.baomidou.mybatisplus.core.mapper.BaseMapper。我们项目里写的UserMapper接口通常的写法就是public interface UserMapper extends BaseMapperUser。当你启动 Spring Boot 应用时MP 会通过动态代理技术为这个接口生成一个具体的实现类。这个实现类里包含了所有BaseMapper中声明方法的默认 SQL 逻辑。那么自定义的入口在哪里关键就在于“继承”和“实现”这两个动作。MP 允许我们创建一个中间层。创建自定义的顶层 Mapper 接口我们不再让业务的UserMapper直接继承 MP 官方的BaseMapper而是让它继承我们自定义的一个接口比如叫MyBaseMapperT。让MyBaseMapper继承官方的BaseMapper这样MyBaseMapper就拥有了所有默认方法。为MyBaseMapper提供默认实现我们可以创建一个类比如MyBaseMapperImpl利用 MP 的扩展机制让它成为MyBaseMapper的默认实现。在这个实现类里我们就可以“夹带私货”添加我们需要的通用逻辑。听起来有点绕我们来看一个更具体的扩展点com.baomidou.mybatisplus.core.injector.ISqlInjectorSQL 注入器。这是 MP 内部用来向 Mapper 接口中“注入”方法 SQL 实现的核心组件。默认的DefaultSqlInjector负责注入insert,selectById等方法。如果我们想全局添加一个新的通用方法比如一个通用的“逻辑删除后查询”方法就可以通过自定义SqlInjector来实现。另一个更贴近业务的扩展点是com.baomidou.mybatisplus.core.conditions.interfaces.Join相关的 Wrapper 构建。虽然不直接修改BaseMapper但通过自定义一个QueryWrapper或UpdateWrapper的子类我们可以在构造查询条件时自动添加一些全局约束如多租户的tenant_id条件这间接影响了所有使用该 Wrapper 的BaseMapper方法。理解这些底层机制能帮助我们在自定义时做出更合适的选择。如果你的需求是添加全新的、通用的数据库操作方法那么自定义SqlInjector和顶层Mapper接口是正道。如果你的需求主要是对现有方法的执行过程进行增强或拦截比如自动填充、多租户过滤那么 MP 的插件机制如InnerInterceptor可能是更优雅的解决方案。接下来我们会聚焦于前者即通过继承体系来自定义方法。3. 实战三步走构建你的 MyBaseMapper 体系理论讲完了我们直接上代码。目标是创建一个自定义的MyBaseMapper让它具备一个额外功能一个可以查询包括逻辑删除数据的方法selectListWithLogicDeleted。3.1 第一步定义顶层接口 MyBaseMapper首先创建一个新的接口它继承自官方的BaseMapper并声明我们想要添加的新方法。import com.baomidou.mybatisplus.core.mapper.BaseMapper; import java.util.List; /** * 自定义通用 Mapper 接口 * param T 实体类型 */ public interface MyBaseMapperT extends BaseMapperT { /** * 查询列表包含已被逻辑删除的数据 * param queryWrapper 实体对象封装操作类可以为 null * return 所有数据的列表包括 deleted1 的记录 */ ListT selectListWithLogicDeleted(Param(Constants.WRAPPER) WrapperT queryWrapper); }这里有几个关键点泛型T必须保留以保证与 MP 原有体系兼容。Param(Constants.WRAPPER)注解这是 MP 的约定用于标明参数是一个查询条件包装器。它保证了 MP 在生成 SQL 时能正确识别这个参数。方法名我们起名为selectListWithLogicDeleted清晰表明其功能避免与原有的selectList混淆。3.2 第二步实现自定义方法逻辑接下来我们需要为这个新方法提供 SQL 实现。MP 通常通过 XML 映射文件或Select注解来实现。这里我们选择更灵活的 XML 方式。首先创建一个对应的实现类。严格来说这个类不是必须的因为 MP 会通过动态代理处理接口。但为了组织代码和提供默认的 XML 映射路径提示我们可以创建一个空的实现类或者未来放置一些默认实现。import com.baomidou.mybatisplus.core.mapper.BaseMapper; /** * 自定义 Mapper 的基类实现 * 注意这个类本身不包含方法实现具体 SQL 由 MyBatis 的 XML 映射文件提供。 * 它的存在主要是为了作为 MyBatis 映射文件对应的 Mapper 接口的“根”方便管理。 */ public interface MyBaseMapperImplT extends MyBaseMapperT { // 方法的具体实现在对应的 XML 文件中 }重点在于 XML 映射文件。我们需要在resources目录下创建对应的映射文件。假设我们的MyBaseMapperImpl接口在包com.example.core.mapper下那么 XML 文件路径通常为resources/com/example/core/mapper/MyBaseMapperImpl.xml。?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.core.mapper.MyBaseMapperImpl !-- 通用查询映射结果可被其他语句引用 -- resultMap idBaseResultMap type你的实体类全路径例如 com.example.entity.User !-- 这里定义你的字段映射通常可以使用 MP 的 TableId, TableField 注解替代 -- !-- 如果实体类字段与数据库列名一致甚至可以省略 -- /resultMap !-- 定义“查询包含逻辑删除数据”的 SQL 片段 -- sql idselectWithLogicDeletedColumn !-- 这里列出所有需要查询的字段 -- id, name, email, deleted, create_time, update_time !-- 更通用的做法是引用一个包含所有字段的 SQL 片段 -- /sql !-- 实现接口中定义的 selectListWithLogicDeleted 方法 -- select idselectListWithLogicDeleted resultMapBaseResultMap SELECT include refidselectWithLogicDeletedColumn/ FROM your_table_name where ${ew.customSqlSegment} !-- 这是 MP 的核心用于拼接 Wrapper 中的条件 -- /where /select /mapper关键解释mapper namespace必须指向我们刚才创建的MyBaseMapperImpl接口的全限定名。这是 MyBatis 将 XML 与接口绑定的依据。${ew.customSqlSegment}这是 MP Wrapper 条件的注入点。ew是WrapperT参数的默认表达式对象。当调用selectListWithLogicDeleted(queryWrapper)时queryWrapper中生成的 SQL 条件片段如name ‘张三’就会替换到这里。注意这里用的是${}而不是#{}因为我们需要注入的是 SQL 片段而不是参数值。最重要的区别在这个自定义的 SQL 中我们没有添加AND deleted 0这个条件。而 MP 默认的selectList方法生成的 SQL 会自动加上这个条件如果你的实体类字段加了TableLogic注解。这就是我们实现“查询包含逻辑删除数据”的核心。3.3 第三步让业务 Mapper 接入自定义体系现在我们的基础设施已经搭建好了。如何让业务代码用上呢非常简单只需修改业务 Mapper 的继承关系。以前你的UserMapper可能是这样的public interface UserMapper extends BaseMapperUser { // 一些自定义查询方法... }现在把它改成继承我们自定义的MyBaseMapperimport com.example.core.mapper.MyBaseMapper; public interface UserMapper extends MyBaseMapperUser { // 原有的自定义查询方法依然可以保留 Select(SELECT * FROM user WHERE email #{email}) User findByEmail(String email); }就这样UserMapper现在不仅拥有原来BaseMapper的所有方法还额外拥有了selectListWithLogicDeleted方法。在 Service 或 Controller 中你可以像使用其他方法一样使用它Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { Override public ListUser getAllUsersIncludingDeleted() { // 使用自定义方法查询所有用户包括已逻辑删除的 return this.baseMapper.selectListWithLogicDeleted(null); } Override public ListUser getDeletedUsersByName(String name) { // 也可以结合 QueryWrapper 使用 QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(deleted, 1); // 只查询已删除的 wrapper.like(name, name); // 并且名字模糊匹配 return this.baseMapper.selectListWithLogicDeleted(wrapper); } }注意这里this.baseMapper是ServiceImpl基类提供的其类型就是UserMapper因此可以直接调用我们新增的方法。4. 进阶玩法注入全局方法与拦截器联动上面的例子解决了单一场景。但在实际企业级应用中需求往往更复杂。比如我们不仅想添加一个方法还想**重写Override**某个默认方法的行为或者让自定义方法能与 MP 的插件如自动填充、多租户完美联动。4.1 重写默认的 updateById 方法网络热词里有一个问题“mybatis-plus 的 basemapper 的 updatebyid 可以修改字段值为 null 吗” 默认情况下MP 的updateById方法使用的是“非空更新”策略。即当你传入一个实体对象MP 会使用UpdateWrapper只将非 null的字段拼接到 SQL 的 SET 语句中。如果你想将某个字段更新为null默认行为是无法实现的。通过自定义BaseMapper我们可以改变这个行为。思路是在自定义的顶层接口中重新声明updateById方法然后在 XML 中提供一个新的实现这个实现使用UPDATE table SET column1#{value1}, column2#{value2} ...的方式更新所有字段无论是否为 null。首先在MyBaseMapper接口中重新声明方法虽然父接口已有但重声明是为了指向我们自己的实现public interface MyBaseMapperT extends BaseMapperT { // ... 其他方法 /** * 全字段更新方法允许将字段更新为 null * param entity 实体对象 * return 影响的行数 */ int updateAllColumnById(Param(Constants.ENTITY) T entity); }然后在MyBaseMapperImpl.xml中实现update idupdateAllColumnById UPDATE ${tableName} !-- 这里需要动态生成 SET column1#{entity.property1}, column2#{entity.property2} ... -- !-- 手动列出所有字段非常繁琐且不易维护 -- WHERE id #{entity.id} /update你会发现手动在 XML 里写所有字段的 SET 语句是不可维护的。这时MP 的SqlInjector和AbstractMethod扩展机制就派上用场了。你可以创建一个自定义的UpdateAllColumnById方法类继承AbstractMethod在内部利用 MP 的TableInfo元数据对象动态生成包含所有字段的 UPDATE SQL。不过这涉及到更深层次的 MP 内核扩展代码量较大。更实用的解决方案是不重写updateById而是使用UpdateWrapper来显式地设置 null 值。UpdateWrapperUser wrapper new UpdateWrapper(); wrapper.eq(id, userId); wrapper.set(email, null); // 显式设置为 null wrapper.set(update_time, LocalDateTime.now()); userMapper.update(null, wrapper);这种方式更灵活也更容易理解。所以自定义BaseMapper不是为了替代所有 MP 功能而是填补其空白。对于update为 null 的需求使用UpdateWrapper通常是更佳实践。4.2 与多租户插件TenantLineInnerInterceptor联动另一个热词是“基于 mybatis-plus 的多租户注解实现”。MP 提供了强大的多租户插件TenantLineInnerInterceptor。配置后它会在所有涉及select、update、delete的 SQL 中自动加上tenant_id ‘当前租户’的条件。但这里有个大坑这个插件默认只对 MP 内置的通用方法即BaseMapper中的方法生效。对于我们自定义的selectListWithLogicDeleted方法由于 SQL 是我们自己写在 XML 里的多租户插件不会自动为我们加上租户条件这就是自定义方法带来的“副作用”——脱离了 MP 的某些自动管理机制。解决方案有两种方案一在自定义 SQL 中手动添加租户条件在 XML 的where标签内手动添加AND tenant_id #{tenantId}。但tenantId需要从当前上下文中获取通常存在ThreadLocal或SecurityContext中并作为参数传入方法。这破坏了方法的通用性且每个自定义方法都要加很麻烦。方案二让自定义方法也支持 MP 的 SQL 解析插件这是更优雅的方式。我们需要确保自定义方法的 SQL 构建仍然通过 MP 的SqlMethod和AbstractMethod机制来生成而不是手写静态 XML。这样生成的 SQL 就会经过TenantLineInnerInterceptor等插件的处理。具体做法是不采用“接口声明 XML 实现”的方式而是采用“完全注入”的方式创建一个自定义的MySqlInjector继承DefaultSqlInjector。重写getMethodList方法在调用super.getMethodList()获取默认方法后添加你自己定义的AbstractMethod子类实例。在你的AbstractMethod子类例如SelectListWithLogicDeleted中重写injectMappedStatement方法在这里面使用 MP 的SqlSource和TableInfo来动态构建 SQL 语句。这样构建出的 SQL 语句MP 会将其识别为“需要被插件处理”的标准语句。public class SelectListWithLogicDeleted extends AbstractMethod { Override public MappedStatement injectMappedStatement(Class? mapperClass, Class? modelClass, TableInfo tableInfo) { SqlMethod sqlMethod SqlMethod.SELECT_LIST; // 参考 SELECT_LIST 的逻辑 String sql String.format(sqlMethod.getSql(), sqlFirst(), sqlSelectColumns(tableInfo, true), tableInfo.getTableName(), sqlWhereEntityWrapper(true, tableInfo)); // 关键移除逻辑删除条件。tableInfo.isWithLogicDelete() 会判断是否有逻辑删除字段 // 我们需要修改 sqlWhereEntityWrapper 的行为但这里比较复杂可能需要重写相关方法 SqlSource sqlSource languageDriver.createSqlSource(configuration, sql, modelClass); return this.addSelectMappedStatementForTable(mapperClass, getMethod(sqlMethod), sqlSource, tableInfo); } }这种方式技术难度较高需要深入理解 MP 内部 SQL 组装逻辑。但它能保证自定义方法享有与内置方法同等的插件待遇多租户、数据权限、性能分析等。对于大多数场景如果自定义方法不多且对多租户有强需求方案一手动加条件在短期内更可控如果项目架构复杂自定义方法会成为标准模式那么投入精力实现方案二是值得的。5. 避坑指南与最佳实践自定义BaseMapper带来了强大的灵活性但也引入了新的复杂性和陷阱。下面是我在多个项目中实践后总结的几点关键经验和避坑指南。5.1 坑一XML 映射文件路径与扫描问题最常见的问题是自定义的 XML 文件没有被 MyBatis 扫描到导致Invalid bound statement (not found)异常。原因与排查默认扫描路径Spring Boot 集成 MyBatis-Plus 后默认的 XML 映射文件扫描路径是classpath*:/mapper/**/*.xml。如果你的MyBaseMapperImpl.xml放在com/example/core/mapper目录下但项目配置的mybatis-plus.mapper-locations没有覆盖这个路径就会找不到。接口与 XML 命名MyBatis 默认要求 XML 文件名与接口名一致。我们的接口叫MyBaseMapperImplXML 文件就必须叫MyBaseMapperImpl.xml。解决方案在application.yml中明确配置 mapper-locations确保它能扫描到你的自定义 XML 文件。mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml, classpath*:/com/example/core/mapper/**/*.xml # 或者更宽泛一点 # mapper-locations: classpath*:/**/*Mapper.xml同时检查你的MyBaseMapperImpl接口上是否加了Mapper注解或者在启动类上用MapperScan指定了其所在包确保接口本身能被 Spring 管理。5.2 坑二事务失效与传播行为当你自定义了一个batchInsertWithLog方法内部先插主表再插日志表并在 Service 中调用你可能会发现日志表插入失败时主表数据没有回滚。原因在 Spring 管理的事务中默认只有来自public方法且通过代理对象调用的数据库操作才会被事务拦截器管理。如果你在自定义 Mapper 方法中直接调用this.方法()this指向实际对象而非代理可能会导致内部方法调用的事务传播行为如REQUIRES_NEW失效。解决方案将复杂逻辑放在 Service 层Mapper 层只做最纯粹的数据访问。像“插入主表日志”这种业务逻辑应该放在 Service 的一个方法中并在此方法上使用Transactional注解。如果必须在 Mapper 的 XML 中执行多个语句确保 MyBatis 的事务管理器与 Spring 的事务管理器正确集成通常使用SpringManagedTransactionFactory并且在 Service 层的方法上开启事务。5.3 最佳实践分层与职责清晰定义清晰的继承层次BaseMapper (MP官方) ↑ MyBaseMapper (项目通用自定义如包含逻辑删除查询) ↑ XxxBaseMapper (模块通用自定义如订单模块独有的统计方法) ↑ UserMapper, OrderMapper (具体业务Mapper)不要把所有自定义方法都堆在顶层的MyBaseMapper里。按功能模块分层保持接口的单一职责。优先使用 MP 现有机制在自定义之前先问问自己这个需求能否通过 MP 的插件InnerInterceptor、全局配置GlobalConfig、元对象处理器MetaObjectHandler或者条件构造器Wrapper来实现这些是 MP 官方推荐的一等公民兼容性和稳定性更好。自定义BaseMapper是补充不是替代。为自定义方法编写单元测试这是保证自定义方法在 MP 版本升级后依然可用的唯一可靠手段。测试应覆盖方法的基本功能、边界条件以及与 Wrapper 的配合使用。文档化在团队内部一定要为自定义的MyBaseMapper及其方法编写清晰的文档。说明每个方法的用途、与内置方法的区别、使用的注意事项如是否受多租户插件影响。这能极大降低后续维护成本。自定义BaseMapper是一把双刃剑。用好了它能成为项目数据访问层的“瑞士军刀”极大提升开发效率和代码规范性用不好则会带来维护的噩梦。核心原则就是谨慎评估按需定制保持简洁充分测试。从解决一个具体的痛点如查询包含逻辑删除的数据开始逐步构建起适合自己项目的数据访问层规范这才是玩转自定义BaseMapper的正道。