从头带你撸一个Springboot Starter
我们知道 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,首先需要实现自动化配置,实现自动化配置需要满足以下两个条件:
- 能够自动配置项目所需要的配置信息,也就是自动加载依赖环境;
- 能够根据项目提供的信息自动生成 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 被加载起来。