写在前面 本篇是笔者一年来对智能照明SaaS平台多租户改造升级的一些实践与体会,这些内容在技术分享会上已做了介绍,下面是一些重要的概念和思想。
多租户 多租户简介 多租户技术,它是一种软件架构技术,用于实现多用户环境下,共用相同的系统或者程序,并确保各个用户间数据的安全隔离。即一个单独的实例,可以为多个组织提供服务。
SaaS多租户 (1)SaaS是Software-as-a-Service的缩写,意为软件即服务,即通过网络提供软件服务。 (2)SaaS平台供应商将应用部署到自己的服务器上,客户可根据实际需要,通过互联网向厂商订购所需的应用软件服务,按照订购的服务多少及时间长短向厂商支付费用,并通过互联网获得SaaS平台供应商提供的服务。 (3)SaaS服务基于一套标准的软件系统为成百上千个不同的客户(又称为租户)提供服务。这要求SaaS服务能够支持不同租户间的数据和配置隔离,同时支持对界面、业务逻辑、数据结构等方面的个性化需求开发。 (4)由于SaaS同时支持多个租户,而每个租户又有很多用户,因此对支撑软件的基础设施平台的性能、稳定性和扩展性有很高的要求。 (5)多租户是SaaS领域特有的产物。对于SaaS服务供应商来说,构建SaaS提醒需要完成两部分工作,即上层服务和底层多租户系统。
SaaS多租户优点 (1)开发和运维成本低。 (2)按需付费,节约成本。 (3)故障排查更及时。
多租户模型 下面是多租户模型的示意图: 可以看到该模型主要涉及到九类: (1)租户。指一个企业或者个人客户,租户间数据与行为完全隔离,上下级的租户之间通过授权实现数据共享。注意,每个租户只能操作归属于自己租户的数据。 (2)组织。如果租户是一个企业客户,那么通常就会有自己的组织架构。 (3)用户。指某个租户下具体的使用者,即具有用户名、密码、邮箱等账号的自然人。 (4)角色。用户操作权限的集合。 (5)员工。组织内的某位员工。 (6)解决方案。为解决客户的某类业务问题,SaaS供应商一般会将产品和服务组合在一起,为客户提供整体的打包方案。 (7)产品能力。帮助客户实现场景解决方案闭环的能力。 (8)资源域。用于运行1个或者多个产品应用的一套云资源环境。 (9)云资源。SaaS产品一般部署在各种云上面,由各种云平台提供计算、存储、网络等资源。
SaaS多租户数据隔离 多租户对于用户来说,最重要的一点在于数据隔离。绝对不允许出现A公司用户登录系统看到了B公司的数据,因此多租户的数据库设计方案具有很高的挑战。
目前主流的SaaS多租户数据库设计方案分为三种,即为每个租户提供独立的数据库、独立的数据表、按照字段区分租户,每种方案都有其适用场景。
一个租户一个数据库 一个租户对应一个数据库,意味着SaaS系统需要连接多个数据库,这种方案有点类似于分库分表。好处是数据隔离级别高、安全性好,毕竟一个租户一个数据库,但缺点就是资源利用率不高,维护成本也高。
一个租户一个数据表 一个租户对应一个数据表,这就意味着所有租户共用一个数据库,只是每个租户在数据库系统中拥有一个独立的数据表。
按租户id字段隔离租户 这是三种方案中最简单的数据隔离方式,即在每张表中都添加一个字段(tenant_id),用于标识数据归属于哪个租户。之后在对数据进行操作时,都需要将该字段作为过滤条件。由于所有租户的数据都存放在同一张表中,因此数据隔离性是最差的,很容易出现把数据弄错的情况。
三种数据隔离架构设计对比 下面是三种数据隔离架构的对比: 结合公司现状,最终决定采用按照租户id字段隔离租户这一架构,这种方式能降低服务器成本,但也提高了开发难度,有利有弊。
基于Mybatis实现多租户 由于本公司目前使用的ORM框架为Mybatis,因此决定在此基础上配合拦截器实现多租户。
实现思路 最简单的方式就是在SQL语句执行前,拦截原始语句,然后在语句后面添加where tenant_id = tenantID,当然了实际情况并不是所有的表中都包含tenant_id字段,因此并不能全部都加上这个字段,这里为了演示,就不考虑这个问题。那么问题来了,如何拦截原始SQL语句呢?可以使用Mybatis提供的拦截器来实现。
点击 这里 阅读Mybatis的插件文档,实际上它就是一个拦截器: 要想使用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就是我们定义的租户字段,用于表示用户的租户信息:
创建实体类 第九步,新建一个名为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如下: (1)查询操作。接着我们访问list接口,页面返回结果如下: 查看一下控制台,可以看到输出如下信息: 可以看到我们的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接口,页面返回如下信息: 再来看一下控制台,可以看到输出如下信息: 而我们的xml文件中也没有传入tenantId这一条件:
1 2 update book set author = #{author}
(3)删除操作。接着我们再来访问delete接口,页面返回如下信息: 再来看一下控制台,可以看到输出如下信息: 而我们的xml文件中也没有传入tenantId这一条件:
由于新增操作使用post方式,且不需要添加where tenant_id这一过滤条件,因此笔者这里就不再演示。 (4)切换租户ID。接着我们再来访问set接口,将租户ID切换为zh002,此时页面返回如下信息: 为确保租户ID已经正确切换,此时我们再次访问get接口,页面返回如下信息: (5)查询操作。接着我们访问list接口,页面返回结果如下:
案例原理分析 接口定义 首先我们定义了一个名为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类的源码: 你会发现其中只有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包里面的类,可以看到目录结构如下:
动态代理 在前面我们说过,Mybatis插件的原理是动态代理加上责任链模式。在Java中,JDK动态代理是通过实现InvocationHandler接口来实现的。查阅一下Plugin这个类的源码: 可以看到这个类确实是实现了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方法的判断逻辑: 可以看到methods集合中存放的则是Mybatis支持的拦截方法,而method则是开发者配置的需要拦截的方法,注意这个方法名也需要在前面提到的拦截类中存在才行。
责任链模式 接下来查阅其中的InterceptorChain类的源码,可以看到里面定义了一个interceptors属性,里面存放了拦截器对象: 接下来我们查看一下pluginAll方法的调用地方,如下所示: 可以看到就是前面介绍的Executor、ParameterHandler 、ResultSetHandler 和StatementHandler这四大对象来调用它,即插件只作用于这四大对象。完整的调用地方,如下所示: 现在就有一个问题了,插件是在什么时候被加载的呢?这就要阅读前面提到的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方法: 一个是XMLConfigBuilder,另一个则是SqlSessionFactoryBean。当我们使用xml配置文件这一方式来配置插件相关内容时,对应的XML配置内容可能是这样的:
1 2 3 4 5 <plugins> <plugin inreceptor="org.mybatis.example.ExamplePlugin> <property name="age" value="18"/> </plugin> </plugins>
之后MyBatis就会对此标签进行解析: 在解析到plugins标签时,就会进入到pluginElement方法中,然后在此方法中调用addInterceptor方法: 由于前面我们使用的都是注解方式,并没有采用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;
可以看到该方法的实现有四个类: 接着我们查看一下MybatisAutoConfiguration#sqlSessionFactory()方法,可以看到上面添加了一个@ConditionalOnMissingBean注解,表示只有当项目中没有自定义SqlSessionFactory对象时,才会注入此对象,这样才会真的注册插件: 注意后面那一段代码:
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方法就是开发者定义拦截器需要实现的方法,可以在该方法中书写对应的业务逻辑。