最近在给我司的微服务做架构升级,从 SpringBoot 1.5.x 升到 2.x。期间对配置做了一些改动,加上 SpringBoot 自身的配置变动,导致升级完以后,单元测试跑不过了。
我司的每一次代码合并,都需要保证通过单元测试。
究其原因,是因为数据不满足测试。数据都是一次性使用,每次进行单元测试时,数据脚本都会将上次新建的数据删除,再重新新建一份。
在1.5.x版本中, 数据脚本的执行,依赖于 Spring Boot 的自动化配置 DataSourceAutoConfiguration
和 DataSourceInitializer
。实际脚本的执行时依赖于 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
。其负责脚本的自动执行,入口有两个:初始化后触发和事件触发。
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 数据源初始化之后会触发。前者后面会讲到,后者有兴趣的同学可以自己看一下。
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);
}
}
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 版本中,对于相关类的职责更加明确,各行其是。避免类的职责过多。
DataSourceInitializationConfiguration
, 避免 DataSourceAutoConfiguration
的职责混淆。DataSourceInitializationConfiguration
中,保证 PostProcessor 的职责单一。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 的两个方法: createSchema 和 initSchema。
/**
* 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");