优秀的编程知识分享平台

网站首页 > 技术文章 正文

SpringBoot单数据库实例多Schema多租户实现

nanyue 2024-07-31 12:02:10 技术文章 8 ℃

介绍

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;
        }
    }

}

运行

  1. 执行 db.sql
  2. 运行TenantApplication
  3. 通过访问 http://a.cdn.system.me/users 就可以看到效果


代码仓库 https://gitee.com/dengmin/mp_tenant

最近发表
标签列表