前言
在目前的Spring Boot框架中,不管是Spring Boot官方還是非官方,都提供了非常多的starter系列組件,助力開發者在企業應用中的開發,提升研發人員的工作效率,Spring Boot框架提出的約定大於配置的規則,確實幫助開發者簡化了以前Spring MVC時代的很多繁雜的配置。讓開發者用起來也是非常爽的。
盡管Spring Boot或者一些開源組件已經幫助我們提供了非常多的starter組件,在滿足日常的開發中,已經完全沒有問題了。但有時候因為需求的可變性,導致企業架構也會隨着調整,那么在Spring Boot框架中,官方或開源的第三方starter肯定不能滿足企業內部研發人員的要求,這時候就需要開發者自定義企業內部的starter了。
企業或個人自定義Spring Boot的starter組件主要從哪些方面來入手呢,或者什么時候需要自定義starter組件?我個人認為主要有以下幾個方面:
- 規范企業內部編碼流程,統一各個技術中間件的代碼規范
- 減少不同類型中間件的使用成本,提升研發人員的研發工作效率
- 減少冗余代碼的使用,統一封裝,統一管理。
- 屏蔽中間件底層細節,暴露配置屬性及方法,減少學習使用成本
- 可能還有更多?
本篇博客結合自身的開發經驗以及目前Spring Boot如何配置元數據的官方介紹文檔進行結合,進行綜合闡述。
Spring Boot官方元數據文檔地址:https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-configuration-metadata.html
封裝Spring Boot的starter范圍可以是一組規范的業務方法,也可以是通用的中間件底層。開發者通過封裝,一定程度上也能起到規范企業編碼的作用,同時也能組合復用公共業務邏輯。
那么我們在自定義Spring Boot框架的starter組件時,我們需要准備什么呢?
我認為主要包含以下幾個方面:
- 自定義starter的作用
- 命名規范
- 理解Maven或者Gradle依賴包管理的jar包引用傳遞機制
- 理解Spring Boot框架中基於Java代碼的Configuration配置
- 理解Spring Boot框架自動裝載的過程
- 學會利用Spring Boot提供的
@Conditional
系列條件注入充分發揮Spring Boot的優點 - 學會如何配置自定義starter組件時對外的屬性注釋配置,可以參考官方文檔
自定義starter的作用
我們在自定義starter組件之前,開發者首先需要想清楚,這個starter組件能帶來什么,簡化開發?或者復用組件的封裝供其他同事使用,不寫重復代碼等等,這些都是需要思考清楚的。
自定義starter的場景很多,例如:
- 項目中發送短信對接了不同的雲服務商,那么可以封裝一個短信的starter,屏蔽對接的細節,開發者只需要配置相應的廠商配置信息就可以使用該服務商發送短信了
- OSS存儲對接不同的雲服務商,例如阿里雲、七牛雲、騰訊雲等等
- 企業內部中間件封裝使用,簡化開發配置
- more...
根據筆者的經驗,我認為自定義的starter的作用無外乎以下幾個方面:
- 充分利用Spring的特性,容器/依賴注入特性,將核心的類組件注入容器中,方便開發者通過注入直接獲取拿來使用
- 通過屬性初始化中間件的流程,屏蔽具體的細節
- ....
starter命名規范
根據Spring Boot的官方要求,如果是開發者指定第三方的starter組件,那么命名規范是yourname-spring-boot-starter
拿Knife4j舉例說明如下:
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<!--在引用時請在maven中央倉庫搜索2.X最新版本號-->
<version>2.0.8</version>
</dependency>
而Spring Boot官方維護發布的starter名稱規范則是:spring-boot-starter-name
例如我們引用最多的web組件,引用maven配置如下:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
jar包引用傳遞依賴機制
這是自定義封裝Spring Boot的starter的前提條件,Gradle筆者並未使用過,這里僅以Maven為例進行闡述說明!
通常我們在封裝一個SDK的jar包時,該jar包可能需要引用到第三方的jar包作為依賴包來輔助我們完成對該jar包的封裝,但是我們在引用的時候是有講究的。
針對Spring Boot的自定義starter說到底也是一個jar包,既然是jar包必然會用到第三方的jar(ps:全部都是你寫的代碼除外),那么我們應該如何明確在starter中的jar包的依賴傳遞,我認為主要有以下方面:
- 作為第三方組件使用jar包時,明確第三方組件的版本
- 作為編譯期間的包,需要修改默認的scope范圍值,僅僅在編譯期間生效,最終打包后引用不傳遞
- 自定義封裝starter必須引用Spring Boot官方提供的
在定義Spring Boot的第三方starter時,主要用到Maven管理jar包中的兩種依賴隔離方式(均可以使用),分別如下:
- 明確使用
<optional>true></optional>
屬性來強指定jar包不傳遞 - 使用
<scope>provided</scope>
僅僅在編譯期間有效,jar包依賴性不傳遞
一般我們在自定義Spring Boot的starter組件時,都需要引用Spring Boot提供給開發者的依賴包,如下:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
<version>2.3.0.RELEASE</version>
<scope>provided</scope>
</dependency>
當然,你也可以使用optional
模式,如下:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
<version>2.3.0.RELEASE</version>
<optional>true</optional>
</dependency>
Java代碼方式的Configuration
基於Java編碼的方式配置Spring的Bean已經成了目前的主流,這主要也是得益於Spring Boot框架的流行!
在Spring MVC框架流行的時候,開發人員一般都是通過配置XML文件來注入實體Bean的
而通過java編碼的方式注入Bean的前提是@Configuration
注解加在一個配置Java實體類上即可,示例如下:
@Configuration
public class MyAutoConfiguration{
//do others...
}
Spring Boot框架的自動裝載
對於Spring Boot框架自定義的starter組件來說,提供的使用方式而言,我認為目前主要有3種方式,這個主要看封裝starter組件的作者如何開放來定
手工@Import
導入
第一種情況:使用者使用@Import
注解將封裝的starter組件的Java編碼Configuration配置文件進行導入
假設目前封裝的一個簡單的Configuration配置如下:
@Configuration
public class DemoAuthConfiguration {
@Bean
public DemoClient demoClient(){
return new DemoClient();
}
}
開發者通過DemoAutoConfiguration.java
向Spring的容器中注入了一個DemoClient
的實體Bean,由於隸屬於不同的package包路徑,自定義的starter組件包路徑是:com.demo.spring
而開發者的項目主目錄包路徑是:com.test
,所以Spring Boot框架默認是不會加載該配置的,此時,如果開發者要在Spring的容器中獲取DemoClient
的實體Bean應該怎么辦呢?使用者應該在自己的主配置中使用@Import
注解將該配置導入進來交給Spring容器初始化時進行創建,示例如下:
@Import(DemoAutoConfiguration.class)
@SpringBootApplication
public class DemoDemoApplication {
public static void main(String[] args){
SpringApplication.run(DemoDemoApplication.class, args);
}
}
提供便於記憶的注解@EnableXXX
@Enablexxx
系列注解相信開發者並不陌生,比如我們要使用Spring Boot的定時任務功能,我們會在啟動入口引入@EnableScheduling
注解,我們使用Springfox的Swagger組件,我們會引入@EnableSwagger2
注解
其實這種方式只是為了讓開發者能夠更加方便的記憶,一個@Enablexxx
系列注解,其所代表的功能特點也基本符合該starter組件,是在上面手工通過@Import
注解的升級版本。
畢竟Enable
單詞所代表的含義是啟用,這有利於開發者記憶
繼續通過上面第一種的示例進行改在,此時,我們可以提供@EnableDemoClient
注解,代碼示例如下:
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Import(DemoAutoConfiguration.class)
public @interface EnableDemoClient {
}
大家應該也看到了,我們在該@EnableDemoClient
注解中,使用了@Import
注解的方式導入了DemoAutoConfiguration
配置
此時,我們在項目中可以使用@EnableDemoClient
注解了,代碼示例如下:
@EnableDemoClient
@SpringBootApplication
public class DemoDemoApplication {
public static void main(String[] args){
SpringApplication.run(DemoDemoApplication.class, args);
}
}
當然,@Enable
這種注解作用不僅僅局限於此,還可以在該注解上定義外部的配置屬性,通過配置該注解的方式達到最終初始化的目的。
自動裝載
自動裝載是Spring Boot的一重大特點,開發者通過配置文件的方式即可默認加載第三方的starter配置,非常的方便,是上面兩種方式的升級版
在之前的基礎上,如果開發者希望在Maven的pom.xml工程中引入了該組件,就可以使用DemoClient
類,那么此時我們應該怎么做呢?
我們需要在工程中創建spring.factories
文件,文件目錄:src/resources/META-INF/spring.factories
在spring.factories
文件中,配置開發者自定義的configuration類,如下:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.demo.spring.DemoAutoConfiguration
配置好后,此時再打包我們自定義的starter組件,Spring Boot框架默認會自動裝載該配置類,我們在業務代碼中也就可以直接使用了
我們可以在SpringApplication.java
源碼中看到Spring Boot初始化獲取該類列表的過程
private <T> Collection<T> getSpringFactoriesInstances(Class<T> type, Class<?>[] parameterTypes, Object... args) {
ClassLoader classLoader = getClassLoader();
// Use names and ensure unique to protect against duplicates
Set<String> names = new LinkedHashSet<>(SpringFactoriesLoader.loadFactoryNames(type, classLoader));
List<T> instances = createSpringFactoriesInstances(type, parameterTypes, classLoader, args, names);
AnnotationAwareOrderComparator.sort(instances);
return instances;
}
上述方法中的SpringFactoriesLoader.loadFactoryNames
方法如下:
public static List<String> loadFactoryNames(Class<?> factoryType, @Nullable ClassLoader classLoader) {
String factoryTypeName = factoryType.getName();
return loadSpringFactories(classLoader).getOrDefault(factoryTypeName, Collections.emptyList());
}
private static Map<String, List<String>> loadSpringFactories(@Nullable ClassLoader classLoader) {
MultiValueMap<String, String> result = cache.get(classLoader);
if (result != null) {
return result;
}
try {
//加載META-INF/spring.factories配置,創建MultiValueMap集合放到該集合中
Enumeration<URL> urls = (classLoader != null ?
classLoader.getResources(FACTORIES_RESOURCE_LOCATION) :
ClassLoader.getSystemResources(FACTORIES_RESOURCE_LOCATION));
result = new LinkedMultiValueMap<>();
while (urls.hasMoreElements()) {
URL url = urls.nextElement();
UrlResource resource = new UrlResource(url);
Properties properties = PropertiesLoaderUtils.loadProperties(resource);
for (Map.Entry<?, ?> entry : properties.entrySet()) {
String factoryTypeName = ((String) entry.getKey()).trim();
for (String factoryImplementationName : StringUtils.commaDelimitedListToStringArray((String) entry.getValue())) {
result.add(factoryTypeName, factoryImplementationName.trim());
}
}
}
cache.put(classLoader, result);
return result;
}
catch (IOException ex) {
throw new IllegalArgumentException("Unable to load factories from location [" +
FACTORIES_RESOURCE_LOCATION + "]", ex);
}
}
充分利用Spring Boot提供的@Conditional
條件注入組件
通過上面的文章介紹,為Spring Boot框架制定一個簡單的starter組件相信已經不在話下。但是,這才僅僅開始而已。
在上面介紹的自動裝載過程中,開發者是否會存在疑問?
當我們在pom.xml引入我們自定義的starter組件后,Spring Boot框架默認會將該組件直接注入到Spring的容器中,這種方式雖然在使用上並沒有什么問題,但當我們封裝給第三方使用時,這種方式往往會存在沖突,假設開發者自定義的starter組件中包含了向容器中注入Filter等過濾器,那么該過濾器直接生效,會全范圍影響整個應用程序.這在實際開發中是不允許的!
那么應該怎么辦呢?此時,我們就需要充分利用Spring Boot框架為開發者提供的
@Conditional
系列條件注入了
條件注入顧名思義,就是只有使用者滿足了組件規定的條件時,組件才會向Spring容器中進行注入Bean或者初始化的操作.這種方式也是將選擇權直接交給使用者進行選擇,減少非必要的組件沖突,是在Spring Boot自定義starter組件中必不可少的一環。
條件注入通常也配合屬性類一起來進行使用,提供配置屬性選項也是方便使用者在Spring Boot的配置文件application.yml
或者application.properties
進行配置開啟操作,例如我們常見的配置操作如下:
server:
port: 18568
servlet:
context-path: /test
為Spring Boot的程序指定啟動端口號和context-path
屬性.
我們繼續以上面示例中的DemoClient
為例進行闡述
假設我們的
DemoClient
是對接外部API接口的封裝組件,該組件規定訪問外部API時需要提供appid
和secret
,根據appid及secret獲取token,最后根據token才能調用API獲取接口數據,
那么,此時,我們的DemoClient
的部分模擬接口代碼可能會如下面示例:
public class DemoClient {
private final String appid;
private final String secret;
public DemoClient(String appid, String secret) {
this.appid = appid;
this.secret = secret;
}
/**
* 獲取資源
* @return
*/
public String listResources(){
//獲取token
String token=getToken();
//根據Token請求數據
return UUID.randomUUID().toString();
}
private String getToken() {
//根據appid & secret獲取第三方API接口token
return null;
}
}
在上面的代碼示例中,如果開發者要使用DemoClient
的方法調用第三方的接口資源,那么需要傳遞appid
及secret
參數才能構造實體類,又考慮到我們需要利用Spring Boot的條件注入,只有開發者配置了開啟操作,才能在Spring容器中使用DemoClient
的方法。
那么此時,我們可以給該starter組件抽象一個DemoProperties
的外部配置類來交給使用者在配置文件中進行配置開啟操作,代碼示例如下:
@ConfigurationProperties(prefix = "demo")
public class DemoProperties {
/**
* 是否啟用
*/
private boolean enable=false;
private String appid;
private String secret;
//getter and setter...
}
在配置類屬性中,我們使用到了@ConfigurationProperties
注解,並配置了prefix
前綴參數,配置前綴也是自定義starter組件中所必須的,這約束了命名空間。一般是結合自身的業務以及starter組件所代表的功能含義進行命名prefix
,有助於開發使用者記憶。
此時,我們的DemoAutoConfiguration.java
配置類進行了調整,代碼如下:
@Configuration
@EnableConfigurationProperties(DemoProperties.class)
@ConditionalOnProperty(name = "demo.enable",havingValue = "true")
public class DemoAutoConfiguration {
@Bean
public DemoClient demoClient(DemoProperties demoProperties){
return new DemoClient(demoProperties.getAppid(), demoProperties.getSecret());
}
}
和上面的配置類進行比較不難發現,此處我們又多用了兩個注解:
@EnableConfigurationProperties
:該注解是我們自定義指定Proerpty實體類時,必須啟用的注解,和實體類中的@ConfigurationProperties
注解配合一起使用@ConditionalOnProperty
:Spring Boot框架中條件注入的一種,代碼根據配置的屬性進行條件判斷注入,此處我們配置了只有當demo.enable=true
時,DemoAutoConfiguration
配置類才會加載,向Spring容器中注入DemoClient
的實體Bean
當自定義starter組件封裝到這一步時,基本已經快完結了,開發者可以通過在Spring Boot的配置文件中進行配置,來開啟是否使用DemoClient
組件
demo:
# 通過配置該屬性的true 或者false ,來開啟組件的使用
enable: true
appid: xxx
secret: xxxx
屬性元數據配置
通過上面的配置,我們已經能夠自定義一個Spring Boot框架的starter組件了,但是對於使用者來說,封裝該starter組件的開發者還尚有最后一步需要完成,那就是給屬性類提供元數據注釋,提供元數據注釋也是為了讓使用者在配置application.yml
屬性時,通過IDEA等編輯器能夠給出提示,這對使用者而已是大有裨益的,因為每一個屬性都會有相應的注釋供開發者進行參考。例如Knife4j組件提供的元數據注釋如下圖:
那么我們在制定starter組件時,如何給屬性類提供元數據注釋呢?目前主要有兩種方式:
引入spring-boot-configuration-processor
自動注釋
我們可以在自定義是starter組件中引入該組件,依賴如下:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<version>2.3.0.RELEASE</version>
<optional>true</optional>
</dependency>
引入該組件后,此時,我們只需要在我們的Java屬性類中給每一個屬性使用標准的javadoc進行注釋即可,如下:
@ConfigurationProperties(prefix = "demo")
public class DemoProperties {
/**
* 是否啟用
*/
private boolean enable=false;
/**
* 第三方appid
*/
private String appid;
/**
* 第三方secret
*/
private String secret;
//getter and setter...
}
最終在使用時,就會出現提示,如下圖:
這種方式如果屬性類不是太多的情況下,開發者可以使用,很方便
手工編寫spring-configuration-meatadata.json
文件
spring-boot-configuration-processor
組件最終在打包生成starter的jar包時,也是幫助我們自動生成了spring-configuration-metadata.json
文件,該文件和上面提到的spring.factories
是同級目錄
手工編寫spring-configuration-metadata.json
也是我推薦的方式,因為不僅僅是每個屬性的注釋,有時候我們還可以用更多的屬性配置以便使用者使用。
結果如下:
{
"groups": [
{
"name": "demo",
"type": "com.demo.spring.DemoProperties",
"sourceType": "com.demo.spring.DemoProperties"
}
],
"properties": [
{
"name": "demo.appid",
"type": "java.lang.String",
"description": "第三方appid",
"sourceType": "com.demo.spring.DemoProperties"
},
{
"name": "demo.enable",
"type": "java.lang.Boolean",
"description": "是否啟用",
"sourceType": "com.demo.spring.DemoProperties",
"defaultValue": false
},
{
"name": "demo.secret",
"type": "java.lang.String",
"description": "第三方secret",
"sourceType": "com.demo.spring.DemoProperties"
}
],
"hints": []
}
我們主要使用到的屬性有3個:groups
、properties
、hints
groups
字面意思分組,按我的理解即當我們使用的實體時,配置的prefix
即代表該group,例如上面我們為DemoProperties
配置了prefix的前綴是demo
,那么分組這里可以設置為demo
,當然如果DemoProperties
類中包含的屬性是一個第三方類,假設如下:
public class DemoProperties{
private OtherProperties other;
}
那么我們可以在groups屬性中配置一個名為demo.other
的分組名稱
其包含的屬性如下:
屬性名稱 | 類型 | 說明 |
---|---|---|
name | String | 分組名稱,可以理解為prefix |
type | String | 組數據類名 |
description | String | 分組簡單的描述,可以省略 |
sourceType | String | 組數據源類名,同type,如果源類型未知,可以忽略該屬性 |
sourceMethod | String | 組方法的名稱,(例如,帶@ConfigurationProperties 注解的@Bean 方法的名稱)。 如果源方法未知,則可以省略。 |
properties
顧名思義,就是我們實體類每個屬性的配置,有多少屬性需要添加元數據注釋說明,就需要在該數組下全部添加,需要注意的是配置name時需要配置全路徑,例如:demo.enable
等
其包含的屬性如下:
屬性名稱 | 類型 | 說明 |
---|---|---|
name | String | 屬性名稱 |
type | String | 屬性類型 |
description | String | 屬性的簡介說明 |
sourceType | String | 該屬性歸屬於那個類型 |
defaultValue | Object | 該屬性默認值 |
deprecation | Deprecation | 用於指定該屬性是否過時 |
過時選項Deprecation
包含以下幾個屬性:
名稱 | 類型 | 說明 |
---|---|---|
level | String | 過時的級別,可以指定warning 或者error ,當指定為warning 時,代表該屬性還可用,而指定error 則代表徹底廢棄 |
reason | String | 原因 |
replacement | String | 替換屬性 |
hints
針對該屬性,我的理解是類似於Java中的枚舉,只不過是給每一個屬性的值配置一個說明,方便使用者在配置的時候能夠按照規定的值進行正確配置
例如上面我們的示例:demo.enable
屬性,該屬性類型為Boolean類型,要配置也只有兩種值(true或者false)
那么我們可以給該值配置一個hints進行說明,示例如下:
"hints": [
{
"name": "demo.enable",
"values":[
{
"value": true,
"description": "啟用DemoClient組件"
},
{
"value": false,
"description": "禁用DemoClient組件"
}
]
}
]
當我們進行這樣的配置后,最終使用者在使用時就會出現如下圖所示的提示:
這對使用該starter組件的開發者來說,每個屬性都有相應的說明,是非常方便的
hints主要包含的屬性如下:
名稱 | 類型 | 說明 |
---|---|---|
name | String | 屬性名稱 |
values | ValueHint[] | 一個ValueHint的數組 |
providers | ValueProvider[] | 一個ValueProvinder數組 |
ValueHint是對其提供的值進行注釋說明,其屬性如下:
名稱 | 類型 | 說明 |
---|---|---|
value | Object | 屬性對應的值 |
description | String | 該值的描述信息 |
ValueProvider包含屬性:
名稱 | 類型 | 說明 |
---|---|---|
name | String | 屬性名稱 |
parameters | JSON Object | 提供程序支持的其他參數類型 |
在上面我提過,hints類似於枚舉,這映射到ValueHint屬性,當我們配置了hints屬性中的values
時而不提供providers
屬性時,如果開發者最終在使用時,只能配置ValueHint中定義的值,否則配置其他值時會在IDEA編輯器中就會爆紅出錯
還是以上面的示例,假設我們給appid配置hint值,如下:
"hints": [
{
"name": "demo.appid",
"values":[
{
"value": "test1",
"description": "測試appid1"
},
{
"value": "test2",
"description": "測試appid2"
}
]
}
]
那么我們在使用組件時,在application.yml
配置文件中配置其他值時,idea會提示錯誤,如下圖:
此時,providers
屬性就可以排上用場了
修改上面的配置如下:
"hints": [
{
"name": "demo.appid",
"values":[
{
"value": "test1",
"description": "測試appid1"
},
{
"value": "test2",
"description": "測試appid2"
}
],
"providers":[
{
"name":"any"
}
]
}
]
我們可以配置providers為any
,這樣說明開發者除了可以配置test1
、test2
外,當配置其他值時,也是允許的
針對providers
中的name屬性,主要有以下類別供選擇:
Name | Description |
---|---|
any |
Permits any additional value to be provided. |
class-reference |
Auto-completes the classes available in the project. Usually constrained by a base class that is specified by the target parameter. |
handle-as |
Handles the property as if it were defined by the type defined by the mandatory target parameter. |
logger-name |
Auto-completes valid logger names and logger groups. Typically, package and class names available in the current project can be auto-completed as well as defined groups. |
spring-bean-reference |
Auto-completes the available bean names in the current project. Usually constrained by a base class that is specified by the target parameter. |
spring-profile-name |
Auto-completes the available Spring profile names in the project. |