最近在做SaaS应用,数据库采用了单实例多schema的架构(详见参考资料1),每个租户有一个独立的schema,同时整个数据源有一个共享的schema,因此需要解决动态增删、切换数据源的问题。
在网上搜了很多文章后,很多都是讲主从数据源配置,或都是在应用启动前已经确定好数据源配置的,甚少讲在不停机的情况如何动态加载数据源,所以写下这篇文章,以供参考。
使用到的技术
- Java8
- Spring + SpringMVC + MyBatis
- Druid连接池
- Lombok
- (以上技术并不影响思路实现,只是为了方便浏览以下代码片段)
思路
当一个请求进来的时候,判断当前用户所属租户,并根据租户信息切换至相应数据源,然后进行后续的业务操作。
代码实现
TenantConfigEntity(租户信息)
@EqualsAndHashCode(callSuper = false)
@Data
@FieldDefaults(level = AccessLevel.PRIVATE)
public class TenantConfigEntity {
/**
* 租户id
**/
Integer tenantId;
/**
* 租户名称
**/
String tenantName;
/**
* 租户名称key
**/
String tenantKey;
/**
* 数据库url
**/
String dbUrl;
/**
* 数据库用户名
**/
String dbUser;
/**
* 数据库密码
**/
String dbPassword;
/**
* 数据库public_key
**/
String dbPublicKey;
}
DataSourceUtil(辅助工具类,非必要)
public class DataSourceUtil {
private static final String DATA_SOURCE_BEAN_KEY_SUFFIX = "_data_source";
private static final String JDBC_URL_ARGS = "?useUnicode=true&characterEncoding=UTF-8&useOldAliasMetadataBehavior=true&zeroDateTimeBehavior=convertToNull";
private static final String CONNECTION_PROPERTIES = "config.decrypt=true;config.decrypt.key=";
/**
* 拼接数据源的spring bean key
*/
public static String getDataSourceBeanKey(String tenantKey) {
if (!StringUtils.hasText(tenantKey)) {
return null;
}
return tenantKey + DATA_SOURCE_BEAN_KEY_SUFFIX;
}
/**
* 拼接完整的JDBC URL
*/
public static String getJDBCUrl(String baseUrl) {
if (!StringUtils.hasText(baseUrl)) {
return null;
}
return baseUrl + JDBC_URL_ARGS;
}
/**
* 拼接完整的Druid连接属性
*/
public static String getConnectionProperties(String publicKey) {
if (!StringUtils.hasText(publicKey)) {
return null;
}
return CONNECTION_PROPERTIES + publicKey;
}
}
DataSourceContextHolder
使用 ThreadLocal 保存当前线程的数据源key name,并实现set、get、clear方法;
public class DataSourceContextHolder {
private static final ThreadLocal<String> dataSourceKey = new InheritableThreadLocal<>();
public static void setDataSourceKey(String tenantKey) {
dataSourceKey.set(tenantKey);
}
public static String getDataSourceKey() {
return dataSourceKey.get();
}
public static void clearDataSourceKey() {
dataSourceKey.remove();
}
}
DynamicDataSource(重点)
继承 AbstractRoutingDataSource (建议阅读其源码,了解动态切换数据源的过程),实现动态选择数据源;
public class DynamicDataSource extends AbstractRoutingDataSource {
@Autowired
private ApplicationContext applicationContext;
@Lazy
@Autowired
private DynamicDataSourceSummoner summoner;
@Lazy
@Autowired
private TenantConfigDAO tenantConfigDAO;
@Override
protected String determineCurrentLookupKey() {
String tenantKey = DataSourceContextHolder.getDataSourceKey();
return DataSourceUtil.getDataSourceBeanKey(tenantKey);
}
@Override
protected DataSource determineTargetDataSource() {
String tenantKey = DataSourceContextHolder.getDataSourceKey();
String beanKey = DataSourceUtil.getDataSourceBeanKey(tenantKey);
if (!StringUtils.hasText(tenantKey) || applicationContext.containsBean(beanKey)) {
return super.determineTargetDataSource();
}
if (tenantConfigDAO.exist(tenantKey)) {
summoner.registerDynamicDataSources();
}
return super.determineTargetDataSource();
}
}
DynamicDataSourceSummoner(重点中的重点)
从数据库加载数据源信息,并动态组装和注册spring bean,
@Slf4j
@Component
public class DynamicDataSourceSummoner implements ApplicationListener<ContextRefreshedEvent> {
// 跟spring-data-source.xml的默认数据源id保持一致
private static final String DEFAULT_DATA_SOURCE_BEAN_KEY = "defaultDataSource";
@Autowired
private ConfigurableApplicationContext applicationContext;
@Autowired
private DynamicDataSource dynamicDataSource;
@Autowired
private TenantConfigDAO tenantConfigDAO;
private static boolean loaded = false;
/**
* Spring加载完成后执行
*/
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
// 防止重复执行
if (!loaded) {
loaded = true;
try {
registerDynamicDataSources();
} catch (Exception e) {
log.error("数据源初始化失败, Exception:", e);
}
}
}
/**
* 从数据库读取租户GSfW'2"fS6r"Тf&GS67FFW2"fS6r7'C'VS6r7'BG2& |