第五章 · MyBatis 数据层与数据权限(mybatis Starter)
这一章回答数据访问的三个核心问题:每个表的公共字段谁负责填充(
BaseDO)、为什么一个 Mapper 不用写任何 SQL 就能 CRUD + 分页(BaseMapperX)、**如何做到”不同角色看到不同部门的数据”**(数据权限插件)。读完你会发现:业务代码写数据时几乎零样板,全靠这一层”拦截 + 填充 + 拼接”的魔法。
1. 模块概述
yudao-spring-boot-starter-mybatis 是基于 MyBatis-Plus 的增强 Starter(24 个文件),它解决业务开发中的”最后一公里“样板问题。另一个独立 Starter yudao-spring-boot-starter-biz-data-permission 在其之上做行级数据权限。
核心能力:
| 能力 | 关键类型 | 说明 |
|---|---|---|
| 公共字段自动填充 | BaseDO + DefaultDBFieldHandler |
createTime/updateTime/creator/updater/deleted 自动写入 |
| 全能 Mapper 基类 | BaseMapperX |
不写 SQL 获得 CRUD + 分页 + Join + 批量操作 |
| 类型处理器 | LongListTypeHandler 等 |
List/Set 与 JSON 互转 |
| 字段加密 | EncryptTypeHandler |
敏感字段(如手机号)加密存储 |
| 通用查询封装 | QueryWrapperX / LambdaQueryWrapperX |
防 SQL 注入 + 便捷分页 |
| 数据源适配 | YudaoDataSourceAutoConfiguration + IdTypeEnvironmentPostProcessor |
自动按库类型决定主键策略 |
| 数据权限 | DataPermissionRuleHandler + DeptDataPermissionRule |
按部门/数据范围自动追加 SQL 片段 |
在请求链路中的位置:
flowchart LR
SV[Service 调用 BaseMapperX 方法] --> MP[MyBatis-Plus 生成 SQL]
MP --> FILL[DefaultDBFieldHandler
INSERT/UPDATE 自动填充]
FILL --> DP[DataPermissionRuleHandler
数据权限追加 WHERE]
DP --> EXEC[执行 SQL]
EXEC --> P[分页插件切分记录]
P --> R[返回 PageResult/实体]
2. 命名体系与易混淆对象对比
2.1 命名规律拆解(Mapper 层面的”字段 → 操作”)
| 前缀 | 模块/对象 | 操作 | 含义 |
|---|---|---|---|
select |
One / FirstOne / List / Page / JoinPage |
(查询) | 按条件查单条/首条/列表/分页/连表分页 |
insert |
Batch |
(写入) | 批量插入 |
update |
Batch |
(写入) | 批量更新 |
delete |
(字段/值) | (删除) | 按字段删除 |
selectCount |
(字段/值) | (统计) | 计数 |
命名规律总结:BaseMapperX 的方法名高度遵循”动词 + 返回形态“(select/create/update/delete + One/List/Page/Count),一眼可读;其中 selectFirstOne 专为”并发下可能有多条但只取第一条”的场景设计。
2.2 易混淆对象对比:DefaultDBFieldHandler vs DataPermissionRuleHandler
| 对比维度 | DefaultDBFieldHandler |
DataPermissionRuleHandler |
|---|---|---|
| 实现接口 | MyBatis-Plus MetaObjectHandler |
MyBatis-Plus MultiDataPermissionHandler |
| 拦截阶段 | 实体对象层面(INSERT/UPDATE 前后) | SQL 语句层面(执行前拼 WHERE) |
| 做的事 | 填充 createTime/creator 等字段值 |
给 SQL 追加”部门/数据范围”条件 |
| 依赖用户信息 | 靠 WebFrameworkUtils.getLoginUserId() |
靠数据权限规则(DeptDataPermissionRule) |
| 一句话区分 | 是”自动填报”工具;是”自动行级过滤”工具。 |
再对比 BaseDO 的 @TableLogic deleted 与 COLA,重点理解逻辑删除:deleted=0 未删、deleted=1 已删,@TableLogic 让 MyBatis-Plus 自动给查询拼 deleted=0、删除变更新。
3. API Signatures
调用关系图(一次 selectPage 的完整路径):
graph TD
SV["Service"] -->|selectPage pageParam wrapper| BX["BaseMapperX.selectPage"]
BX --> EMPTY{"pageSize==PAGE_SIZE_NONE?"}
EMPTY -->|是 不分页| L["selectList 全量"]
EMPTY -->|否| BUILD["MyBatisUtils.buildPage 构建 IPage"]
BUILD --> MPQ["MP 分页插件"]
MPQ --> DATA["数据权限注入"]
DATA --> EXE["执行"]
EXE --> PR["PageResult records+total"]
L --> PR
1 | // —— Mapper 全能基类(继承 MPJ 以支持连表)—— |
关键方法用途:
selectPage的PAGE_SIZE_NONE特判:当pageSize为 -1 时退化为全量selectList,避免”分页列表还想看全部”时写两套逻辑。insertBatch对 SQL Server 特判:规避其批量插入后回填主键报错的问题,其他库走Db.saveBatch。BaseDO implements TransPojo是为了配合 Easy-Trans 做数据翻译(把 id 翻译成名称),见 第八章 的字典/部门回显。
4. 数据结构深度解析
4.a 结构体存在的理由:BaseDO
为什么要把这五个字段抽成公共父类?因为几乎每张业务表都需要”谁在什么时间创建的、谁改的、有没有被删”。若每张表都手写一遍,极易出现字段名/类型/逻辑删除的飘移。抽成
BaseDO后:①字段统一;②@TableField(fill=...)声明填充时机;③@TableLogic统一逻辑删除。所有 DO 继承它即自动获得。
4.b 代码:BaseDO
📍 源码:BaseDO.java
1 |
|
📌
creator/updater用 String 而非常见 Long:源码注释解释”未来可能存在非数值情况(如 member 编号),留好拓展性”——这是典型的”为未来留缝”设计。
4.c 字段三层分析表
| 字段 | 设计动机(为什么需要) | 反事实(如果去掉会怎样) | 替代方案(还能怎么做) |
|---|---|---|---|
createTime |
审计/排序需要创建时间 | 无法按时间排列表单 | Service 手填,到处重复 |
updateTime |
更新追踪 | 不知道最后改于何时 | 同上 |
creator/updater |
审计”谁干的” | 出问题无从追责 | 每次手填,易漏 |
deleted |
逻辑删除,防误删、可回收 | 物理删除不可恢复 | 无 @TableLogic,手写删除标记,易飘移 |
4.d 生命周期状态图(一条用户记录)
stateDiagram-v2
[*] --> 正常未删: INSERT(deleted=0, creator=当前用户)
正常未删 --> 在线修改: UPDATE(updateTime/updater 刷新)
在线修改 --> 正常未删
正常未删 --> 逻辑已删: delete → UPDATE deleted=1
逻辑已删 --> [*]
逻辑已删 --> 正常未删: 恢复(手动置 deleted=0,一般不提供)
5. 函数逐行精讲
5.a 场景卡片
**类:
DefaultDBFieldHandler**(源码)
- 调用时机:MyBatis-Plus 执行 INSERT/UPDATE 时(依据
@TableField(fill=...))。- 典型调用者:任意
BaseMapperX.insert/update。- 前置条件:实体继承
BaseDO。- 目的:把”当前时间、当前用户”等与业务无关的公共字段自动补全。
5.b 逐行注释式精讲:insertFill
1 |
|
🔍 对紧耦合之处:
DefaultDBFieldHandler依赖WebFrameworkUtils(web starter)拿用户——这体现 Starter 之间通过 request 属性(login_user_id)共享状态,而非直接调 Security API,保持了耦合最小化。
5.c 场景卡片:BaseMapperX.selectPage
函数:
selectPage(PageParam, sortingFields, Wrapper)
- 调用时机:列表/分页查询。
- 典型调用者:各 Service 的分页列表。
- 前置条件:调用方告知
PageParam(页码 + 每页条数)与Wrapper。- 目的:把”分页三件套(封装 Page、查询、转 PageResult)”压缩成一行。
1 | default PageResult<T> selectPage(PageParam pageParam, Collection<SortingField> sortingFields, Wrapper<T> queryWrapper) { |
6. 关键机制剖析:数据权限(Data Permission)
6.1 原理
DataPermissionRuleHandler 实现 MyBatis-Plus 的 MultiDataPermissionHandler。其核心是 SQL 拦截:在执行前拿到待执行的 SQL(jsqlparser 解析后),根据 Mapper 关联的数据权限规则,动态往 WHERE 追加条件,只放行用户有权访问的行。
getSqlSegment 流程:
ruleFactory.getDataPermissionRule(mappedStatementId)——获取该 Mapper 命中的权限规则集合;- 遍历规则,若规则命中的表名包含当前
table,则调用rule.getExpression(tableName, alias)生成条件表达式; - 多条规则用
AndExpression拼接(取交集),最终作为 SQL 片段追加进 WHERE。
6.2 一个可落地的规则:DeptDataPermissionRule
按部门/数据范围隔离(如”只看本部门数据”),它会为部门字段生成类似 dept_id IN (本部门, 子部门...) 的条件。规则的 getTableNames() 声明它作用于哪些表,getExpression() 声明拼什么条件。这就实现了”同一张表、不同人看到不同行“。
6.3 边界处理
- 表名不匹配跳过:规则明确声明
tableNames,避免误伤无关表。 - 在
@DataPermission注解或DataPermissionUtils中可临时禁用:比如某些统计接口需要”穿透”权限看全量数据。 - 数据权限只影响查询,不改变写入(写入由业务校验 + 事务保证)。
7. 设计决策分析
- 为什么用逻辑删除(
deleted)而非物理删除? 权衡:逻辑删除保留历史、可审计可恢复、且不用动索引/外键,代价是查询必须带deleted=0(MyBatis-Plus 自动处理)且表会随时间增长。对管理后台型业务,恢复价值 > 存储代价。 - 为什么把主键策略做成”智能适配”而非硬编码?
IdTypeEnvironmentPostProcessor依据数据源类型自动选AUTO(MySQL 自增)/INPUT(Oracle 等)/ASSIGN_ID(雪花),一处配置适应多库,是平台”支持国产生信数据库“的重要一环(见 第十一章)。 - 为什么数据权限用”SQL 注入式”而非”代码层过滤”? 若在每个 Service 手写
if (超管) else 只看本部门,会散落成灾且易漏。SQL 拦截保证”凡经过这个表的所有查询都被统一过滤“,一处生效、全局防护;代价是 SQL 复杂度略增、需在异常统计时显式关闭。
8. 学习检查点
📝 本章小结
BaseDO统一五个公共字段并声明填充时机;DefaultDBFieldHandler自动补全(仅空值填充)。BaseMapperX通过 default 方法让所有 Mapper”零 SQL”获得 CRUD + 分页 + 连表 + 批量。- 逻辑删除靠
@TableLogic自动加deleted=0,删除变更新。 - 数据权限靠
MultiDataPermissionHandler在 SQL 执行前追加 WHERE 条件,实现行级隔离。
🤔 思考题
为什么
DefaultDBFieldHandler采用”仅当字段为 null 时才填充”?批量导入或定时任务如何受益?(提示:系统自动任务没有登录用户)参考答案
好处是尊重显式赋值。例如定时任务/批量导入插入数据时,
WebFrameworkUtils.getLoginUserId()可能为 null(无人登录),此时creator会保持 null、由调用方或默认值兜底;如果调用方显式传了creator="job",框架不会覆盖。这使”框架统一填充”与”业务按需指定”二者能共存。若改为”无条件覆盖”,系统任务创建的数据就会把creator覆盖成 null,丢失来源。数据权限拦截器能给 SQL 追加条件,那它会不会被告知这是一条
SELECT * FROM user?它如何知道该拼dept_id IN (...)?给你制造一次”拼接逻辑分错表”的隐患。(提示:jsqlparser 的 Table + 规则的 getTableNames())参考答案
getSqlSegment(table, where, ...)会拿到每张被引用表的Table对象(jsqlparser 解析后);处理器遍历规则,只在rule.getTableNames().contains(当前表名)时才用该规则生成表达式。隐患在于:如果规则把表名声明错了(例如在user规则里误写进dept表名),就会给错误的表追加不相关条件,导致该表查询结果异常。这也是为什么规则的getTableNames()必须精确。