Spring Boot 升级后,初始化SQL不跑了?

Spring Boot 单元测试自动执行测试脚本

起因

最近在给我司的微服务做架构升级,从 SpringBoot 1.5.x 升到 2.x。期间对配置做了一些改动,加上 SpringBoot 自身的配置变动,导致升级完以后,单元测试跑不过了。

我司的每一次代码合并,都需要保证通过单元测试。

究其原因,是因为数据不满足测试。数据都是一次性使用,每次进行单元测试时,数据脚本都会将上次新建的数据删除,再重新新建一份。

1.5.x

在1.5.x版本中, 数据脚本的执行,依赖于 Spring Boot 的自动化配置 DataSourceAutoConfigurationDataSourceInitializer。实际脚本的执行时依赖于 DataSourceInitializer,不过DataSourceInitializer 的创建又依赖于 DataSourceAutoConfiguration。所以需要一并分析这两个类。

当然,如果对于 Spring 容器管理 Bean 生命周期比较熟悉的同学,其实 DataSourceAutoConfiguration 是可以略过的。

DataSourceAutoConfiguration

DataSourceAutoConfiguration 实现了对 DataSource 的自动化配置(实际就是多种 @Conditional 注解的 Configuration 类),不过由于其支持的 DataSource 类型过于单一,所以一般不可能依赖于其自身的 DataSource 初始化。

我司的微服务,都是实现了自己的 DataSource 自动化配置,使用的是 DruidDataSource。

@Configuration
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
@EnableConfigurationProperties(DataSourceProperties.class)
@Import({ Registrar.class, DataSourcePoolMetadataProvidersConfiguration.class })
public class DataSourceAutoConfiguration {

     // 初始化时,注入 DataSourceProperties 和 上下文
	@Bean
	@ConditionalOnMissingBean
	public DataSourceInitializer dataSourceInitializer(DataSourceProperties properties,
			ApplicationContext applicationContext) {
		return new DataSourceInitializer(properties, applicationContext);
	}
	/* 省略无关的代码*/
}

DataSourceAutoConfiguration 最主要的作用是声明了DataSourceInitializer的实例化,并通过 @Import 依赖初始化了 Registrar

@EnableConfigurationProperties(DataSourceProperties.class) 同时初始化了 DataSourceProperties 类。

如果依赖数据源自动化配置的话,那么数据源的配置时依靠 DataSourceProperties 所存储的配置。
但是由于我司的数据源配置是自行配置初始化的,所以 DataSourceProperties 存在的意义就是自动执行测试脚本时的配置.

关键配置如下(1.5.x 与 2.x 相同):

# 定义自动化脚本的 DML 脚本
spring.datasource.schema=
# 定义自动化脚本的 DDL 脚本
spring.datasource.data=
# 由于已初始化数据源, 因此用不上的数据源配置
# 用于执行脚本时, 初始化数据源
spring.datasource.schemaUsername=
spring.datasource.schemaPassword=
spring.datasource.dataUsername=
spring.datasource.dataPassword=

对应的 DataSourceProperties 属性如下:

/**
 * Schema (DDL) script resource references.
 */
private List<String> schema;

/**
 * User of the database to execute DDL scripts (if different).
 */
private String schemaUsername;

/**
 * Password of the database to execute DDL scripts (if different).
 */
private String schemaPassword;

/**
 * Data (DML) script resource references.
 */
private List<String> data;

/**
 * User of the database to execute DML scripts.
 */
private String dataUsername;

/**
 * Password of the database to execute DML scripts.
 */
private String dataPassword;

那么 Registrar 的作用是什么?
其本身是为了注入一个 PostProcessor, 而 PostProcessor 的作用是为了在初始化 DataSource 之后立即初始化 DataSourceInitializer。

class DataSourceInitializerPostProcessor implements BeanPostProcessor, Ordered {

	private int order = Ordered.HIGHEST_PRECEDENCE;

	@Override
	// 最高优先级初始化、执行
	public int getOrder() {
		return this.order;
	}

	@Autowired
	private BeanFactory beanFactory;

	@Override
	public Object postProcessBeforeInitialization(Object bean, String beanName)
			throws BeansException {
		return bean;
	}

	@Override
	public Object postProcessAfterInitialization(Object bean, String beanName)
			throws BeansException {
		if (bean instanceof DataSource) {
			// force initialization of this bean as soon as we see a DataSource
			// 当 DataSource 初始化之后, 立即强制初始化 DataSourceInitializer
			this.beanFactory.getBean(DataSourceInitializer.class);
		}
		return bean;
	}

	/**
	 * {@link ImportBeanDefinitionRegistrar} to register the
	 * {@link DataSourceInitializerPostProcessor} without causing early bean instantiation
	 * issues.
	 */
	static class Registrar implements ImportBeanDefinitionRegistrar {

		private static final String BEAN_NAME = "dataSourceInitializerPostProcessor";

		@Override
		public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata,
				BeanDefinitionRegistry registry) {
				// 为了注入 DataSourceInitializerPostProcessor 的 BeanDefinition
				// 保证容器对其初始化
			if (!registry.containsBeanDefinition(BEAN_NAME)) {
				GenericBeanDefinition beanDefinition = new GenericBeanDefinition();
				beanDefinition.setBeanClass(DataSourceInitializerPostProcessor.class);
				beanDefinition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE);
				// We don't need this one to be post processed otherwise it can cause a
				// cascade of bean instantiation that we would rather avoid.
				beanDefinition.setSynthetic(true);
				registry.registerBeanDefinition(BEAN_NAME, beanDefinition);
			}
		}
	}
}
DataSourceInitializer

接下来看看主要的 DataSourceInitializer。其负责脚本的自动执行,入口有两个:初始化后触发和事件触发。

class DataSourceInitializer implements ApplicationListener<DataSourceInitializedEvent> {
    /* 所需的属性、数据源、上下文*/
	private final DataSourceProperties properties;

	private final ApplicationContext applicationContext;

	private DataSource dataSource;
    /* 标识是否已初始化脚本, 全局只执行一次 */
	private boolean initialized = false;

	@PostConstruct
	public void init() {
	    // 配置 spring.datasource.initialize
	    // 默认为true, 也就是说 1.5.x 默认是自动执行初始化脚本的
		if (!this.properties.isInitialize()) {
			logger.debug("Initialization disabled (not running DDL scripts)");
			return;
		}
		// 获取 DataSource
		if (this.applicationContext.getBeanNamesForType(DataSource.class, false,
				false).length > 0) {
			this.dataSource = this.applicationContext.getBean(DataSource.class);
		}
		if (this.dataSource == null) {
			logger.debug("No DataSource found so not initializing");
			return;
		}
		// 执行 DML 脚本
		runSchemaScripts();
	}

	@Override
	public void onApplicationEvent(DataSourceInitializedEvent event) {
	    // 响应 DataSourceInitializedEvent(DataSource source) 事件
	    // 配置判断
		if (!this.properties.isInitialize()) {
			logger.debug("Initialization disabled (not running data scripts)");
			return;
		}
		// NOTE the event can happen more than once and
		// the event datasource is not used here
		// 幂等判断
		if (!this.initialized) {
		    // 执行 DDL 脚本
			runDataScripts();
			this.initialized = true;
		}
	}
}	

DataSourceInitializedEvent(DataSource source) 是触发自动执行 DDL 的事件,可以注意到其事件源是 DataSource。但是注意:执行脚本是不依赖事件的DataSource的。就没啥用。
其触发时机有两个,1是 runSchemaScripts 中执行完 DML 脚本会推送事件, 2 是 JPA 数据源初始化之后会触发。前者后面会讲到,后者有兴趣的同学可以自己看一下。

runSchemaScripts & runDataScripts
    private void runSchemaScripts() {
        // 1.获取 DML 脚本位置, this.properties.getXxx 是用户自行定义的
        List<Resource> scripts = getScripts("spring.datasource.schema",
                this.properties.getSchema(), "schema");
        if (!scripts.isEmpty()) {
            // 存在脚本财执行
            // 2.对应的 用户名 和 密码
            String username = this.properties.getSchemaUsername();
            String password = this.properties.getSchemaPassword();
            // 3.执行脚本
            runScripts(scripts, username, password);
            try {
                // 推送 DataSourceInitializedEvent
                this.applicationContext
                        .publishEvent(new DataSourceInitializedEvent(this.dataSource));
                // 注意: 这个时候事件监听器可能还未注册到容器中, 所以不能靠事件驱动来保证执行 DDL 脚本
                // The listener might not be registered yet, so don't rely on it.
                if (!this.initialized) {
                    // 执行 DDL 脚本
                    runDataScripts();
                    this.initialized = true;
                }
            }
            catch (IllegalStateException ex) {
                logger.warn("Could not send event to complete DataSource initialization ("
                        + ex.getMessage() + ")");
            }
        }
    }

    private void runDataScripts() {
        // 1.获取DDL 脚本位置, this.properties.getXxx 是用户自行定义的
		List<Resource> scripts = getScripts("spring.datasource.data",
				this.properties.getData(), "data");
        // 2.对应的 用户名 和 密码
		String username = this.properties.getDataUsername();
		String password = this.properties.getDataPassword();
		// 3.执行脚本
		runScripts(scripts, username, password);
	}

	private List<Resource> getScripts(String propertyName, List<String> resources,
			String fallback) {
        // 如果是用户自己定义的位置, 则直接根据用户定义的的路径查找资源
        // 最后一次参数是资源不存在时,是否抛出异常. 用户自定义的资源,则抛出异常, 容器启动快速失败
		if (resources != null) {
			return getResources(propertyName, resources, true);
		}
		// spring.datasource.platform = test ,用于拼接脚本名称
		String platform = this.properties.getPlatform();
		List<String> fallbackResources = new ArrayList<String>();
		// fallback 就是传入的 'data' 或 'schema'
		// 举例:classpath*:data-test.sql
		fallbackResources.add("classpath*:" + fallback + "-" + platform + ".sql");
		// 举例:classpath*:data.sql
		fallbackResources.add("classpath*:" + fallback + ".sql");
		// 根据默认的路径获取资源. 默认则不抛出, 表示就不执行了
		return getResources(propertyName, fallbackResources, false);
	}

	private List<Resource> getResources(String propertyName, List<String> locations,
			boolean validate) {
		List<Resource> resources = new ArrayList<Resource>();
		for (String location : locations) {
			for (Resource resource : doGetResources(location)) {
				if (resource.exists()) {
					resources.add(resource);
				}
				else if (validate) {
					throw new ResourceNotFoundException(propertyName, resource);
				}
			}
		}
		return resources;
	}

	private Resource[] doGetResources(String location) {
		try {
			SortedResourcesFactoryBean factory = new SortedResourcesFactoryBean(
					this.applicationContext, Collections.singletonList(location));
			factory.afterPropertiesSet();
			return factory.getObject();
		}
		catch (Exception ex) {
			throw new IllegalStateException("Unable to load resources from " + location,
					ex);
		}
	}
runScripts
    private void runScripts(List<Resource> resources, String username, String password) {
		if (resources.isEmpty()) {
			return;
		}
		// 脚本执行器, 内置了一些对脚本的配置, 比如编码、sql 语句分隔字符、注解字符、错误是否继续、删除表失败是否继续等
		ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
		// spring.datasource.continueOnError 默认 false, 执行失败是否继续
		populator.setContinueOnError(this.properties.isContinueOnError());
		// spring.datasource.separator 默认';', sql语句分隔字符
		populator.setSeparator(this.properties.getSeparator());
		// 脚本字符编码
		if (this.properties.getSqlScriptEncoding() != null) {
			populator.setSqlScriptEncoding(this.properties.getSqlScriptEncoding().name());
		}
		for (Resource resource : resources) {
			populator.addScript(resource);
		}
		DataSource dataSource = this.dataSource;
		if (StringUtils.hasText(username) && StringUtils.hasText(password)) {
		// 这里的username 就是之前的 datausername或schemausername, password 同样
		// 如果不为空, 则根据配置初始化 DataSource.
			dataSource = DataSourceBuilder.create(this.properties.getClassLoader())
					.driverClassName(this.properties.determineDriverClassName())
					.url(this.properties.determineUrl()).username(username)
					.password(password).build();
		}
		// 由于项目时自行初始化DataSource, 配置也是和 spring.datasource. 隔离
		// 所以默认根据我们自己创建的 DataSource 执行.
		DatabasePopulatorUtils.execute(populator, dataSource);
	}

由于我司的规范, 是把初始化脚本放在 classpath*:/db/xxx.sql, 所以需要在配置中手动加上。
虽然规范要求表结构是提前定义好的,但是上述看到, 必须要有 schema.sql 才能执行 data.sql。
所以:

spring.datasource.schema=classpath*:db/schema.sql
spring.datasource.data=classpath*:db/data.sql

但是, 在升级到 Spring Boot 2.x 之后, 对配置进行了一些清理.
一是误清理了上述的配置。但是后来排查加上去了,还是不行。
二是2.x 废弃了 spring.datasource.initialized,新的配置默认是数据源为内嵌的, 才执行初始化脚本的。
那新的配置是啥呢?先来看看这一块逻辑的变化。

2.x版本

结构上的变化

2.x 版本中,对于相关类的职责更加明确,各行其是。避免类的职责过多。

  • 独立 DataSourceInitializationConfiguration, 避免 DataSourceAutoConfiguration 的职责混淆。
  • 将注册 PostProcessor 的 Registrar 挪到了 DataSourceInitializationConfiguration中,保证 PostProcessor 的职责单一。
  • 将 DataSourceInitializer 拆分出 DataSourceInitializerInvoker。对外只暴露 DataSourceInitializerInvoker,配置注册的实际是这个类。
    DataSourceInitializerInvoker 负责初始化 DataSourceInitializer、初始化脚本前的配置、数据源、上下文的准备、对事件的处理等。而 DataSourceInitializer 只负责执行 DML 和 DDL 脚本。
DataSourceInitializerInvoker
class DataSourceInitializerInvoker implements ApplicationListener<DataSourceSchemaCreatedEvent>, InitializingBean {

	private final ObjectProvider<DataSource> dataSource;

	private final DataSourceProperties properties;

	private final ApplicationContext applicationContext;

	private DataSourceInitializer dataSourceInitializer;

	private boolean initialized;

	DataSourceInitializerInvoker(ObjectProvider<DataSource> dataSource, DataSourceProperties properties,
			ApplicationContext applicationContext) {
		this.dataSource = dataSource;
		this.properties = properties;
		this.applicationContext = applicationContext;
	}

	@Override
	public void afterPropertiesSet() {
		DataSourceInitializer initializer = getDataSourceInitializer();
		if (initializer != null) {
		    // 执行 DML 脚本
			boolean schemaCreated = this.dataSourceInitializer.createSchema();
			if (schemaCreated) {
				initialize(initializer);
			}
		}
	}

	private void initialize(DataSourceInitializer initializer) {
		try {
		    // 推送事件
			this.applicationContext.publishEvent(new DataSourceSchemaCreatedEvent(initializer.getDataSource()));
			// The listener might not be registered yet, so don't rely on it.
			if (!this.initialized) {
			    // 执行 DDL 脚本
				this.dataSourceInitializer.initSchema();
				this.initialized = true;
			}
		}
		catch (IllegalStateException ex) {
			logger.warn("Could not send event to complete DataSource initialization (" + ex.getMessage() + ")");
		}
	}

	@Override
	public void onApplicationEvent(DataSourceSchemaCreatedEvent event) {
		// NOTE the event can happen more than once and
		// the event datasource is not used here
		DataSourceInitializer initializer = getDataSourceInitializer();
		if (!this.initialized && initializer != null) {
		    // 执行 DDL 脚本
			initializer.initSchema();
			this.initialized = true;
		}
	}

	private DataSourceInitializer getDataSourceInitializer() {
	    // 延迟初始化 dataSourceInitializer
		if (this.dataSourceInitializer == null) {
		    // 确保只有一个 DataSource 或 多个 DataSource 设置了 Primary
			DataSource ds = this.dataSource.getIfUnique();
			if (ds != null) {
				this.dataSourceInitializer = new DataSourceInitializer(ds, this.properties, this.applicationContext);
			}
		}
		return this.dataSourceInitializer;
	}

}

可以看到,大致流程都是一样的。只是某些地方的逻辑抽取出来,可以更清晰一些。但是还是有些小变化:

  • DataSourceInitializer 原先是有容器初始化的,但是现在由 DataSourceInitializerInvoker 初始化。
    初始化过程保证存在唯一或主要的 DataSource。否则会报错.
    原先是只要有 DataSource, 则任取一个,这是一个变化。
  • 原先在 PostConstruct 中,首先判断是否允许初始化(spring.datasource.initialized)。现在却放到后续步骤去判断。
  • DataSourceInitializedEvent -> DataSourceSchemaCreatedEvent。只是名称发生了变化,行为和触发时机都没有变化。

上述主要就是依赖了 DataSourceInitializer 的两个方法: createSchema 和 initSchema。

DataSourceInitializer
    /**
	 * Create the schema if necessary.
	 * @return {@code true} if the schema was created
	 * @see DataSourceProperties#getSchema()
	 */
	public boolean createSchema() {
		List<Resource> scripts = getScripts("spring.datasource.schema", this.properties.getSchema(), "schema");
		if (!scripts.isEmpty()) {
		    // 判断是否允许执行
			if (!isEnabled()) {
				logger.debug("Initialization disabled (not running DDL scripts)");
				return false;
			}
			String username = this.properties.getSchemaUsername();
			String password = this.properties.getSchemaPassword();
			runScripts(scripts, username, password);
		}
		// 上述返回的 schemaCreated, 只在乎 schema 脚本是否为空, 并不在乎是否真正执行了
		return !scripts.isEmpty();
	}

	/**
	 * Initialize the schema if necessary.
	 * @see DataSourceProperties#getData()
	 */
	public void initSchema() {
		List<Resource> scripts = getScripts("spring.datasource.data", this.properties.getData(), "data");
		if (!scripts.isEmpty()) {
		     // 判断是否允许执行
			if (!isEnabled()) {
				logger.debug("Initialization disabled (not running data scripts)");
				return;
			}
			String username = this.properties.getDataUsername();
			String password = this.properties.getDataPassword();
			runScripts(scripts, username, password);
		}
	}

	private boolean isEnabled() {
	    // spring.datasource.initialization-mode = never || embedded || always
	    // 默认是 embedded
		DataSourceInitializationMode mode = this.properties.getInitializationMode();
		//never 永不执行
		if (mode == DataSourceInitializationMode.NEVER) {
			return false;
		}
		// embedded , 数据源为 内嵌的, 才允许执行
		if (mode == DataSourceInitializationMode.EMBEDDED && !isEmbedded()) {
			return false;
		}
		// always 始终允许执行
		return true;
	}

	private boolean isEmbedded() {
		try {
			return EmbeddedDatabaseConnection.isEmbedded(this.dataSource);
		}
		catch (Exception ex) {
			logger.debug("Could not determine if datasource is embedded", ex);
			return false;
		}
	}

2.x DataSourceInitializer 还有一个变化,就是新增了一个属性 resourceLoader:用来加载脚本资源的加载器。

1.5.x 中,使用 ApplicationContext 来加载的。现在可以自己指定,否则创建一个默认资源加载器

DataSourceInitializer(DataSource dataSource, DataSourceProperties properties, ResourceLoader resourceLoader) {
    this.dataSource = dataSource;
    this.properties = properties;
    this.resourceLoader = (resourceLoader != null) ? resourceLoader : new DefaultResourceLoader();
}

private Resource[] doGetResources(String location) {
    try {
        SortedResourcesFactoryBean factory = new SortedResourcesFactoryBean(this.resourceLoader,
                Collections.singletonList(location));
        factory.afterPropertiesSet();
        return factory.getObject();
    }
    catch (Exception ex) {
        throw new IllegalStateException("Unable to load resources from " + location, ex);
    }
}

总结

因为不是一个很紧要的情况, 一般执行失败, 就手动跑脚本, 再重新跑测试就好。
所以问题也是放了很久,直到最近同事升级时发现了坑,才填上了。

注意: 虽然我司的数据表结构是事先初始化好的,但是一般要求有 DML,才能执行 DDL。所以 schema.sql 还是要有,而且必须有语句。

因此,一般会加上一句 show tables;。不然实际执行时,会判断脚本内容是否为空或空字符串的。

Assert.hasText(script, "'script' must not be null or empty");

你可能感兴趣的:(springboot)