shigen
日更文章的博客写手,擅长Java、python、vue、shell等编程语言和各种应用程序、脚本的开发。记录成长,分享认知,留住感动。
不知道为什么,最近老是有一些失眠,凌晨睡,两点半还在醒着。脑子里想着自己生活、vlog计划……就是怎么睡不着。实在是没啥可干的了,我拿起了电脑,写着博客,反正迟早是要写的。
今天要分享的主题是《mybatis实现租户拦截器》。具体的需求是这样的,shigen在周五的时候接收到了这样的一个任务:实现系统的多租户。一想,系统的用户不到10个,还多租户。不抱怨,不抄旧的代码,我开始了研究。
相信大家看到的教程也主要是两种方式实现多租户。
多租户的实现方式
共享数据库、数据表
这种方式我们直接共享数据库和数据表,在每个数据表中加上tenant_id
字段做数据的隔离,类似于这样的:
select * from user where tenant_id = '100001';
那这种方式的优点肯定是显而易见的,简单到家了,实现起来也是快速的(相对第二种方式而言),运维的成本也很小。
但是缺点更加的显而易见:
- 数据的隔离性差。让我想到了哈希环的数据倾斜。一个租户下边的数据很多,另一个租户下边的数据很少,势必会影响性能;
- 每个表都要加字段,很大的侵入性;
- 数据备份难。实现基于单租户的数据备份显得很难了。
实现
那实现上估计又有人皱起了眉头,因为这样的话,项目之前的代码都要改,每个查询的语句都要加上enant_id =xxx
的代码。像shigen
这样讨厌重复性工作的人,这得加到猴年马月,而且很容易仍人心烦意乱,emo,哭爹骂娘……
那这种更好的方式就是在sql执行之前做一个统一的拦截,拼上租户的条件。别急,今天的代码案例shigen
就会降到这种方式的具体实现。
我必须承认这不是一种很好的方式,也是我一直在思考的TB业务和TC业务的区别。万一用户的数据量急剧的增上,就像某个大集团,动辄好几十万员工,沉淀的各种业务数据是很可怕的。还用一个数据库,玩什么分布式、高并发。
共享数据库,独立一张表
这里只是讲一下概念哈。这里我们获得当前用户的tenant_id
,然后再读取和写入查表的时候,在表名字后边拼接上tenant_id
。
如:张三的租户ID=‘10001’,所以他的数据存放在user_10001
。
相较于第一种方式,这种方式的优点在于数据的隔离性更好,数据的侵入性小。
但是缺点也依旧存在,操作租户产生的效率问题依旧的存在,备份依旧的困难。
所以,更好的方式出现额。
独立数据库
这个是有落地实现的案例的。shigen
的文章spring boot+mybatis实现读写分离中有异曲同工之妙,实现了多数据源的切换,这里的实现也是类似的,一个租户一个数据库,数据库的数据表都是相同的。我们在查询的时候,就根据租户ID进行动态的路由数据源。
这样实现下来岂不是很nice。拓展性、隔离性都是绝对的nice。
但是缺点也是有的,维护的成本高了吧(当然,数据量不大的情况下,忽略不计)。
说了半天的理论,我们来实践一下吧。shigen
还是采用的第一种方式。
实话说,
shigen
在自己写着代码之前,也找了很多的教程,但都存在着代码质量不高、功能不能实现、考虑问题不全面等问题。
代码实现
自定义注解
这里的作用是标注一下哪些数据的查询是需要用tenant_id
进行过滤的。毕竟系统字典、公共数据我们还是得老老实实的放行。
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Tenant {
boolean flag() default true;
}
注意作用域,只能坐拥在方法上,怀着为什么的心态继续往下看。
userMapper.xml
选取了在mapper层的两个接口方法:
@Tenant
User selectByPrimaryKey(Long id);
@Tenant
List<User> selectSelective(User user);
相同点是都用了注解且都是根据条件查询的。
xml不展示了,都是魔法生成的,注意,没有
tenant_id
的存在!它只存在user
表中。
拦截器
重头戏来了,拦截器可是核心!
@Component
@Slf4j
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class TenantInterceptor implements Interceptor {
public static final String TENANT_ID = "tenant_id";
public static final String WHERE = "where";
public static final String FROM = "from";
public static final String FAKE_TENANT_ID = "'string'";
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
MetaObject metaObject = MetaObject.forObject(statementHandler, SystemMetaObject.DEFAULT_OBJECT_FACTORY, SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY, new DefaultReflectorFactory());
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
// id为执行的mapper方法的全路径名,如com.gitee.shigen.mapper.UserMapper.selectByPrimaryKey
String id = mappedStatement.getId();
// sql语句类型 select、delete、insert、update
SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
BoundSql boundSql = statementHandler.getBoundSql();
// 获取到原始sql语句 带?号
String sql = boundSql.getSql().toLowerCase();
// 注解逻辑判断 添加注解了才拦截
// 如:com.gitee.shigen.mapper.UserMapper
Class<?> classType = Class.forName(mappedStatement.getId().substring(0, mappedStatement.getId().lastIndexOf(".")));
// selectByPrimaryKey
String mName = mappedStatement.getId().substring(mappedStatement.getId().lastIndexOf(".") + 1);
for (Method method : classType.getDeclaredMethods()) {
if (method.isAnnotationPresent(Tenant.class) && mName.equals(method.getName())) {
Tenant tenantId = method.getAnnotation(Tenant.class);
if (tenantId.flag() && sqlCommandType.equals(SqlCommandType.SELECT)) {
StringBuilder sb = new StringBuilder(sql);
if (sql.contains(WHERE)) {
int whereIndexOf = sql.indexOf(WHERE);
sb.insert(whereIndexOf + WHERE.length(), " " + TENANT_ID + "=" + FAKE_TENANT_ID + " and ");
} else {
// 不存在where
sb.insert(getTableNameAfterIndex(sql) + 1, WHERE + " " + TENANT_ID + "=" + FAKE_TENANT_ID + " ");
}
sql = sb.toString();
}
}
}
// 通过反射修改sql语句
Field field = boundSql.getClass().getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, sql);
return invocation.proceed();
}
private int getTableNameAfterIndex(String sql) {
int fromIndex = sql.indexOf(FROM);
return sql.indexOf(" ", fromIndex + 5);
}
}
区区代码,折腾了俩天,但是想起来是值得的。
主要的原理就是这样的,我们正常的查询是这样的:
select * from user where id =1;
select * from user;
对于存在where关键字的,我直接在where后边拼上tenant_id
的条件;
不存在的,那更好办了,直接where tenan_id =xxx
。
一想,你这样合适吗?
SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS date, COUNT(*) AS count
FROM user
GROUP BY create_time
ORDER BY create_time DESC;
这样的代码直接G了。直接拼上去,sql的语法都检查不过,就别提数据的隔离了。
为此,我还写了一个接口方法验证呢。
@Tenant
List<UserCountByCreateTimeVo> getUserCount();
<select id="getUserCount" resultType="com.gitee.shigen.vo.UserCountByCreateTimeVo"
resultMap="UserCountByCreateTimeVo">
SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS date, COUNT(*) AS count
FROM user
GROUP BY create_time
ORDER BY create_time DESC;
</select>
不多说了,我说一下我的优化点,也相当于是我作为创作者制定的一个规范:
- sql拦截器只处理带了注解的方法,且方法的sql是查询的;
- 不存在where关键字,我就通过from关键字定位。
好了,到此,大部分的工作已经结束了,我们可以松一口气了。
接口测试
@RestController
@RequestMapping(value = "user")
public class UserController {
@Resource
private UserMapper userMapper;
@GetMapping(value = "{id}")
public User getById(@PathVariable("id") Long id) {
return userMapper.selectByPrimaryKey(id);
}
@GetMapping
public List<User> getUser(User user) {
return userMapper.selectSelective(user);
}
@GetMapping(value = "userCount")
public List<UserCountByCreateTimeVo> getUserCount() {
return userMapper.getUserCount();
}
}
比较粗狂,但是都是为了测试,这里就先这样啦。
测试
我们来跑起项目测试一下:
根据ID查询
sql是这样的:
select id,username,password, nickname,phone,introduction, avatar,create_time,update_time, is_deleted,tenant_id from user where tenant_id='string' and id = ?
模糊查询
sql是这样的:
select * from user where tenant_id='string' and username like concat('%', ?, '%') and password = ? order by create_time desc
不给条件查询
sql是这样的:
select * from user where tenant_id='string' order by create_time desc
聚合查询
sql是这样的:
select date_format(create_time, '%y-%m-%d') as date, count(*) as count from user where tenant_id='string' group by create_time order by create_time desc;
所以,可以看到效果实现了。但是实际的业务场景是复杂的,如:
- 多表查询,直接G
后期具体的效果,也得看我把代码粘贴到项目里,会不会有问题的。shigen
后续也会持续观察和分享。
好了,这个夜不敢熬了,dog命要紧。也突然有了困意,新的一天,预祝元气满满!
以上就是今天分享的全部内容了,觉得不错的话,记得点赞 在看 关注
支持一下哈,您的鼓励和支持将是shigen
坚持日更的动力。同时,shigen
在多个平台都有文章的同步,也可以同步的浏览和订阅:
平台 | 账号 | 链接 |
---|---|---|
CSDN | shigen01 | shigen的CSDN主页 |
知乎 | gen-2019 | shigen的知乎主页 |
掘金 | shigen01 | shigen的掘金主页 |
腾讯云开发者社区 | shigen | shigen的腾讯云开发者社区主页 |
微信公众平台 | shigen | 公众号名:shigen |
与shigen
一起,每天不一样!