­

教你寫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 作的拓展最多的周期

它包括:

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