背景

在项目初建或者版本迭代的演进的时候,一般都会附带数据库的变化,会专门出一个脚本进行数据库更新。最近遇到java做的单机版客户端,使用的H2数据库,每次换了数据库都要进行手动执行数据库脚本,非常不便利,因此开始查找资料实现数据库随着启动自动化初始化。

springboot自带

根据资料显示,springboot自带的有初始化数据库的属性,配置属性如下:

# 忽略正常的DataSource配置
# 执行建shema语句
spring.datasource.schema=classpath:schema.sql
# 执行建表或者初始化data的的语句
spring.datasource.data=classpath:data.sql
# 执行创建函数的语句
spring.datasource.sql-script-encoding=utf-8
# 执行脚本的模式 三种:always为始终执行初始化,embedded只初始化内存数据库(默认值),如h2等,never为不执行初始化
spring.datasource.initialization-mode=ALWAYS
#  为sql脚本中语句分隔符(默认的分隔符和脚本的不一致)
spring.datasource.separator:
# 遇到语句错误时是否继续,若已经执行过某些语句,再执行可能会报错,可以忽略,不会影响程序启动
spring.datasource.continue-on-error: false

PS:

  1. 启动类中的 DataSourceAutoConfiguration.class注解会让配置失效,或者druid的防火墙也会让此方法失效。
  2. 它还会加载schema-platform.sql 文 件,或 者data−{platform}.sql文件,其中platform就是spring.datasource.platform的值

这种配置方法需要将脚本整理成两个sql文件,一个是shema一个是data的。由于系统本身提供脚本的时候按照建表、注释、索引等方式建立了不同的数据库脚本,如果再合成一个给维护带来了额外的工作量,因此采用下面这种代码的方式进行初始化。

DataSourceInitializer代码的方式

废话不多说,先上代码:

package XXX.config;

import java.io.IOException;
import java.util.List;

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternUtils;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.init.DataSourceInitializer;
import org.springframework.jdbc.datasource.init.DatabasePopulator;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;

import lombok.extern.slf4j.Slf4j;

/**
 * 
 * CustomizeDataSourceInitializer
 * 
 * @description 自动初始化和执行脚本
 * @author elvis
 * @date xx年xx月xx日 上午10:28:47
 * @version 1.0.0
 */
@Configuration
@Slf4j
public class CustomizeDataSourceInitializer {

    /**
     * 是否强制覆盖
     */
    @Value("${commons.forceInitDatabase:false}")
    private boolean forceInitDatabase;
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Value("${commons.database.schema:sql/schema}")
    private String sqlScriptSchema;
    @Value("${commons.database.table:sql/table}")
    private String sqlScriptData;
    @Value("${commons.database.datainit:sql/datainit}")
    private String sqlScriptProcedure;

    @Bean
    public DataSourceInitializer dataSourceInitializer(final DataSource dataSource) {
        DataSourceInitializer dataSourceInitializer = new DataSourceInitializer();
        dataSourceInitializer.setDataSource(dataSource);
        // H2专属
        String sql = "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA;";
        List<String> allShema = jdbcTemplate.queryForList(sql, String.class);
        boolean hasShema = allShema.contains("XXXX");
        if (allShema.contains("XXXX") && !forceInitDatabase) {
            return dataSourceInitializer;
        }
        try {
            dataSourceInitializer.setDatabasePopulator(databasePopulator(hasShema));
        } catch (Exception e) {
            log.error("自动执行脚本失败,不影响正常启动流程,请启动后再手动执行脚本!!!", e);
        }
        return dataSourceInitializer;
    }

    private DatabasePopulator databasePopulator(boolean hasShema) throws IOException {
        ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
        ResourcePatternResolver resolver = ResourcePatternUtils.getResourcePatternResolver(null);;
        Resource[] resources = null;
        if (!hasShema) {
            resources = resolver.getResources("classpath:/" + sqlScriptSchema + "/*");
            for (Resource resource : resources) {
                populator.addScript(resource);
            }
        }
        resources = resolver.getResources("classpath:/" + sqlScriptData + "/*");
        for (Resource resource : resources) {
            populator.addScript(resource);
        }
        resources = resolver.getResources("classpath:/" + sqlScriptProcedure + "/*");
        for (Resource resource : resources) {
            populator.addScript(resource);
        }
        return populator;
    }
}

代码解释:

核心就是在数据库初始化的类DataSourceInitializer中的DatabasePopulator对象加上对应的script脚本然后交由springboot在启动的时候自动执行。为了可以重复执行,使用jdbcTemplate查询数据库增加了针对特定shema的判断(根据实际要执行的脚本进行判断,防止每次执行清掉数据)。

由于整个是基于jar的形式发布的,因此如何获取打包后的sql脚本路径是一个问题?通过查询资料:

  1. ResourceUtils可以直接获取jar包里面文件或者某个文件夹的列表,但是linux中无法使用
  2. ClassPathResource可以获取指定的文件,但是暂时没有找到可以获取文件夹列表的方法,不适用
  3. ResourceLoader和第二个类似
  4. ResourcePatternResolver可以根据pattern的语法来获取文件,符合要求(见上面代码)

H2相关

记录一个小发现,H2的数据库文件需要制定路径,但是我们在实际使用的时候并不知道有一些什么路径,按照经验来看windows肯定是有C盘的,但是C盘各个文件夹有的有权限限制,并不一定都能写入,因此获取用户空间来存放H2的数据文件是最优选择。

通过java倒是可以用代码获取路径,但是配置文件怎么去写?要去拦截配置参数做修改嘛?这样看起来感觉有点麻烦,那直接就写文件名呢,这样文件会在哪里建立呢,尝试了一下直接报错:

A file path that is implicitly relative to the current working directory is not allowed in the database URL "jdbc:h2:file:name". Use an absolute path, ~/name, ./name, or the baseDir setting instead. [90011-200]

从堆栈进去看到对应报错代码可以可以明显看出这种直接写的方式是不受支持的,必须增加对应的前置定位。

if (!name.contains("./") &&
                            !name.contains(".\\") &&
                            !name.contains(":/") &&
                            !name.contains(":\\")) {
                        // the name could start with "./", or
                        // it could start with a prefix such as "nio:./"
                        // for Windows, the path "\test" is not considered
                        // absolute as the drive letter is missing,
                        // but we consider it absolute
                        throw DbException.get(
                                ErrorCode.URL_RELATIVE_TO_CWD,
                                originalURL);
                    }

报错信息里面的**~**这个符号对linux了解的都很熟悉,这个表示的就是用户目录,因此将连接直接改为jdbc:h2:file:~/name就把name的数据库建立到用户空间中了。

项目发布优化

根据自动执行的特性,那么可以将执行的脚本按照版本归属(1.0,1.1,1.2…)直接放到启动的jar中,然后再加入一张version表,然后根据version表和代码维护的迭代信息可以实现脚本按照版本自动升级。