最近完成了手头上项目的一系列重构,引入了 Spring Boot 和其他较新的技术框架。在此过程中有很多值得回味和交流的地方,因此做一个简单的记录。
为什么要配置最小化
整个重构的起源是因为想要将项目的配置最小化。那为什么需要最小化配置呢?我的原因有以下几点:
- 已有的配置文件数量巨大,仅 xml 文件就有五六百。维护大量的配置文件成本非常高,同时由于部分配置文件包含了特殊逻辑,更是增加了维护的复杂度,如生产环境和测试环境使用了不同的配置文件,改动时仅改动了测试环境的配置,导致测试一切正常,而发布以后出现各种问题。
- 由于不支持注解,新增一个 bean 需要编写相应的配置代码,新增的配置代码也不像新项目一样随便找一个文件放,需要遵循原来的做法,放到相应的模块下,一来二去,增加了无谓的开发成本。
- 同样的,一个功能不再使用可能需要移除对应的一个或多个 bean,前代为了省事可能只会注释掉相应配置甚至不做任何改动,长年累月下来项目中包含了很多「无注释、无文档、无引用」的三无代码。
- 由于以上原因,项目的复杂度也大大提升,增加了新接手项目的同学的学习成本,不利于项目持续健康的发展。
- 另一方面,Spring Boot 本身具有「零配置即可运行」的特性,是我希望改造的方向,同时在组内也做过其他项目基于 Spring Boot 的项目,也积累了一些经验,团队内也有一些技术栈的沉淀,使用 Spring Boot 后也可以享受无缝接入其他服务的便利。
Spring Boot 如何做到配置最小化
既然目标是精简配置,那么先来看看 Spring Boot 是如何做到配置最小化的。和大多数优秀的开源框架一样,Spring Boot 核心理念之一是「约定大于配置」。和一些遵循这个理念的框架不同的是,有些框架的默认配置,只能做到「不报错」、「跑起来」,不一定能提供正确的业务逻辑,而 Spring Boot 的默认配置具有很高的通用性,真正做到「零配置」可用。
Spring Boot 延续了 Spring 一贯的可配置性和扩展性。通过配置文件或启动参数修改配置已经可以满足很大一部分项目的需求。此外,由于接口设计的非常优雅,那些无法通过配置文件配置的需求,大部分都可以通过简单的继承重写实现(相比起来,老项目中复制粘贴其他开源框架代码然后做一些细微改动的例子屡见不鲜)。
Spring Boot 使用注解来启用一组默认配置。这些注解大多是基于 Spring 注解实现的。首先来复习下 Spring 中基于注解的配置。我认为比较关键的注解有这几个:
@Component / @Controller / @Service / @Repository
Spring 在 2.x 时代提供的注解,可以说是最早的一批注解,是最常见的定义一个 bean 的形式,也能满足项目中 80% 的需求。
@Autowired / @Qualifier / @Resource
前两个注解也是 Spring 最早的一批注解,@Resource
是 JDK 提供的注解。这三个注解是最常见的属性注入方式之一(通过@Value
注入也是一种常见方式)。
@ComponentScan / @Configuration / @Bean
@ComponentScan
定义了有哪些包下的类需要 Spring 扫描。@Configuration
把一个类标记为配置类,让ConfigurationClassPostProcessor
类扫描解析配置,其中以 @Bean
注释的方法和属性都会被解析为 bean 的定义,基本能满足第一条中剩下的 19% 需求。值得注意的一点是,直接调用被 bean 注释的方法也会认为是对 bean 的引用,直接通过构造函数构造和使用 @Bean
构造的的对象可能是不同的。如
@Bean
public Foo foo(){ return new Foo();}
public void doSth() { doSth(new Foo());}
public void doSth2() {doSth(foo());}
doSth
和 doSth2
两个方法看起来好像一样,但实际上可能是不同的。例如当 Foo 实现了 InitializingBean
接口,doSth() 可能不会调用 afterPropertiesSet()
方法;又例如 foo 通过 AbstractAutoProxyCreator
被定义为一个代理对象,doSth()获取不到代理对象,可能导致 aop 逻辑丢失。所以,任何类定义时依赖 Spring 容器托管的 bean,使用 FactoryBean
构造的 bean,预期通过代理构造的 bean,注意不要直接构造。
@Import / @ImportResource
这两个注解负责将配置类和配置文件关联起来。
@ImportResource
用于导入配置文件。Spring 默认提供了 xml 和 groovy 类型配置文件的解析,同时也提供了 BeanDefinitionReader
接口以实现自定义格式的文件解析。
@Import
用于导入配置类。一个配置类要被 Spring 识别,除了在 @ComponentScan
扫描路径下并配置了 @Configuraion
注解外,还有一种方式就是在这些配置类上通过 @Import
导入。值得一提的是,Spring 提供了几个接口,以实现对导入配置类行为的扩展。
接口 ImportBeanDefinitionRegistrar
允许我们获取注解的属性并手动向容器注册 bean。我们可以通过一个例子来了解这个接口可以做些什么:
@Import(SongRegistar.class)
public @interface EnableSing {
String singerName();
String[] songNames();
boolean useSingerNameAsPrefix();
}
public SongRegistar implements ImportBeanDefinitionRegistrar {
@Override
public void registerBeanDefinitions(AnnotationMetadata metadata,
BeanDefinitionRegistry registry) {
AnnotationAttributes attrs =
AnnotationConfigUtils.attributesFor(metadata, EnableSing.class);
String singerName = attrs.getString("singerName");
BeanDefinition def = registry.getBeanDefinition("singer");
def.getPropertyValues().add("name", singerName);
boolean useSingerName = attrs.getBoolean("useSingerNameAsPrefix");
for (String songName : attrs.getStringArray("songNames")) {
BeanDefinition songDef = build(songName);
String beanName = useSingerName ? (singerName + songName) : songName;
registry.registerBeanDefinition(beanName, songDef);
}
}
}
简单的总结下 demo,这个接口可以:
- 使用注解值生成一个或一组 bean,这意味着我们可以通过注解定义新的 bean。
- 修改一个已注册 bean 的定义。
def.getPropertyValues().add("name", singerName)
这段代码并不是只能覆盖 name 属性,如果这个字段是Set
、List
、Map
、Properties
,还可以向已有属性里追加值。这意味着我们可以通过注解对已有 bean 进行修改。 - 由于可以拿到
BeanDefinitionRegistry
,这意味着我们可以根据具体场景决定对 bean 的不同操作,和下面讲到的@Conditional
相比能做的事情更多。
接口 ImportSelector
允许 @Import
动态导入配置类。这允许我们根据某些条件动态选择配置类,具体的应用后面会再提到。与此同时,Spring 为这个接口提供了一个特殊的子接口 DeferredImportSelector
,实现了这个接口的配置类会在所有 @Configuration
bean 处理完成后再处理,一般应用于 Spring Boot 中。
@Conditional
它定义了一组条件,只有满足这组条件才会向容器注册 bean,这也为我们的配置提供了更灵活的支持。
举个例子,我们需要定义一个 bean,测试环境和生产环境需要配置不同值。如果 bean 名称一致,只需要简单的在 @Bean
定义时,判断环境再设置不同值即可。那么如果 bean 名称不一致呢?如生产环境注册 beanProd,测试环境注册 beanTest。由于注解的参数要求是常量,所以无法通过 @Bean
动态指定名字。
在旧项目中的实现方式是,不同环境的 bean 定义在不同配置文件中。通过 ant/maven 将不同的配置文件打包从而生成不同的 war 包,这样做的缺点就如上面提到的,修改配置时容易遗漏,还需要额外维护打包脚本。
使用@Conditional
的话,可以如下这样配置。
@Configuration
public class CustomConfiguration {
@Bean
@Conditional(ProdCondition.class)
public Foo beanProd() {
Foo bean = createBasic();
// 生产环境配置
return bean;
}
@Bean
@Conditional(TestCondition.class)
public Foo beanTest() {
Foo bean = createBasic();
// 测试环境配置
return bean;
}
private Foo createBasic() {
// 相同的配置
}
}
// Conditional 实现
public abstract class EnvironmentCondition implements Conditional {
protected abstract String enable();
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
String env = System.getProperty("env");
return Objects.equals(enable(), env);
}
public static class ProdCondition extends EnvironmentCondition {
@Override
protected abstract String enable() {
return "prod";
}
}
public static class TestCondition extends EnvironmentCondition {
@Override
protected abstract String enable() {
return "test";
}
}
}
通过这种方式配置,有几个好处:
- 不必再维护多个配置文件,相应地也不用额外维护 ant/maven 脚本。
- 相同部分的配置可以重用,重用配置的方式比 XML 实现简单(通过 XML 配置暂时只想到 FactoryBean 和 parent bean 两种形式来重用配置)。
- 通过 IDE 全局搜索 ProdCondition 和 TestCondition 的引用,可以轻松的知道,生产/测试环境做了哪些特殊的改变。
- 当条件发生变更时,只需要改变 Conditional 实现,相比更改 ant/maven 配置更简单。
前面也提到 ImportBeanDefinitionRegistrar
可以修改已有的 bean,对于这个需求,我们也可以这样实现:
@Configuration
@Import(TestRegistrar.class)
public class CustomConfiguration{
@Bean
public Foo beanProd() {
Foo bean = new Foo();
// 生产环境配置
return bean;
}
public static class TestRegistrar implements ImportBeanDefinitionRegistrar {
@Override
public void registerBeanDefinitions(AnnotationMetadata metadata,
BeanDefinitionRegistry registry) {
String env = System.getProperty("env");
if ("test".equals(env)) {
BeanDefinition def = registry. getBeanDefinition("foo");
// 修改为测试环境配置
registry.removeBeanDefinition("beanProd");
registry.registerBeanDefinition("beanTest", def);
}
}
}
不过显然,对于这个需求而言,从工程的角度看,方法一维护性会更好。
@AliasFor
这个注解自 4.2 版本后才引入,又因为不是必须的注解,所以很多开发者在日常开发中往往忽略了它。但在简化配置中,这个注解却不容忽视。
我们知道,Spring 通过注解、反射和 ClassLoader,隐藏了配置实现细节,从而达到精简配置的目的。但随之而来的问题是可用的注解不断的增多,而 Java 的注解类型是不支持继承的,这又会导致新的繁琐和冗余。针对这一点,Spring 定义了自己的注解「继承」规则,简单来说就是——如果注解 A 上有注解 B、C,对于需要同时注解 B/C 的场景,可以直接用注解 A 代替。@AliasFor
的作用就是在这个场景下,定义将 B/C 上的某些字段映射为 A 的某些字段。这一点在我们基于 Spring 定制自己的框架时将会非常有帮助。
回顾完 Spring 的注解,我们来看看 Spring Boot。如前所述,Spring 通过 @Configuration
和 @Import
,实现了基础的自动配置,如@EnableAspectJAutoProxy
。Spring Boot 则是在此基础上,实现了更丰富的自动配置。Spring Cloud 全家桶以及部分开源框架如 druid 都实现了类似的自动化配置(spring-boot-*-starter)。下面让我们来总结下 Spring Boot 自动配置用到的关键注解。
@ConditionalOn*
这一系列的注解是 Spring Boot 提供的 @Conditional
实现,覆盖了大部分日常开发所需要的条件,这里列举几个 Spring Boot starter 的注解:
-
@ConditionalOnBean/@ConditionalOnMissingBean
,最常用的条件之一, 表示某些 bean 存在 / 不存在。后者也是对配置最小化起到很大帮助的注解之一,相当于 Bean 的 Override 机制。 -
@ConditionalOnClass/@ConditionalOnMissingClass
,框架类项目最常用的条件之一,表示 ClassLoader 存在 / 不存在某些类。最常见的用法是「弱依赖检查」,某个包可依赖也可以不依赖,但是对两种情况需要进行不同的注册操作。对于一个业务项目来说不太用的到,因为引用的包是确定的。 -
@ConditionalOnProperty
,框架最常用的条件之一,表示某些属性存在。是通过属性启动配置的重要实现方式之一。顺带一提,上面的那个例子可以简化为
@Configuration
public class CustomConfiguration {
@Bean
@ConditionalOnProperty(name = "env", havingValue = "prod")
public Foo beanProd() {
Foo bean = createBasic();
// 生产环境配置
return bean;
}
@Bean
@ConditionalOnProperty(name = "env", havingValue = "test")
public Foo beanTest() {
Foo bean = createBasic();
// 测试环境配置
return bean;
}
private Foo createBasic() {
// 相同的配置
}
}
-
@ConditionalOnJava
,表示需要 Java 版本满足特定需求,可以用来处理 JDK 升级的兼容性问题。 -
@ConditionalOnResource
,表示存在某个配置文件。 -
@ConditionalOnExpression
,表示 SpEL 返回某个确定值。 -
@ConditionalOnSingleCandidate
,表示某个类型的 Bean 只存在一个,或是多个类型的 Bean 中有一个指定了@Primary
。 -
@ConditionalOnJndi
,表示存在某些 JNDI 接口。 -
@ConditionalOnWebApplication / @ConditionalOnNotWebApplication
,表示运行环境是 / 不是 Web 应用。
@EnableAutoConfiguration
启用自动配置的核心注解。原理其实非常简单,正是用了上面提到的 @Import
和 DeferredImportSelector
接口。Spring Boot 使用 AutoConfigurationMetadataLoader
解析自动配置相关的配置文件。它采用「约定大于配置」的做法,读取 classpath:META-INF/spring-autoconfigure-metadata.properties
并解析为注解配置,这些配置可以简单理解为 @Configuration
和 @ConditionOn*
注解,告诉框架需要识别哪些类为配置类。
Spring Boot 提供了一个默认的版本,囊括了所有默认配置。我们也可以在自己的应用或框架下的同样位置提供配置文件实现自己的自动配置。当然通常情况下我们更多的会使用 spring.factories 配置文件来实现自定义的自动配置,有关其原理会在其他文章中单独讨论。
简而言之,@EnableAutoConfiguration
就是通过 @Import
机制,导入了大量预定义的配置,从而达到配置最小化的目的。对框架使用者而言,只需要使用 @EnableAutoConfiguration
注解,不必关系哪些配置被启用哪些没有。
@EnableConfigurationProperties / @ConfigurationProperties
@ConfigurationProperties
将 Spring Boot 配置项注入配置 Bean,@EnableConfigurationProperties
可以将注入后的配置 Bean 注入到配置类中。Spring Boot 通过这两个注解简化了配置文件的读取和使用。
最后进行一下总结。在这一期中,我们对 Spring 的注解配置方式有了一个全面但并不深入的了解,同时也对 Spring Boot 的自动配置、默认配置有了简单的认识。这些不仅能帮我们更好的定制 Spring 框架,还能为我们的应用架构设计带来启发。
网友评论