写在前面

本篇是笔者一年来对智能照明SaaS平台多租户改造升级的一些实践与体会,这些内容在技术分享会上已做了介绍,下面是一些重要的概念和思想。

多租户

多租户简介

多租户技术,它是一种软件架构技术,用于实现多用户环境下,共用相同的系统或者程序,并确保各个用户间数据的安全隔离。即一个单独的实例,可以为多个组织提供服务。

SaaS多租户

(1)SaaS是Software-as-a-Service的缩写,意为软件即服务,即通过网络提供软件服务。
(2)SaaS平台供应商将应用部署到自己的服务器上,客户可根据实际需要,通过互联网向厂商订购所需的应用软件服务,按照订购的服务多少及时间长短向厂商支付费用,并通过互联网获得SaaS平台供应商提供的服务。
(3)SaaS服务基于一套标准的软件系统为成百上千个不同的客户(又称为租户)提供服务。这要求SaaS服务能够支持不同租户间的数据和配置隔离,同时支持对界面、业务逻辑、数据结构等方面的个性化需求开发。
(4)由于SaaS同时支持多个租户,而每个租户又有很多用户,因此对支撑软件的基础设施平台的性能、稳定性和扩展性有很高的要求。
(5)多租户是SaaS领域特有的产物。对于SaaS服务供应商来说,构建SaaS提醒需要完成两部分工作,即上层服务和底层多租户系统。

SaaS多租户优点

(1)开发和运维成本低。
(2)按需付费,节约成本。
(3)故障排查更及时。

多租户模型

下面是多租户模型的示意图:
image.png
可以看到该模型主要涉及到九类:
(1)租户。指一个企业或者个人客户,租户间数据与行为完全隔离,上下级的租户之间通过授权实现数据共享。注意,每个租户只能操作归属于自己租户的数据。
(2)组织。如果租户是一个企业客户,那么通常就会有自己的组织架构。
(3)用户。指某个租户下具体的使用者,即具有用户名、密码、邮箱等账号的自然人。
(4)角色。用户操作权限的集合。
(5)员工。组织内的某位员工。
(6)解决方案。为解决客户的某类业务问题,SaaS供应商一般会将产品和服务组合在一起,为客户提供整体的打包方案。
(7)产品能力。帮助客户实现场景解决方案闭环的能力。
(8)资源域。用于运行1个或者多个产品应用的一套云资源环境。
(9)云资源。SaaS产品一般部署在各种云上面,由各种云平台提供计算、存储、网络等资源。

SaaS多租户数据隔离

多租户对于用户来说,最重要的一点在于数据隔离。绝对不允许出现A公司用户登录系统看到了B公司的数据,因此多租户的数据库设计方案具有很高的挑战。

目前主流的SaaS多租户数据库设计方案分为三种,即为每个租户提供独立的数据库、独立的数据表、按照字段区分租户,每种方案都有其适用场景。

一个租户一个数据库

一个租户对应一个数据库,意味着SaaS系统需要连接多个数据库,这种方案有点类似于分库分表。好处是数据隔离级别高、安全性好,毕竟一个租户一个数据库,但缺点就是资源利用率不高,维护成本也高。

一个租户一个数据表

一个租户对应一个数据表,这就意味着所有租户共用一个数据库,只是每个租户在数据库系统中拥有一个独立的数据表。

按租户id字段隔离租户

这是三种方案中最简单的数据隔离方式,即在每张表中都添加一个字段(tenant_id),用于标识数据归属于哪个租户。之后在对数据进行操作时,都需要将该字段作为过滤条件。由于所有租户的数据都存放在同一张表中,因此数据隔离性是最差的,很容易出现把数据弄错的情况。

三种数据隔离架构设计对比

下面是三种数据隔离架构的对比:
image.png
结合公司现状,最终决定采用按照租户id字段隔离租户这一架构,这种方式能降低服务器成本,但也提高了开发难度,有利有弊。

基于Mybatis实现多租户

由于本公司目前使用的ORM框架为Mybatis,因此决定在此基础上配合拦截器实现多租户。

实现思路

最简单的方式就是在SQL语句执行前,拦截原始语句,然后在语句后面添加where tenant_id = tenantID,当然了实际情况并不是所有的表中都包含tenant_id字段,因此并不能全部都加上这个字段,这里为了演示,就不考虑这个问题。那么问题来了,如何拦截原始SQL语句呢?可以使用Mybatis提供的拦截器来实现。

点击 这里 阅读Mybatis的插件文档,实际上它就是一个拦截器:
image.png
要想使用Mybatis提供的插件,只需实现Interceptor接口,并指定想要拦截的方法签名即可。默认情况下,MyBatis 允许使用插件来拦截的方法调用包括:

  • Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed);
  • ParameterHandler (getParameterObject, setParameters);
  • ResultSetHandler (handleResultSets, handleOutputParameters);
  • StatementHandler (prepare, parameterize, batch, update, query).

Mybatis的插件使用起来非常简单,只需如下三步:
(1)实现Interceptor接口;
(2)指定想要拦截的方法签名;
(3)注册这个插件。

代码实战

创建项目

第一步,新建一个名为interceptor-mybatis的SpringBoot项目,然后在POM文件中引入redis、mysql和web依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--添加mybatis依赖-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.3.2</version>
</dependency>
<!--添加数据库驱动依赖-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--添加数据库连接池依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.10</version>
</dependency>
</dependencies>

第二步,在application.yml配置文件中新增redis和mysql配置信息及项目运行端口信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
url: jdbc:mysql://127.0.0.1:3306/many_tenant?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
username: root
password: root
redis:
host: 127.0.0.1
database: 2
port: 6379
password:
timeout: 5000
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
com.gutsyzhan.interceptormybatis.mapper: debug
server:
port: 8081

创建Redis工具类

第三步,新建utils包,并在utils包内新建一个名为RedisUtil的工具类,该类用于封装对Redis的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Component
public class RedisUtils {
public static final String TENANT_ID_KEY = "tenant_id";

@Resource
private RedisTemplate<Object,Object> redisTemplate;

public boolean setExpire(Object value, Long expireTime){
boolean result = false;
try{
redisTemplate.opsForValue().set(TENANT_ID_KEY, value, expireTime, TimeUnit.SECONDS);
result = true;
}catch (Exception e){
e.printStackTrace();
}
return result;
}

public String get(){
try{
Object o = redisTemplate.opsForValue().get(TENANT_ID_KEY);
if(o == null){
return "";
}
return o.toString();
}catch (Exception e){
e.printStackTrace();
}
return "";
}
}

创建Bean工具类

第四步,在utils包内新建一个名为BeanUtils的工具类,该类用于获取Spring容器中的Bean对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class BeanUtils implements BeanFactoryAware {
private static BeanFactory beanFactory;
@Override
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
BeanUtils.beanFactory = beanFactory;
}

public static Object getBean(String beanName){
return beanFactory.getBean(beanName);
}

public static <T> T getBean(Class<? extends T> clazz){
return beanFactory.getBean(clazz);
}
}

创建拦截器类

第五步,新建interceptor包,并在interceptor包内新建一个名为TenantInterceptor的拦截器类,该类用于拦截原始SQL语句并对SQL语句进行拼接操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
@Intercepts({
@Signature(type = Executor.class, method = "query", args = { MappedStatement.class, Object.class,
RowBounds.class, ResultHandler.class }),
@Signature(type = Executor.class, method = "update", args = { MappedStatement.class, Object.class})
})
public class TenantInterceptor implements Interceptor {

@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
Object parameter = invocation.getArgs()[1];
BoundSql boundSql = mappedStatement.getBoundSql(parameter);

//获取原始的SQL语句
String sql = boundSql.getSql();
// 获取当前租户标识,可以从 ThreadLocal、Spring Security 等获取
String tenantId = BeanUtils.getBean(RedisUtils.class).get();
//拼接新的SQL语句
String newSql = sql + " where tenant_id = '" + tenantId + "'";
//将修改后的SQL语句重新添加到BoundSql中
BoundSql newBoundSql = new BoundSql(mappedStatement.getConfiguration(), newSql,
boundSql.getParameterMappings(), parameter);
//将新的BoundSql对象设置到MappedStatement对象中
MappedStatement newMappedStatement = copyFromMappedStatement(mappedStatement, new BoundSqlSqlSource(newBoundSql));
//更新参数列表
invocation.getArgs()[0] = newMappedStatement;
//调用原始的方法,执行SQL语句
return invocation.proceed();
}

@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}

@Override
public void setProperties(Properties properties) {
//可以在这里设置一些属性
}

private MappedStatement copyFromMappedStatement(MappedStatement ms, SqlSource newSqlSource) {
MappedStatement.Builder builder = new MappedStatement.Builder(ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType());
builder.resource(ms.getResource());
builder.fetchSize(ms.getFetchSize());
builder.statementType(ms.getStatementType());
builder.keyGenerator(ms.getKeyGenerator());
builder.keyProperty(ms.getKeyProperties() == null ? null : ms.getKeyProperties()[0]);
builder.timeout(ms.getTimeout());
builder.parameterMap(ms.getParameterMap());
builder.resultMaps(ms.getResultMaps());
builder.resultSetType(ms.getResultSetType());
builder.cache(ms.getCache());
builder.flushCacheRequired(ms.isFlushCacheRequired());
builder.useCache(ms.isUseCache());
return builder.build();
}

private static class BoundSqlSqlSource implements SqlSource {
BoundSql boundSql;
public BoundSqlSqlSource(BoundSql boundSql) {
this.boundSql = boundSql;
}
@Override
public BoundSql getBoundSql(Object parameterObject) {
return boundSql;
}
}
}

注册拦截器对象

第六步,新建config包,并在config包内新建一个名为MybatisConfig的配置类,这个配置类需要注册前面定义的拦截器对象:

1
2
3
4
5
6
7
8
9
10
@Configuration
public class MybatisConfig {
@Bean
public ConfigurationCustomizer mybatisConfigurationCustomizer(){
return configuration -> {
//创建并添加拦截器到配置中
configuration.addInterceptor(new TenantInterceptor());
};
}
}

重写Redis序列化

第七步,在config包内新建一个名为RedisConfig的配置类,这个配置类需要定义一个redisTemplate方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
// 使用Jackson2JsonRedisSerialize 替换默认序列化(默认采用的是JDK序列化)
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
redisTemplate.setKeySerializer(jackson2JsonRedisSerializer);
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setHashKeySerializer(jackson2JsonRedisSerializer);
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
return redisTemplate;
}
}

新建数据库和表

第八步,创建名为many_tenant的数据库,并在里面执行如下建表语句:

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE `book` (
`id` int NOT NULL AUTO_INCREMENT COMMENT 'id',
`name` varchar(128) DEFAULT NULL COMMENT '名称',
`author` varchar(64) DEFAULT NULL COMMENT '作者',
`tenant_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '租户id',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;

INSERT INTO `book` VALUES (2, '西游记', '吴承恩', 'zh001');
INSERT INTO `book` VALUES (4, '三国演义', '罗贯中', 'zh002');
INSERT INTO `book` VALUES (5, '水浒传', '施耐庵', 'zh003');
INSERT INTO `book` VALUES (6, '红楼梦', '曹雪芹', 'zh004');

可以看到这个tenant_id就是我们定义的租户字段,用于表示用户的租户信息:
image.png

创建实体类

第九步,新建一个名为entity的包,并在该包下创建一个名为Book的类:

1
2
3
4
5
6
7
public class Book {
private Integer id;
private String name;
private String author;
private String tenantId;
//getter、setter和toString方法
}

创建数据库访问层

第十步,新建一个名为mapper的包,并在该包下创建一个名为BookMapper的java类:

1
2
3
4
5
6
7
8
9
@Component
@Mapper
public interface BookMapper {
List<Book> getAllBooks();

void update(String author);

void delete();
}

接着在该包内定义一个同名的xml文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gutsyzhan.interceptormybatis.mapper.BookMapper">
<select id="getAllBooks" resultType="com.gutsyzhan.interceptormybatis.entity.Book">
select
id as id,
name as name,
author as author,
tenant_id as tenantId
from book
</select>
<update id="update">
update book
set author = #{author}
</update>
<delete id="delete">
delete from book
</delete>
</mapper>

配置pom.xml文件

第十一步,配置pom文件。由于我们将mapper类和xml文件放在同一个包内,且不在resources目录下,此时运行项目肯定会抛出mapper文件找不到的异常,因为Maven运行时会忽略包内的xml文件,因此需要在pom.xml文件中重新指明资源文件的位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<build>
<!--使用mybatis时需要手动指明xml的位置-->
<resources>
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.xml</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</build>

创建控制层

第十二步,新建一个名为controller的包,并在该包下创建一个名为BookController的java类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@RestController
public class BookController {
@Resource
private BookMapper bookMapper;

@Resource
private RedisUtils redisUtils;

@GetMapping("/list")
public String list(){
List<Book> bookList = bookMapper.getAllBooks();
return "【bookList】>>>>>>"+ bookList;
}

@GetMapping("/get")
public String get(){
String tenantId = redisUtils.get();
return "现在的租户id为:" + tenantId;
}

@GetMapping("/set")
public String set(@RequestParam("tenantId")String tenantId){
String oldTenantId = redisUtils.get();
redisUtils.setExpire(tenantId,100L);
return "之前的租户id为:" + oldTenantId + ";现在的租户id为:" + tenantId;
}

@GetMapping("/update")
public String update(){
bookMapper.update("张三");
return "数据修改成功";
}

@GetMapping("/delete")
public String delete(){
bookMapper.delete();
return "数据删除成功";
}
}

运行项目

第十三步,运行项目,在浏览器地址栏中输入http://localhost:8081/set?tenantId=zh001,可以看到当前租户id如下:
image.png
(1)查询操作。接着我们访问list接口,页面返回结果如下:
image.png
查看一下控制台,可以看到输出如下信息:
image.png
可以看到我们的xml文件中是没有传入tenantId这一条件:

1
2
3
4
5
6
select
id as id,
name as name,
author as author,
tenant_id as tenantId
from book

但是代码在执行过程中被拦截了,然后拼接上了tenantId这个过滤条件。
(2)修改操作。接着我们再来访问update接口,页面返回如下信息:
image.png
再来看一下控制台,可以看到输出如下信息:
image.png
而我们的xml文件中也没有传入tenantId这一条件:

1
2
update book
set author = #{author}

(3)删除操作。接着我们再来访问delete接口,页面返回如下信息:
image.png
再来看一下控制台,可以看到输出如下信息:
image.png
而我们的xml文件中也没有传入tenantId这一条件:

1
delete from book

由于新增操作使用post方式,且不需要添加where tenant_id这一过滤条件,因此笔者这里就不再演示。
(4)切换租户ID。接着我们再来访问set接口,将租户ID切换为zh002,此时页面返回如下信息:
image.png
为确保租户ID已经正确切换,此时我们再次访问get接口,页面返回如下信息:
image.png
(5)查询操作。接着我们访问list接口,页面返回结果如下:
image.png

案例原理分析

接口定义

首先我们定义了一个名为TenantInterceptor的类,它需要实现Interceptor接口,并实现其中的intercept、plugin和setProperties方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Intercepts(
@Signature(type = Executor.class,method = "query",args = {MappedStatement.class,Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "update", args = { MappedStatement.class, Object.class})
)
public class TenantInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
//TODO 拦截方法前处理
//执行被代理方法
Object obj = invocation.proceed();
//TODO 拦截方法后处理
return obj;
}
}

Intercepts注解的源码如下:

1
2
3
4
5
6
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface Intercepts {
Signature[] value();
}

可以看到它里面可以配置多个Signature注解,Signature注解的源码如下:

1
2
3
4
5
6
7
8
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({})
public @interface Signature {
Class<?> type();
String method();
Class<?>[] args();
}

该注解的属性解释如下:
(1)type:用于指定拦截的类型;
(2)method:用于指定拦截的方法;
(3)args:用于指定拦截的方法参数。
通过上面这三个属性,就能完全确定一个方法。我们在TenantInterceptor类中配置了两个拦截器:

1
2
3
4
5
@Intercepts({
@Signature(type = Executor.class, method = "query", args = { MappedStatement.class, Object.class,
RowBounds.class, ResultHandler.class }),
@Signature(type = Executor.class, method = "update", args = { MappedStatement.class, Object.class})
})

分别拦截了Executor类中的query和update方法:

1
2
3
int update(MappedStatement ms, Object obj) throws SQLException;

<E> List<E> query(MappedStatement ms, Object obj, RowBounds rb, ResultHandler rh);

也就是会代理他们,会进入到TenantInterceptor#intercept()方法中。

拦截类型

Mybatis中可被拦截的类有四种,按照拦截的先后顺序如下所示:
(1)Executor:拦截执行器的方法,用于进行增删改查操作。
(2)ParameterHandler:拦截参数的处理,用于处理SQL语句中的参数对象。
(3)ResultHandler:拦截结果集的处理,用于处理SQL语句的返回结果。
(4)StatementHandler:拦截SQL语句构建的处理,也就是数据库的处理对象,用于执行SQL语句。
这些拦截类中可以被拦截的方法如下所示:

  • Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
  • ParameterHandler (getParameterObject, setParameters)
  • ResultSetHandler (handleResultSets, handleOutputParameters)
  • StatementHandler (prepare, parameterize, batch, update, query)

这些地方包含了sql执行的全部过程,这些方法也可以从拦截类型类中进行查阅。对应到Signature注解的源码:

1
2
3
4
5
6
7
8
9
10
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({})
public @interface Signature {
Class<?> type();

String method();

Class<?>[] args();
}

我们就能知道type字段存放是的Class对象,即前面提到的四大类型。method字段存放的是class对象的具体方法。args字段存放的是具体方法的参数。实际上看到这里,你应该就能联想到反射相关的内容了。

案例说明

在案例中我们是需要对查询、修改和删除操作自动添加where条件,因此使用Executor对象就能满足我们的要求,即type字段可以设置为Executor.class。method字段应该存放方法名称,查阅Executor类的源码:
image.png
你会发现其中只有query和update方法,并没有delete方法。实际上我们可以认为delete也是一种update,从SqlSession接口中查阅一下源码,可以发现delete确实是一种update:

1
2
3
public int delete(String statement) {
return this.update(statement, (Object)null);
}

实际上不仅仅是delete,插入使用的insert方法其实也是一种update方法:

1
2
3
4
public int insert(String statement, Object parameter) {

return this.update(statement, parameter);
}

因此method字段就只需填写query和query,而args字段存放的就是方法入参。那么这样完整的配置参数如下:

1
2
3
4
5
@Intercepts({
@Signature(type = Executor.class, method = "query", args = { MappedStatement.class, Object.class,
RowBounds.class, ResultHandler.class }),
@Signature(type = Executor.class, method = "update", args = { MappedStatement.class, Object.class})
})

这样我们其实就对自定义插件的工作原理有了一个较为清晰的理解,但是对于Mybatis插件的源码阅读才刚刚开始。

Mybatis插件源码

总览

先来阅读Mybatis源码中plugin包里面的类,可以看到目录结构如下:
image.png

动态代理

在前面我们说过,Mybatis插件的原理是动态代理加上责任链模式。在Java中,JDK动态代理是通过实现InvocationHandler接口来实现的。查阅一下Plugin这个类的源码:
image.png
可以看到这个类确实是实现了InvocationHandler接口,因此它采用了JDK动态代理。接着这个类里面定义了三个属性,分别为target(目标对象)、interceptor(拦截器)和signatureMap(签名map)。

再来看一下wrap方法,可以看到它其实就是判断是否需要生成Plugin代理对象:

1
2
3
4
5
6
7
8
9
10
public static Object wrap(Object target, Interceptor interceptor) {
//解析当前inteceptor上所有配置的Inteceptors注解,并获取配置的拦截方法
Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
Class<?> type = target.getClass();
Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
//如果拦截器配置的拦截方法与当前target中存在的方法匹配的上,那么当前对象需要被代理
return interfaces.length > 0 ? Proxy.newProxyInstance(type.getClassLoader(),
interfaces, new Plugin(target, interceptor, signatureMap))
: target;
}

而invoke方法的源码如下:

1
2
3
4
5
6
7
8
9
10
11
//动态代理方法被调用时返回
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
Set<Method> methods = (Set)this.signatureMap.get(method.getDeclaringClass());
return methods != null && methods.contains(method) ?
this.interceptor.intercept(new Invocation(this.target, method, args))
: method.invoke(this.target, args);
} catch (Exception var5) {
throw ExceptionUtil.unwrapThrowable(var5);
}
}

这里就是判断当前方法是否需要被拦截,如果需要被拦截,则使用代理对象并执行拦截逻辑,否则执行正常的逻辑。接下来我们通过debug代码,来看一下上面invoke方法的执行过程,尤其是if方法的判断逻辑:
image.png
可以看到methods集合中存放的则是Mybatis支持的拦截方法,而method则是开发者配置的需要拦截的方法,注意这个方法名也需要在前面提到的拦截类中存在才行。

责任链模式

接下来查阅其中的InterceptorChain类的源码,可以看到里面定义了一个interceptors属性,里面存放了拦截器对象:
image.png
接下来我们查看一下pluginAll方法的调用地方,如下所示:
image.png
可以看到就是前面介绍的Executor、ParameterHandler 、ResultSetHandler 和StatementHandler这四大对象来调用它,即插件只作用于这四大对象。完整的调用地方,如下所示:
image.png
现在就有一个问题了,插件是在什么时候被加载的呢?这就要阅读前面提到的InterceptorChain类的源码了。完整的源码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class InterceptorChain {
//保存所有的拦截器配置
private final List<Interceptor> interceptors = new ArrayList();

public InterceptorChain() {
}

//创建executor或者handler时调用
public Object pluginAll(Object target) {
Interceptor interceptor;
//验证所有的inteceptor和当前要创建的对象是否匹配
for(Iterator var2 = this.interceptors.iterator();
var2.hasNext(); t
arget = interceptor.plugin(target)
)
{
interceptor = (Interceptor)var2.next();
}

return target;
}

//解析plugins配置时调用
public void addInterceptor(Interceptor interceptor) {
this.interceptors.add(interceptor);
}

public List<Interceptor> getInterceptors() {
return Collections.unmodifiableList(this.interceptors);
}
}

可以看到它定义了一个interceptors属性,这个属性被final修改,也就是说它本身不能被修改,但是里面的内容是可以被修改的。而getInterceptors方法则让interceptors属性不可以被修改,因此要想修改这个interceptors属性,必须使用addInterceptor方法,这样可以保证集合的修改是可被控制的。

看到这里,我们就知道了,要想知道插件什么时候被加载的,只需知道哪里调用了addInterceptor方法。通过分析,可以看到只有下面两个地方调用了addInterceptor方法:
image.png
一个是XMLConfigBuilder,另一个则是SqlSessionFactoryBean。当我们使用xml配置文件这一方式来配置插件相关内容时,对应的XML配置内容可能是这样的:

1
2
3
4
5
<plugins>
<plugin inreceptor="org.mybatis.example.ExamplePlugin>
<property name="age" value="18"/>
</plugin>
</plugins>

之后MyBatis就会对此标签进行解析:
image.png
在解析到plugins标签时,就会进入到pluginElement方法中,然后在此方法中调用addInterceptor方法:
image.png
由于前面我们使用的都是注解方式,并没有采用XML配置文件的方式,因此接下来着重分析SqlSessionFactoryBean。通过分析,我们发现MybatisAutoConfiguration类的构造方法中初始化了interceptors:

1
2
3
4
5
6
7
8
9
10
11
public MybatisAutoConfiguration(MybatisProperties properties, 
ObjectProvider<Interceptor[]> interceptorsProvider,
ResourceLoader resourceLoader,
ObjectProvider<DatabaseIdProvider> databaseIdProvider,
ObjectProvider<List<ConfigurationCustomizer>> configurationCustomizersProvider) {
this.properties = properties;
this.interceptors = (Interceptor[])interceptorsProvider.getIfAvailable();
this.resourceLoader = resourceLoader;
this.databaseIdProvider = (DatabaseIdProvider)databaseIdProvider.getIfAvailable();
this.configurationCustomizers = (List)configurationCustomizersProvider.getIfAvailable();
}

实际上是调用了interceptorsProvider对象的getIfAvailable()方法,该方法的源码如下所示:

1
T getIfAvailable() throws BeansException;

可以看到该方法的实现有四个类:
image.png
接着我们查看一下MybatisAutoConfiguration#sqlSessionFactory()方法,可以看到上面添加了一个@ConditionalOnMissingBean注解,表示只有当项目中没有自定义SqlSessionFactory对象时,才会注入此对象,这样才会真的注册插件:
image.png
注意后面那一段代码:

1
2
3
if (!ObjectUtils.isEmpty(this.interceptors)) {
factory.setPlugins(this.interceptors);
}

可以看到只有interceptors不为空的时候,才会调用factory.setPlugins(this.interceptors)来注册插件。看到这里我们就知道了插件的三种配置方式,现总结如下:
(1)XML文件配置方式;
(2)如果没有自定义SqlSessionFactory对象,那么直接使用@Bean注解,直接注入拦截器即可;
(3)如果自定义了SqlSessionFactory对象,那么需要在自定义的地方手动调用factory.setPlugins()方法来注册插件。

总结

(1)Mybatis在执行语句时,会通过Configuration对象来创建对应的Executor或者Handler;
(2)创建完成后,会调用InterceptorChain#pluginAll()方法来判断该方法是否配置对应的拦截器,判断依据是通过配置的@Inteceptors注解中的方法签名,来判断当前target是否存在。如果需要则创建一个代理对象,否则返回原对象;
(3)代理对象在执行被拦截的方法时,首先调用Plugin#invoke()方法,来触发对应拦截器的intercept方法,而这个intercept方法就是开发者定义拦截器需要实现的方法,可以在该方法中书写对应的业务逻辑。