MyBatis拦截器实现saas租户同库同表数据隔离
1.租户的概念
同一表结构内,通过租户id隔离不同的数据,也就是将一个系统给多个不同的客户用。
那么这就有一个前提:
需要将租户id冗余进每一张数据表内。
2.实现步骤一-数据库准备
2.1租户表(基础字段)
CREATE TABLE `sys_tenant` (`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',`code` varchar(50) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT '租户编号',`name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT '租户名称',`status` varchar(50) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT '状态',`domain_name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT '域名',`create_user` bigint(20) DEFAULT NULL COMMENT '创建人id',`create_time` datetime DEFAULT NULL COMMENT '创建时间',`update_user` bigint(20) DEFAULT NULL COMMENT '修改人id',`update_time` datetime DEFAULT NULL COMMENT '修改时间',`is_deleted` tinyint(4) DEFAULT NULL COMMENT '逻辑删除字段',PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci COMMENT='租户';
2.2将其他所以表都冗余租户id
示例:
CREATE TABLE `sys_user` (`user_id` bigint(20) NOT NULL COMMENT '用户ID',......`tenant_id` int(5) DEFAULT '0' COMMENT '租户编号',PRIMARY KEY (`user_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='用户信息表';
3.实现步骤2-controller层拦截器
3.1拦截器中从前端请求中的header中获取到租户id放进ThreadLocal
没错这需要前端小伙伴的配合
@Slf4j
public class TenantWebInterceptor implements HandlerInterceptor {private static final String Tenant_Header="tenant";@Resourceprivate SysTenantManager sysTenantManager;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//获取到租户idString tenantId = request.getHeader(Tenant_Header);if (StringUtils.isNotBlank(tenantId)) {//获取到之后去租户表中获取到租户的其他信息存进ThreadLocal,如果你不需要可以不进行SysTenantVo detail = sysTenantManager.detail(Integer.parseInt(tenantId));//只保存需要的租户数据在线程中TenantInfo tenantInfo=getTenantInfoFromSysTenant(detail);TenantInfoContext.setCurrentTenant(tenantInfo);} else {throw new BizException(ExceptionCodeEnum.Tenant_Exception.getCode(),"未查询到请求中的租户信息,请联系管理员");}return true;}//这是一个实体转换的方法,将获取到租户的所有信息选择性的保存进ThreadLocalprivate TenantInfo getTenantInfoFromSysTenant(SysTenantVo detail) {TenantInfo tenantInfo = new TenantInfo();if(detail==null){throw new BizException(ExceptionCodeEnum.Tenant_Exception.getCode(), "未找到该租户");}tenantInfo.setTenantId(detail.getId());tenantInfo.setName(detail.getName());return tenantInfo;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//请求结束,释放内存TenantInfoContext.clear();}
}
3.2配置拦截器
如你所见,我还配置了登录获取用户id的拦截器,获取用户id和获取租户id的拦截器不能放在一起的,因为登录拦截并不是每个接口都需要,但是租户拦截是每个接口都需要的。
@Configuration
public class WebConfig implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(tenantWebInterceptor()).addPathPatterns("/**");registry.addInterceptor(authenticationInterceptor()).addPathPatterns("/**").excludePathPatterns("/api/sysUser/loginByPassword","/swagger-ui/**", // Swagger UI 页面"/swagger-ui.html", // 传统 Swagger UI 路径"/swagger-resources/**", // Swagger 资源"/v3/api-docs/**", // OpenAPI 3.0 API 文档"/webjars/**" // Swagger UI 依赖的静态资源);}@Beanpublic AuthenticationInterceptor authenticationInterceptor() {return new AuthenticationInterceptor();}@Beanpublic TenantWebInterceptor tenantWebInterceptor() {return new TenantWebInterceptor();}
3.3放置租户id的ThreadLocal工具类
这个没什么好说的,和登录拦截中存储登录用户信息一个道理
public abstract class TenantInfoContext {private static final ThreadLocal<TenantInfo> TenantInfoThreadLocalHolder = new NamedThreadLocal("tenantInfo");public static void setCurrentTenant(TenantInfo tenantId) {TenantInfoThreadLocalHolder.set(tenantId);}public static TenantInfo getCurrentTenant() {return TenantInfoThreadLocalHolder.get();}public static Integer getCurrentTenantId() {return TenantInfoThreadLocalHolder.get().getTenantId();}public static void clear() {TenantInfoThreadLocalHolder.remove();}
}
4.实现步骤3-Mybatis拦截器
拦截所有的SQL为它们添加租户id的筛选条件。
实际上也并非所有的SQL,和租户表相关的就不可以拦截,因为租户表是唯一没有租户id字段的表。
当然,如果你图省事的话给租户表的后面也加上租户id也是可以的,毕竟租户管理员也只有第一个租户,加一个租户id字段不影响。
但这明显是不够合理和优雅的,所以我选更艰难但优雅的道路。
但是优雅的办法有个大坑,请往下看,同时代码中的注释亦有说明。
4.1Mybatis拦截器实现
这里是重点,但代码逻辑并不复杂,总之就是通过mybatis拦截器提供的API拿到执行的SQL,
然后根据执行的操作是编辑还是查找还是修改来区分使用不同的API进行添加租户id筛选条件。
如果是新增,我不建议用mybatis拦截器来进行属性注入,可以选用Mybatis-plus提供的封装后的拦截器更为高效。
@Slf4j
@Component
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare",args = {Connection.class, Integer.class})
})
public class TenantInterceptor implements Interceptor {@Overridepublic Object intercept(Invocation invocation) throws Throwable {log.info("mybatis租户拦截器开启");StatementHandler statementHandler = (StatementHandler) invocation.getTarget();MetaObject metaObject = SystemMetaObject.forObject(statementHandler);MappedStatement mappedStatement = (MappedStatement)metaObject.getValue("delegate.mappedStatement");log.info("拦截的Mapper方法: {}", mappedStatement.getId());/*** 这里不能通过invocation.getMethod().getAnnotations()来获取注解,* 因为在mybatis拦截器中的invocation.getMethod()返回的是StatementHandler的prepare方法,* 是 MyBatis 生成的代理方法,而不是被注解的原方法,所以会获取不到注解*/// 获取Mapper接口方法String mappedStatementId = mappedStatement.getId();String mapperClassName = mappedStatementId.substring(0, mappedStatementId.lastIndexOf('.'));String methodName = mappedStatementId.substring(mappedStatementId.lastIndexOf('.') + 1);try {// 反射获取Mapper接口Class<?> mapperClass = Class.forName(mapperClassName);if("SysTenantMapper".equals(mapperClassName.substring(mapperClassName.lastIndexOf('.') + 1))){//租户相关方法跳过租户拦截return invocation.proceed(); // 跳过处理}// 查找方法并检查是否有SkipTenantFilter注解for (Method m : mapperClass.getDeclaredMethods()) {if (m.getName().equals(methodName)) {if (m.isAnnotationPresent(SkipTenantFilter.class)) {return invocation.proceed(); // 跳过处理}break;}}} catch (ClassNotFoundException e) {log.warn("无法加载Mapper类: {}", mapperClassName);}Integer tenantId = TenantInfoContext.getCurrentTenantId();if (tenantId == null) {log.warn("未找到租户ID,跳过拦截");throw new BizException(ExceptionCodeEnum.Tenant_Exception.getCode(), ExceptionCodeEnum.Tenant_Exception.getValue());}BoundSql boundSql = statementHandler.getBoundSql();String originalSql = boundSql.getSql();log.info("originalSql:"+originalSql);String modifiedSql = addTenantCondition(originalSql, tenantId);metaObject.setValue("delegate.boundSql.sql", modifiedSql);log.info("modifiedSql:"+modifiedSql);return invocation.proceed();}private String addTenantCondition(String sql, Integer tenantId) {try {Statement statement = CCJSqlParserUtil.parse(sql);if (statement instanceof Select) {return processSelect((Select) statement, tenantId);} else if (statement instanceof Update) {return processUpdate((Update) statement, tenantId);} else if (statement instanceof Delete) {return processDelete((Delete) statement, tenantId);}} catch (JSQLParserException e) {log.warn("SQL解析添加租户筛选失败");throw new BizException(ExceptionCodeEnum.Tenant_Exception.getCode(), "SQL解析添加租户筛选失败");
// return addTenantConditionWithRegex(sql, tenantId);}return sql;}private String processSelect(Select select, Integer tenantId) {PlainSelect plainSelect = (PlainSelect) select.getSelectBody();Expression where = plainSelect.getWhere();// 创建租户过滤条件Expression tenantCondition = new EqualsTo().withLeftExpression(new Column("tenant_id")).withRightExpression(new LongValue(tenantId));if (where == null) {plainSelect.setWhere(tenantCondition);} else {plainSelect.setWhere(new AndExpression(where, tenantCondition));}return select.toString();}private String processUpdate(Update update, Integer tenantId) {Expression where = update.getWhere();// 创建租户过滤条件Expression tenantCondition = new EqualsTo().withLeftExpression(new Column("tenant_id")).withRightExpression(new LongValue(tenantId));update.setWhere(new AndExpression(where, tenantCondition));return update.toString();}private String processDelete(Delete delete, Integer tenantId) {Expression where = delete.getWhere();// 创建租户过滤条件Expression tenantCondition = new EqualsTo().withLeftExpression(new Column("tenant_id")).withRightExpression(new LongValue(tenantId));delete.setWhere(new AndExpression(where, tenantCondition));return delete.toString();}@Overridepublic Object plugin(Object target) {return Plugin.wrap(target, this);}
}
4.2Mybatis-plus的拦截器封装对新增赋值
新增操作,我建议选用Mybatis-plus提供的拦截器封装更为高效。
4.2.1实体类
@Data
public class BaseEntity {@TableField(fill = FieldFill.INSERT)private Long createUser;@TableField(fill = FieldFill.INSERT)private LocalDateTime createTime;@TableField(fill = FieldFill.INSERT_UPDATE)private Long updateUser;@TableField(fill = FieldFill.INSERT_UPDATE)private LocalDateTime updateTime;@TableField(fill = FieldFill.INSERT)private Integer isDeleted;@TableField(fill = FieldFill.INSERT)private Integer tenantId;
}
4.2.2Mybatis-plus封装的拦截器
@Component
public class AutoFillHandler implements MetaObjectHandler {@Overridepublic void insertFill(MetaObject metaObject) {Long userId = LoginUserInfoContext.getUserId();this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());this.strictInsertFill(metaObject, "createUser", Long.class, userId);this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());this.strictInsertFill(metaObject, "updateUser", Long.class, userId);this.strictInsertFill(metaObject, "isDeleted", Integer.class, 0);Integer tenantId = TenantInfoContext.getCurrentTenantId();this.strictInsertFill(metaObject, "tenantId", Integer.class, tenantId);}@Overridepublic void updateFill(MetaObject metaObject) {Long userId = LoginUserInfoContext.getUserId();this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());this.strictInsertFill(metaObject, "updateUser", Long.class, userId);}
}
4.3优雅避免租户表的mybatis拦截
租户表是sass系统中唯一一个没有租户id字段的表,所以我们需要对它进行特殊处理,越过SQL拦截。
实现逻辑就是通过自定义一个注解来避免,mybatis拦截器会拦截执行的方法,
此时你就可以检查这个方法是否被这个自定义注解注掉了,如果被注解了,就直接放过这个方法。
逻辑完全可行。
4.3.1自定义注解
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Component
public @interface SkipTenantFilter {String value() default "";
}
4.3.2 租户表的mapper文件,方法都用自定义注解注掉。
public interface SysTenantMapper extends BaseMapper<SysTenant> {@SkipTenantFilterList<SysTenant> list(SysTenantParam param);@SkipTenantFilterSysTenantVo detail(String id);@SkipTenantFilterint save(SysTenant sysTenant);@SkipTenantFilterint updateById(SysTenant sysTenant);}
4.3.3mybatis拦截器中的逻辑
刚开始的做法:(错误的别抄)
聪明的发现mybatis拦截器提供的invocation能方便的获取到method,于是有了如下做法。
Method method = invocation.getMethod();if(method.isAnnotationPresent(SkipTenantFilter.class)) {return invocation.proceed(); // 跳过处理}
结果根本获取不到注解,原因竟然是
在mybatis拦截器中的invocation.getMethod()返回的是StatementHandler的prepare方法,是 MyBatis 生成的代理方法,而不是被注解的原方法,所以会获取不到注解
于是只能老老实实根据方法的完整类路径通过反射获取方法拿注解了。
// 获取Mapper接口方法String mappedStatementId = mappedStatement.getId();String mapperClassName = mappedStatementId.substring(0, mappedStatementId.lastIndexOf('.'));String methodName = mappedStatementId.substring(mappedStatementId.lastIndexOf('.') + 1);try {// 反射获取Mapper接口Class<?> mapperClass = Class.forName(mapperClassName);// 查找方法并检查是否有SkipTenantFilter注解for (Method m : mapperClass.getDeclaredMethods()) {if (m.getName().equals(methodName)) {if (m.isAnnotationPresent(SkipTenantFilter.class)) {return invocation.proceed(); // 跳过处理}break;}}} catch (ClassNotFoundException e) {log.warn("无法加载Mapper类: {}", mapperClassName);}
至此,租户表里的SQL总算不会被拦截了。
4.3.4新的问题
如你所见,我的租户mapper里有一个list方法。
众所周知,list方法一般是需要分页的;
众所周知,分页一般是用的PageHelper;
众所周知,PageHelper会用拦截器帮你生成一个计算count的方法。
那么这个拦截器生成的count方法会有 @SkipTenantFilter注解吗?
当然没有。
所以,一怒之下,为了一劳永逸,干脆我在拦截器中设置只要是租户mapper里的方法都不准拦截。
如下:
if("SysTenantMapper".equals(mapperClassName.substring(mapperClassName.lastIndexOf('.') + 1))){//租户相关方法跳过租户拦截return invocation.proceed(); // 跳过处理}
不够优雅了,罪过。
4.3.5还没完
租户mapper里的方法都不拦截了,但是租户相关方法的权限如何保证呢?
我的做法是在controller层检查登录用户的租户是否是管理员租户
@GetMapping("/detail")@Operation(summary = "租户详情")public Result<Object> detail(Integer id){checkTenant();if(id==null){return Result.error("id不能为空");}return Result.success(sysTenantManager.detail(id));}private void checkTenant(){Integer tenantId = TenantInfoContext.getCurrentTenantId();if(tenantId!=0){throw new BizException(ExceptionCodeEnum.Tenant_Exception.getCode(), "您没有权限操作此模块");}}
太不优雅了,真是罪过。