最近完成了手頭上項(xiàng)目的一系列重構(gòu)窍蓝,引入了 Spring Boot 和其他較新的技術(shù)框架穿肄。在此過(guò)程中有很多值得回味和交流的地方狡逢,因此做一個(gè)簡(jiǎn)單的記錄。
為什么要配置最小化
整個(gè)重構(gòu)的起源是因?yàn)橄胍獙㈨?xiàng)目的配置最小化颠锉。那為什么需要最小化配置呢?我的原因有以下幾點(diǎn):
- 已有的配置文件數(shù)量巨大史汗,僅 xml 文件就有五六百琼掠。維護(hù)大量的配置文件成本非常高,同時(shí)由于部分配置文件包含了特殊邏輯停撞,更是增加了維護(hù)的復(fù)雜度瓷蛙,如生產(chǎn)環(huán)境和測(cè)試環(huán)境使用了不同的配置文件,改動(dòng)時(shí)僅改動(dòng)了測(cè)試環(huán)境的配置戈毒,導(dǎo)致測(cè)試一切正常艰猬,而發(fā)布以后出現(xiàn)各種問(wèn)題。
- 由于不支持注解埋市,新增一個(gè) bean 需要編寫(xiě)相應(yīng)的配置代碼冠桃,新增的配置代碼也不像新項(xiàng)目一樣隨便找一個(gè)文件放,需要遵循原來(lái)的做法道宅,放到相應(yīng)的模塊下食听,一來(lái)二去,增加了無(wú)謂的開(kāi)發(fā)成本污茵。
- 同樣的樱报,一個(gè)功能不再使用可能需要移除對(duì)應(yīng)的一個(gè)或多個(gè) bean,前代為了省事可能只會(huì)注釋掉相應(yīng)配置甚至不做任何改動(dòng)泞当,長(zhǎng)年累月下來(lái)項(xiàng)目中包含了很多「無(wú)注釋迹蛤、無(wú)文檔、無(wú)引用」的三無(wú)代碼襟士。
- 由于以上原因盗飒,項(xiàng)目的復(fù)雜度也大大提升,增加了新接手項(xiàng)目的同學(xué)的學(xué)習(xí)成本敌蜂,不利于項(xiàng)目持續(xù)健康的發(fā)展箩兽。
- 另一方面,Spring Boot 本身具有「零配置即可運(yùn)行」的特性章喉,是我希望改造的方向,同時(shí)在組內(nèi)也做過(guò)其他項(xiàng)目基于 Spring Boot 的項(xiàng)目身坐,也積累了一些經(jīng)驗(yàn)秸脱,團(tuán)隊(duì)內(nèi)也有一些技術(shù)棧的沉淀,使用 Spring Boot 后也可以享受無(wú)縫接入其他服務(wù)的便利部蛇。
Spring Boot 如何做到配置最小化
既然目標(biāo)是精簡(jiǎn)配置摊唇,那么先來(lái)看看 Spring Boot 是如何做到配置最小化的。和大多數(shù)優(yōu)秀的開(kāi)源框架一樣涯鲁,Spring Boot 核心理念之一是「約定大于配置」巷查。和一些遵循這個(gè)理念的框架不同的是有序,有些框架的默認(rèn)配置,只能做到「不報(bào)錯(cuò)」岛请、「跑起來(lái)」旭寿,不一定能提供正確的業(yè)務(wù)邏輯,而 Spring Boot 的默認(rèn)配置具有很高的通用性崇败,真正做到「零配置」可用盅称。
Spring Boot 延續(xù)了 Spring 一貫的可配置性和擴(kuò)展性。通過(guò)配置文件或啟動(dòng)參數(shù)修改配置已經(jīng)可以滿足很大一部分項(xiàng)目的需求后室。此外缩膝,由于接口設(shè)計(jì)的非常優(yōu)雅,那些無(wú)法通過(guò)配置文件配置的需求岸霹,大部分都可以通過(guò)簡(jiǎn)單的繼承重寫(xiě)實(shí)現(xiàn)(相比起來(lái)疾层,老項(xiàng)目中復(fù)制粘貼其他開(kāi)源框架代碼然后做一些細(xì)微改動(dòng)的例子屢見(jiàn)不鮮)。
Spring Boot 使用注解來(lái)啟用一組默認(rèn)配置贡避。這些注解大多是基于 Spring 注解實(shí)現(xiàn)的痛黎。首先來(lái)復(fù)習(xí)下 Spring 中基于注解的配置。我認(rèn)為比較關(guān)鍵的注解有這幾個(gè):
@Component / @Controller / @Service / @Repository
Spring 在 2.x 時(shí)代提供的注解贸桶,可以說(shuō)是最早的一批注解舅逸,是最常見(jiàn)的定義一個(gè) bean 的形式,也能滿足項(xiàng)目中 80% 的需求皇筛。
@Autowired / @Qualifier / @Resource
前兩個(gè)注解也是 Spring 最早的一批注解琉历,@Resource
是 JDK 提供的注解。這三個(gè)注解是最常見(jiàn)的屬性注入方式之一(通過(guò)@Value
注入也是一種常見(jiàn)方式)水醋。
@ComponentScan / @Configuration / @Bean
@ComponentScan
定義了有哪些包下的類需要 Spring 掃描旗笔。@Configuration
把一個(gè)類標(biāo)記為配置類,讓ConfigurationClassPostProcessor
類掃描解析配置拄踪,其中以 @Bean
注釋的方法和屬性都會(huì)被解析為 bean 的定義蝇恶,基本能滿足第一條中剩下的 19% 需求。值得注意的一點(diǎn)是惶桐,直接調(diào)用被 bean 注釋的方法也會(huì)認(rèn)為是對(duì) bean 的引用撮弧,直接通過(guò)構(gòu)造函數(shù)構(gòu)造和使用 @Bean
構(gòu)造的的對(duì)象可能是不同的。如
@Bean
public Foo foo(){ return new Foo();}
public void doSth() { doSth(new Foo());}
public void doSth2() {doSth(foo());}
doSth
和 doSth2
兩個(gè)方法看起來(lái)好像一樣姚糊,但實(shí)際上可能是不同的贿衍。例如當(dāng) Foo 實(shí)現(xiàn)了 InitializingBean
接口,doSth() 可能不會(huì)調(diào)用 afterPropertiesSet()
方法救恨;又例如 foo 通過(guò) AbstractAutoProxyCreator
被定義為一個(gè)代理對(duì)象贸辈,doSth()獲取不到代理對(duì)象,可能導(dǎo)致 aop 邏輯丟失肠槽。所以擎淤,任何類定義時(shí)依賴 Spring 容器托管的 bean奢啥,使用 FactoryBean
構(gòu)造的 bean,預(yù)期通過(guò)代理構(gòu)造的 bean嘴拢,注意不要直接構(gòu)造桩盲。
@Import / @ImportResource
這兩個(gè)注解負(fù)責(zé)將配置類和配置文件關(guān)聯(lián)起來(lái)。
@ImportResource
用于導(dǎo)入配置文件炊汤。Spring 默認(rèn)提供了 xml 和 groovy 類型配置文件的解析正驻,同時(shí)也提供了 BeanDefinitionReader
接口以實(shí)現(xiàn)自定義格式的文件解析。
@Import
用于導(dǎo)入配置類抢腐。一個(gè)配置類要被 Spring 識(shí)別姑曙,除了在 @ComponentScan
掃描路徑下并配置了 @Configuraion
注解外,還有一種方式就是在這些配置類上通過(guò) @Import
導(dǎo)入迈倍。值得一提的是伤靠,Spring 提供了幾個(gè)接口,以實(shí)現(xiàn)對(duì)導(dǎo)入配置類行為的擴(kuò)展啼染。
接口 ImportBeanDefinitionRegistrar
允許我們獲取注解的屬性并手動(dòng)向容器注冊(cè) bean宴合。我們可以通過(guò)一個(gè)例子來(lái)了解這個(gè)接口可以做些什么:
@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);
}
}
}
簡(jiǎn)單的總結(jié)下 demo,這個(gè)接口可以:
- 使用注解值生成一個(gè)或一組 bean迹鹅,這意味著我們可以通過(guò)注解定義新的 bean卦洽。
- 修改一個(gè)已注冊(cè) bean 的定義。
def.getPropertyValues().add("name", singerName)
這段代碼并不是只能覆蓋 name 屬性斜棚,如果這個(gè)字段是Set
阀蒂、List
、Map
弟蚀、Properties
蚤霞,還可以向已有屬性里追加值。這意味著我們可以通過(guò)注解對(duì)已有 bean 進(jìn)行修改义钉。 - 由于可以拿到
BeanDefinitionRegistry
昧绣,這意味著我們可以根據(jù)具體場(chǎng)景決定對(duì) bean 的不同操作,和下面講到的@Conditional
相比能做的事情更多捶闸。
接口 ImportSelector
允許 @Import
動(dòng)態(tài)導(dǎo)入配置類夜畴。這允許我們根據(jù)某些條件動(dòng)態(tài)選擇配置類,具體的應(yīng)用后面會(huì)再提到删壮。與此同時(shí)斩启,Spring 為這個(gè)接口提供了一個(gè)特殊的子接口 DeferredImportSelector
,實(shí)現(xiàn)了這個(gè)接口的配置類會(huì)在所有 @Configuration
bean 處理完成后再處理醉锅,一般應(yīng)用于 Spring Boot 中。
@Conditional
它定義了一組條件发绢,只有滿足這組條件才會(huì)向容器注冊(cè) bean硬耍,這也為我們的配置提供了更靈活的支持垄琐。
舉個(gè)例子,我們需要定義一個(gè) bean经柴,測(cè)試環(huán)境和生產(chǎn)環(huán)境需要配置不同值狸窘。如果 bean 名稱一致,只需要簡(jiǎn)單的在 @Bean
定義時(shí)坯认,判斷環(huán)境再設(shè)置不同值即可翻擒。那么如果 bean 名稱不一致呢?如生產(chǎn)環(huán)境注冊(cè) beanProd牛哺,測(cè)試環(huán)境注冊(cè) beanTest陋气。由于注解的參數(shù)要求是常量,所以無(wú)法通過(guò) @Bean
動(dòng)態(tài)指定名字引润。
在舊項(xiàng)目中的實(shí)現(xiàn)方式是巩趁,不同環(huán)境的 bean 定義在不同配置文件中。通過(guò) ant/maven 將不同的配置文件打包從而生成不同的 war 包淳附,這樣做的缺點(diǎn)就如上面提到的议慰,修改配置時(shí)容易遺漏,還需要額外維護(hù)打包腳本奴曙。
使用@Conditional
的話别凹,可以如下這樣配置。
@Configuration
public class CustomConfiguration {
@Bean
@Conditional(ProdCondition.class)
public Foo beanProd() {
Foo bean = createBasic();
// 生產(chǎn)環(huán)境配置
return bean;
}
@Bean
@Conditional(TestCondition.class)
public Foo beanTest() {
Foo bean = createBasic();
// 測(cè)試環(huán)境配置
return bean;
}
private Foo createBasic() {
// 相同的配置
}
}
// Conditional 實(shí)現(xiàn)
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";
}
}
}
通過(guò)這種方式配置洽糟,有幾個(gè)好處:
- 不必再維護(hù)多個(gè)配置文件炉菲,相應(yīng)地也不用額外維護(hù) ant/maven 腳本。
- 相同部分的配置可以重用脊框,重用配置的方式比 XML 實(shí)現(xiàn)簡(jiǎn)單(通過(guò) XML 配置暫時(shí)只想到 FactoryBean 和 parent bean 兩種形式來(lái)重用配置)颁督。
- 通過(guò) IDE 全局搜索 ProdCondition 和 TestCondition 的引用,可以輕松的知道浇雹,生產(chǎn)/測(cè)試環(huán)境做了哪些特殊的改變沉御。
- 當(dāng)條件發(fā)生變更時(shí),只需要改變 Conditional 實(shí)現(xiàn)昭灵,相比更改 ant/maven 配置更簡(jiǎn)單吠裆。
前面也提到 ImportBeanDefinitionRegistrar
可以修改已有的 bean,對(duì)于這個(gè)需求烂完,我們也可以這樣實(shí)現(xiàn):
@Configuration
@Import(TestRegistrar.class)
public class CustomConfiguration{
@Bean
public Foo beanProd() {
Foo bean = new Foo();
// 生產(chǎn)環(huán)境配置
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");
// 修改為測(cè)試環(huán)境配置
registry.removeBeanDefinition("beanProd");
registry.registerBeanDefinition("beanTest", def);
}
}
}
不過(guò)顯然试疙,對(duì)于這個(gè)需求而言,從工程的角度看抠蚣,方法一維護(hù)性會(huì)更好祝旷。
@AliasFor
這個(gè)注解自 4.2 版本后才引入,又因?yàn)椴皇潜仨毜淖⒔猓院芏嚅_(kāi)發(fā)者在日常開(kāi)發(fā)中往往忽略了它怀跛。但在簡(jiǎn)化配置中距贷,這個(gè)注解卻不容忽視。
我們知道吻谋,Spring 通過(guò)注解忠蝗、反射和 ClassLoader,隱藏了配置實(shí)現(xiàn)細(xì)節(jié)漓拾,從而達(dá)到精簡(jiǎn)配置的目的阁最。但隨之而來(lái)的問(wèn)題是可用的注解不斷的增多,而 Java 的注解類型是不支持繼承的骇两,這又會(huì)導(dǎo)致新的繁瑣和冗余速种。針對(duì)這一點(diǎn),Spring 定義了自己的注解「繼承」規(guī)則脯颜,簡(jiǎn)單來(lái)說(shuō)就是——如果注解 A 上有注解 B哟旗、C,對(duì)于需要同時(shí)注解 B/C 的場(chǎng)景栋操,可以直接用注解 A 代替闸餐。@AliasFor
的作用就是在這個(gè)場(chǎng)景下,定義將 B/C 上的某些字段映射為 A 的某些字段矾芙。這一點(diǎn)在我們基于 Spring 定制自己的框架時(shí)將會(huì)非常有幫助舍沙。
回顧完 Spring 的注解,我們來(lái)看看 Spring Boot剔宪。如前所述拂铡,Spring 通過(guò) @Configuration
和 @Import
,實(shí)現(xiàn)了基礎(chǔ)的自動(dòng)配置葱绒,如@EnableAspectJAutoProxy
感帅。Spring Boot 則是在此基礎(chǔ)上,實(shí)現(xiàn)了更豐富的自動(dòng)配置地淀。Spring Cloud 全家桶以及部分開(kāi)源框架如 druid 都實(shí)現(xiàn)了類似的自動(dòng)化配置(spring-boot-*-starter)失球。下面讓我們來(lái)總結(jié)下 Spring Boot 自動(dòng)配置用到的關(guān)鍵注解。
@ConditionalOn*
這一系列的注解是 Spring Boot 提供的 @Conditional
實(shí)現(xiàn)帮毁,覆蓋了大部分日常開(kāi)發(fā)所需要的條件实苞,這里列舉幾個(gè) Spring Boot starter 的注解:
-
@ConditionalOnBean/@ConditionalOnMissingBean
,最常用的條件之一烈疚, 表示某些 bean 存在 / 不存在黔牵。后者也是對(duì)配置最小化起到很大幫助的注解之一,相當(dāng)于 Bean 的 Override 機(jī)制爷肝。 -
@ConditionalOnClass/@ConditionalOnMissingClass
猾浦,框架類項(xiàng)目最常用的條件之一陆错,表示 ClassLoader 存在 / 不存在某些類。最常見(jiàn)的用法是「弱依賴檢查」跃巡,某個(gè)包可依賴也可以不依賴危号,但是對(duì)兩種情況需要進(jìn)行不同的注冊(cè)操作。對(duì)于一個(gè)業(yè)務(wù)項(xiàng)目來(lái)說(shuō)不太用的到素邪,因?yàn)橐玫陌谴_定的。 -
@ConditionalOnProperty
猪半,框架最常用的條件之一兔朦,表示某些屬性存在。是通過(guò)屬性啟動(dòng)配置的重要實(shí)現(xiàn)方式之一磨确。順帶一提沽甥,上面的那個(gè)例子可以簡(jiǎn)化為
@Configuration
public class CustomConfiguration {
@Bean
@ConditionalOnProperty(name = "env", havingValue = "prod")
public Foo beanProd() {
Foo bean = createBasic();
// 生產(chǎn)環(huán)境配置
return bean;
}
@Bean
@ConditionalOnProperty(name = "env", havingValue = "test")
public Foo beanTest() {
Foo bean = createBasic();
// 測(cè)試環(huán)境配置
return bean;
}
private Foo createBasic() {
// 相同的配置
}
}
-
@ConditionalOnJava
,表示需要 Java 版本滿足特定需求乏奥,可以用來(lái)處理 JDK 升級(jí)的兼容性問(wèn)題摆舟。 -
@ConditionalOnResource
,表示存在某個(gè)配置文件邓了。 -
@ConditionalOnExpression
恨诱,表示 SpEL 返回某個(gè)確定值。 -
@ConditionalOnSingleCandidate
骗炉,表示某個(gè)類型的 Bean 只存在一個(gè)照宝,或是多個(gè)類型的 Bean 中有一個(gè)指定了@Primary
。 -
@ConditionalOnJndi
句葵,表示存在某些 JNDI 接口厕鹃。 -
@ConditionalOnWebApplication / @ConditionalOnNotWebApplication
,表示運(yùn)行環(huán)境是 / 不是 Web 應(yīng)用乍丈。
@EnableAutoConfiguration
啟用自動(dòng)配置的核心注解剂碴。原理其實(shí)非常簡(jiǎn)單,正是用了上面提到的 @Import
和 DeferredImportSelector
接口轻专。Spring Boot 使用 AutoConfigurationMetadataLoader
解析自動(dòng)配置相關(guān)的配置文件忆矛。它采用「約定大于配置」的做法,讀取 classpath:META-INF/spring-autoconfigure-metadata.properties
并解析為注解配置铭若,這些配置可以簡(jiǎn)單理解為 @Configuration
和 @ConditionOn*
注解洪碳,告訴框架需要識(shí)別哪些類為配置類。
Spring Boot 提供了一個(gè)默認(rèn)的版本叼屠,囊括了所有默認(rèn)配置瞳腌。我們也可以在自己的應(yīng)用或框架下的同樣位置提供配置文件實(shí)現(xiàn)自己的自動(dòng)配置。當(dāng)然通常情況下我們更多的會(huì)使用 spring.factories 配置文件來(lái)實(shí)現(xiàn)自定義的自動(dòng)配置镜雨,有關(guān)其原理會(huì)在其他文章中單獨(dú)討論嫂侍。
簡(jiǎn)而言之,@EnableAutoConfiguration
就是通過(guò) @Import
機(jī)制,導(dǎo)入了大量預(yù)定義的配置挑宠,從而達(dá)到配置最小化的目的菲盾。對(duì)框架使用者而言,只需要使用 @EnableAutoConfiguration
注解各淀,不必關(guān)系哪些配置被啟用哪些沒(méi)有懒鉴。
@EnableConfigurationProperties / @ConfigurationProperties
@ConfigurationProperties
將 Spring Boot 配置項(xiàng)注入配置 Bean,@EnableConfigurationProperties
可以將注入后的配置 Bean 注入到配置類中碎浇。Spring Boot 通過(guò)這兩個(gè)注解簡(jiǎn)化了配置文件的讀取和使用临谱。
最后進(jìn)行一下總結(jié)。在這一期中奴璃,我們對(duì) Spring 的注解配置方式有了一個(gè)全面但并不深入的了解悉默,同時(shí)也對(duì) Spring Boot 的自動(dòng)配置、默認(rèn)配置有了簡(jiǎn)單的認(rèn)識(shí)苟穆。這些不僅能幫我們更好的定制 Spring 框架抄课,還能為我們的應(yīng)用架構(gòu)設(shè)計(jì)帶來(lái)啟發(fā)。