本文已参与「新人创作礼」活动,一起开启掘金创作之路。
在我的博客阅读本文 有一段时间没有更新博客了,其实这段时间也有在写一些框架的使用,比如quartz的基本使用等等,后来都没有发布出来,主要还是感觉过于简单,对于博客的定位我希望能讨论一些更深层次的设计方面的东西。
最近公司有个需求,客户试用我们的产品到期了,签了正式的合同,准备迁移到生产环境,但是又想要保留测试的时候数据,因此我们开发组准备设计一个迁移程序,要考虑后期拓展与重复使用。这其中有个重要的点就是如何在一个项目中同时操作两个数据源。
今天这篇文章主要是围绕动态数据源切换的设计实现进行展开,其他关于数据迁移方面的问题也可以联系我私人进行讨论。
1. 前言
首先,对一个概念进行定义,本文所述数据源动态切换是指在一个项目工程中,代码层面能够同时操作两个数据源,共用一套数据源操作代码,并非客户端在调用的时候进行动态切换。如果您无意间搜索到这篇文章,请知悉,避免辜负您的时间。
要实现数据源的动态切换,笔者主要考虑以下几点:
- 每种数据源要能够实现单例
- 能够正常执行事务提交回滚
- 数据源切换的线程安全问题
- 符合SpringBoot整体风格,
约定优于配置(convention over configuration)
2. 代码
您可以选择直接clone代码,如果对您有帮助,请一定不要吝惜你的star。
如果对其中有困惑,可以继续往下阅读。
3. 具体实现
我们模拟两个数据源切换,分别为masaiqi_dev和masaiqi_prod,对应测试环境和生产环境
3.1. 准备工作
3.1.1. 引入pom
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.1.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.masaiqi</groupId>
<artifactId>datasource-exchage</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>datasource-exchage</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.48</version>
</dependency>
<!--日志-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
<!--数据库连接池-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.21</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<resources>
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.properties</include>
<include>**/*.xml</include>
<include>**/*.tld</include>
</includes>
<filtering>false</filtering>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
3.1.2. 准备两个数据源
sql如下:
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) DEFAULT NULL,
`introduce` varchar(50) DEFAULT NULL,
PRIMARY KEY (`id`)
);
3.1.3. application.yml配置
datasource:
dev:
driverClass: com.mysql.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/masaiqi_dev?allowMultiQueries=true&useUnicode=true&characterEncoding=utf8&useSSL=false
username: root
password: 123456
prod:
driverClass: com.mysql.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3307/masaiqi_prod?allowMultiQueries=true&useUnicode=true&characterEncoding=utf8&useSSL=false
username: root
password: 123456
3.2. 定义数据源切换类型约束常量
/**
* 数据源类型定义
* <p>
* 记录数据源的beanName
*
* @author sq.ma
* @date 2019/11/26 下午6:09
*/
public class DataSourceType {
public static final String DB_DEV = "DEVDataSource";
public static final String DB_PROD = "PRODDataSource";
}
3.3. 定义数据源切换变量
/**
* 数据源上下文环境,记录当前使用数据源
* <p>
* 数据源类型定义见{@link DataSourceType}
*
* @author sq.ma
* @date 2019/11/26 下午6:06
*/
public class DataSourceContextHolder {
/**
* 数据源beanName存放容器
*/
private static final ThreadLocal<String> contextHolder = new ThreadLocal<String>();
/**
* 设置数据源类型
*
* @param dbType {@link DataSourceType}
* @return void
* @author sq.ma
* @date 2019/11/26 下午6:08
*/
public static void setDBType(String dbType) {
contextHolder.set(dbType);
}
/**
* 获取数据源类型
*
* @param
* @return java.lang.String {@link DataSourceType}
* @author sq.ma
* @date 2019/11/26 下午6:07
*/
public static String getDBType() {
return contextHolder.get();
}
/**
* 清除数据源类型
*
* @param
* @return void
* @author sq.ma
* @date 2019/11/26 下午6:08
*/
public static void clearDBType() {
contextHolder.remove();
}
}
可能和上面的约束常量会混淆,这里主要是定义一个常量,为这个常量增加赋值,取值,置空等方法,而赋值的值只可以在上面的约束常量中进行选择。
我们通过这个变量的值的不同,对数据源进行相应的处理切换(数据源切换见下文)。
注意我们这里用的ThreadLocal这个类对String类型的常量进行了封装,ThreadLocal保证了每个线程都拥有一份独特的变量的拷贝,因而在多线程环境下,每个线程执行数据源操作时候都是线程安全的,也就实现了我们前言中的考虑:数据源切换的线程安全问题
3.4. 定义动态数据源切换路由
/**
* 动态数据源切换
*
* @author sq.ma
* @date 2019/11/26 下午6:04
*/
public class MultipleDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDBType();
}
}
Spring为我们提供了AbstractRoutingDataSource
类,用来做数据源切换。
AbstractRoutingDataSource
类内部维护了一个Map变量,你可以简单理解为:
- key:标志
- value:数据源
不难看出,我们重写的determineCurrentLookupKey()
方法主要作用就是获取这个Map变量的key,Spring会帮我们根据这个key去寻找对应的value,实现路由到不同的数据源。
3.5. 创建数据库配置类
/**
* @author sq.ma
* @date 2019/11/26 上午9:58
*/
@Configuration
public class DatabaseConfig {
/**
* dev环境配置
*/
@Value("${datasource.dev.driverClass}")
private String devDriverClass;
@Value("${datasource.dev.url}")
private String devUrl;
@Value("${datasource.dev.username}")
private String devUsername;
@Value("${datasource.dev.password}")
private String devPassword;
/**
* 生产环境配置
*/
@Value("${datasource.prod.driverClass}")
private String prodDriverClass;
@Value("${datasource.prod.url}")
private String prodUrl;
@Value("${datasource.prod.username}")
private String prodUsername;
@Value("${datasource.prod.password}")
private String prodPassword;
@Bean(name = "devDataSource")
public DruidDataSource getDevDataSource() throws SQLException {
DruidDataSource datasource = new DruidDataSource();
datasource.setUrl(devUrl);
datasource.setUsername(devUsername);
datasource.setPassword(devPassword);
datasource.setDriverClassName(devDriverClass);
datasource.setFilters("stat,wall");
return datasource;
}
@Bean(name = "prodDataSource")
public DruidDataSource getProdDataSource() throws SQLException {
DruidDataSource datasource = new DruidDataSource();
datasource.setUrl(prodUrl);
datasource.setUsername(prodUsername);
datasource.setPassword(prodPassword);
datasource.setDriverClassName(prodDriverClass);
datasource.setFilters("stat,wall");
return datasource;
}
@Bean
public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
return new SqlSessionTemplate(sqlSessionFactory);
}
@Bean
public SqlSessionFactory sqlSessionFactory(MultipleDataSource dataSource)
throws Exception {
SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
sessionFactory.setDataSource(dataSource);
sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver()
.getResources("classpath:com/masaiqi/exchage/mapper/**.xml"));
return sessionFactory.getObject();
}
@Bean
public DataSourceTransactionManager getDataSourceTransactionManager(MultipleDataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
/**
* 配置动态数据源
* <p>
* 数据源类型见 {@link DataSourceType}
*
* @param devDataSource dev数据源
* @param prodDataSource prod数据源
* @return {@link com.masaiqi.exchage.config.MultipleDataSource}
* @author sq.ma
* @date 2019/11/26 下午10:07
*/
@Bean
public MultipleDataSource dynamicDataSource(@Qualifier("devDataSource") DruidDataSource devDataSource, @Qualifier("prodDataSource")DruidDataSource prodDataSource) {
//配置切换的动态数据源
Map<Object, Object> datasourceMap = new HashMap<>(2);
datasourceMap.put(DataSourceType.DB_DEV, devDataSource);
datasourceMap.put(DataSourceType.DB_PROD, prodDataSource);
MultipleDataSource multipleDataSource = new MultipleDataSource();
multipleDataSource.setTargetDataSources(datasourceMap);
//配置默认数据源
multipleDataSource.setDefaultTargetDataSource(devDataSource);
return multipleDataSource;
}
}
重点关注一下dynamicDataSource()
这个方法,这个方法主要是将对应的标志-数据源的数据插入到AbstractRoutingDataSource
的Map变量中,这样我们就可以根据这个key去寻找这个Map变量中对应的值,决定使用的数据源。
我们重写的determineCurrentLookupKey()
方法中,有写了获取key值的方式:
return DataSourceContextHolder.getDBType();
也就是说DataSourceContextHolder
中的getDBType()
决定了当前使用的数据源。
3.6. 创建数据源切换注解
/**
* 切换数据源注解
*
* @author sq.ma
* @date 2019/11/27 上午10:02
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DataSourceChoose {
/**
* 数据源名称
* <p>
* @see DataSourceType
*/
String value();
}
这个注解用来识别当前方法需要使用的数据源
3.7. 创建Dao层
这一块直接贴代码了,比较简单。
UserDAO.java:
@Repository
public interface UserDAO {
int deleteByPrimaryKey(Integer id);
int insert(User record);
int insertSelective(User record);
User selectByPrimaryKey(Integer id);
int updateByPrimaryKeySelective(User record);
int updateByPrimaryKey(User record);
}
UserDAO.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.masaiqi.exchage.dao.UserDAO">
<resultMap id="BaseResultMap" type="com.masaiqi.exchage.entity.User">
<id column="id" jdbcType="INTEGER" property="id" />
<result column="name" jdbcType="VARCHAR" property="name" />
<result column="introduce" jdbcType="VARCHAR" property="introduce" />
</resultMap>
<sql id="Base_Column_List">
id, `name`, introduce
</sql>
<select id="selectByPrimaryKey" parameterType="java.lang.Integer" resultMap="BaseResultMap">
select
<include refid="Base_Column_List" />
from user
where id = #{id,jdbcType=INTEGER}
</select>
<delete id="deleteByPrimaryKey" parameterType="java.lang.Integer">
delete from user
where id = #{id,jdbcType=INTEGER}
</delete>
<insert id="insert" keyColumn="id" keyProperty="id" parameterType="com.masaiqi.exchage.entity.User" useGeneratedKeys="true">
insert into user (`name`, introduce)
values (#{name,jdbcType=VARCHAR}, #{introduce,jdbcType=VARCHAR})
</insert>
<insert id="insertSelective" keyColumn="id" keyProperty="id" parameterType="com.masaiqi.exchage.entity.User" useGeneratedKeys="true">
insert into user
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="name != null">
`name`,
</if>
<if test="introduce != null">
introduce,
</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="name != null">
#{name,jdbcType=VARCHAR},
</if>
<if test="introduce != null">
#{introduce,jdbcType=VARCHAR},
</if>
</trim>
</insert>
<update id="updateByPrimaryKeySelective" parameterType="com.masaiqi.exchage.entity.User">
update user
<set>
<if test="name != null">
`name` = #{name,jdbcType=VARCHAR},
</if>
<if test="introduce != null">
introduce = #{introduce,jdbcType=VARCHAR},
</if>
</set>
where id = #{id,jdbcType=INTEGER}
</update>
<update id="updateByPrimaryKey" parameterType="com.masaiqi.exchage.entity.User">
update user
set `name` = #{name,jdbcType=VARCHAR},
introduce = #{introduce,jdbcType=VARCHAR}
where id = #{id,jdbcType=INTEGER}
</update>
</mapper>
3.8. 创建service模拟主业务
/**
* @author sq.ma
* @date 2019/12/1 下午4:06
*/
@Service
public class MainServiceImpl implements MainService {
@Autowired
private UserDAO userDAO;
@Override
@Transactional(rollbackFor = Exception.class)
@DataSourceChoose(DataSourceType.DB_DEV)
public void doJobDev() {
User user = User.builder()
.introduce("我是dev数据源来的~")
.name("dec")
.build();
userDAO.insert(user);
}
@Override
@Transactional(rollbackFor = Exception.class)
@DataSourceChoose(DataSourceType.DB_PROD)
public void doJobProd() {
User user = User.builder()
.introduce("我是prod数据源来的~")
.name("prod")
.build();
userDAO.insert(user);
}
}
3.9. 创建AOP切入类
上文中我们创建了数据源切换注解,这里我们通过AOP进行动态代理,读取数据源注解,进行数据源切换,其实也就是重新设置DataSourceContextHolder.getDBType()
的值。
/**
* 数据源切换aop
* <p>
* 解析数据源切换注解 {@link DataSourceChoose}
* <p>
* 注意@Order(1) 设置执行顺序,否则如果事务注解先处理,会导致切换数据源失效
*
* @author sq.ma
* @date 2019/11/27 上午9:42
*/
@Aspect
@Component
@Order(1)
public class DataSourceAOP {
@Pointcut("execution( * com.masaiqi.exchage.service..*.*(..))")
public void service(){
}
@Before("service()")
public void dataSourceExchange(JoinPoint joinPoint) throws NoSuchMethodException {
Object target = joinPoint.getTarget();
String methodName = joinPoint.getSignature().getName();
Class[] parameterTypes = ((MethodSignature) joinPoint.getSignature()).getMethod().getParameterTypes();
Class clazz = target.getClass() ;
Method m = clazz.getMethod(methodName, parameterTypes);
if (m != null && m.isAnnotationPresent(DataSourceChoose.class)) {
DataSourceChoose data = m.getAnnotation(DataSourceChoose.class);
String dataSourceName = data.value();
DataSourceContextHolder.setDBType(dataSourceName);
}
}
}
这里一定要注意@Order(1)
这个注解,提高这个代理的优先级,如果不指定的话,@Transactional
注解的事务代理会优先于数据源切换,会直接导致数据源切换失败,这一点很关键也很重要,这是我们对于能够正常执行事务提交回滚
的思考。
当然,也不一定要指定为1,我们可以看一下源码:
在springBoot中,我们已经不需要写入@EnableTransactionManagement
注解来开启事务,springBoot会自动帮我们配置,也就是org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration
这个类:
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ JdbcTemplate.class, PlatformTransactionManager.class })
@AutoConfigureOrder(Ordered.LOWEST_PRECEDENCE)
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceTransactionManagerAutoConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnSingleCandidate(DataSource.class)
static class DataSourceTransactionManagerConfiguration {
@Bean
@ConditionalOnMissingBean(PlatformTransactionManager.class)
DataSourceTransactionManager transactionManager(DataSource dataSource,
ObjectProvider<TransactionManagerCustomizers> transactionManagerCustomizers) {
DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
transactionManagerCustomizers.ifAvailable((customizers) -> customizers.customize(transactionManager));
return transactionManager;
}
}
}
@AutoConfigureOrder(Ordered.LOWEST_PRECEDENCE)
这个注解,定义了优先级,这个常量的值我们也可以看到:
这个值是int类型的最大值,也就是说,事务的优先级是很低的,你定义的值只要在这个前面就可以。
其实这也是SpringBoot约定大于配置的体现,如果你没有设置,他会默认开启事务并给定一个极低等优先级,这是约定。当然你也可以自己在通过EnableTransactionManagement
注解主动开启事务,传参给定一个值,作为事务切入的优先级。
在上文中,你会发现我们在数据源配置的时候,也设置了默认的数据源,也就是说,即使用户不使用注解,我们也有默认的数据源去执行,用户可以选择使用注解指定数据源,这是我们对于约定大于配置的思考。
4. 测试总结
/**
* 配置一个在SpringBoot项目启动后跟随启动的类
*
* @author sq.ma
* @date 2019/12/1 下午3:52
*/
@Component
public class CommandRun implements CommandLineRunner {
@Autowired
private MainService mainService;
@Override
public void run(String... args) throws Exception {
mainService.doJobDev();
mainService.doJobProd();
mainService.doJobDev();
mainService.doJobProd();
}
}
CommandLineRunner接口主要是帮助我们在springBoot项目启动后执行一段代码,这里我们交替两个数据源进行插入。
结果:
可以看到,数据都按照我们设置的,插入到了数据库中。
查看控制台:
两个数据源尽管交替执行,仍然只初始化了一次(单例),这是我们对于每种数据源要能够实现单例
的思考。当然,这其实是Spring的IOC容器的@Bean
注解,默认只会创建一个实例帮了忙。