從頭帶你擼一個Springboot Starter

rickiyang發表於2021-11-29

我們知道 SpringBoot 提供了很多的 Starter 用於引用各種封裝好的功能:

名稱 功能
spring-boot-starter-web 支援 Web 開發,包括 Tomcat 和 spring-webmvc
spring-boot-starter-redis 支援 Redis 鍵值儲存資料庫,包括 spring-redis
spring-boot-starter-test 支援常規的測試依賴,包括 JUnit、Hamcrest、Mockito 以及 spring-test 模組
spring-boot-starter-aop 支援面向切面的程式設計即 AOP,包括 spring-aop 和 AspectJ
spring-boot-starter-data-elasticsearch 支援 ElasticSearch 搜尋和分析引擎,包括 spring-data-elasticsearch
spring-boot-starter-jdbc 支援JDBC資料庫
spring-boot-starter-data-jpa 支援 JPA ,包括 spring-data-jpa、spring-orm、Hibernate

SpringBoot 通過 Starter 機制將各個獨立的功能從 jar 包的形式抽象為統一框架中的一個子集,從而使得 SpringBoot 的完整度從框架層面達到了統一。其實現的機制也不復雜,SpringBoot 在啟動時會從依賴的 starter 包中尋找 /META-INF/spring.factories 檔案,然後根據檔案中配置的啟動類完成 Starter 的初始化,同 Java 的 SPI 機制類似。

考慮到 SpringBoot Starter 機制的意義本身就是對獨立功能的封裝,這些功能要求改動少,可以作為多個專案的公共部分對外提供服務。那麼對於我們日常專案中底層不變經常變的公共服務是否可以起到借鑑意義。或者對於公司內部專案的架構師來說也是首選。

如果想自定義 Starter,首先需要實現自動化配置,實現自動化配置需要滿足以下兩個條件:

  1. 能夠自動配置專案所需要的配置資訊,也就是自動載入依賴環境;

  2. 能夠根據專案提供的資訊自動生成 Bean,並且註冊到 Bean 管理容器中;

條件 1 的實現需要引入如下兩個 jar 包:

<dependencies>
 <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-autoconfigure</artifactId>
    <version>2.0.0.RELEASE</version>
 </dependency>
 <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <version>2.0.0.RELEASE</version>
    <optional>true</optional>
  </dependency>
</dependencies>

通過 autoconfigure 根據專案 jar 包的依賴關係自動配置應用程式。spring.factories 檔案指定了AutoConfiguration 類列表,只有在列表中的自動配置才會被檢索到。Spring 會檢測 classpath 下所有的META-INF/spring.factories 檔案;若要引入自定義的自動配置,需要將自定義的 AutoConfiguration 類新增到 spring.factories 檔案中。

條件 2 則是在條件 1 的基礎上載入你自定義的 bean。

命名規範

對於 SpringBoot 官方的 jar 包都是有一套命名規則:

規則:spring-boot-starter-模組名。比如:spring-boot-starter-web、spring-boot-starter-jdbc

對於我們自己自定義的 Starter,為了區別於普通的 jar 包我們也應該有明顯的 starter 標識,比如:

模組-spring-boot-starter
通過這種方式讓呼叫方更直觀的知道這是一個 Starter,從而很快就知道使用方式。

一個可以執行的示例

以下程式碼可以從 Github 倉庫找到:redis-sentinel-spring-boot-starter

我們通過自己實現一個可以執行的示例來演示實際開發中如何通過 Starter 快速搭建基礎服務。下面的示例主要功能實現是重寫 Springboot 的 Redis Sentinel,底層將 Lettuce 替換為 Jedis。

我們的整體專案框架如下:

如同別的 Starter 一樣,我們要實現引用方通過自定義配置來使用 Redis,那我們要提供配置解析類:

package com.rickiyang.redis.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;


/**
 * @date: 2021/11/16 11:39 上午
 * @author: rickiyang
 * @Description:
 */
@Data
@ConfigurationProperties(prefix = RedisSentinelClientProperties.SENTINEL_PREFIX)
public class RedisSentinelClientProperties {
    public final static String SENTINEL_PREFIX = "rickiyang.redis.sentinel";
    private String masterName;
    private String sentinels;
    private long maxWait;
    private int maxIdle;
    private int maxActive;
    private boolean blockWhenExhausted;
    private long maxWaitMillis;
    private int maxTotal;
    private int minIdle;
    private long minEvictableIdleTimeMillis;
    private boolean testOnBorrow;
    private boolean testOnReturn;
    private boolean testWhileIdle;
    private int numTestsPerEvictionRun;
    private long softMinEvictableIdleTimeMillis;
    private long timeBetweenEvictionRunsMillis;
    private byte whenExhaustedAction;
}

如何將 yml 中的配置解析出來呢?這就需要我們去定義一個 yml 解析檔案。resources下新增 META-INF 資料夾,新增配置解析類:spring-configuration-metadata.json

{
  "hints": [],
  "groups": [
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "name": "rickiyang.redis.sentinel",
      "type": "com.starter.demo.config.RedisSentinelClientProperties"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "defaultValue": false,
      "name": "rickiyang.redis.sentinel.block-when-exhausted",
      "type": "java.lang.Boolean"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "name": "rickiyang.redis.sentinel.masterName",
      "type": "java.lang.String"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "defaultValue": 0,
      "name": "rickiyang.redis.sentinel.max-active",
      "type": "java.lang.Integer"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "defaultValue": 0,
      "name": "rickiyang.redis.sentinel.max-idle",
      "type": "java.lang.Integer"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "defaultValue": 0,
      "name": "rickiyang.redis.sentinel.max-total",
      "type": "java.lang.Integer"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "defaultValue": 0,
      "name": "rickiyang.redis.sentinel.max-wait",
      "type": "java.time.Duration"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "defaultValue": 0,
      "name": "rickiyang.redis.sentinel.min-evictable-idle-time-millis",
      "type": "java.lang.Long"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "defaultValue": 0,
      "name": "rickiyang.redis.sentinel.min-idle",
      "type": "java.lang.Integer"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "defaultValue": 0,
      "name": "rickiyang.redis.sentinel.num-tests-per-eviction-run",
      "type": "java.lang.Integer"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "name": "rickiyang.redis.sentinel.sentinels",
      "type": "java.lang.String"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "defaultValue": 0,
      "name": "rickiyang.redis.sentinel.soft-min-evictable-idle-time-millis",
      "type": "java.lang.Long"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "defaultValue": false,
      "name": "rickiyang.redis.sentinel.test-on-borrow",
      "type": "java.lang.Boolean"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "defaultValue": false,
      "name": "rickiyang.redis.sentinel.test-on-return",
      "type": "java.lang.Boolean"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "defaultValue": false,
      "name": "rickiyang.redis.sentinel.test-while-idle",
      "type": "java.lang.Boolean"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "defaultValue": 0,
      "name": "rickiyang.redis.sentinel.time-between-eviction-runs-millis",
      "type": "java.lang.Long"
    },
    {
      "sourceType": "com.starter.demo.config.RedisSentinelClientProperties",
      "defaultValue": 0,
      "name": "rickiyang.redis.sentinel.when-exhausted-action",
      "type": "java.lang.Byte"
    }
  ]
}

這一套配置解析規則就是通過我們上面引入的兩個 Spring 配置解析相關的 jar 包來實現的。

SpringBoot 遵循約定大於配置的思想,通過約定好的配置來實現程式碼簡化。@ConfigurationProperties 可以把指定路徑下的屬性注入到物件中。

SpringAutoConfigration 自動配置

SpringBoot 沒出現之前所有的配置都是通過 xml 的方式進行解析。一個專案裡面的依賴一旦多了起來開發者光是理清裡面的依賴關係都很頭疼。SpringBoot 的 AutoConfig 基本思想就是通過專案的 jar 包依賴關係來自動配置程式。

@EnableAutoConfiguration@SpringBootApplication 都有開啟 AutoConfig 能力。

@SpringBootApplication的作用等同於一起使用這三個註解:@Configuration、@EnableAutoConfiguration、和@ComponentScan

spring.factories 檔案指定了AutoConfiguration類列表,只有在列表中的自動配置才會被檢索到。Spring 會檢測 classpath 下所有的 META-INF/spring.factories 檔案;若要引入自定義的自動配置,需要將自定義的AutoConfiguration 類新增到 spring.factories 檔案中。

spring.factories 的解析由 SpringFactoriesLoader 負責。SpringFactoriesLoader.loadFactoryNames() 掃描所有 jar 包類路徑下 META-INF/spring.factories檔案, 把掃描到的這些檔案的內容包裝成 properties 物件從 properties 中獲取到 EnableAutoConfiguration.class 類(類名)對應的值,然後把他們新增在容器中 。

同樣我們的專案中也配置了自動載入配置的啟動類,spring.factories:

# Initializers
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.starter.demo.config.RedisSentinelClientAutoConfiguration

AutoConfigration 啟動的時候會去檢測配置類是否從 application.yml 獲取到對應的配置值,如果沒有則使用預設配置或者拋異常。

上例中的 Redis autoConfigration 對應的配置類:

package com.rickiyang.redis.config;

import com.google.common.collect.Sets;
import com.rickiyang.redis.annotation.EnableRedisSentinel;
import com.rickiyang.redis.redis.RedisClient;
import com.rickiyang.redis.redis.sentinel.RedisSentinelFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.annotation.Resource;
import java.lang.reflect.Field;
import java.lang.reflect.Method;

import static com.rickiyang.redis.config.RedisSentinelClientProperties.SENTINEL_PREFIX;


/**
 * @date: 2021/11/16 9:52 上午
 * @author: rickiyang
 * @Description:
 */
@Slf4j
@Configuration
@ConditionalOnClass(EnableRedisSentinel.class)
@ConditionalOnProperty(prefix = SENTINEL_PREFIX, name = "masterName")
@EnableConfigurationProperties(RedisSentinelClientProperties.class)
public class RedisSentinelClientAutoConfiguration {

    @Resource
    RedisSentinelClientProperties redisSentinelClientProperties;

    @Bean(initMethod = "init", destroyMethod = "destroy")
    public RedisSentinelFactory redisSentinelClientFactory() throws Exception {
        RedisSentinelFactory redisSentinelClientFactory = new RedisSentinelFactory();

        String[] sentinels = redisSentinelClientProperties.getSentinels().split(",");
        redisSentinelClientFactory.setMasterName(redisSentinelClientProperties.getMasterName());
        redisSentinelClientFactory.setServers(Sets.newHashSet(sentinels));
        reflectProperties(redisSentinelClientFactory);
        log.info("[init redis sentinel factory, redisSentinelClientProperties={}]", redisSentinelClientProperties);
        return redisSentinelClientFactory;
    }

    @Bean
    public RedisClient redisClient(RedisSentinelFactory redisSentinelFactory) throws Exception {
        return new RedisClient(redisSentinelFactory);
    }

    private String createGetMethodName(Field propertiesField, String fieldName) {
        String convertFieldName = fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);
        return propertiesField.getType() == boolean.class ? "is" + convertFieldName : "get" + convertFieldName;
    }

    private String createSetMethodName(String fieldName) {
        String convertFieldName = fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);
        return "set" + convertFieldName;
    }

    private boolean isPropertyBlank(Object value) {
        return value == null || "0".equals(value.toString()) || "false".equals(value.toString());
    }

    private void reflectProperties(RedisSentinelFactory redisSentinelClientFactory) throws Exception {
        Field[] propertiesFields = RedisSentinelClientProperties.class.getDeclaredFields();
        for (Field propertiesField : propertiesFields) {
            String fieldName = propertiesField.getName();
            if ("masterName".equals(fieldName) || "sentinels".equals(fieldName) || "SENTINEL_PREFIX".equals(fieldName)) {
                continue;
            }
            Method getMethod = RedisSentinelClientProperties.class.getMethod(createGetMethodName(propertiesField, fieldName));
            Object value = getMethod.invoke(redisSentinelClientProperties);
            if (!isPropertyBlank(value)) {
                Method setMethod = RedisSentinelFactory.class.getMethod(createSetMethodName(fieldName), propertiesField.getType());
                setMethod.invoke(redisSentinelClientFactory, value);
            }
        }
    }
}

可以看到類頭加了一些註解,這些註解的作用是限制這個類被載入的條件和時機。

常用的類載入限定條件有:

  • @ConditionalOnBean:當容器裡有指定的 bean 時生效。
  • @ConditionalOnMissingBean:當容器裡不存在指定 bean 時生效。
  • @ConditionalOnClass:當類路徑下有指定類時生效。
  • @ConditionalOnMissingClass:當類路徑下不存在指定類時生效。
  • @ConditionalOnProperty:指定的屬性是否有指定的值,比如@ConditionalOnProperty(prefix=”aaa.bb”, value=”enable”, matchIfMissing=true),表示當 aaa.bb 為 enable 時條件的布林值為 true,如果沒有設定的情況下也為 true 的時候這個類才會被載入。

除了 Condition 開頭的限定類註解之外,還有 Import 開頭的註解,主要作用是引入類並將其宣告為一個 bean。主要目的是將多個分散的 bean 配置融合為一個更大的配置類。

  • @Import:在註解使用類載入之前先載入被引入的類。
  • @ImportResource:在註解使用類載入之前引入配置檔案。

上面的 Config 類頭有一個註解:

@ConditionalOnClass(EnableRedisSentinel.class)

即載入的限定條件是 EnableRedisSentinel 類要先載入。EnableRedisSentinel 是一個註解:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface EnableRedisSentinel {
}

這個註解的使用同別的 Starter 一樣都是放在專案的啟動類上即可。

基礎的程式碼部分大概如上,關於 Redis 連線相關的程式碼大家可以看原始碼部分自己參考。將程式碼現在下來之後本地通過 maven 打成 jar 包,然後新開一個 SpringBoot 專案引入 maven jar 包。在啟動類加上註解 @EnableRedisSentinel ,application.yml 檔案中配置:

rickiyang:
  redis:
    sentinel:
      masterName: redis-sentinel-test
      sentinels: 127.0.0.1:20012::,127.0.0.2:20012::,127.0.0.3:20012
      maxTotal: 1000
      maxIdle: 50
      minIdle: 16
      maxWaitMillis: 15000

啟動專案就能看到我們的 Starter 被載入起來。

相關文章