我们以一个实例来详细说明一下如何在SpringBoot中动态切换MyBatis的数据源。
一、需求
1、用户可以界面新增数据源相关信息,提交后,保存到数据库
2、保存后的数据源需要动态生效,并且可以由用户动态切换选择使用哪个数据源
3、数据库保存了多个数据源的相关记录后,要求在系统启动时把这些个数据源创建出来,用户在使用时可以自由选择切换
二、项目准备
创建项目的基础骨架
建项目
项目名:dds
改pom
4.0.0
org.springframework.boot
spring-boot-starter-parent
3.3.5
com.xiaoxie
dds
0.0.1-SNAPSHOT
dds
dds
17
org.springframework.boot
spring-boot-starter-thymeleaf
org.springframework.boot
spring-boot-starter-web
org.mybatis.spring.boot
mybatis-spring-boot-starter
3.0.3
com.mysql
mysql-connector-j
runtime
com.alibaba
druid-spring-boot-starter
1.2.8
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
test
org.mybatis.spring.boot
mybatis-spring-boot-starter-test
3.0.3
test
org.springframework.boot
spring-boot-maven-plugin
org.projectlombok
lombok
修改yml
server:
port: 8888
spring:
datasource:
druid:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/dds?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
mybatis:
mapper-locations: classpath:mapper/**.xml
configuration:
map-underscore-to-camel-case: true
主启动类
@SpringBootApplication
public class DdsApplication {
public static void main(String[] args) {
SpringApplication.run(DdsApplication.class, args);
}
}
做完成上面就是不带任何业务类的一个基础项目框架。
数据库准备
新增一个数据库dds,其中有两个数据表,一个是用来存储用户提交的数据源信息的(ds),一个是后续我们测试效果用的(test)。
CREATE TABLE `ds` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '数据源名称',
`url` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'url',
`username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'username',
`password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'password',
`create_time` datetime NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `test` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
INSERT INTO `dds`.`test` (`id`, `name`) VALUES (1, '王二麻子');
新增一个测试库test,其中有一个测试数据表,这个表的结构保持与dds库中的test表一致,但数据不一样。
CREATE TABLE `test` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `test`.`test` (`id`, `name`) VALUES (1, '张三');
三、处理与前端的交互
后端接口
新增一个Controller类,这个类中添加一个处理器方法:AddSourceController
@Controller
@Slf4j
@RequiredArgsConstructor
public class AddSourceController {
private final DsService dsService;
@GetMapping("/toAddSource")
public String addSource(){
return "add_source";
}
}
这样的话当我们请求项目的/toAddSource接口时,跳转到add_source.html,在这个页面中我们进行用户数据的提交动作。
前端页面
html
添加数据源
添加数据源(MySQL)
css
页面css文件,js文件如下:style.css
body {
font-family: Arial, sans-serif;
margin: 20px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 8px;
box-sizing: border-box;
}
button {
padding: 10px 20px;
margin-right: 10px;
}
.error {
color: red;
font-size: 0.9em;
}
.valid-icon {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: green;
/*display: none;*/
}
.input-container {
position: relative;
display: inline-block;
width: 100%;
}
.input-container input {
padding-right: 25px; /* 为对勾图标留出空间 */
}
js
const secretKey = CryptoJS.enc.Latin1.parse("aab11e66fa232e32");
const iv = CryptoJS.enc.Latin1.parse("aab11e66fa232e32");
let isValid = true;
/**
* 测试连通性
*/
function testConnection() {
const dataSourceName = $('#dataSourceName').val().trim();
const url = $('#url').val().trim();
const username = $('#username').val().trim();
const password = $('#password').val().trim();
checkDataSourceName();
checkUrl();
if (username === '') {
$('#usernameError').text('连接账号不能为空');
isValid = false;
} else {
$('#usernameError').text('');
}
if (password === '') {
$('#passwordError').text('连接密码不能为空');
isValid = false;
} else {
$('#passwordError').text('');
}
if(isValid) {
// 禁用提交按钮
$('#dataSourceForm button[type="submit"]').prop('disabled', true);
let encryptedPassword = CryptoJS.AES.encrypt(password, secretKey, {iv: iv, mode:CryptoJS.mode.CBC,
padding:CryptoJS.pad.ZeroPadding}).toString();
const data = {
name: dataSourceName,
url: url,
username: username,
password: encryptedPassword
}
// 向后端发送ajax请求
$.ajax({
url: '/testConnection',
type: 'POST',
data: JSON.stringify(data),
contentType: 'application/json',
success: function(response) {
if(response.code === 200) {
alert('测试连接成功!');
} else {
alert('测试连接失败!');
}
},
error: function() {
alert('测试连接时发生错误!');
},
complete: function() {
// 无论成功还是失败,都重新启用提交按钮
$('#dataSourceForm button[type="submit"]').prop('disabled', false);
}
});
}
}
/**
* 校验数据源名称是否已经在数据库中记录表中已存在,如果已经存在,则提示用户,否则继续执行。
*/
function checkDataSourceName() {
const dataSourceName = $('#dataSourceName').val().trim();
if (dataSourceName === '') {
$('#dataSourceNameError').text('数据源名称不能为空!')
isValid = false;
}
/* 向后端发送ajax请求,密码是否已经存在 */
$.ajax({
url: '/checkDataSourceName',
type: 'POST',
data: JSON.stringify({dataSourceName: dataSourceName}),
contentType: 'application/json',
success: function(response) {
if(response.code === 200) {
if(response.msg === '存在') {
$('#dataSourceNameError').text('数据源名称已存在,不可重复哦!');
$('#sourceNameValid').hide(); // 隐藏对勾图标
isValid = false;
} else if(response.msg === '不存在') {
$('#dataSourceNameError').text('');
$('#sourceNameValid').show(); // 显示对勾图标
isValid = true;
} else {
$('#dataSourceNameError').text('检查数据源名称时发生错误!')
$('#sourceNameValid').hide(); // 隐藏对勾图标
isValid = false;
}
} else {
$('#dataSourceNameError').text('检查数据源名称时发生错误!')
$('#sourceNameValid').hide(); // 隐藏对勾图标
isValid = false;
}
},
error: function() {
$('#dataSourceNameError').text('检查数据源名称时发生错误!')
$('#sourceNameValid').hide(); // 隐藏对勾图标
isValid = false;
}
})
}
/**
* 检查输入的url是否满足mysql连接字符串的规则
*/
function checkUrl() {
const url = $('#url').val().trim();
if (url === '') {
$('#urlError').text('URL连接字符串不能为空');
}
const urlRegex = /^jdbc:mysql:\/\/[a-zA-Z0-9.-]+(:\d+)?(\/[a-zA-Z0-9._-]+)?(\?[^=]+=.*)?$/;
if(!urlRegex.test(url)) {
$('#urlError').text('URL连接字符串格式不正确,请重新输入');
isValid = false;
} else {
$('#urlError').text('');
$('#urlValid').show();
isValid = true;
}
}
/**
* 提交数据源数据
*/
function submitForm() {
const dataSourceName = $('#dataSourceName').val().trim();
const url = $('#url').val().trim();
const username = $('#username').val().trim();
const password = $('#password').val().trim();
if (checkDataSourceName() && checkUrl()) {
isValid = true;
}
if (username === '') {
$('#usernameError').text('连接账号不能为空');
isValid = false;
} else {
$('#usernameError').text('');
}
if (password === '') {
$('#passwordError').text('连接密码不能为空');
isValid = false;
} else {
$('#passwordError').text('');
}
if(isValid) {
// 防止表单多次重复提交
$('#dataSourceForm button[type="submit"]').prop('disabled', true);
let encryptedPassword = CryptoJS.AES.encrypt(password, secretKey, {iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.ZeroPadding}).toString();
const data = {
name: dataSourceName,
url: url,
username: username,
password: encryptedPassword
};
$.ajax({
url: '/addDataSource',
type: 'POST',
data: JSON.stringify(data),
contentType: 'application/json',
success: function(response) {
if(response.code === 200) {
alert("添加成功!")
} else {
alert("添加失败!")
}
},
error: function() {
alert("添加时发生错误!")
},
complete: function() {
// 无论成功还是失败,都重新启用提交按钮
$('#dataSourceForm button[type="submit"]').prop('disabled', false);
}
});
}
}
这个js要注意一下我们使用了CryptoJS,对于前端传到后端的数据库连接密码进行加密,其中需要有key和iv,我们当前设置为:aab11e66fa232e32,后端到时解密时也需要使用相同的值,所以我们把这个值配置到后端项目的application.yml当中
crypto:
secret: aab11e66fa232e32
四、后端处理接口
在上面js当中有多个地方需要使用ajax调用后端的接口,所以后端要完善这些接口,在完善它们之前我们要在后端要把实体类、统一的json返回这些基础搭建好。
实体类
Ds
@Data
public class Ds implements Serializable {
/**
* 主键
*/
private Long id;
/**
* 数据源名称
*/
private String name;
/**
* url
*/
private String url;
/**
* username
*/
private String username;
/**
* password
*/
private String password;
/**
* 创建时间
*/
private Date createTime;
private static final long serialVersionUID = 1L;
}
Test
@Data
public class Test {
private Long id;
private String name;
}
DsDto
@Data
public class DsDto {
private String name;
private String url;
private String username;
private String password;
}
统一返回
ReturnCode枚举
public enum ReturnCode {
SUCCESS(200, "成功"),
FAIL(400, "失败"),
INTERNAL_SERVER_ERROR(500, "服务器内部错误");
private Integer code;
private String msg;
ReturnCode(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
}
ResultData
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ResultData {
private Integer code;
private String msg;
private Object data;
public static ResultData ok(Object data) {
return new ResultData(ReturnCode.SUCCESS.getCode(), ReturnCode.SUCCESS.getMsg(), data);
}
public static ResultData ok(String msg) {
return new ResultData(ReturnCode.SUCCESS.getCode(), msg, null);
}
public static ResultData ok(String msg, Object data) {
return new ResultData(ReturnCode.SUCCESS.getCode(), msg, data);
}
public static ResultData ok(ReturnCode returnCode) {
return new ResultData(returnCode.getCode(), returnCode.getMsg(), null);
}
public static ResultData ok(ReturnCode returnCode,Object data) {
return new ResultData(returnCode.getCode(), returnCode.getMsg(), data);
}
public static ResultData fail(Object data) {
return new ResultData(ReturnCode.FAIL.getCode(), ReturnCode.FAIL.getMsg(), data);
}
public static ResultData fail(String msg) {
return new ResultData(ReturnCode.FAIL.getCode(), msg, null);
}
public static ResultData fail(Integer code,String msg) {
return new ResultData(code, msg, null);
}
public static ResultData fail(String msg, Object data) {
return new ResultData(ReturnCode.FAIL.getCode(), msg, data);
}
public static ResultData fail(ReturnCode returnCode) {
return new ResultData(returnCode.getCode(), returnCode.getMsg(),null);
}
public static ResultData fail(ReturnCode returnCode,Object data) {
return new ResultData(returnCode.getCode(), returnCode.getMsg(), data);
}
public static ResultData fail(ReturnCode returnCode,String msg) {
return new ResultData(returnCode.getCode(), msg, null);
}
}
相关接口
/checkDataSourceName
这个接口的目的是检测数据源的名称是否在数据库已经存在,我们动态切换数据源的时候是按这个名称来切换的,所以要求这里的名称要保持一致!
@PostMapping("/checkDataSourceName")
@ResponseBody
public ResultData checkDataSourceName(@RequestBody Map params){
// dsService.switchDataSource("defaultDataSource");
String name = params.get("dataSourceName");
log.info("检查数据源名称开始:" + name);
List dsList = dsService.selectByName(name);
if (!dsList.isEmpty()){
return ResultData.ok("存在");
}
return ResultData.ok("不存在");
}
这里我们要调service,所以新增service接口及对应的接口
public interface DsService {
List selectByName(String name);
}
@Service
@RequiredArgsConstructor
public class DsServiceImpl implements DsService {
private final DsMapper dsMapper;
@Override
public List selectByName(String name) {
return dsMapper.selectByName(name);
}
}
service中需要依赖mybatis查询数据库,新增mapper接品及对应的sql映射文件
@Mapper
public interface DsMapper {
List selectByName(@Param("name") String name);
List selectAll();
}
id, `name`, url, username, `password`, create_time
/testConnection
这个接口是获取用户提供的值,根据这些值我们来测试是否可以正常连上数据库
controller中新增方法
@PostMapping("/testConnection")
@ResponseBody
public ResultData testConnection(@RequestBody DsDto dsDto) throws Exception {
boolean result = dsService.testConnection(dsDto);
return result ? ResultData.ok("连接成功") : ResultData.fail("连接失败");
}
dsService中新增方法testConnection(),service接口和实现类如下:
boolean testConnection(DsDto dsDto) throws Exception;
@Value("${crypto.secret}")
private String secretKey;
@Override
public boolean testConnection(DsDto dsDto) {
try {
String password = AESUtils.decrypt(dsDto.getPassword(), secretKey);
return testConnectDB(dsDto.getUrl(), dsDto.getUsername(), password);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 测试数据库是否可连接
* @param url
* @param username
* @param password
* @return
*/
private boolean testConnectDB(String url, String username, String password) {
try{
Connection connection = DriverManager.getConnection(url, username, password);
return connection != null && !connection.isClosed();
} catch (Exception e) {
return false;
}
}
这里注意一下,我们从前端传过来的是密码是加过密的,所以对于dto中的密码我们是要进行解密的,所以我们提供一个工具类:AESUtils
public class AESUtils {
public static String decrypt(String content, String key) throws Exception{
try {
byte[] encrypted1 = Base64.getDecoder().decode(content);
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
SecretKeySpec keyspec = new SecretKeySpec(key.getBytes(), "AES");
IvParameterSpec ivspec = new IvParameterSpec(key.getBytes());
cipher.init(Cipher.DECRYPT_MODE, keyspec, ivspec);
byte[] original = cipher.doFinal(encrypted1);
// 去掉补充的字符
String originalString = new String(original).trim();
return originalString;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/addDataSource
用户在界面点击提交后,我们需要把数据库连接信息保存到数据库当中,使用这个接口来完成,这个接口除了保存这些信息外,还有一个本次最要的工作要做,就是所保存这个数据库连接相关的信息动态创建一个数据源,以便供后续可以动态切换使用!!
Controller中新增如下代码:
@PostMapping("/addDataSource")
@ResponseBody
public ResultData addDataSource(@RequestBody DsDto dsDto){
System.out.println(dsDto);
boolean result = dsService.addDs(dsDto);
return result ? ResultData.ok("添加成功") : ResultData.fail("添加失败");
}
service接口及实现类
boolean addDs(DsDto dsDto);
private final DynamicDataSourceManager dynamicDataSourceManager;
private final Map
要完成第一个功能简单,调用mapper中的方法来向数据库中插入数据
mpper接口中方法:
int insertSelective(Ds record);
sql映射文件
id, `name`, url, username, `password`, create_time
insert into ds
`name`,
url,
username,
`password`,
create_time,
#{name,jdbcType=VARCHAR},
#{url,jdbcType=VARCHAR},
#{username,jdbcType=VARCHAR},
#{password,jdbcType=VARCHAR},
#{createTime,jdbcType=TIMESTAMP},
接下来重点介绍如何动态创建数据源、切换数据源
五、数据源动态添加及切换
第一步:新增数据源理类,用来动态创建切换数据源
这里使用了ThreadLoacal
/**
* 动态数据源管理类,用于管理和切换数据源
*/
public class DynamicDataSourceManager extends AbstractRoutingDataSource {
private static final ThreadLocal contextHolder = new ThreadLocal<>();
public DynamicDataSourceManager(DruidDataSource druidDataSource,
Map
第二步:配置默认数据源
/**
* 配置类用来管理动态数据源
*/
@Configuration
public class DataSourceConfig {
@Value("${spring.datasource.druid.url}")
private String url;
@Value("${spring.datasource.druid.username}")
private String username;
@Value("${spring.datasource.druid.password}")
private String password;
@Value("${spring.datasource.druid.driver-class-name}")
private String driverClassName;
// 注意:这里要使用Autowired,不要使用Bean,否则启动后mybatis的mapper不会加载
@Autowired
public DruidDataSource defaultDataSource() {
DruidDataSource dataSource = new DruidDataSource();
dataSource.setUrl(url);
dataSource.setUsername(username);
dataSource.setPassword(password);
dataSource.setDriverClassName(driverClassName);
// System.out.println(dataSource.getUrl());
return dataSource;
}
@Bean
public DynamicDataSourceManager dynamicDataSourceManager() {
Map
从上面的代码可以看出来我们默认的数据源是"defaultDs"
在项目启动的时候就会去构造它这个默认的数据源。
注意:我们实际创建的的方法上使用的注解是@Autowired,不能使用@Bean,是因为我们要让它存在依赖关系而不是直接创建一个Bean
第三步:我们在项目启动的时候就加载数据中所有的数据连接信息创建好数据源
@Component
@RequiredArgsConstructor
@Slf4j
public class DataSourceInitializer implements CommandLineRunner {
private final DynamicDataSourceManager dynamicDataSourceManager;
private final DsService dsService;
@Value("${crypto.secret}")
private String secretKey;
@Override
public void run(String... args) throws Exception {
log.info("数据源初始化开始...");
// 获取所有数据源
List dsList = dsService.selectAll();
// 创建数据源map
Map
六、测试
新增一个controller来测试动态数据源的测试
@RestController
@RequiredArgsConstructor
public class TestController {
private final TestMapper testMapper;
private final DsService dsService;
@GetMapping("/test")
public List getAllTest(@RequestParam("ds") String datasourceName){
// System.out.println("datasourceName:"+datasourceName);
// DynamicDataSourceManager.setDataSource("test");
dsService.switchDataSource(datasourceName);
List tests = testMapper.selectAll();
dsService.clearDataSource();
return tests;
}
}
其中service接口及实现类
void switchDataSource(String dataSourceName);
void clearDataSource();
List selectAll();
@Override
public void switchDataSource(String dataSourceName) {
DynamicDataSourceManager.setDataSource(dataSourceName);
}
@Override
public void clearDataSource() {
DynamicDataSourceManager.clearDataSource();
}
@Override
public List selectAll() {
return dsMapper.selectAll();
}
动态数据源管理类中实现如下:
public static void setDataSource(String dataSourceKey) {
contextHolder.set(dataSourceKey);
}
public static void clearDataSource() {
contextHolder.remove();
}
mapper及sql映射比较简单就是查询一下test表中的记录
@Mapper
public interface TestMapper {
List selectAll();
}
接下来我们看看我们ds表示的记录:
启动项目:
1、访问项目时使用test数据源
2、访问项目使用ds1数据源
3、使用默认数据源defaultDs