saas|实战saas系统多租户数据隔离(二)使用租户id字段区分租户数据

目录
0. 前言
1. why mybatis-plus?
2. 需求分析
3. 环境准备
4. 实现步骤
4.1 准备TenantContext
4.2 创建MybatisPlusConfig配置类
4.3 编写接口
4.4 编写拦截器,在接口调用前进行多租户处理
5. 项目测试
总结
参考资料
0. 前言 【saas|实战saas系统多租户数据隔离(二)使用租户id字段区分租户数据】上一篇文章中,我们一起了解了一下saas系统架构中实现租户数据隔离的解决方案。本文中我们就来自己实现其中的一种解决方案,即使用租户id字段来实现同一张数据表中不同租户数据的增删改查。
我们将使用 springboot + mybatis-plus 这套组合框架,写一个小小的demo来演示如何实现这个方案。
事先声明,本文仅说明如何实现多租户的数据隔离,并不展开讨论其他问题,例如登录后会话有效期,超时后会话清理,数据库集群环境下的数据同步等。
嫌看文章麻烦啰嗦的大神,可以直接去看本文所涉及的代码。下面是码云仓库地址
https://gitee.com/zectorlion/MultiTenancy
仓库中的Solution3项目既是本文相关的代码(带sql脚本哦)
1. why mybatis-plus? 之所以使用 springboot + mybatis-plus 这套组合框架,除了他们是现在用的比较多的框架这个原因外,最重要的原因是,mybatis-plus框架已经给我们提供了现成的多租户数据crud解决方案。如果大家经常关注mybatis-plus的官方github仓库,那大家一定会注意到这么一个maven项目
saas|实战saas系统多租户数据隔离(二)使用租户id字段区分租户数据
文章图片

这个项目就是mybatis-plus提供给我们的,使用租户id字段解决同一张数据表中不同租户数据的crud问题的。
既然已经有轮子了,那我们就不需要再重复造轮子了,这样能节省我们好多功夫。感谢mybatis-plus开发小组,这里必须给你们点32个赞。
我们展开这个mybatis-plus-sample-tenant项目,在com.baomidou.mybatisplus.samples.tenant.config这个包下面有一个MybatisPlusConfig配置类。mybatis-plus解决多租户数据crud问题的方法,就在这个类的paginationInterceptor方法中。在paginationInterceptor方法中,创建了一个处理多租户sql语句的TenantSqlParser,而这个TenantSqlParser又通过调用setTenantHandler方法指定一个TenantHandler,这个TenantHandler是在mybatis-plus实际进行sql语句改写之前,为mybatis-plus指定租户id字段名称,租户id的值以及判断是否进行sql改写的处理器。
在TenantHandler中一共就三个方法,getTenantId,getTenantIdColumn,doTableFilter。
saas|实战saas系统多租户数据隔离(二)使用租户id字段区分租户数据
文章图片

getTenantId方法用于在改写sql之前设置租户id,getTenantIdColumn方法用于指定数据表中区分租户的字段。通过这两个方法,mybatis-plus就可以改写sql语句,管理某个租户的数据。例如getTenantId方法返回1,getTenantIdColumn方法返回tenant_id,那么在执行select语句的时候,就会增加一个 tenant_id=1 的where条件。
doTableFilter方法用于指定不进行多租户处理的表,例如用户信息表。
由此可知,我们只需要编写一个我们自己的TenantHandler,就可以实现通过租户id字段在单个数据表中区分租户数据的功能了。

2. 需求分析 我们整个项目将为用户提供两个对外的api接口,它们分别是

  1. 用户登录接口:接口访问地址是/user/login/{id},用户将自己的id号传递给接口进行登录。为了避免用户在登录时,租户id被抓包工具窃取,造成安全隐患,所以我们在用户登录时为用户生成一个 token 并返回给用户,然后将 token 和该用户的租户id关联,放入一个叫 tenantContext 的对象中。tenantContext 对象是用于保存用户 token 和租户id的关联关系的工具类,它将在项目启动时被注入到 springIOC 容器中。
  2. 资料数据增删改查接口:接口访问地址是/profile/findAll/{token}、/profile/add/{token}等。用户调用接口时,必须携带其登录时系统返回给他的 token。系统将根据用户传递的token,从 tenantContext 对象中取出用户的租户id,然后将该租户id传递给 mybatis-plus ,让mybatis-plus使用sql生成器生成特定租户的sql并执行,从而完成租户数据的增删改查。
saas|实战saas系统多租户数据隔离(二)使用租户id字段区分租户数据
文章图片

saas|实战saas系统多租户数据隔离(二)使用租户id字段区分租户数据
文章图片


OK,确定好了我们要干的“活”,下面我们就正式开工。

3. 环境准备 在开发任何系统之前,我们要先把环境准备好,所谓“工欲善其事,必先利其器”嘛。
我先给大家交代一下我所使用的系统环境和对应的版本,避免大家在版本号的问题上踩坑。
- springboot:2.1.4.RELEASE
- mybatis-plus:3.0.5
- mysql数据库:5.7.26-log MySQL Community Server (GPL)
- 谷歌浏览器:76.0.3809.132(正式版本) (64 位)

首先我们先准备一些测试数据,就是建个库,再建两张表,导入点数据。这两张表分别是存放用户信息的user表,和存放资料信息的profile表。
其中user表中有6条用户数据,分别属于两个不同的租户
saas|实战saas系统多租户数据隔离(二)使用租户id字段区分租户数据
文章图片

profile表中有两条数据,也分别属于不同的租户
saas|实战saas系统多租户数据隔离(二)使用租户id字段区分租户数据
文章图片


使用下面的sql脚本,可以直接完成建库,建表和导数据的整个过程。
-- Dump created by MySQL pump utility, version: 5.7.26, Win64 (x86_64) -- Dump start time: Thu Aug 29 16:52:01 2019 -- Server version: 5.7.26SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0; SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0; SET @OLD_SQL_MODE=@@SQL_MODE; SET SQL_MODE="NO_AUTO_VALUE_ON_ZERO"; SET @@SESSION.SQL_LOG_BIN= 0; SET @OLD_TIME_ZONE=@@TIME_ZONE; SET TIME_ZONE='+00:00'; SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT; SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS; SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION; SET NAMES utf8mb4; CREATE DATABASE /*!32312 IF NOT EXISTS*/ `multi-tenancy3` /*!40100 DEFAULT CHARACTER SET utf8 */; CREATE TABLE `multi-tenancy3`.`profile` ( `id` int(11) NOT NULL AUTO_INCREMENT, `tenant_id` int(11) DEFAULT NULL, `title` varchar(20) DEFAULT NULL, `content` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 ; CREATE TABLE `multi-tenancy3`.`user` ( `id` bigint(20) NOT NULL COMMENT '主键', `tenant_id` bigint(20) NOT NULL COMMENT '服务商ID', `name` varchar(30) DEFAULT NULL COMMENT '姓名', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 ; INSERT INTO `multi-tenancy3`.`user` VALUES (1,1,"Tony老师"),(2,1,"William老师"),(3,2,"路人甲"),(4,2,"路人乙"),(5,2,"路人丙"),(6,2,"路人丁"); INSERT INTO `multi-tenancy3`.`profile` VALUES (1,1,"1号档案","1号档案"),(2,2,"2号档案","2号档案"); SET TIME_ZONE=@OLD_TIME_ZONE; SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT; SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS; SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION; SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS; SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS; SET SQL_MODE=@OLD_SQL_MODE; -- Dump end time: Thu Aug 29 16:52:04 2019

然后我们再创建一个maven项目,导入需要的依赖坐标,例如springboot,mybatis-plus,lombok等等。
org.springframework.boot spring-boot-starter-parent 2.1.4.RELEASE org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-test test org.projectlombok lombok provided com.google.guava guava 19.0 com.baomidou mybatis-plus-boot-starter 3.0.5 com.baomidou mybatis-plus 3.0.5 com.baomidou mybatis-plus-generator 3.0.5 com.alibaba druid-spring-boot-starter 1.1.9 mysql mysql-connector-java

当然,实际编写的时候,我是设计成了一父多子的项目结构,然后把子项目中都会用到的依赖坐标放入父项目中。我的项目结构如下图所示
saas|实战saas系统多租户数据隔离(二)使用租户id字段区分租户数据
文章图片

其中的Solution3子项目,就是本文要进行开发的项目。细心的同学肯定看到了还有Solution2和Solution1,那是我们后面将要分享的内容,本文暂且不表。
搞定maven依赖坐标后,我们再把springboot的配置文件和启动引导类创建出来,然后能够正常启动springboot,准备工作就算完成了。
springboot配置文件的内容如下,各位可根据自己的实际情况进行修改
server: port: 8080 spring: application: name: solution3 datasource: url: jdbc:mysql://127.0.0.1:3306/multi-tenancy3?characterEncoding=UTF8 username: root password: 123456 type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.jdbc.Driver initialSize: 5 minIdle: 5 maxActive: 20 maxWait: 60000 timeBetweenEvictionRunsMillis: 60000 minEvictableIdleTimeMillis: 300000 validationQuery: SELECT 1 FROM DUAL testWhileIdle: true testOnBorrow: false testOnReturn: false poolPreparedStatements: true maxPoolPreparedStatementPerConnectionSize: 20

springboot启动引导类的代码如下
@SpringBootApplication public class Solution3App {public static void main(String[] args) { SpringApplication.run(Solution3App.class, args); } }

至此,所有的准备工作就算完成了。整个Solution3子项目的目录结构如下图
saas|实战saas系统多租户数据隔离(二)使用租户id字段区分租户数据
文章图片


4. 实现步骤 项目搭建完了,测试数据也准备好了,下面我们就一步一步把我们的系统实现出来。
4.1 准备TenantContext 前面我们在进行需求分析的时候提到了,用户在登录以后,系统会为他生成一个token,并将该token和用户的租户id绑定,放入TenantContext对象中保存。用户在访问数据接口的时候会携带token,系统会根据token,再从TenantContext中取出用户的租户id,交给mybatis-plus进行sql语句的改写。所以我们要先创建一个TenantContext对象,并将该对象注入到springIOC容器中。
我是在utils包下面创建的TenantContext对象,TenantContext对象的代码如下
public class TenantContext {private static final Map contextMap = Maps.newConcurrentMap(); public void putTokenTenantIdPair(String token, Long tenantId) { contextMap.put(token, tenantId); }public Long getTenantIdWithToken(String token) { return (Long) contextMap.get(token); } }

其实就是用一个CocurrentHashMap来存放token和租户id的键值对。token是键,tenantId是值。
然后在启动引导类中把TenantContext对象注入到springIOC容器中
saas|实战saas系统多租户数据隔离(二)使用租户id字段区分租户数据
文章图片

这样的话,我们就可以在需要使用TenantContext对象的地方,通过@Autowired注解来获取到它了。

4.2 创建MybatisPlusConfig配置类 前面我们看到,在mybatis-plus官方的多租户实例项目mybatis-plus-sample-tenant中是通过MybatisPlusConfig这个配置类来完成mybatis-plus的多租户sql处理器配置的。那我们也就照猫画虎,在config包下面创建一个我们自己的MybatisPlusConfig配置类。
由于官方实例项目中创建的TenantHandler,其getTenantId方法返回的租户id是写死的,不能用,所以我们需要自己编写一个TenantHandler。
public class MyTenantHandler implements TenantHandler {//用来区分不同租户数据的字段名 private static final String SYSTEM_TENANT_ID = "tenant_id"; //不进行多租户sql条件改写处理的表 private static final List IGNORE_TENANT_TABLES = Lists.newArrayList("provider", "user"); private Long tenantId; public void setTenantId(Long tenantId) { this.tenantId = tenantId; }@Override public Expression getTenantId() { if (null == this.tenantId) { throw new RuntimeException("getCurrentTenantId error."); } return new LongValue(this.tenantId); }@Override public String getTenantIdColumn() { return SYSTEM_TENANT_ID; }@Override public boolean doTableFilter(String tableName) { // 忽略掉一些表:如用户信息表(user)本身不需要执行这样的处理。 return IGNORE_TENANT_TABLES.stream().anyMatch((e) -> e.equalsIgnoreCase(tableName)); } }

然后在MybatisPlusConfig配置类中使用我们编写的这个MyTenantHandler
@Configuration @MapperScan("multi.tenancy.solution3.mapper") public class MybatisPlusConfig {@Bean public PaginationInterceptor paginationInterceptor() { PaginationInterceptor paginationInterceptor = new PaginationInterceptor(); // SQL解析处理拦截:增加租户处理回调。 TenantSqlParser tenantSqlParser = new TenantSqlParser() .setTenantHandler(new MyTenantHandler()); paginationInterceptor.setSqlParserList(Lists.newArrayList(tenantSqlParser)); return paginationInterceptor; }@Bean(name = "performanceInterceptor") public PerformanceInterceptor performanceInterceptor() { return new PerformanceInterceptor(); } }

这样mybatis-plus就可以根据我们在MyTenantHandler中设置的tenantId的值来生成对应租户的sql语句了。

4.3 编写接口 这里我就不多废话了,直接上核心代码。实体类,mapper这些代码我就不贴了,没啥技术含量
首先是读取user表中的数据,实现用户登录的接口UserController
@RestController @RequestMapping("/user") public class UserController {@Autowired private TenantContext tenantContext; @Autowired private UserMapper userMapper; private static Random random = new Random(); @GetMapping("/login/{id}") public String login(@PathVariable String id) {//直接根据用户id号查询出用户信息 User user = userMapper.selectById(id); //生成一个1000以内的随机数,作为用户登录后返回给用户的token String token = String.valueOf(random.nextInt(1000)); //将token、tenantId键值对放入tenantContext tenantContext.putTokenTenantIdPair(token,user.getTenantId()); return "login success, token is " + token + " tenant id is " + user.getTenantId(); } }

接口功能很简单,就是根据用户的id取出用户的信息,然后给这个用户生成一个token(1000以内的随机数),再把token和tenantId键值对放入tenantContext对象中,最后把相关信息返回给用户。

然后是对profile表中租户数据进行增删改查的接口ProfileController
@RestController @RequestMapping("/profile") public class ProfileController {@Autowired private ProfileMapper profileMapper; @GetMapping("/findAll/{token}") public String findAll(@PathVariable String token) {//prepareTenantContext(token); List profiles = profileMapper.selectList(null); profiles.forEach(System.out::println); return "operation complete, the following is the result \n" + profiles.toString(); }@GetMapping("/add/{token}") public String add(@PathVariable String token) {Profile p = new Profile(); p.setId((long) 3); p.setTitle("3号档案"); p.setContent("3号档案"); int result = profileMapper.insert(p); return "operation complete, the following is the result \n" + String.valueOf(result); }@GetMapping("/update/{token}") public String update(@PathVariable String token) {Profile p = new Profile(); p.setId((long) 3); p.setTitle("4号档案"); p.setContent("4号档案"); int result = profileMapper.updateById(p); return "operation complete, the following is the result \n" + String.valueOf(result); }@GetMapping("/delete/{token}") public String delete(@PathVariable String token) {int result = profileMapper.deleteById((long)3); return "operation complete, the following is the result \n" + String.valueOf(result); } }

在这个接口中,我没有在任何调用profileMapper的方法前,设置MyTenantHandler中的tenantId的值。那是因为我编写了一个拦截器,在调用接口之前就进行了设置租户id的处理(涉及user表的用户接口不进行拦截)。这样我就不用在每个接口的方法中去编写设置MyTenantHandler中的tenantId值的代码了,可以像以前那样去写crud功能。这样能少些不少冗余代码。
4.4 编写拦截器,在接口调用前进行多租户处理 我在interceptors包下面编写了TenantInterceptor拦截器,以实现在用户调用接口之前进行设置租户id的处理。下面是拦截器的代码
@Component public class TenantInterceptor implements HandlerInterceptor {@Autowired private TenantContext tenantContext; @Autowired private PaginationInterceptor pi; @Override public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception { String path=httpServletRequest.getRequestURI(); String token = path.substring(path.lastIndexOf("/") + 1); if (isTokenValid(token)) { prepareTenantContext(token); return true; }return false; }private void prepareTenantContext(String token) { TenantSqlParser tenantSqlParser = (TenantSqlParser) pi.getSqlParserList().get(0); MyTenantHandler myTenantHandler = (multi.tenancy.solution3.handler.MyTenantHandler) tenantSqlParser.getTenantHandler(); myTenantHandler.setTenantId(tenantContext.getTenantIdWithToken(token)); }private boolean isTokenValid(String token) { return null != tenantContext.getTenantIdWithToken(token); } }

在这个拦截器中,我编写了两个方法,验证token有效性的isTokenValid方法,以及根据token获取租户id,并将租户id放入myTenantHandler中的prepareTenantContext方法,然后在拦截器的preHandle方法中去调用。这样用户在调用接口之前,他的租户id就放入的mybatis-plus的TenantHandler中,mybatis-plus就可以根据他的租户id去改写sql语句了。

写好了拦截器后还不算完,我们还需要把拦截器配置到springMVC容器中,它才能生效。
在config包下面创建一个拦截器配置类,代码如下
@Configuration public class InterceptorConfig extends WebMvcConfigurerAdapter {@Autowired private TenantInterceptor tenantInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(tenantInterceptor).addPathPatterns("/**").excludePathPatterns("/user/login/**"); super.addInterceptors(registry); } }


搞定拦截器以后,我们的这个项目就算是写完了。下面我们可以把项目跑起来,然后测试一下,看看效果了。

5. 项目测试 首先我们运行Solution3App这个启动引导类,把这个springboot应用跑起来。然后我们打开浏览器,访问http://localhost:8080/user/login/1,得到1号的用户的token
saas|实战saas系统多租户数据隔离(二)使用租户id字段区分租户数据
文章图片

然后我们再访问http://localhost:8080/user/login/6,得到6号的用户的token
saas|实战saas系统多租户数据隔离(二)使用租户id字段区分租户数据
文章图片

我们看到,1号用户属于1号租户,6号用户属于2号租户。
现在我们调用profile中的findAll接口,把1号用户的token传递过去,看一下能否从profile表中获取出1号租户的那条数据
saas|实战saas系统多租户数据隔离(二)使用租户id字段区分租户数据
文章图片

没有问题。那我们把token换成6号用户的,再试一下能否获取出2号租户的那条数据
saas|实战saas系统多租户数据隔离(二)使用租户id字段区分租户数据
文章图片

也同样ok。

总结 篇幅问题,其他接口我就不逐一演示了。在我们调用增加,修改,和删除接口的时候,mybatis-plus都可以很好地根据租户id改写sql,尤其是insert的时候。只要你能够正确告诉mybatis-plus用来区分租户数据的字段名和租户id的值,mybatis-plus就可以自动帮你处理好。
可能有的朋友会担心,在高并发环境下,会不会产生多线程中的数据共享问题,即1号用户调用接口之前设置了MyTenantHandler的租户id,在mybatis-plus正要改写sql之前,6号用户又通过拦截器修改了MyTenantHandler的租户id,结果1号用户和6号用户查询出来的都是2号租户的数据。关于这个问题,我自己使用两个jmeter进行过测试,一个jmeter代表1号用户进行接口调用,另一个代表6号用户,两个jmeter返回的结果中,并没有出现数据混乱的情况。感兴趣的朋友,也可以自己测试一下看看效果。

参考资料 https://mybatis.plus/guide/tenant.html
https://gitee.com/baomidou/mybatis-plus-samples/tree/master/mybatis-plus-sample-tenant

    推荐阅读