最近接到一个任务,要将现有的用户系统改成租户模式。改造成租户模式最简单的方式就是为需要进行数据隔离的表加上租户 id 字段,然后前端调接口查询数据时,根据当前用户的租户 id,在查询的 sql 中的 where 条件中,对数据的查询范围进行限定。
一开始对系统进行租户模式改造时,写了很多重复的根据租户 id 限定数据范围的冗余代码。后面查看网上资料时,发现 Mybatis-plus 本身对租户模式的支持,于是就有了这篇心得分享。
首先来看看官方的 Mybatis-plus 提供的接口:TenantHandler.java
package com.baomidou.mybatisplus.extension.plugins.tenant; import net.sf.jsqlparser.expression.Expression; public interface TenantHandler { Expression getTenantId(); String getTenantIdColumn(); boolean doTableFilter(String tableName); }
只要能实现以上三个接口方法,Mybatis-plus 就能自动处理查询时对租户 id 字段的限定,和增删改时自动为语句增加租户 id 字段的维护。
接下来创建 MyTenantHandler.java,继承 TenantHandler 接口的同时,增加一个 setTenantId 的接口,以便设置当前用户的租户 id。
package com.rjkj.quickboot.base.tenant; import com.baomidou.mybatisplus.extension.plugins.tenant.TenantHandler; public interface MyTenantHandler extends TenantHandler { void setTenantId(String tenantId); }
然后在 MybatisPlusConfig 配置中,实现接口方法:MybatisPlusConfig.java
package com.rjkj.quickboot.base.config; import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.core.parser.SqlParserHelper; import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor; import com.baomidou.mybatisplus.extension.plugins.PerformanceInterceptor; import com.google.common.collect.Lists; import com.rjkj.quickboot.base.tenant.MyTenantHandler; import com.rjkj.quickboot.base.tenant.MyTenantSqlParser; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.StringValue; import org.apache.ibatis.mapping.MappedStatement; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.List; import java.util.regex.Pattern; @Configuration public class MybatisPlusConfig { @Bean public PaginationInterceptor paginationInterceptor() { PaginationInterceptor paginationInterceptor = new PaginationInterceptor(); TenantSqlParser tenantSqlParser = new TenantSqlParser() .setTenantHandler(new MyTenantHandler() { //进行多租户sql条件改写处理的表 private final List<String> TENANT_TABLES = Lists.newArrayList("user", "sys_user", "sys_role"); //线程局部变量 private final ThreadLocal<String> TENANT_CONTEXT = new InheritableThreadLocal<String>() { @Override protected String initialValue() { return null; } }; @Override public void setTenantId(String tenantId) { TENANT_CONTEXT.set(tenantId); } @Override public Expression getTenantId() { String tenantId = TENANT_CONTEXT.get(); if (null == tenantId) { return null; } return new StringValue(tenantId); } @Override //用来区分不同租户数据的字段名 public String getTenantIdColumn() { return "tenant_id"; } @Override public boolean doTableFilter(String tableName) { // 忽略掉一些表,不在TENANT_TABLES中的表忽略掉 return TENANT_TABLES.stream().noneMatch((e) -> e.equalsIgnoreCase(tableName)); } }); paginationInterceptor.setSqlParserList(Lists.newArrayList(tenantSqlParser)); return paginationInterceptor; } }
接下来增加一个拦截器,获取当前会话的用户的租户id。
小编是通过 Shiro 获取当前登录用户的信息,也可以通过解析 token 获取当前用户信息,很多方法都可以。
package com.rjkj.quickboot.base.inteceptor; import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor; import com.baomidou.mybatisplus.extension.plugins.tenant.TenantSqlParser; import com.rjkj.quickboot.base.system.vo.LoginUser; import com.rjkj.quickboot.base.tenant.MyTenantHandler; import org.apache.shiro.SecurityUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @Component public class TenantInterceptor implements HandlerInterceptor { @Autowired private PaginationInterceptor pi; @Override public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception { TenantSqlParser tenantSqlParser = (TenantSqlParser) pi.getSqlParserList().get(0); MyTenantHandler myTenantHandler = (MyTenantHandler) tenantSqlParser.getTenantHandler(); //获取当前用户 LoginUser currentUser = getCurrentUser(); myTenantHandler.setTenantId(currentUser.getTenantId()); return true; } private static LoginUser getCurrentUser(){ return (LoginUser) SecurityUtils.getSubject().getPrincipal(); } }
最后通过实现 WebMvcConfigurer接口,在自定义拦截器中添加刚刚的拦截器。
package com.rjkj.quickboot.base.config; import com.rjkj.quickboot.base.inteceptor.TenantInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import javax.annotation.Resource; @Configuration public class WebMvcConfiguration implements WebMvcConfigurer { @Resource private TenantInterceptor tenantInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { //租户拦截器 registry.addInterceptor(tenantInterceptor) .addPathPatterns( "/sys/user/**", "/user/**", "/sysRole/**") .excludePathPatterns("/sys/user/login", "/user/login"); } }
当查询 “user” , “sys_user” , “sys_role” 这些表时,会在 where 条件中自动加上对 tenant_id 字段的条件限定,并且匹配拦截器路径时会设置tenant_id 的值为当前用户的 tenant_id。
目前大致能实现租户模式的功能,但当自测进行使用时还是发现有一些问题。
假设小编是系统管理员,想看到租户A 数据的同时,也能看到租户B 的数据,这样就不能给系统管理员的账号设置租户id 。但如果不设置租户id ,当系统管理员进行查询时,sql 的 where 条件会加上 ”tenant_id = null” ,结果当然就是租户A 和租户B 的数据都看不到了。
这样就需要在对租户sql解析进行重写,增加对当前用户是否系统管理员的判断,这个放到下文继续研究。