介绍
springboot mybatis-plus 实现的单数据库实例多schema的多租户系统, 也就是一个租户使用一个数据库schema
网上的教程大部分都是基于mybatis-plus的TenantLineInnerInterceptor 实现所有的租户通过tenant_id来处理多租户之间打数据隔离
但是这个并不符合我打需求, 我需要每个租户使用一个数据库schema, 和其他的租户数据完全隔离
本实例只在本地测试通过,请勿用于生产环境!
数据初始化
这里使用数据库表 tenant存储所有的租户信息 结构如下
CREATE TABLE `tenant`.`tenant` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`db_name` varchar(255) DEFAULT '',
`alias` varchar(255) DEFAULT NULL COMMENT '唯一标识',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO tenant.tenant (id, name, db_name, alias) VALUES (1, '阳光', 'db_1', 'a');
INSERT INTO tenant.tenant (id, name, db_name, alias) VALUES (2, '锤子门', 'db_2', 'b');
INSERT INTO tenant.tenant (id, name, db_name, alias) VALUES (3, '海贼王', 'db_3', 'c');
有3条数据,阳光,锤子门,海贼王 分别对应他们自己打数据库 db_1, db_2, db_3 这3个数据库里面有一个user表
CREATE TABLE if not exists `user` (
`id` bigint(22) NOT NULL AUTO_INCREMENT,
`username` varchar(255) DEFAULT NULL,
`password` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
租户识别
租户的信息识别是通过域名和nginx的代理来实现 ,思路是给每个租户分配一个域名,然后通过nginx的代理转发 本地调试在/etc/hosts里面增加
127.0.0.1 a.cdn.system.me
127.0.0.1 b.cdn.system.me
127.0.0.1 c.cdn.system.me
127.0.0.1 d.cdn.system.me
域名前面打abc 分别对应tenant 表中的alias字段 如果使用正式域名,需要在dns解析 那里增加一个 *.cdn 的泛域名cname到api的域名上面 nginx 就方便来之间使用 serverName *.cdn.xxxx.com;
比如 a.cdn.system.me 通过域名访问系统时会识别出租户a
数据源的切换
这个是该方案的核心思路, 通过实现mybatis的拦截器 Interceptor 改写原来的sql, 把sql语句里面的数据库表都加上对应的schema
配置spring的拦截器, 识别租户的标识
@Slf4j
public class RequestDomainInterceptor implements HandlerInterceptor {
@Value("${domainSuffix}")
private String domainSuffix;
@Resource
private TenantMapper tenantMapper;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String serverName = request.getServerName();
//截取域名 比如 test.cdn.system.me domainSuffix=.cdn.system.me 那么截取打字符串test就是分配个商户的名字
String tenantName = StringUtils.delete(serverName, domainSuffix);
Tenant tenant = TenantMap.get(tenantName);
if(null == tenant){
QueryWrapper<Tenant> query = new QueryWrapper<>();
query.eq("name", tenantName);
tenant = tenantMapper.selectOne(query);
if(null == tenant){
throw new RuntimeException("找不到该商户名 => "+ tenantName);
}
TenantMap.put(tenant.getName(), tenant);
return false;
}
TenantHolder.set(tenant);
log.info("get tenant => {}", tenant);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
TenantHolder.remove();
}
}
通过获取request.getServerName()截取字符串来获取租户标识, 然后把当前的租户放到ThreadLocal中
实现mybatis的拦截器, 在sql中的表名前增加schema
@Slf4j
@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 ms = (MappedStatement)invocation.getArgs()[0];
Object parameterObject = null;
if(invocation.getArgs().length > 1){
parameterObject = invocation.getArgs()[1];
}
BoundSql boundSql = ms.getBoundSql(parameterObject);
BoundSql newBoundSql = new BoundSql(
ms.getConfiguration(),
replace(boundSql.getSql()), //sql替换
boundSql.getParameterMappings(),
boundSql.getParameterObject()
);
MappedStatement.Builder build = new MappedStatement.Builder(
ms.getConfiguration(),
ms.getId(),
new CustomSqlSource(newBoundSql),
ms.getSqlCommandType()
);
build.resource(ms.getResource());
build.fetchSize(ms.getFetchSize());
build.statementType(ms.getStatementType());
build.keyGenerator(ms.getKeyGenerator());
build.timeout(ms.getTimeout());
build.parameterMap(ms.getParameterMap());
build.resultMaps(ms.getResultMaps());
build.cache(ms.getCache());
MappedStatement newStmt = build.build();
//替换原来的MappedStatement
invocation.getArgs()[0] = newStmt;
return invocation.proceed();
}
private String replace(String sql) throws JSQLParserException {
Statement stmt = CCJSqlParserUtil.parse(sql);
Tenant tenant = TenantHolder.get();
if(null == tenant){
return sql;
}
String schemeName = String.format("`%s`", tenant.getDbName());
if(stmt instanceof Insert){
Insert insert = (Insert)stmt;
return SQLParser.doInsert(insert, schemeName);
}else if(stmt instanceof Update){
Update update = (Update) stmt;
return SQLParser.doUpdate(update, schemeName);
}else if(stmt instanceof Delete){
Delete delete = (Delete) stmt;
return SQLParser.doDelete(delete, schemeName);
}else if(stmt instanceof Select){
Select select = (Select)stmt;
return SQLParser.doSelect(select, schemeName);
}
throw new RuntimeException("非法SQL语句 不可能执行到这里");
}
public static class CustomSqlSource implements SqlSource{
private BoundSql boundSql;
protected CustomSqlSource(BoundSql boundSql){
this.boundSql = boundSql;
}
@Override
public BoundSql getBoundSql(Object o) {
return boundSql;
}
}
}
运行
- 执行 db.sql
- 运行TenantApplication
- 通过访问 http://a.cdn.system.me/users 就可以看到效果