Spring Boot实现第一次启动时自动初始化数据库

4,869 阅读14分钟

在现在的后端开发中,只要是使用关系型数据库,相信SSM架构(Spring Boot + MyBatis)已经成为首选。

不过在我们第一次运行或者部署项目的时候,通常要先手动连接数据库,执行一个SQL文件以创建数据库以及数据库表格完成数据库的初始化工作,这样我们的SSM应用程序才能够正常工作。

这样也对实际部署或者是容器化造成了一些麻烦,必须先手动初始化数据库再启动应用程序。

那能不能让我们的SSM应用程序第一次启动时,自动地帮我们执行SQL文件以完成数据库初始化工作呢?

这样事实上是没问题的,今天就以Spring Boot + MyBatis-Flex为例,使用MySQL作为数据库,完成上述的数据库初始化功能。

1,整体思路

我们可以编写一个配置类,在一个标注了@PostConstruct注解的方法中编写初始化数据库的逻辑,这样应用程序启动时,就会执行该方法帮助我们完成数据库的初始化工作。

那么这个初始化数据库的逻辑大概是什么呢?可以总结为如下步骤:

  1. 首先解析用户配置的地址,并重新组装用户配置的连接地址并连接,使得本次连接不再是连接至具体的数据库
  2. 连接后借助JDBC的DatabaseMetaData接口获取数据库元数据,得到数据库是否存在,以及该数据库中有多少张表
  3. 如果数据库不存在,执行create database语句创建数据库
  4. 如果数据库存在但是数据库中没有表,我们就执行SQL文件完成表格初始化

上述逻辑中大家可能会有疑问:什么是使得本次连接不再是连接至具体的数据库?

假设用户配置的连接地址是jdbc:mysql://127.0.0.1:3306/init_demo,相信这个大家非常熟悉了,它表示:连接的MySQL地址是127.0.0.1,端口是3306,并且连接到该MySQL中名为init_demo的数据库中

那么如果MySQL中init_demo的库并不存在,Spring Boot还尝试连接上述地址的话,就会抛出SQLException异常:

image.png

正是因为上述地址中指定了要连接的具体数据库,而数据库又不存在,才会连接失败,那能不能连接时不指定数据库,仅仅是连接到MySQL上就行呢?当然可以,我们将上述的连接地址改成:jdbc:mysql://127.0.0.1:3306/,就可以连接成功了!

不过通常SSM应用程序中,配置数据库地址都是要指定库名的,因此我们在最先开始连接数据库检查元数据(数据库是否存在、有哪些表)时,先重新组装一下用户给的配置连接地址即可,即把jdbc:mysql://127.0.0.1:3306/init_demo通过代码处理成jdbc:mysql://127.0.0.1:3306/并发起连接即可。

可见,这是为了防止用户配置的数据库不存在而导致连接并检查元数据时发生异常。

完成了数据库的创建,后面就是完成表格创建了!表格创建就写在SQL文件里即可,由于数据库创建好了,我们在下一步中又可以重新使用用户给的配置地址jdbc:mysql://127.0.0.1:3306/init_demo再次连接并执行SQL文件完成初始化了!

创建表格时,如果数据库中表格数量为0才会触发表格创建的操作,否则不会执行SQL文件创建表格。

上述步骤中,我们将使用JDBC自带的接口完成数据库连接等等,而不是使用MyBatis的SqlSessionFactory,因为我们第二步需要改变连接地址。

下面,我们就来实现一下。

2,具体实现

首先是在本地或者其它地方搭建好MySQL服务器,这里就不再赘述怎么去搭建MySQL了。

我这里在本地搭建了MySQL服务器,下面通过Spring Boot进行连接。

(1) 创建应用程序并配置

首先创建一个Spring Boot应用程序,并集成好MySQL驱动和MyBatis支持,我这里的依赖如下:

<!-- Spring Web -->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- MySQL连接支持 -->
<dependency>
	<groupId>com.mysql</groupId>
	<artifactId>mysql-connector-j</artifactId>
	<scope>runtime</scope>
</dependency>

<!-- Hutool实用工具 -->
<dependency>
	<groupId>cn.hutool</groupId>
	<artifactId>hutool-all</artifactId>
	<version>5.8.18</version>
</dependency>

<!-- MyBatis-Flex -->
<dependency>
	<groupId>com.mybatis-flex</groupId>
	<artifactId>mybatis-flex-spring-boot-starter</artifactId>
	<version>1.7.7</version>
</dependency>

<!-- 连接池 -->
<dependency>
	<groupId>com.zaxxer</groupId>
	<artifactId>HikariCP</artifactId>
</dependency>

<!-- Lombok注解 -->
<dependency>
	<groupId>org.projectlombok</groupId>
	<artifactId>lombok</artifactId>
	<scope>provided</scope>
</dependency>

<!-- Spring Boot测试 -->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-test</artifactId>
	<scope>test</scope>
</dependency>

然后在配置文件application.yml中加入下列配置:

# 数据库配置
spring:
  datasource:
    url: "jdbc:mysql://127.0.0.1:3306/init_demo?serverTimezone=GMT%2B8"
    username: "swsk33"
    password: "dev-2333"

这就是正常的数据库连接配置,不再过多讲述。我这里使用yaml格式配置文件,大家也可以使用properties格式的配置文件。

(2) 完成解析JDBC URL的逻辑

我们首先需要实现如何解析用户配置的JDBC URL,然后实现重新组装,我们可以创建如下的类来表示一个JDBC URL的信息:

package com.gitee.swsk33.sqlinitdemo.model;

import lombok.Data;
import lombok.extern.slf4j.Slf4j;

import java.net.URI;

/**
 * 数据库连接的相关信息
 * 用于表示一个JDBC连接地址中每个部分例如地址和端口、库名等等
 */
@Data
@Slf4j
public class ConnectionMetadata {

	/**
	 * 数据库平台名,例如:mysql
	 */
	private String databasePlatform;

	/**
	 * 连接地址以及端口,例如:127.0.0.1:3306
	 */
	private String hostAndPort;

	/**
	 * 连接到的数据库名
	 */
	private String databaseName;

	/**
	 * 从一个完整的JDBC连接地址构造连接信息对象
	 *
	 * @param jdbcUrl JDBC连接地址
	 */
	public ConnectionMetadata(String jdbcUrl) {
		try {
			// 解析连接地址
			URI databaseURI = new URI(jdbcUrl.replace("jdbc:", ""));
			// 获取平台名
			databasePlatform = databaseURI.getScheme();
			// 获取地址端口
			hostAndPort = databaseURI.getAuthority();
			// 获取库名
			databaseName = databaseURI.getPath().substring(1);
		} catch (Exception e) {
			log.error("解析JDBC地址出错!");
			log.error(e.getMessage());
		}
	}

}

可见我们创建了一个类用于表示JDBC连接地址中每一部分的信息,并在构造函数中使用URI类解析用户配置的连接地址,然后把每个地方拆分出来。

这样后续借助这个类,我们就可以完成JDBC URL地址的拆分,然后后续进行重新组装。

(3) 编写元数据检测逻辑

现在我们专门创建一个实用类,用于实现检测数据库的元数据,主要是检测某个数据库是否存在,以及一个数据库中有多少表格。

package com.gitee.swsk33.sqlinitdemo.util;

import lombok.extern.slf4j.Slf4j;

import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;

/**
 * 获取数据库元数据相关的实用类
 */
@Slf4j
public class DatabaseMetadataUtils {

	/**
	 * 判断某个数据库是否存在
	 *
	 * @param databaseName 数据库名称
	 * @param connection   JDBC连接
	 * @return 对应名称数据库是否存在
	 */
	public static boolean databaseExists(String databaseName, Connection connection) {
		try {
			// 获取连接的元数据
			DatabaseMetaData metaData = connection.getMetaData();
			// 获取全部的Catalog(也就是数据库)元数据
			ResultSet catalogs = metaData.getCatalogs();
			// 遍历每个数据库名称
			while (catalogs.next()) {
				if (catalogs.getString("TABLE_CAT").equals(databaseName)) {
					return true;
				}
			}
		} catch (Exception e) {
			log.error("获取数据库相关元数据出错!");
			log.error(e.getMessage());
		}
		return false;
	}

	/**
	 * 获取一个数据库中的表格数量
	 *
	 * @param databaseName 数据库库名
	 * @param connection   数据库连接
	 * @return 表格数量
	 */
	public static int getDatabaseTableCount(String databaseName, Connection connection) {
		int result = 0;
		try {
			// 获取连接的元数据
			DatabaseMetaData metaData = connection.getMetaData();
			// 查询元数据中,对应的数据库中的所有的普通表格信息
			ResultSet tables = metaData.getTables(databaseName, null, null, new String[]{"TABLE"});
			// 遍历查询得到的表格结果
			while (tables.next()) {
				result++;
			}
		} catch (Exception e) {
			log.error("获取表格元数据出错!");
			log.error(e.getMessage());
			result = -1;
		}
		return result;
	}

}

上述我们封装了两个使用方法,用于检测数据库是否存在,以及检测某个数据库中表格数。上述代码中有下列要点:

  • DatabaseMetaData接口是JDBC中获取数据库元数据的接口,它可以从JDBC连接对象获取
  • DatabaseMetaData接口的getCatalogs方法可以获取当前连接的数据库中所有的数据库信息,返回ResultSet对象,ResultSet对象可以理解为一个二维表格,在该方法中返回的ResultSet对象中只有一个字段TABLE_CAT表示数据库名称
  • DatabaseMetaData接口的getTables方法可以获取当前连接数据库中所有表格信息,包括每个表的表名、结构等等,该方法有下列参数:
    • 第一个参数catalog:目录名称,即数据库名称,表示指定查询哪个数据库中的表格元数据,如果传入null则返回所有数据库中的表
    • 第二个参数schemaPattern:模式名称,表示指定查询哪些模式schema中的表格,如果为null则返回所有模式的表,可以使用通配符%来匹配任意字符序列
    • 第三个参数tableNamePattern:表名称,表示指定查询哪些名称的表,如果为null则返回所有表名称,可以使用通配符%来匹配任意字符序列
    • 第四个参数types:要查询的表的类型,是字符串数组,传入null表示查询全部类型的表

上述getTables中我们指定了第一个参数为我们要查询的数据库名称,指定了第四个参数为字符串数组{"TABLE"}表示只查询普通类型表格。

需要注意的是,对于不同的数据库,getTables方法运行结果可能会有所差异:

  • 在MySQL中,无论你的连接地址中指定的是连接哪个数据库,该方法仍然会以传入的第一个参数为依据去查询对应数据库,假设连接地址指定的数据库为demo,而getTables方法第一个参数传入test,那么该方法会查询数据库test中的全部数据库表信息
  • 在PostgreSQL中,则是根据数据库地址中指定的数据库为依据进行查询的,无论你第一个参数传入什么,假设连接地址指定的数据库为demo,而getTables方法第一个参数传入test,那么该方法会查询数据库demo中的全部数据库表信息

(4) 编写执行SQL语句以及读取SQL文件执行逻辑

再创建一个实用类,用于执行SQL语句创建数据库,以及读取SQL文件并执行:

package com.gitee.swsk33.sqlinitdemo.util;

import cn.hutool.core.io.resource.ClassPathResource;
import lombok.extern.slf4j.Slf4j;

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.Statement;

/**
 * 常用的SQL执行操作的实用类
 */
@Slf4j
public class SQLExecuteUtils {

	/**
	 * 创建一个数据库
	 *
	 * @param databaseName 要创建的数据库名
	 * @param connection   JDBC连接
	 */
	public static void createDatabase(String databaseName, Connection connection) {
		try (Statement statement = connection.createStatement()) {
			statement.execute("create database if not exists `" + databaseName + "`");
			log.info("已创建数据库{}!", databaseName);
		} catch (Exception e) {
			log.error("创建数据库失败!");
			log.error(e.getMessage());
		}
	}

	/**
	 * 读取并执行一个SQL脚本文件
	 *
	 * @param filepath    SQL脚本文件路径
	 * @param isClasspath SQL脚本路径是否是classpath路径
	 * @param connection  执行脚本文件的JDBC连接
	 */
	public static void executeSQLScript(String filepath, boolean isClasspath, Connection connection) {
		// 根据传入参数判断是从classpath读取还是本地文件系统读取
		try (InputStream sqlFileStream = isClasspath ? new ClassPathResource(filepath).getStream() : new FileInputStream(filepath); Statement statement = connection.createStatement()) {
			// 以UTF-8编码读取文件流
			BufferedReader sqlFileStreamReader = new BufferedReader(new InputStreamReader(sqlFileStream, StandardCharsets.UTF_8));
			// 读取到的行
			String line;
			// 每次执行的语句
			StringBuilder sqlExecute = new StringBuilder();
			// 开始读取
			while ((line = sqlFileStreamReader.readLine()) != null) {
				// 去除两端不可见字符
				line = line.trim();
				// 跳过注释或者空字符行
				if (line.isEmpty() || line.startsWith("--") || line.startsWith("#")) {
					continue;
				}
				// 将语句追加至每次执行的语句
				sqlExecute.append(line).append(System.lineSeparator());
				// 如果当前语句以分号结尾,说明已经得到了一个完整的SQL语句,执行
				if (line.endsWith(";")) {
					statement.execute(sqlExecute.toString());
					System.out.println(sqlExecute);
					// 清空语句以便下一条语句的追加
					sqlExecute.setLength(0);
				}
			}
			// 若读取完成后,每次执行的语句中还有语句,则继续执行
			if (!sqlExecute.isEmpty()) {
				statement.execute(sqlExecute.toString());
				System.out.println(sqlExecute);
			}
		} catch (Exception e) {
			log.error("执行SQL文件失败!");
			log.error(e.getMessage());
		}
	}

}

这两个方法主要是以执行SQL语句为主,总体来说比较简单,在此不再赘述。

(5) 编写配置类完成数据库的检测和初始化逻辑

上述我封装好了许多的实用操作,下面我们就可以编写配置类并将整个逻辑串联起来了!

这里先给出这个配置类的代码:

package com.gitee.swsk33.sqlinitdemo.config;

import com.gitee.swsk33.sqlinitdemo.model.ConnectionMetadata;
import com.gitee.swsk33.sqlinitdemo.util.DatabaseMetadataUtils;
import com.gitee.swsk33.sqlinitdemo.util.SQLExecuteUtils;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

import java.sql.Connection;
import java.sql.DriverManager;

/**
 * 用于第一次启动时,初始化数据库的配置类
 */
@Slf4j
@Configuration
public class DatabaseInitialize {

	/**
	 * 读取连接地址
	 */
	@Value("${spring.datasource.url}")
	private String url;

	/**
	 * 读取用户名
	 */
	@Value("${spring.datasource.username}")
	private String username;

	/**
	 * 读取密码
	 */
	@Value("${spring.datasource.password}")
	private String password;

	/**
	 * 该方法用于检测数据库是否需要初始化,如果是则执行SQL脚本进行初始化操作
	 */
	@PostConstruct
	private void initDatabase() {
		log.info("开始检查数据库是否需要初始化...");
		// 解析连接地址
		ConnectionMetadata urlMeta = new ConnectionMetadata(url);
		// 建立连接,但是不连接至指定的数据库
		// 主要是检查元数据
		String checkUrl = "jdbc:" + urlMeta.getDatabasePlatform() + "://" + urlMeta.getHostAndPort() + "/";
		try (Connection connection = DriverManager.getConnection(checkUrl, username, password)) {
			// 检测数据库是否存在
			if (!DatabaseMetadataUtils.databaseExists(urlMeta.getDatabaseName(), connection)) {
				log.warn("数据库不存在!准备创建!");
				SQLExecuteUtils.createDatabase(urlMeta.getDatabaseName(), connection);
			} else {
				log.info("数据库存在,不需要创建!");
			}
		} catch (Exception e) {
			log.error("连接至数据库检查元数据时失败!");
			log.error(e.getMessage());
		}
		// 然后再次连接,本次将会连接至指定数据库,执行脚本初始化库中的表格
		try (Connection connection = DriverManager.getConnection(url, username, password)) {
			// 检测数据库中表的数量,如果为0则执行SQL文件创建表
			if (DatabaseMetadataUtils.getDatabaseTableCount(urlMeta.getDatabaseName(), connection) == 0) {
				log.warn("数据库{}中没有任何表,将进行创建!", urlMeta.getDatabaseName());
				SQLExecuteUtils.executeSQLScript("/create-table.sql", true, connection);
				log.info("初始化表格完成!");
			} else {
				log.info("数据库中已经存在表格,不需要创建!");
			}
		} catch (Exception e) {
			log.error("初始化表格时,连接数据库失败!");
			log.error(e.getMessage());
		}
	}

}

上述代码中,有下列要点:

  • 我们使用@Value注解读取了配置文件中数据库的连接信息,包括连接地址、用户名和密码
  • 借助我们封装的ConnectionMetadata实现了JDBC URL解析
  • 然后重新组装地址并连接数据库,检查数据库中对应数据库是否存在,不存在则创建
  • 再查询数据库中表格数量,若为0则执行SQL脚本文件完成表格初始化

上述的初始化表格脚本位于工程目录的src/main/resources/create-table.sql,即classpath中,内容如下:

-- 初始化表格前先删除
drop table if exists `user`;

-- 创建表格
create table `user`
(
	`id`       int unsigned auto_increment,
	`username` varchar(16) not null,
	`password` varchar(32) not null,
	primary key (`id`)
) engine = InnoDB
  default charset = utf8mb4;

好的,现在先保证MySQL数据库中不存在init_demo的库(或者init_demo库中没有任何表格),启动程序试试:

image.png

可见成功地完成了数据库的检测、初始化工作。

现在再重新启动一下程序试试:

image.png

可见第二次启动时,名为init_demo的数据库已经存在了,这时就不需要执行初始化逻辑了!

3,如果有的Bean初始化时需要访问数据库

假设现在有一个类,在初始化为Bean的时候需要访问数据库,例如:

// 省略package和import

/**
 * 启动时需要查询数据库的Beans
 */
@Slf4j
@Component
public class UserService {

	@Autowired
	private UserDAO userDAO;

	@PostConstruct
	private void init() {
		log.info("执行数据库测试访问...");
		User user = new User();
		user.setUsername("用户名");
		user.setPassword("密码");
		userDAO.insert(user);
		List<User> users = userDAO.selectAll();
		for (User eachUser : users) {
			System.out.println(eachUser);
		}
	}

}

这个类在被初始化为Bean的时候,就需要访问数据库进行读写操作,那问题来了,如果这个类UserServiceDemo在上述数据库初始化类DatabaseInitialize之前被初始化了怎么办呢?这会导致数据库还没有被初始化时,UserServiceDemo就去访问数据库,导致初始化失败。

这时,我们可以使用@DependsOn注解,这个注解可以控制UserServiceDemoDatabaseInitialize初始化之后再进行初始化:

@Slf4j
@Component
// 使用@DependsOn注解表示当前类依赖于名为databaseInitialize的Bean
// 这样可以使得databaseInitialize这个Bean(我们的数据库检查类)先被初始化,并执行完成数据库初始化后再初始化本类,以顺利访问数据库
@DependsOn("databaseInitialize")
public class UserServiceDemo {

	// 省略这个类的内容

}

在这里我们在UserServiceDemo上标注了注解@DependsOn,并传入databaseInitialize作为参数,表示UserServiceDemo这个类是依赖于名(id)为databaseInitialize的Bean的,这样Spring Boot就会在DatabaseInitialize初始化之后再初始化UserServiceDemo

标注了@Component等等的类,默认情况下被初始化为Bean的时候,其名称是其类名的小驼峰形式,例如上述的DatabaseInitialize类,初始化为Bean时名字默认为databaseInitialize,因此上述@DependsOn注解就传入databaseInitialize

现在删除init_demo库,再次启动应用程序:

image.png

可见在初始化数据库后,又成功地在启动时访问了数据库。

4,总结

本文以Spring Boot + Mybatis-Flex为例,使用MySQL数据库,实现了SSM应用程序第一次启动时自动检测并完成数据库初始化的功能,理论上上述方式适用于所有的关系型数据库,大家稍作修改即可。

本文仅仅是我自己提供的思路,以及部分内容也是和“机器朋友”交流后的结果,如果大家对此有更好的思路,欢迎在评论区提出您的建议。

本文代码的仓库地址:传送门