教你寫Spring組件
前言
原文地址://www.cnblogs.com/qnlcy/p/15905682.html
一、宗旨
在如日中天的 Spring 架構體系下,不管是什麼樣的組件,不管它採用的接入方式如何眼花繚亂,它們永遠只有一個目的:
接入Spring容器
二、Spring 容器
Spring 容器內可以認為只有一種東西,那就是 bean
,但是圍繞 bean
的生命周期,Spring 添加了許多東西
2.1 bean
的生命周期
2.1.1 實例化 bean 實例
實例化 bean 實例是 spring 針對 bean 作的拓展最多的周期
它包括:
- bean 的掃描
- bean 的解析
- bean 實例化
常見掃描相關內容:
@Component
、@Service
、@Controller
、@Configuration
、applicationContext.xml
spring/springboot 在啟動的時候,會掃描到這些註解或配置文件修飾的類信息
根據拿到的類信息,經過第二步解析後,轉換成 BeanDefintion
存入到 spring 容器當中,BeanDefintion
描述 bean 的 class、scop、beanName 等信息
在 bean 的解析過程中,我們常用到的 Properties 讀取 、 @Configuration
配置類的處理 會在這一步完成
bean 的實例化實際有自動完成和調用 getBean()
時候完成,還有容器初始化完畢之後實例化 bean ,他們都是根據 bean 的定義 BeanDefintion
來反射目標 bean 類,並放到 bean 容器當中
這就是大名鼎鼎的 bean 容器,就是一個 Map
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
2.1.2 設置實例屬性
這一階段是 @Value
、@Autowired
、@Resource
註解起作用的階段
2.1.3 bean 前置處理
BeanPostProcessor
前置處理方法
2.1.4 bean init 處理
@PostConstruct
註解起作用的階段
2.1.5 bean 後置處理
BeanPostProcessor
後置處理方法
2.1.6 正常使用
2.1.7 bean 銷毀
@PreDestroy
註解起作用的階段
bean
的銷毀過程中,主要的作用就是釋放一些需要手動釋放的資源和一些收尾工作,如文件歸併、連接池釋放等
在了解了 Spring bean 的生命周期後,我們接下來介紹自建 Spring 組件的接入方式
三、使用簡單配置類接入方式
使用配置類接入 Spring ,一般需要搭配 PostConstruct
來使用,並且要確保 Spring 能掃描到配置
如,在組件 quartz-configable
1.0 版本當中,就是使用的這種方式
quartz-configable
需要掃描用戶自定義的 job
來註冊到 quartz-configable
自動創建的調度器 Scheduler
當中,並啟動調度器 Scheduler
在註冊 Job
的過程當中,又添加了自定義的 TriggerListener
監聽器,來監聽配置的變動,以動態調整 Job
執行時機
@Configuration
public class QuartzInitConfig {
@Autowired
private Scheduler scheduler;
@Autowired
private CustomTriggerListener customTriggerListener;
@PostConstruct
public void init() {
//先把所有jobDetail放到map里
initJobMap();
//添加自定義Trigger監聽器,進行任務開關的監聽和故障定位的配置
addTriggerListener(scheduler, customTriggerListener);
//添加任務到任務調度器中
addJobToScheduler(scheduler);
//啟動任務調度器
try {
scheduler.start();
} catch (SchedulerException e) {
log.error("任務調度器啟動失敗", e);
throw new RuntimeException("任務調度器啟動失敗");
}
log.info("任務調度器已啟動");
}
private void initJobMap() {
//省略部分代碼
}
private void addJobToScheduler(Scheduler scheduler) {
//省略部分代碼
}
private void addTriggerListener(Scheduler scheduler, CustomTriggerListener customTriggerListener) {
//省略部分代碼
}
}
QuartzInitConfig
類的作用是把掃描到的任務類放入調度器當中,並添加自定義監聽(用於動態修改 cron 表達式)
此類加載有兩個過程:
- 注入組件初始化需要的資源
- 根據注入的資源初始化組件
步驟 1
所需要的功能與 Spring 的注入功能完美契合,而恰好 @Configuration
修飾的類也被當作了一個 Spring bean
,所以才能順利注入組件需要的資源
步驟 2
的初始化任務,極為契合 Spring bean
創建完畢後的初始化動作 @PostConstruct
當中,它同樣是資源注入完畢後的初始化動作。
四、帶有條件的簡單配置類
有時候,我們希望通過開關或者特定的配置來啟用應用內具備的功能,這時候,我們可以使用 @ConditionalOnProperty
來解決問題
risk
組件掃描出符合規則的切點,在切點執行之前,去執行發送風控數據到風控平台的動作
@Configuration
@ConditionalOnProperty({"risk.expression", "risk.appid", "risk.appsecret", "risk.url"})
public class RiskAspectConfig {
//項目內配置
@Value("${risk.expression}")
private String riskExpression;
@Bean
public DefaultPointcutAdvisor defaultPointcutAdvisor() {
SpringBeans springBeans = springBeans();
RiskSenderDelegate riskSenderDelegate = new RiskSenderDelegate(springBeans);
GrjrMethodInterceptor grjrMethodInterceptor = new GrjrMethodInterceptor(riskSenderDelegate);
JdkRegexpMethodPointcut jdkRegexpMethodPointcut = new JdkRegexpMethodPointcut();
jdkRegexpMethodPointcut.setPattern(riskExpression);
log.info("切面準備完畢,切點為{}", riskExpression);
return new DefaultPointcutAdvisor(jdkRegexpMethodPointcut, grjrMethodInterceptor);
}
//省略部分代碼
}
雖然類 RiskAspectConfig
是一個 Spring 配置類,方法 defaultPointcutAdvisor()
創建了一個切點顧問,用來在切點方法處實現風控的功能,但是,並不是應用啟動之後,切點就會生效,這是因為有 @ConditionalOnProperty
的存在
@ConditionalOnProperty
的作用:
根據提供的條件判斷對應的屬性是否存在,存在,則加載此配置類,不存在,則忽略。
當應用中存在如下配置時:
grjr:
risk:
expression: xxxx
appid: xxx
appsecret: xxx
url: xxx
RiskAspectConfig
配置類才會被加載,才會生成切點顧問 DefaultPointcutAdvisor
,因此切點就會生效
當需要的配置逐漸增多的時候,一條條添加進 @ConditionalOnProperty
顯得比較冗長複雜,這時候該如何處理呢?
五、使用對應的 Properties 配置類來封裝配置
在項目 fastdfs-spring-boot-starter
當中,像上述需要的配置有很多,那麼它是怎麼處理的呢?
它把需要的配置放到了一個 Java 類里
@ConfigurationProperties(prefix = "fastdfs.boot")
public class FastDfsProperties {
private String trackerServerHosts;
private int trackerHttpPort = 80;
private int connectTimeout = 5000;
private int networkTimeout = 30000;
private boolean antiStealToken = false;
private String charset = "ISO8859-1";
private String secretKey;
//省略字段 get set 方法
}
其中, @ConfigurationProperties
指定了配置的 prefix
,上述配置相當於
fastdfs:
boot:
trackerServerHosts: xxx
trackerHttpPort: 80
connectTimeout: 5000
networkTimeout: 30000
antiStealToken: false
charset: ISO8859-1
secretKey: xxx
這種類到現在為止還不可以和 Spring 結合起來,尚需要把它聲明為 Spring bean
才生效
聲明為 Spring bean
有兩種形式
- 在類本身上添加
@Component
註解,標識這是一個Spring bean
- 在
@Configuration
類上使用@EnableConfigurationProperties
來啟用配置
通常的,在開發組件的時候,我們使用第二種方式,把 Properties 的啟用,交給 @Configuration
配置類來管理,大家可以想想為什麼
@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(FastDfsClient.class) // 當 Spring 容器中不存在 FastDfsClient 時才加載這個類
@EnableConfigurationProperties(FastDfsProperties.class) //啟用上面的 FastDfsProperties
public class FastDfsAutoConfiguration {
/**
* 創建 FastDfsClient 放到 Spring 容器當中
*/
@Bean
@ConditionalOnProperty("fastdfs.boot.trackerServerHosts")
FastDfsClient fastDFSClient(FastDfsProperties fastDfsProperties) {
globalInit(fastDfsProperties);
return new FastDfsClient();
}
/**
* 根據 properties 來配置 fastdfs
*/
private void globalInit(FastDfsProperties fastDFSProperties) {
// 省略部分代碼
}
//省略部分代碼
}
@EnableConfigurationProperties(FastDfsProperties.class)
啟用了括號內的 Properties 類,並把它們注入到 Spring 容器當中,使其可以被其他 Spring bean
導入
六、使用 META-INF/spring.factories 文件來代替掃描
有時候,我們開發的組件的類路徑和應用的類路徑不同,比如,應用類路徑常常為 com.xxx.xxx
,而組件的類路徑常常為 com.xxx.yyy
,這時候,經常需要為 Spring 指定掃描路徑,才能把我們的組件加載進去,如果在自己項目當中加載上述 quartz-configable
組件,組件類路徑為 com.xxx.yyy
:
@ComponentScan({"com.xxx.xxx", "com.xxx.yyy"})
@SpringBootApplication
public class GrjrFundBatch {
public static void main(String[] args) {
SpringApplication.run(GrjrFundBatch.class);
}
}
如果新增了類似這樣的 quartz-configable
組件,就需要改動 @ComponentScan
代碼,這對啟動類是有侵入性的,也是繁瑣的,也極有可能寫錯,當組件路徑有改動的時候也需要跟着改動
怎樣避免這種硬編碼形式的注入呢?
Springboot 在加載類的時候,會掃描 classpath
下的 META-INF/spring.factories
文件,當發現了 spring.factories
文件後,根據文件中的配置來加載類
其中一項配置為 org.springframework.boot.autoconfigure.EnableAutoConfiguration=xxx.xxx.xxx.xxxx
,它聲明了 Springboot 要加載的自動配置類,Springboot根據配置自動去加載配置類
借用這個規則,現在來升級我們的 quartz-configable
組件
我們在組件項目 resources
目錄下添加 META-INF/spring.factories
文件,文件內容如下
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.grjr.quartz.config.GjSchedulerAutoConfiguration
然後在應用啟動類當中刪除已經無用的 @Component
註解即可
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class);
}
}
此時,quartz-configable
依然能生效
使用 META-INF/spring.factories
雖然帶來了簡潔和便利,但是它總是去自動加載配置類,所以我們在設計組件的時候,一定要搭配 @ConditionOnxxxx
註解,有條件的去加載我們的組件
七、使用自定義 @EnableXxxx 註解的形式開啟組件功能
就像上面說的一樣,使用 META-INF/spring.factories
總會去加載配置類,自定義掃描路徑有可能會寫錯類路徑,那麼,還有沒有其他方式呢?
有,使用自定義註解來注入自己的組件,就像 dubbo
的 starter 組件一樣,我們自己造一個 @EnableXxx
註解
7.1 自定義註解的核心
自定義註解的核心是 Spring 的 @Import
註解,它基於 @Import
註解來注入組件自身需要的資源和初始化組件自身
7.2 @Import 註解解析
@Import
註解是 Spring 用來注入 Spring bean
的一種方式,可以用來修飾別的註解,也可以直接在 Springboot 配置類上使用。
它只有一個value屬性需要設置,來看一下源碼
public @interface Import {
Class<?>[] value();
}
這裡的 value屬性只接受三種類型的Class:
- 被
@Configuration
修飾的配置類 - 接口
org.springframework.context.annotation.ImportBeanDefinitionRegistrar
的實現類 - 接口
org.springframework.context.annotation.ImportSelector
的實現類
下面針對三種類型的 Class 分別做簡單介紹,中間穿插自定義註解與外部配置的結合使用方式。
7.2.1 被@Configuration
修飾的配置類
像 Springboot 中的配置類一樣正常使用,需要注意的是,如果該類的包路徑已在 Springboot 啟動類上配置的掃描路徑下,則不需要再重新使用 @Import
導入了,因為 @Import
的目的是注入 bean,但是 Springboot 啟動類自動掃描已經可以注入你想通過 @Import
導入的 bean 了。
7.2.2 接口 org.springframework.context.annotation.ImportBeanDefinitionRegistrar
的實現類
當 @Import
修飾自定義註解時候,通常會導入這個接口的實現類。
來看一下接口定義
public interface ImportBeanDefinitionRegistrar {
default void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry,
BeanNameGenerator importBeanNameGenerator) {
registerBeanDefinitions(importingClassMetadata, registry);
}
/**
* importingClassMetadata 被@Import修飾的自定義註解的元信息,可以獲得屬性集合
* registry Spring bean註冊中心
**/
default void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
}
通過這種方式,我們可以根據自定義註解配置的屬性值來注入 Spring Bean 信息。
來看如下案例,我們通過一個註解,啟動 RocketMq 的消息發送器:
@SpringBootApplication
@EnableMqProducer(group="xxx")
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class);
}
}
這是一個服務項目的啟動類,這個服務開啟了 RocketMq 的一個發送器,並且分到 xxx 組裡。
來看一下 @EnableMqProducer
註解
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
@Documented
@Import({XXXRegistrar.class,XXXConfig.class})
public @interface EnableMqProducer {
String group() default "DEFAULT_PRODUCER_GROUP";
String instanceName() default "defaultProducer";
boolean retryAnotherBrokerWhenNotStoreOK() default true;
}
這裡使用 @Import
導入了兩個配置類,第一個是接口 org.springframework.context.annotation.ImportBeanDefinitionRegistrar
的實現類,第二個是被 @Configuration
修飾的配置類
我們看第一個類 XXXRegistrar
,這個類的功能是注入一個自定義的 DefaultMQProducer
到Spring 容器中,使業務方可以直接通過 @Autowired
注入 DefaultMQProducer
使用
public class XXXRegistrar implements ImportBeanDefinitionRegistrar {
@Override
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
//獲取註解里配置的屬性
AnnotationAttributes attributes = AnnotationAttributes.fromMap(importingClassMetadata.getAnnotationAttributes(EnableMqProducer.class.getName()));
//根據配置的屬性注入自定義 bean 到 spring 容器當中
registerBeanDefinitions(attributes, registry);
}
private void registerBeanDefinitions(AnnotationAttributes attributes, BeanDefinitionRegistry registry) {
//獲取配置
String group = attributes.getString("group");
//省略部分代碼...
//添加要注入的類的字段值
Map<String, Object> values = new HashMap<>();
//這裡有的同學可能不清楚為什麼key是這個
//這裡的key就是DefaultMQProducer的字段名
values.put("producerGroup", group);
//省略部分代碼
//註冊到Spring中
BeanRegistrationUtil.registerBeanDefinitionIfNotExists(registry, DefaultMQProducer.class.getName(), DefaultMQProducer.class, values);
}
到這裡,我們已經注入了一個 DefaultMQProducer
的實例到 Spring 容器中,但是這個實例,還不完整,比如:
- 還沒有啟動
- nameServer地址還沒有配置
- 外部配置的屬性還沒有覆蓋實例已有的值(nameServer地址建議外部配置)。
但是好消息是,我們已經可以通過注入來使用這個未完成的實例了。
上面遺留的問題,就是第二個類接下來要做的事。
來看第二個配置類
@Configuration
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
@EnableConfigurationProperties(XxxProperties.class) //Spring提供的配置自動映射功能,配置後可直接注入
public class XXXConfig {
@Resource //直接注入
private XxxProperties XxxProperties;
@Autowired //注入上一步生成的實例
private DefaultMQProducer producer;
@PostConstruct
public void init() {
//省略部分代碼
//獲取外部配置的值
String nameServer = XxxProperties.getNameServer();
//修改實例
producer.setNamesrvAddr(nameServer);
//啟動實例
try {
this.producer.start();
} catch (MQClientException e) {
throw new RocketMqException("mq消息發送實例啟動失敗", e);
}
}
@PreDestroy
public void destroy() {
producer.shutdown();
}
到這裡,通過自定義註解和外部配置的結合,一個完整的消息發送器就可以使用了,但方式有取巧之嫌,因為在消息發送器啟動之前,不知道還有沒有別的類使用了這個實例,這是不安全的。
7.2.3 接口org.springframework.context.annotation.ImportSelector
的實現類
首先看一下接口
public interface ImportSelector {
/**
* importingClassMetadata 註解元信息,可獲取自定義註解的屬性集合
* 根據自定義註解的屬性,或者沒有屬性,返回要注入Spring的Class全限定類名集合
如:XXX.class.getName(),Spring會自動注入XXX的一個實例
*/
String[] selectImports(AnnotationMetadata importingClassMetadata);
@Nullable
default Predicate<String> getExclusionFilter() {
return null;
}
}
這個接口的實現類如果沒有進行 Spring Aware
接口拓展,功能比較單一,因為我們無法參與 Spring Bean 的構建過程,只是告訴 Spring 要注入的 Bean 的名字。不再詳述。
八、總結
綜上所述,我們一共聊了三種形式的組件創建方式
- 相同路徑下,
@Configuration
修飾的配置類 - 使用
META-INF/spring.factories
文件接入 - 結合
@Import
註解注入
其中穿插了 @ConditionOnXxxx
選擇性啟動、Properties
封裝的技術,快去試一下吧