当前位置: 首页 > 图灵资讯 > 技术篇> SpringCloud微服务实战——搭建企业级开发框架(二十二):基于MybatisPlus插件TenantLineInnerInterceptor实现多租户功能

SpringCloud微服务实战——搭建企业级开发框架(二十二):基于MybatisPlus插件TenantLineInnerInterceptor实现多租户功能

来源:图灵教育
时间:2023-10-20 17:56:50

多租户技术的基本概念:  多租户技术(英语:multi-tenancy technology)或称多重租赁技术,是一种软件架构技术,它是在探讨与实现如何于多用户的环境下共用相同的系统或程序组件,并且仍可确保各用户间数据的隔离性。  在云计算的加持之下,多租户技术被广为运用于开发云各式服务,不论是IaaS,PaaS还是SaaS,都可以看到多租户技术的影子。  前面介绍过GitEgg框架与数据库交互使用了Mybatis增强工具Mybatis-Plus,Mybatis-Plus提供了TenantLineInnerInterceptor租户处理器来实现多租户功能,其原理就是Mybatis-Plus实现了自定义Mybatis拦截器(Interceptor),在需要执行的sql后面自动添加租户的查询条件,实际和分页插件,数据权限拦截器是同样的实现方式。  简而言之多租户技术就是:可以让一套系统通过配置给不同的客户提供服务,每个客户看到的数据都是属于自己的,就好像每个客户都拥有自己一套独立完善的系统。

常见的多租户的实现方式有以下几种:
  1. 数据库隔离:在一个数据库实例中创建多个租户的数据库,每个租户只能访问自己的数据库。这种方式实现简单,但是不利于扩展和维护。

  2. 数据表隔离:在一个数据库中创建一张租户表,所有租户的数据存储在同一张表中,但是可以根据租户ID进行数据隔离。这种方式比较灵活,但是需要在应用程序中手动处理租户ID。

  3. 模式隔离:在一个数据库中创建多个模式(Schema),每个租户被分配到独立的模式中。这种方式比较灵活,支持多个租户共用同一张表,但是需要在应用程序中手动处理模式切换。

  4. 实例隔离:在一个应用程序中创建多个实例,每个租户被分配到独立的实例中。这种方式维护成本较高,但是支持多个租户之间的完全隔离。

  5. 混合模式:可以选择以上几种方式的组合实现多租户。例如,数据表隔离和模式隔离的结合方式,可以在一个数据库中创建多个租户表和多个租户模式,以增加灵活性和数据隔离性。

下面是在GitEgg系统的应用配置:1、在gitegg-platform-mybatis工程下新建多租户组件配置文件TenantProperties.java和TenantConfig.java,TenantProperties.java用于系统读取配置文件,这里会在Nacos配置中心设置多组户的具体配置信息,TenantConfig.java是插件需要读取的配置有三个配置项:

TenantId租户ID、TenantIdColumn多租户的字段名、ignoreTable不需要多租户隔离的表。TenantProperties.java:

package com.gitegg.platform.mybatis.props;import lombok.Data;import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.context.annotation.Configuration;import java.util.List;/** * 白名单配置 */@Data@Configuration@ConfigurationProperties(prefix = "tenant")public class TenantProperties {    /**     * 是否开启租户模式     */    private Boolean enable;    /**     * 多租户字段名称     */    private String column;    /**     * 需要排除的多租户的表     */    private List<String> exclusionTable;}

TenantConfig.java:

package com.gitegg.platform.mybatis.config;import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;import com.gitegg.platform.boot.util.GitEggAuthUtils;import com.gitegg.platform.mybatis.props.TenantProperties;import lombok.RequiredArgsConstructor;import net.sf.jsqlparser.expression.Expression;import net.sf.jsqlparser.expression.NullValue;import net.sf.jsqlparser.expression.StringValue;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.autoconfigure.AutoConfigureBefore;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;/** * 多租户配置中心 * * @author GitEgg */@Configuration@RequiredArgsConstructor(onConstructor_ = @Autowired)@AutoConfigureBefore(MybatisPlusConfig.class)public class TenantConfig {private final TenantProperties tenantProperties;/** * 新多租户插件配置,一缓和二缓遵循mybatis的规则, * 需要设置 MybatisConfiguration#useDeprecatedExecutor = false * 避免缓存万一出现问题 * * @return TenantLineInnerInterceptor */@Beanpublic TenantLineInnerInterceptor tenantLineInnerInterceptor() {return new TenantLineInnerInterceptor(new TenantLineHandler() {/** * 获取租户ID * @return Expression */@Overridepublic Expression getTenantId() {String tenant = GitEggAuthUtils.getTenantId();if (tenant != null) {return new StringValue(GitEggAuthUtils.getTenantId());}return new NullValue();}/** * 获取多租户的字段名 * @return String */@Overridepublic String getTenantIdColumn() {return tenantProperties.getColumn();}/** * 过滤不需要根据租户隔离的表 * 这是 default 方法,默认返回 false 表示所有表都需要拼多租户条件 * @param tableName 表名 */@Overridepublic boolean ignoreTable(String tableName) {return tenantProperties.getExclusionTable().stream().anyMatch((t) -> t.equalsIgnoreCase(tableName));}});}}
2、可在工程下新建application.yml,配置将来需要在Nacos上配置的信息:
tenant:  # 是否开启租户模式  enable: true  # 需要排除的多租户的表  exclusionTable:    - "t_sys_district"    - "oauth_client_details"  # 租户字段名称  column: tenant_id
3、修改MybatisPlusConfig.java,把多租户过滤器加载进来使其生效:
package com.gitegg.platform.mybatis.config;import com.baomidou.mybatisplus.annotation.DbType;import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;import com.gitegg.platform.mybatis.props.TenantProperties;import lombok.RequiredArgsConstructor;import org.mybatis.spring.annotation.MapperScan;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;@Configuration@RequiredArgsConstructor(onConstructor_ = @Autowired)@MapperScan("com.gitegg.**.mapper.**")public class MybatisPlusConfig {    private final TenantLineInnerInterceptor tenantLineInnerInterceptor;    private final TenantProperties tenantProperties;    /**     * 新的分页插件,一缓和二缓遵循mybatis的规则,需要设置 MybatisConfiguration#useDeprecatedExecutor = false     * 避免缓存出现问题(该属性会在旧插件移除后一同移除)     */    @Bean    public MybatisPlusInterceptor mybatisPlusInterceptor() {        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();        //多租户插件        if (tenantProperties.getEnable()) {            interceptor.addInnerInterceptor(tenantLineInnerInterceptor);        }        //分页插件        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));        //防止全表更新与删除插件: BlockAttackInnerInterceptor        BlockAttackInnerInterceptor blockAttackInnerInterceptor = new BlockAttackInnerInterceptor();        interceptor.addInnerInterceptor(blockAttackInnerInterceptor);        return interceptor;    }    /**     * 乐观锁插件 当要更新一条记录的时候,希望这条记录没有被别人更新     * https://www.tulingxueyuan.cn/d/file/p/20231020/nz4tw5q3rpz.html     */    @Bean    public OptimisticLockerInnerInterceptor optimisticLockerInterceptor() {        return new OptimisticLockerInnerInterceptor();    }}
4、在GitEggAuthUtils方法中新增获取租户信息的公共方法,租户信息在Gateway进行转发时进行设置,后面会说明如何讲租户信息设置到Header中:
package com.gitegg.platform.boot.util;import cn.hutool.json.JSONUtil;import com.gitegg.platform.base.constant.AuthConstant;import com.gitegg.platform.base.domain.GitEggUser;import org.springframework.util.StringUtils;import javax.servlet.http.HttpServletRequest;import java.io.UnsupportedEncodingException;import java.net.URLDecoder;public class GitEggAuthUtils {    /**     * 获取用户信息     *     * @return GitEggUser     */    public static GitEggUser getCurrentUser() {        HttpServletRequest request = GitEggWebUtils.getRequest();        if (request == null) {            return null;        }        try {            String user = request.getHeader(AuthConstant.HEADER_USER);            if (StringUtils.isEmpty(user))            {                return null;            }            String userStr = URLDecoder.decode(user,"UTF-8");            GitEggUser gitEggUser = JSONUtil.toBean(userStr, GitEggUser.class);            return gitEggUser;        } catch (UnsupportedEncodingException e) {            e.printStackTrace();            return null;        }    }    /**     * 获取租户Id     *     * @return tenantId     */    public static String getTenantId() {        HttpServletRequest request = GitEggWebUtils.getRequest();        if (request == null) {            return null;        }        try {            String tenantId = request.getHeader(AuthConstant.TENANT_ID);            String user = request.getHeader(AuthConstant.HEADER_USER);            //如果请求头中的tenantId为空,那么尝试是否能够从登陆用户中去获取租户id            if (StringUtils.isEmpty(tenantId) && !StringUtils.isEmpty(user))            {                String userStr = URLDecoder.decode(user,"UTF-8");                GitEggUser gitEggUser = JSONUtil.toBean(userStr, GitEggUser.class);                if (null != gitEggUser)                {                    tenantId = gitEggUser.getTenantId();                }            }            return tenantId;        } catch (UnsupportedEncodingException e) {            e.printStackTrace();            return null;        }    }}
5、GitEgg-Cloud工程中gitegg-gateway子工程的AuthGlobalFilter增加设置TenantId的过滤方法
        String tenantId = exchange.getRequest().getHeaders().getFirst(AuthConstant.TENANT_ID);        String token = exchange.getRequest().getHeaders().getFirst(AuthConstant.JWT_TOKEN_HEADER);        if (StrUtil.isEmpty(tenantId) && StrUtil.isEmpty(token)) {            return chain.filter(exchange);        }        Map<String, String> addHeaders = new HashMap<>();        // 如果系统配置已开启租户模式,设置tenantId        if (enable && StrUtil.isEmpty(tenantId)) {            addHeaders.put(AuthConstant.TENANT_ID, tenantId);        }
6、以上为后台的多租户功能集成步骤,在实际项目开发过程中,我们需要考虑到前端页面在租户信息上的配置,实现思路,不用的租户拥有不同的域名,前端页面根据当前域名获取到对应的租户信息,并在公共请求方法设置TenantId参数,保证每次请求能够携带租户信息。
// request interceptorrequest.interceptors.request.use(config => {  const token = storage.get(ACCESS_TOKEN)  // 如果 token 存在  // 让每个请求携带自定义 token 请根据实际情况自行修改  if (token) {    config.headers['Authorization'] = token  }  config.headers['TenantId'] = process.env.VUE_APP_TENANT_ID  return config}, errorHandler)

  TenantLineInnerInterceptor是Mybatis-Plus框架提供的多租户插件中的一个拦截器,用于实现多租户数据隔离的功能。它的实现原理如下:

  1. 在Mybatis的执行过程中,每个SQL语句都会被解析成ParameterMapping对象和BoundSql对象,其中BoundSql对象是SQL语句和参数的封装。

  2. TenantLineInnerInterceptor在执行SQL之前,会先获取当前租户的标识,然后遍历BoundSql对象的参数列表,将当前租户的标识设置到BoundSql对象对应的参数中,从而实现了SQL语句的多租户数据隔离。

  3. TenantLineInnerInterceptor还支持多租户字段的自动填充功能,可以自动将租户标识填充到实体类的对应字段中。这个功能是通过Mybatis-Plus提供的MetaObjectHandler机制实现的。

  4. TenantLineInnerInterceptor还提供了一些可配置参数,比如租户标识的参数名称、需要排除的表名等等,可以根据实际需求进行配置。

  总之,TenantLineInnerInterceptor的实现原理就是利用Mybatis的拦截器机制,在SQL执行之前,修改BoundSql对象中的参数,从而实现多租户数据隔离的功能。