目录
0. 前言
1. why mybatis-plus?
2. 需求分析
3. 环境准备
4. 实现步骤
4.1 准备TenantContext
4.2 创建MybatisPlusConfig配置类
4.3 编写接口
4.4 编写拦截器,在接口调用前进行多租户处理
5. 项目测试
总结
参考资料
上一篇文章中,我们一起了解了一下saas系统架构中实现租户数据隔离的解决方案。本文中我们就来自己实现其中的一种解决方案,即使用租户id字段来实现同一张数据表中不同租户数据的增删改查。
我们将使用 springboot + mybatis-plus 这套组合框架,写一个小小的demo来演示如何实现这个方案。
事先声明,本文仅说明如何实现多租户的数据隔离,并不展开讨论其他问题,例如登录后会话有效期,超时后会话清理,数据库集群环境下的数据同步等。
嫌看文章麻烦啰嗦的大神,可以直接去看本文所涉及的代码。下面是码云仓库地址
https://gitee.com/zectorlion/MultiTenancy
仓库中的Solution3项目既是本文相关的代码(带sql脚本哦)
之所以使用 springboot + mybatis-plus 这套组合框架,除了他们是现在用的比较多的框架这个原因外,最重要的原因是,mybatis-plus框架已经给我们提供了现成的多租户数据crud解决方案。如果大家经常关注mybatis-plus的官方github仓库,那大家一定会注意到这么一个maven项目
这个项目就是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。
getTenantId方法用于在改写sql之前设置租户id,getTenantIdColumn方法用于指定数据表中区分租户的字段。通过这两个方法,mybatis-plus就可以改写sql语句,管理某个租户的数据。例如getTenantId方法返回1,getTenantIdColumn方法返回tenant_id,那么在执行select语句的时候,就会增加一个 tenant_id=1 的where条件。
doTableFilter方法用于指定不进行多租户处理的表,例如用户信息表。
由此可知,我们只需要编写一个我们自己的TenantHandler,就可以实现通过租户id字段在单个数据表中区分租户数据的功能了。
我们整个项目将为用户提供两个对外的api接口,它们分别是
OK,确定好了我们要干的“活”,下面我们就正式开工。
在开发任何系统之前,我们要先把环境准备好,所谓“工欲善其事,必先利其器”嘛。
我先给大家交代一下我所使用的系统环境和对应的版本,避免大家在版本号的问题上踩坑。
- 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条用户数据,分别属于两个不同的租户
profile表中有两条数据,也分别属于不同的租户
使用下面的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.26
SET @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
当然,实际编写的时候,我是设计成了一父多子的项目结构,然后把子项目中都会用到的依赖坐标放入父项目中。我的项目结构如下图所示
其中的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子项目的目录结构如下图
项目搭建完了,测试数据也准备好了,下面我们就一步一步把我们的系统实现出来。
前面我们在进行需求分析的时候提到了,用户在登录以后,系统会为他生成一个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容器中
这样的话,我们就可以在需要使用TenantContext对象的地方,通过@Autowired注解来获取到它了。
前面我们看到,在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语句了。
这里我就不多废话了,直接上核心代码。实体类,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功能。这样能少些不少冗余代码。
我在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);
}
}
搞定拦截器以后,我们的这个项目就算是写完了。下面我们可以把项目跑起来,然后测试一下,看看效果了。
首先我们运行Solution3App这个启动引导类,把这个springboot应用跑起来。然后我们打开浏览器,访问http://localhost:8080/user/login/1,得到1号的用户的token
然后我们再访问http://localhost:8080/user/login/6,得到6号的用户的token
我们看到,1号用户属于1号租户,6号用户属于2号租户。
现在我们调用profile中的findAll接口,把1号用户的token传递过去,看一下能否从profile表中获取出1号租户的那条数据
没有问题。那我们把token换成6号用户的,再试一下能否获取出2号租户的那条数据
也同样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