教你寫Spring元件

去哪裡吃魚發表於2022-02-17

前言

原文地址:https://www.cnblogs.com/qnlcy/p/15905682.html

一、宗旨

在如日中天的 Spring 架構體系下,不管是什麼樣的元件,不管它採用的接入方式如何眼花繚亂,它們永遠只有一個目的:

接入Spring容器

二、Spring 容器

Spring 容器內可以認為只有一種東西,那就是 bean,但是圍繞 bean 的生命週期,Spring 新增了許多東西

2.1 bean 的生命週期

2.1.1 例項化 bean 例項

例項化 bean 例項是 spring 針對 bean 作的擴充最多的週期

它包括:

  1. bean 的掃描
  2. bean 的解析
  3. bean 例項化

常見掃描相關內容:

@Component@Service@Controller@ConfigurationapplicationContext.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. 注入元件初始化需要的資源
  2. 根據注入的資源初始化元件

步驟 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 封裝的技術,快去試一下吧

相關文章