springboot + Mybatis 动态热加载数据源(SaaS多租户平台)
第一部分(基础)
一个简单的租户表结构
项目依赖
<?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.4.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.hydrogen</groupId>
<artifactId>test_saas</artifactId>
<version>1.0.0</version>
<name>test_saas</name>
<description>Demo project for test_saas</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- spring aop -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.4</version>
</dependency>
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.3.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.75</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
配置文件
数据库连接的key为
jdbc-url
或jdbcUrl
DataSourceAutoConfiguration
为关闭springboot自动数据源配置
server:
port: 8088
spring:
application:
name: test_saas
autoconfigure:
exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/education?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&verifyServerCertificate=false&useSSL=false
username: root
password: root
mybatis:
mapper-locations: classpath:mappers/*.xml
type-aliases-package: com.hydrogen.saas.entity
configuration:
map-underscore-to-camel-case: true
pagehelper:
helper-dialect: mysql
page-size-zero: true
reasonable: true
auto-runtime-dialect: true
定义一个简单的租户类
/**
* 租户类
*/
@Data
public class Tenant implements Serializable {
private Integer id;
/**
* 租户名
*/
private String tenantName;
/**
* 数据库类型
*/
private String type;
/**
* 驱动类
*/
private String driverClassName;
/**
* 数据库连接
*/
private String jdbcUrl;
/**
* 数据库用户名
*/
private String username;
/**
* 数据库密码
*/
private String password;
/**
* 数据库名
*/
private String dataBase;
}
Mapper实现根据租户名获取租户连接信息
public interface TenantMapper {
Tenant getByName(String name);
void insert(Tenant tenant);
}
<?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.hydrogen.saas.mapper.TenantMapper">
<insert id="insert">
insert into tenant values (#{id}, #{tenantName}, #{type}, #{driverClassName}, #{jdbcUrl}, #{username}, #{password}, #{dataBase})
</insert>
<select id="getByName" resultType="com.hydrogen.saas.entity.Tenant">
select * from tenant where tenant_name = #{name}
</select>
</mapper>
第二部分(配置)
关闭springboot的自动配置数据源
有2种方式:
1.配置文件中关闭
spring:
autoconfigure:
exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
2.启动类注解关闭
@MapperScan("com.hydrogen.saas.mapper")
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class TestSaasApplication {
public static void main(String[] args) {
SpringApplication.run(TestSaasApplication.class, args);
}
}
自定义动态数据源
/**
* 自定义动态数据源
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
private Map<Object, Object> dataSources = new HashMap<>();
/**
* 获取当前数据源的键
*/
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSourceKey();
}
/**
* 获取当前数据源
*/
@Override
protected DataSource determineTargetDataSource() {
return super.determineTargetDataSource();
}
/**
* 设置默认数据源
*
* @param defaultDataSource
*/
public void setDefaultDataSource(Object defaultDataSource) {
super.setDefaultTargetDataSource(defaultDataSource);
}
/**
* 设置数据源
*
* @param dataSources
*/
public void setDataSources(Map<Object, Object> dataSources) {
this.dataSources = dataSources;
super.setTargetDataSources(dataSources);
// 保存数据源的key
DataSourceContextHolder.addDataSourceKeys(dataSources.keySet());
}
/**
* 追加数据源
*
* @param key
* @param dataSource
*/
public void addDataSource(String key, DataSource dataSource) {
dataSources.put(key, dataSource);
super.setTargetDataSources(dataSources);
// 保存数据源的key
DataSourceContextHolder.addDataSourceKey(key);
// 加载新的数据源
super.afterPropertiesSet();
}
}
动态数据源的配置
/**
* 自定义动态数据源配置类
*/
@Configuration
@EnableTransactionManagement
public class DataSourceConfig {
@Value("${mybatis.type-aliases-package}")
private String typeAliasesPackage;
@Value("${mybatis.mapper-locations}")
private String mapperLocations;
@Value("${mybatis.configuration.map-underscore-to-camel-case}")
private Boolean mapUnderscoreToCamelCase;
/**
* 默认基础数据源
*
* @return
*/
@Bean("defaultSource")
@ConfigurationProperties("spring.datasource")
public DataSource defaultSource() {
return DataSourceBuilder.create().build();
}
/**
* 自定义动态数据源
*
* @return
*/
@Bean("dynamicDataSource")
public DataSource dynamicDataSource() {
DynamicDataSource dynamicDataSource = new DynamicDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put("default", defaultSource());
// 默认数据源
dynamicDataSource.setDefaultDataSource(defaultSource());
// 动态数据源
dynamicDataSource.setDataSources(dataSourceMap);
return dynamicDataSource;
}
/**
* @return 修改Mybatis数据源配置
* @throws IOException
*/
@Bean
public SqlSessionFactoryBean sqlSessionFactoryBean() throws IOException {
SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
// 配置自定义动态数据源
sessionFactory.setDataSource(dynamicDataSource());
// 开启驼峰转下划线设置
org.apache.ibatis.session.Configuration configuration = new org.apache.ibatis.session.Configuration();
configuration.setMapUnderscoreToCamelCase(mapUnderscoreToCamelCase);
sessionFactory.setConfiguration(configuration);
// 实体、Mapper类映射
sessionFactory.setTypeAliasesPackage(typeAliasesPackage);
sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(mapperLocations));
return sessionFactory;
}
/**
* 开启动态数据源@Transactional注解事务管理的支持
*
* @return
*/
@Bean
public PlatformTransactionManager transactionManager() {
return new DataSourceTransactionManager(dynamicDataSource());
}
}
动态数据源的管理
/**
* 动态数据源管理
*/
public class DataSourceContextHolder {
private static final ThreadLocal<String> contextHolder = new ThreadLocal<String>() {
/**
* 设置默认数据源的key
*/
@Override
protected String initialValue() {
return "default";
}
};
/**
* 数据源key的集合
*/
public static Set<Object> dataSourceKeys = new HashSet<>();
/**
* 切换数据源
*
* @param key
*/
public static void setDataSourceKey(String key) {
contextHolder.set(key);
}
/**
* 获取数据源
*
* @return
*/
public static String getDataSourceKey() {
return contextHolder.get();
}
/**
* 重置数据源
*/
public static void clearDataSourceKey() {
contextHolder.remove();
}
/**
* 判断数据源是否存在
*
* @param key 数据源key
* @return
*/
public static boolean containDataSourceKey(String key) {
return dataSourceKeys.contains(key);
}
/**
* 添加数据源key
*
* @return
*/
public static boolean addDataSourceKey(Object key) {
return dataSourceKeys.add(key);
}
/**
* 添加多个数据源keys
*
* @param keys
* @return
*/
public static boolean addDataSourceKeys(Collection<? extends Object> keys) {
return dataSourceKeys.addAll(keys);
}
}
第三部分(实现)
使用AOP实现数据源动态切换
@Aspect
@Order(1) //该切面应当先于 @Transactional 执行
@Component
public class DynamicDataSourceAspect {
@Autowired
private TenantService tenantService;
@Autowired
private DynamicDataSource dynamicDataSource;
/**
* 切换数据源
*/
@Before("execution(public * com.hydrogen.saas.controller.*.*(..))")
public void switchDataSource() {
System.out.println("切换数据源");
// 获取token
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String token = request.getHeader("token");
// 将新租户的数据源添加到动态数据源
if (!DataSourceContextHolder.containDataSourceKey(token)) {
Tenant tenant = tenantService.getByName(token);
System.out.println(tenant);
if (tenant == null) throw new RuntimeException("租户信息异常");
DataSourceBuilder dataSourceBuilder = DataSourceBuilder.create();
dataSourceBuilder.driverClassName(tenant.getDriverClassName());
dataSourceBuilder.url(tenant.getJdbcUrl());
dataSourceBuilder.username(tenant.getUsername());
dataSourceBuilder.password(tenant.getPassword());
DataSource source = dataSourceBuilder.build();
dynamicDataSource.addDataSource(token, source);
}
// 切换数据源
DataSourceContextHolder.setDataSourceKey(token);
System.out.println(DataSourceContextHolder.getDataSourceKey());
}
/**
* 重置数据源
*/
@After("execution(public * com.hydrogen.saas.controller.*.*(..))")
public void restoreDataSource() {
// 将数据源置为默认数据源
System.out.println("重置数据源");
DataSourceContextHolder.clearDataSourceKey();
}
}
切面异常处理
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(value = RuntimeException.class)
@ResponseBody
public Map exceptionHandler(RuntimeException e) {
Map result = new HashMap();
result.put("code", 401);
result.put("msg", e.getMessage());
result.put("data", null);
return result;
}
}
第四部分(测试)
分页查询接口
@Log4j2
@RestController
@RequestMapping("address")
public class AddressController {
@Autowired
private AddressService addressService;
@GetMapping("getAddress")
public PageInfo<Address> getAddress() {
PageInfo<Address> pageInfo = new PageInfo<>();
try {
pageInfo = addressService.getList(2,2);
} catch (Exception e) {
log.error("getAddress Exception: ", e);
}
return pageInfo;
}
}
请求头中添加token信息
响应结果
{
"total": 4,
"list": [
{
"id": 3,
"stuId": "3",
"address": "杭州萧山区"
},
{
"id": 4,
"stuId": "4",
"address": "上海静安"
}
],
"pageNum": 2,
"pageSize": 2,
"size": 2,
"startRow": 3,
"endRow": 4,
"pages": 2,
"prePage": 1,
"nextPage": 0,
"isFirstPage": false,
"isLastPage": true,
"hasPreviousPage": true,
"hasNextPage": false,
"navigatePages": 8,
"navigatepageNums": [
1,
2
],
"navigateFirstPage": 1,
"navigateLastPage": 2
}
更换请求头中token信息
响应结果
{
"total": 6,
"list": [
{
"id": 333,
"stuId": "33",
"address": "深圳"
},
{
"id": 444,
"stuId": "44",
"address": "广州"
}
],
"pageNum": 2,
"pageSize": 2,
"size": 2,
"startRow": 3,
"endRow": 4,
"pages": 3,
"prePage": 1,
"nextPage": 3,
"isFirstPage": false,
"isLastPage": false,
"hasPreviousPage": true,
"hasNextPage": true,
"navigatePages": 8,
"navigatepageNums": [
1,
2,
3
],
"navigateFirstPage": 1,
"navigateLastPage": 3
}