springboot + druid 实现数据源下动态切换
1. 项目背景
最近接到一个需求:为多个业务系统开发统一的管理后台,这几个业务系统的相关业务惊人地相似,用的是同一份代码,只是地区、受众不同,因此服务器、数据库都单独分开了。
项目很明显要用到多数据源,经过在google多方打探,终于了解到基于AbstractRoutingDataSource
的多数据源解决方案了。废话不多说,上代码介绍我的实现方式。
2. 代码实现
2.1 引入多数据源
在 application.yml
引入多数据源配置,这里使用的是阿里的 Druid
连接池:
# 数据源配置
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
druid:
# 主库数据源
main:
url: jdbc:mysql://127.0.0.1:3306/main?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: 123
# 应用数据源
app:
# db1
db1:
url: jdbc:mysql://127.0.0.1:3306/db1?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: 123
# db2
db2:
url: jdbc:mysql://127.0.0.1:3306/db2?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: 123
# ds3
db3:
url: jdbc:mysql://127.0.0.1:3306/db3?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: 123
这里一共引入了4个数据源:main
、db1
、db2
、db3
main
: 管理后台的数据源,存放管理后台相关数据,如用户登录数据、系统菜单等db1
: 业务系统1的数据源db2
: 业务系统2的数据源db3
: 业务系统3的数据源
2.2 实例化多数据源
在java中,使用DataSourceConfig
处理多数据源实例化:
@Slf4j
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.druid.main")
public DataSource mainDataSource() {
return DruidDataSourceBuilder.create().build();
}
/**
* 处理多数据源
* @param appDataSourceProperties
* @return
*/
@Bean
public Map<String, DataSource> appDataSourceMap(
AppDataSourceProperties appDataSourceProperties) {
LinkedHashMap<String, DataSource> appDataSourceMap = new LinkedHashMap<>();
List<String> appDataSourceList = appDataSourceProperties.getDataSourceList();
for(String dataSourceName : appDataSourceList) {
log.info("创建app数据源:{}", dataSourceName);
DruidDataSource dataSource = new DruidDataSource();
dataSource.setUrl(appDataSourceProperties.getUrl(dataSourceName));
dataSource.setUsername(appDataSourceProperties.getUsername(dataSourceName));
dataSource.setPassword(appDataSourceProperties.getPassword(dataSourceName));
appDataSourceMap.put(dataSourceName, dataSource);
}
return appDataSourceMap;
}
@Bean(name = "dynamicDataSource")
@Primary
public DynamicDataSource dataSource(DataSource mainDataSource, @Qualifier("appDataSourceMap") Map<String, DataSource> appDataSourceMap) {
Map<Object, Object> targetDataSources = new HashMap<>(appDataSourceMap.size() + 1);
targetDataSources.put(DataSourceType.main.name(), mainDataSource);
targetDataSources.putAll(appDataSourceMap);
return new DynamicDataSource(mainDataSource, targetDataSources);
}
}
2.3 AbstractRoutingDataSource
:spring多数据源支持
AbstractRoutingDataSource
是 spring 提供的一个抽象类,可以实现该类处理数据源切换:
public class DynamicDataSource extends AbstractRoutingDataSource {
public DynamicDataSource(DataSource defaultTargetDataSource, Map<Object, Object> targetDataSources) {
super.setDefaultTargetDataSource(defaultTargetDataSource);
super.setTargetDataSources(targetDataSources);
super.afterPropertiesSet();
}
/**
* 在实际进行数据库查询时,spring会调用该方法,从而拿到当前的数据源
*/
@Override
protected Object determineCurrentLookupKey() {
return DynamicDataSourceContextHolder.getDataSourceType();
}
}
DynamicDataSourceContextHolder
代码如下:
@Slf4j
public class DynamicDataSourceContextHolder {
/**
* 使用ThreadLocal维护变量,ThreadLocal为每个使用该变量的线程提供独立的变量副本,
* 所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。
*/
private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
/**
* 设置数据源的变量
*/
public static void setDataSourceType(String dsType) {
if(StringUtils.isNotBlank(dsType)) {
log.info("切换到{}数据源", dsType);
CONTEXT_HOLDER.set(dsType);
}
}
/**
* 获得数据源的变量
*/
public static String getDataSourceType() {
return CONTEXT_HOLDER.get();
}
/**
* 清空数据源变量
*/
public static void clearDataSourceType() {
CONTEXT_HOLDER.remove();
}
/**
* 获取主数据源
* @return
*/
public static String getMainDateSource() {
return DataSourceType.main.name();
}
}
2.4 使用 spring 切面声明需要动态切换数据源的类
这里主要是使用注解 DataSourceDeclareAspect
来声明该类需要多数据源支持:
/**
* 数据源声明,有此标记,表示需要动态切换数据源
*
* @author funcy
* @date 2019-09-06 10:30
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface DataSourceDeclare {
//多自由添加其他属性进行数据源的一些其他声明
}
切面按如下方式处理:
@Slf4j
@Aspect
@Order(1)
@Component
public class DataSourceDeclareAspect {
@Pointcut("@annotation(com.gitee.funcy.multiple.datasource.aspectj.annotation.DataSourceDeclare)"
+ "|| @within(com.gitee.funcy.multiple.datasource.aspectj.annotation.DataSourceDeclare)"
+ "|| execution(* com.gitee.funcy.multiple.datasource.mapper..*.*(..))"
)
public void dsPointCut() {
}
@Around("dsPointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
DataSourceDeclare dataSource = getDataSource(point);
if(null != dataSource) {
DynamicDataSourceContextHolder.setDataSourceType(AppStorageContextHolder.getApp());
}
try {
return point.proceed();
} finally {
// 销毁数据源 在执行方法之后
if(null != dataSource) {
DynamicDataSourceContextHolder.clearDataSourceType();
}
}
}
/**
* 获取需要切换的数据源
*/
public DataSourceDeclare getDataSource(ProceedingJoinPoint point) {
MethodSignature signature = (MethodSignature) point.getSignature();
//1.获取方法上的DataSource
Method method = signature.getMethod();
DataSourceDeclare dataSource = method.getAnnotation(DataSourceDeclare.class);
if(null != dataSource) {
return dataSource;
}
//2.获取声明类的DataSource
dataSource = method.getDeclaringClass().getAnnotation(DataSourceDeclare.class);
if(null != dataSource) {
return dataSource;
}
//3.获取目标类上的DataSourceDeclare
Class<? extends Object> targetClass = point.getTarget().getClass();
dataSource = targetClass.getAnnotation(DataSourceDeclare.class);
if (null != dataSource) {
return dataSource;
}
//反射获取
/*
* point.getTarget()(为com.baomidou.mybatisplus.core.override.MybatisMapperProxy的实例)的
* 结构如下:
*
* point.getTarget()
* |-h
* |-sqlSession
* |-mapperInterface
* |-methodCache
*
*/
Object h = ReflectUtils.getFieldValue(point.getTarget(), "h");
if(null != h) {
Class<? extends Object> clazz = ReflectUtils.getFieldValue(h, "mapperInterface");
if (null != clazz) {
dataSource = clazz.getAnnotation(DataSourceDeclare.class);
if (null != dataSource) {
return dataSource;
}
}
}
log.warn("未找到对应的DataSource,方法:{},声明类:{},目标类:{}", method.getName(),
method.getDeclaringClass().getName(), targetClass.getName());
return null;
}
}
可以看到,定义切点时这里不但使用了 @annotation
、@within
,还使用了 execution
,这是因为在继承的情况,前两个注解并不切到目标方法的执行,这里这块的说明,请见(spring aop 如何准备切 mapper)[http://www.baidu.com]
2.5 创建业务类
所谓的业务类,是指 po
、mapper
、service
与 controller
那些与业务相关的类,其他的类与多数据源并无联系,这里主要说明下mapper
类:
/**
* UserMapper 的示例
*/
@DataSourceDeclare
public interface UserMapper extends BaseMapper<User> {
}
UserMapper
对应业务数据库中 user
表的操作。使用注解 @DataSourceDeclare
来声明该类需要进行动态数据源切换,关于切面为何选在 mapper
层而不是 service
层,主要是考虑到在一个 service
中可能需要查询 main
数据源中的数据,如果切service层就不太合适了。
对于下AppMapper
:
/**
* AppMapper 的示例
*/
public interface AppMapper extends BaseMapper<App> {
}
AppMapper
对应 main
数据源中 app
表的操作,主要是用来定义接入的业务数据源。
2.6 拦截启用多数据源的路径
请求业务数据库时,页面传入的 appId
参数并非与业务相关,并不需要传到 controller
层,这里使用拦截器将其拦截:
@Slf4j
@Component
public class AppDataSourceInterceptor implements HandlerInterceptor {
private static final Logger logger = LoggerFactory.getLogger(AppDataSourceInterceptor.class);
private final String PARAM_APP_ID = "appId";
@Autowired
private IAppService appService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String appId = request.getParameter(PARAM_APP_ID);
if(StringUtils.isNotBlank(appId)) {
App app = appService.selectById(NumberUtils.toLong(appId));
if(null != app) {
AppStorageContextHolder.setApp(app.getDataSource());
}
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) throws Exception {
AppStorageContextHolder.remove();
}
}
注册到spring中:
@Configuration
public class ResourcesConfig implements WebMvcConfigurer {
@Autowired
private AppDataSourceInterceptor appDataSourceInterceptor;
/**
* 自定义拦截规则
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 指定拦截的路径为 /api/xxx
registry.addInterceptor(appDataSourceInterceptor).addPathPatterns("/api/**");
}
}
拦截后,将其保存到 AppStorageContextHolder
中,当需要使用时,再从 AppStorageContextHolder
获取:
@Slf4j
public class AppStorageContextHolder {
private static final ThreadLocal<String> STORAGE = new ThreadLocal<>();
/**
* 设置app
*/
public static void setApp(String dsType) {
STORAGE.set(dsType);
}
/**
* 获得app
*/
public static String getApp() {
return STORAGE.get();
}
/**
* 移除app
*/
public static void remove() {
STORAGE.remove();
}
}
AppStorageContextHolder
相当于一个中间存储器,暂时保存业务的数据源,以便后期使用。
那为何不直接保存在 DynamicDataSourceContextHolder
中呢?因为在请求过程中,可能会访问下 main
数据源再访问业务数据源,直接保存就不行了。而且 DynamicDataSourceContextHolder
用完后就清理了,如果一次请求中多次访问业务数据源也不行。
2.7 运行
运行前,请先执行project.sql
中脚本,这里创建了三个库,每个库上数据如下:
-
main.admin
表:id app_name data_source 1 应用1 db1 2 应用2 db2 3 应用3 db3 -
db1.user
表:uid nick 1 db1_张三 2 db1_李四 3 db1_王五 -
db2.user
表:uid nick 1 db2_张三 2 db2_李四 3 db2_王五 -
db3.user
表:uid nick 1 db3_张三 2 db3_李四 3 db3_王五
启动后,请求接口/api/user/get
,结果如下:
- 数据源1
http://localhost:8084/api/user/get?appId=1
[
{
"uid": 1,
"nick": "db1_张三"
},
{
"uid": 2,
"nick": "db1_李四"
},
{
"uid": 3,
"nick": "db1_王五"
}
]
- 数据源2
http://localhost:8084/api/user/get?appId=2
[
{
"uid": 1,
"nick": "db2_张三"
},
{
"uid": 2,
"nick": "db2_李四"
},
{
"uid": 3,
"nick": "db2_王五"
}
]
- 数据源2
http://localhost:8084/api/user/get?appId=3
[
{
"uid": 1,
"nick": "db3_张三"
},
{
"uid": 2,
"nick": "db3_李四"
},
{
"uid": 3,
"nick": "db3_王五"
}
]
可以看到数据已经正确请求了。
以上代码见gitee仓库https://gitee.com/funcy/springboot-demomultiple-datasource
模块.
相关文章: