Skip to content

DDK MyBatis Starter ​

给 MyBatis-Plus 配好一个 DDD 项目需要的默认件:分页、乐观锁、防全表更新删除、雪花 ID、审计字段自动填充,以及可选的多租户;并注册通用仓储依赖的 MapperProvider。

能做什么 ​

能力说明
分页PaginationInnerInterceptor,方言、单页上限、越界行为可配
乐观锁默认开启。PO 上标了 @Version 的字段在更新时带上 WHERE version = ?,这是 AggregateRoot.version() 和 ConcurrentUpdateException 生效的前提,见通用仓储
防全表更新删除默认开启。不带 WHERE 的 update / delete 直接抛异常
雪花 ID作用于 @TableId(type = IdType.ASSIGN_ID) 的主键,机器号可配
审计字段插入、更新时自动填充 createTime、updateTime、createBy、updateBy
多租户可选。每条 SQL 自动追加租户条件,取不到租户时拒绝执行
行级数据权限可选。受控表的查询、更新、删除按操作者的数据范围收窄,取不到操作者时拒绝执行(0.8.0 起)
MapperProviderGenericRepositoryImpl 做 Entity ↔ PO 转换时依赖它。它在 ddk-core 的 jar 里,应用的组件扫描扫不到,所以由本 starter 注册:用通用仓储时要引这个 starter,而不是只引 ddk-mybatis

MyBatis / MyBatis-Plus 本身的装配(SqlSessionFactory、Mapper 扫描、mybatis-plus.* 配置)来自 mybatis-plus-spring-boot4-starter,本 starter 不参与。

引入方式 ​

xml
<dependency>
    <groupId>com.domaindrivenkit</groupId>
    <artifactId>ddk-mybatis-starter</artifactId>
</dependency>

还需要数据库驱动和 spring.datasource.* 配置。Mapper 接口的扫描由应用自己声明(@MapperScan,或在接口上加 @Mapper)。

操作者上下文 ​

审计字段和多租户都从同一个地方取「当前是谁在操作」:ddk-core 里的 OperatorContext。

mermaid
flowchart LR
    R[HTTP 请求] --> F[OperatorContextFilter<br/>OperatorResolver → Operator]
    J[定时任务 / 消息监听] -->|OperatorContext.runAs| C
    F --> C[(OperatorContext)]
    C --> A[审计字段<br/>createBy / updateBy]
    C --> T[租户条件<br/>WHERE tenant_id = ?]
  • Web 请求:应用声明一个 OperatorResolver Bean,把请求解析成 Operator。DDK 不假设认证方式,可以从 Spring Security 的上下文、网关透传的请求头或 JWT 里取。Web starter 发现这个 Bean 后,在请求期间设置上下文,结束时清除。

    java
    @Bean
    OperatorResolver operatorResolver() {
        return request -> {
            String userId = request.getHeader("X-User-Id");
            return userId == null ? null : Operator.of(userId, request.getHeader("X-Tenant-Id"));
        };
    }
  • 定时任务、消息监听:没有请求,自己指定身份:OperatorContext.runAs(Operator.of("system", tenantId), () -> service.closeExpired())。

  • 换线程:上下文保存在线程上。0.9 起 ddk-core 为它注册了 Micrometer 的上下文访问器,Tracer Starter 注册的 ContextPropagatingTaskDecorator 会把操作者(连同租户)带进 @Async 方法和 Spring Boot 创建的线程池;关掉 ddk.tracer.async-propagation.enabled 或者没有引入 tracer starter 时不会传播。应用自己创建的线程不经过装饰器,用 OperatorContext.wrap(task)。

  • runAs / callAs 结束时恢复之前的操作者。请求线程来自线程池,如果只设置不清除,上一个请求的操作者会留给下一个请求。

审计字段 ​

PO 上声明字段并标上填充策略即可:

java
@TableField(fill = FieldFill.INSERT)
private Long createBy;

@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;

@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
  • 字段名固定为 createTime、updateTime、createBy、updateBy。时间字段是 LocalDateTime,操作者字段是 String 或 Long,其他类型会报错并指出字段。
  • 创建人只在为空时填写,调用方显式设置的值(例如数据迁移)不会被覆盖;修改人在每次更新时换成当前操作者。
  • 没有操作者时两个操作者字段保持为空,不会填一个占位值冒充。

多租户 ​

yaml
ddk:
  mybatis:
    tenant:
      enabled: true
      column: tenant_id
      ignore-tables: [ t_dict, t_tenant ]

开启后,MyBatis-Plus 的租户插件给每条语句加上租户条件:查询、更新、删除追加 tenant_id = ?,插入自动写入租户列。租户取自 OperatorContext 里操作者的 tenantId。

取不到租户时语句直接失败,而不是不加条件放行。 少了租户条件的查询会读到所有租户的数据,而且不会有任何报错,这类问题必须在开发阶段就暴露:

text
tenant = OperatorContext.current().tenantId
没有操作者,或操作者没有租户
  → IllegalStateException,提示三种处理办法
否则
  → 追加 tenant_id = <tenant>

两种豁免都要显式声明:

  • 不分租户的表(字典表、租户表本身)列在 ignore-tables 里;
  • 个别需要跨租户的语句,在 Mapper 方法上加 MyBatis-Plus 的 @InterceptorIgnore(tenantLine = "true")。

租户插件排在插件链的最前面,乐观锁、分页都作用在已经带上租户条件的 SQL 上。

行级数据权限 ​

0.8.0 起。

多租户把数据按租户切开。在一个租户内部,多数业务系统还要再切一刀:销售主管看本部门的合同,普通员工只看自己创建的,管理员看全部。这类条件靠每个查询手写最容易漏,而漏掉的后果是数据泄露,却不会让任何东西失败。

模型 ​

DataScope(在 ddk-core 里)从两个维度描述一个操作者能看到什么:数据属于哪个组(部门、门店、项目,由业务自己定义),以及数据由谁创建。两个维度是「或」的关系。

范围能看到
DataScope.all()全部
DataScope.ofGroups(ids)这些组的数据
DataScope.ownOnly()自己创建的
DataScope.ofGroups(ids).andOwn()两者满足其一
DataScope.none()什么都看不到

用法 ​

DDK 不知道角色和组织关系怎么存,所以由应用通过一个 Bean 回答一个问题:这个操作者的数据范围是什么。

java
@Bean
DataScopeResolver dataScopeResolver(RoleService roles) {
    return operator -> roles.dataScopeOf(operator.id());
}
yaml
ddk:
  mybatis:
    data-scope:
      enabled: true
      tables: [t_contract, t_invoice]     # 只有列在这里的表受控

之后这些表上的查询、更新、删除都会被追加条件,例如 (c.dept_id IN (10, 20) OR c.create_by = 3)。按主键读取、分页的计数、带别名的联表查询同样受限;范围之外的数据 updateById、deleteById 返回 0。

text
受控表上的 SELECT / UPDATE / DELETE
  OperatorContext 里没有操作者     → 抛出异常(和多租户一样,拒绝执行而不是放行)
  范围是「全部」                    → 语句不变
  否则追加条件                      → (组 IN (...) OR 创建人 = ?)
  两个维度都不适用                  → 追加 1 = 0,什么都读不到

要知道的几点 ​

  • DataScopeResolver 要自己缓存。 每条涉及受控表的语句都会调用它一次。实现里如果查库,查询落在受控的表上还会再次进入它。

  • 后台任务和消息消费方没有操作者。 它们访问受控表时要用 OperatorContext.runAs(...) 指定一个系统操作者,并让 DataScopeResolver 对它返回 DataScope.all()。

  • 个别语句的豁免:在 Mapper 方法上标 @InterceptorIgnore(dataPermission = "true")。

  • 列名可以按表覆盖,也可以声明某张表没有组这一列:

    yaml
    ddk:
      mybatis:
        data-scope:
          columns:
            t_note:
              group-column: ""          # 这张表只按创建人控制
              owner-column: author
              numeric-owner-id: false   # 这一列存的是字符串
  • 插入不受控。 数据权限只管读取和改动已有的行;新数据写到哪个组,由业务代码决定。

  • 范围之外的聚合在通用仓储看来是「不存在」。 find 返回空,update 影响 0 行,后者会被当成并发冲突报出来。

  • 开启后必须提供 DataScopeResolver,否则应用启动失败。

配置项 ​

配置项默认值说明
ddk.mybatis.db-typemysql分页方言。用 PostgreSQL、Oracle、达梦、H2 等数据库时必须改
ddk.mybatis.max-page-size500单页最大条数,超过会被静默截断
ddk.mybatis.overflowfalse页码超过总页数时是否回到第一页。默认返回空结果
ddk.mybatis.optimistic-lockertrue乐观锁插件
ddk.mybatis.block-attacktrue防全表更新删除插件
ddk.mybatis.worker-id / datacenter-id无雪花 ID 的机器号与数据中心号(0–31),两个要同时设置。多实例部署时应当显式指定
ddk.mybatis.tenant.enabledfalse多租户
ddk.mybatis.tenant.columntenant_id租户列名
ddk.mybatis.tenant.numeric-idtrue租户列是否为数值类型;为 false 时按字符串比较
ddk.mybatis.tenant.ignore-tables空不分租户的表,不区分大小写
ddk.mybatis.data-scope.enabledfalse是否启用行级数据权限
ddk.mybatis.data-scope.tables空受控的表,不区分大小写
ddk.mybatis.data-scope.group-columndept_id记录数据属于哪个组的列
ddk.mybatis.data-scope.owner-columncreate_by记录数据由谁创建的列
ddk.mybatis.data-scope.numeric-group-idtrue组的标识是否为数值类型
ddk.mybatis.data-scope.numeric-owner-idtrue创建人的标识是否为数值类型
ddk.mybatis.data-scope.columns.<表名>.*空按表覆盖上面四项;列名设为空字符串表示这张表没有这个维度
mybatis-plus.*MyBatis-PlusMapper XML 位置、逻辑删除字段、主键策略等,由 MyBatis-Plus 自己的 starter 处理

装配条件与降级行为 ​

  • 自动配置类:com.ddk.mybatis.starter.config.DdkMybatisAutoConfiguration(0.9 及以前叫 MybatisPlusAutoConfiguration),classpath 上有 MybatisPlusInterceptor 时生效。
  • MapperProvider、IdentifierGenerator、MybatisPlusInterceptor、MetaObjectHandler 四个 Bean 都带 @ConditionalOnMissingBean,应用声明同类型的 Bean 即可替换。
  • 自己声明 MybatisPlusInterceptor 会替换掉整个插件链:分页、乐观锁、防全表更新删除、多租户、数据权限都要自己重新加上。
  • 插件顺序固定为:多租户 → 数据权限 → 乐观锁 → 防全表更新删除 → 分页。分页放在最后,计数语句因此也带着租户和数据权限的条件。
  • 本 starter 自身不连接数据库;数据源由 Spring Boot 的自动配置创建。

不适用的场景 / 已知问题 ​

  1. 单页上限是静默截断:请求 size=1000 不会报错,会被压到 max-page-size 条返回。
  2. 审计字段的名字和类型是约定好的。字段名不同(如 gmtCreate)或时间字段用 Date 时不会填充,也不会报错。
  3. 多租户只在默认的插件链里生效。自己声明 MybatisPlusInterceptor 后要自己加租户插件。手写的原生 JDBC(JdbcTemplate)不经过 MyBatis-Plus,同样不会有租户条件。
  4. 数据权限只有「组」和「创建人」两个维度,两者取或。更复杂的规则(例如按金额、按状态、多个条件取且)不在范围内;手写的原生 JDBC 同样不受控。
  5. 逻辑删除没有额外封装:用 MyBatis-Plus 自带的 @TableLogic 即可。它与通用仓储的配合没有专门的测试。
  6. 多数据源:与 DB starter 组合时,MyBatis-Plus 只绑定 @Primary 数据源,其余数据源要自己配 SqlSessionFactory。

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权