背景
这边有个做 saas 化应用的需求,要求做到数据源级别隔离,选了 RuoyiCRM: 基于若依Vue平台搭建的多租户独立数据库CRM系统, 项目不断迭代中。欢迎提BUG交流~ (gitee.com) 这个项目做分析
先放一下码云上作者画的图,后面我把整个多租户实现的逻辑也梳理一遍
数据库结构分析
作者给的案例需要初始化三个数据库
master 多一张表 master_tenant,存放租户库的数据库连接信息
简单给大家看下初始化完毕以后,这张表的信息
初始化的过程后面会讲到
debug
注册
我们先注册账号
@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';
再看下这块拼接出来的 对象
看下面一个函数
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
登录
填写 登录信息
/**
* 登录方法,前端通过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表里面的信息
小结
这块其实离商用还有不少距离,比如没有一个超级管理员来进到一个主界面管理所有租户数据,但好像如果采用这种数据源粒度的隔离都会有这个问题,后面再分析下只根据表隔离的实现思路