背景

这边有个做 saas 化应用的需求,要求做到数据源级别隔离,选了 RuoyiCRM: 基于若依Vue平台搭建的多租户独立数据库CRM系统, 项目不断迭代中。欢迎提BUG交流~ (gitee.com) 这个项目做分析

up-fc18a6102b182f0eb981e1114be155dbca4

先放一下码云上作者画的图,后面我把整个多租户实现的逻辑也梳理一遍

数据库结构分析

作者给的案例需要初始化三个数据库

image-20221206162000858 image-20221206161840004

master 多一张表 master_tenant,存放租户库的数据库连接信息

image-20221206162123774

简单给大家看下初始化完毕以后,这张表的信息

image-20221206162203549

初始化的过程后面会讲到

debug

注册

我们先注册账号

image-20221206170120601
@PostMapping("/register")
    public AjaxResult registerTenant(@RequestBody TenantRegisterBody tenantRegisterBody){
        loginService.validateCaptcha(tenantRegisterBody.getTenantName(), tenantRegisterBody.getCode(), tenantRegisterBody.getUuid());

        if (TenantConstants.NOT_UNIQUE.equals(masterTenantService.checkTenantNameUnique(tenantRegisterBody.tenantName)))
        {
            return AjaxResult.error("注册'" + tenantRegisterBody.getTenantName() + "'失败,账号已存在");
        }
        TenantDatabaseDTO tenantDatabase=null;
        try {
            tenantDatabase=tenantRegisterService.initDatabase(tenantRegisterBody);
        } catch (SQLException ex) {
            ex.printStackTrace();
            return AjaxResult.error("注册'" + tenantRegisterBody.getTenantName() + "'失败,创建租户时发生错误");
        }catch (Exception ex){
            ex.printStackTrace();
            return AjaxResult.error("注册'" + tenantRegisterBody.getTenantName() + "'失败,请与我们联系");
        }
        int i = masterTenantService.insertMasterTenant(tenantDatabase);
        return toAjax(i);
    }

看下初始化数据库这个函数

public TenantDatabaseDTO initDatabase(TenantRegisterBody form) throws Exception {
        Connection conn = getConnection();
        Statement stmt = null;
        TenantDatabaseDTO tenantDatabaseDTO = null;
        //创建数据库ID
        String tenantDatabaseID = ShortUUID.nextID();
        //组合数据库名
        String tenantDatabase = prefix + tenantDatabaseID;
        try {
            conn.setAutoCommit(false);
            stmt = conn.createStatement();

            // 创建库
            String createDatabaseSQL = "CREATE DATABASE IF NOT EXISTS `" + tenantDatabase + "` DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;";
            stmt.execute(createDatabaseSQL);

            //创建数据库用户名及密码
            String tenantDatabaseUsername = tenantDatabase;
            String tenantDatabasePassword = ShortUUID.nextID();

            //创建用户
            String createUser = "create user '"+tenantDatabaseUsername+"'@'localhost' identified by '"+tenantDatabasePassword+"';";
            stmt.execute(createUser);

            //用户授权
            String grantSQL = "GRANT select, insert, update, delete ON "+tenantDatabase+".* TO '" + tenantDatabaseUsername + "'@'localhost';";
            stmt.execute(grantSQL);

            // 切换到
            conn.setCatalog(tenantDatabase);

            // 获取当前数据库名称
            log.info("当前数据库:{}", conn.getCatalog()); // 若未选择数据库,则 getCatalog 返回空
            conn.getCatalog();

            //创建返回对象
            tenantDatabaseDTO = new TenantDatabaseDTO();
            tenantDatabaseDTO.setTenantDatabase(tenantDatabase);
            tenantDatabaseDTO.setTenantName(form.tenantName);
            tenantDatabaseDTO.setDbUser(tenantDatabaseUsername);
            tenantDatabaseDTO.setDbPass(tenantDatabasePassword);
            tenantDatabaseDTO.setAdminName(form.adminName);
            tenantDatabaseDTO.setAdminPass(form.adminPass);
            String tenantUrl = getUrl() + tenantDatabaseDTO.tenantDatabase + "?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true";
            tenantDatabaseDTO.setUrl(tenantUrl);

            //执行初始化脚本
            executeInitScript(conn, tenantDatabaseDTO);

        } catch (Exception ex){
            ex.printStackTrace();


            //删除数据库
            stmt.execute("DROP DATABASE IF EXISTS "+ tenantDatabase);
            log.error("删除数据库:{}",tenantDatabase );

            throw new ServiceException("执行数据库操作时发生错误");
        }finally{
            if (stmt != null) {
                stmt.close();
            }
            conn.close();
        }
        return tenantDatabaseDTO;
    }

我把这块拼出来的几条 sql 粘贴出来

CREATE DATABASE IF NOT EXISTS `ryt_lljZ8o8T` DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;
create user 'ryt_lljZ8o8T'@'localhost' identified by 'mNz9Xu4u';
GRANT select, insert, update, delete ON ryt_lljZ8o8T.* TO 'ryt_lljZ8o8T'@'localhost';

再看下这块拼接出来的 对象

image-20221206164641334

看下面一个函数

private void executeInitScript(Connection conn, TenantDatabaseDTO tenantDatabaseDTO) throws SQLException {
        try {
            ScriptRunner runner = new ScriptRunner(conn);
            runner.setErrorLogWriter(null);
            runner.setLogWriter(null);

            Resources.setCharset(StandardCharsets.UTF_8);//设置字符集,不然中文乱码插入错误
            Reader reader = Resources.getResourceAsReader("init-sql-script/rycrm-tenant-sample.sql");
            runner.runScript(reader);

            SqlRunner sqlRunner = new SqlRunner(conn);
            String insertSql;

            //插入部门
            insertSql = "INSERT INTO sys_dept VALUES (100, 0, '0', '" + tenantDatabaseDTO.tenantName + "', 0, '" + tenantDatabaseDTO.tenantName + "', '00000000000', 'admin@admin.com', '0', '0', 'admin', '" + DateUtils.getTime() + "', 'admin', '" + DateUtils.getTime() + "');";
            sqlRunner.run(insertSql);

            //生成密码
            String encryptPassword = SecurityUtils.encryptPassword(tenantDatabaseDTO.adminPass);
            //插入系统超级管理员
            insertSql = "INSERT INTO sys_user (`user_id`, `dept_id`, `user_name`, `nick_name`, `user_type`, `email`, `phonenumber`, `sex`, `avatar`, `password`, `status`, `del_flag`, `login_ip`, `login_date`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) " +
                    "VALUES (1, 100, '" + tenantDatabaseDTO.adminName + "', '管理员', '00', 'admin@admin.com', '00000000000', '1', '', '" + encryptPassword + "', '0', '0', '127.0.0.1', '" + DateUtils.getTime() + "', '" + tenantDatabaseDTO.adminName + "', '" + DateUtils.getTime() + "', '', '" + DateUtils.getTime() + "', '管理员');";
            sqlRunner.run(insertSql);

            conn.commit();

        } catch (SQLException e) {
            e.printStackTrace();
            conn.rollback();
            throw new ServiceException("初始化用户数据脚本时出错");
        }catch (Exception ex){
            ex.printStackTrace();
            throw new ServiceException("执行初始用户数据时出错");
        }

    }

看下sql

INSERT INTO sys_dept VALUES (100, 0, '0', 'tenant1', 0, 'tenant1', '00000000000', 'admin@admin.com', '0', '0', 'admin', '2022-12-06 16:50:23', 'admin', '2022-12-06 16:50:23');
INSERT INTO sys_user (`user_id`, `dept_id`, `user_name`, `nick_name`, `user_type`, `email`, `phonenumber`, `sex`, `avatar`, `password`, `status`, `del_flag`, `login_ip`, `login_date`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (1, 100, 'zds', '管理员', '00', 'admin@admin.com', '00000000000', '1', '', '$2a$10$TYPn2q6Ccp8uf5qiKg33Q.Un5GCGkIcrVPSYh3SgBtItbG5NFW9je', '0', '0', '127.0.0.1', '2022-12-06 16:50:55', 'zds', '2022-12-06 16:50:55', '', '2022-12-06 16:50:55', '管理员');

最后把初始化出来的租户schema信息写入 master_tenant

登录

填写 登录信息

image-20221206170249377
/**
     * 登录方法,前端通过Header方式传递tenant信息
     * 
     * @param loginBody 登录信息
     * @return 结果
     */
    @PostMapping("/login")
    public AjaxResult login(HttpServletRequest request, @RequestBody LoginBody loginBody)
    {
        String tenant= request.getHeader("tenant");

        if(StringUtils.isEmpty(tenant)){
            return AjaxResult.error("租户ID不能为空");
        }

        AjaxResult ajax = AjaxResult.success();
        // 生成令牌
        String token = loginService.login(tenant, loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
                loginBody.getUuid());
        ajax.put(Constants.TOKEN, token);
        return ajax;
    }

这块会动态切换数据源,去这个租户对应的数据源连接里面来登录

这块的实现在 TenantInterceptor

@Component
@Slf4j
public class TenantInterceptor implements HandlerInterceptor {

    @Autowired
    private IMasterTenantService masterTenantService;

    @Autowired
    private DynamicRoutingDataSource dynamicRoutingDataSource;

    @Value("${spring.datasource.driverClassName}")
    private String driverClassName;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        String url = request.getServletPath();
        String tenant= request.getHeader("tenant");
        log.info("&&&&&&&&&&&&&&&& 租户拦截 &&&&&&&&&&&&&&&&");
        if (StringUtils.isNotBlank(tenant)) {
            if (!dynamicRoutingDataSource.existDataSource(tenant)) {
                //搜索默认数据库,去注册租户的数据源,下次进来直接session匹配数据源
                MasterTenant masterTenant = masterTenantService.selectMasterTenant(tenant);
                if (masterTenant == null) {
                    throw new RuntimeException("无此租户:"+tenant );
                }else if(TenantStatus.DISABLE.getCode().equals(masterTenant.getStatus())){
                    throw new RuntimeException("租户["+tenant+"]已停用" );
                }else if(masterTenant.getExpirationDate()!=null){
                    if(masterTenant.getExpirationDate().before(DateUtils.getNowDate())){
                        throw new RuntimeException("租户["+tenant+"]已过期");
                    }
                }
                Map<String, Object> map = new HashMap<>();
                map.put("driverClassName", driverClassName);
                map.put("url", masterTenant.getUrl());
                map.put("username", masterTenant.getUsername());
                map.put("password", masterTenant.getPassword());
                dynamicRoutingDataSource.addDataSource(tenant, map);

                log.info("&&&&&&&&&&& 已设置租户:{} 连接信息: {}", tenant, masterTenant);
            }else{
                log.info("&&&&&&&&&&& 当前租户:{}", tenant);
            }
        }else{
            throw new RuntimeException("缺少租户信息");
        }
        // 为了单次请求,多次连接数据库的情况,这里设置localThread,AbstractRoutingDataSource的方法去获取设置数据源
        DynamicDataSourceContextHolder.setDataSourceKey(tenant);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
                           ModelAndView modelAndView) throws Exception {
        // 请求结束删除localThread
        DynamicDataSourceContextHolder.clearDataSourceKey();
    }
}

会去取出我们刚才注册的时候写到 master 数据源当中的master_tenant表里面的信息

小结

image-20221206171235400

这块其实离商用还有不少距离,比如没有一个超级管理员来进到一个主界面管理所有租户数据,但好像如果采用这种数据源粒度的隔离都会有这个问题,后面再分析下只根据表隔离的实现思路

Logo

快速构建 Web 应用程序

更多推荐