• 教你写Spring组件


    前言

    原文地址: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

    复制代码
    • 1
    java
    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 执行时机

    复制代码
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    java
    @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 组件扫描出符合规则的切点,在切点执行之前,去执行发送风控数据到风控平台的动作

    复制代码
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    java
    @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 的作用:
    根据提供的条件判断对应的属性是否存在,存在,则加载此配置类,不存在,则忽略。

    当应用中存在如下配置时:

    复制代码
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    yaml
    grjr: risk: expression: xxxx appid: xxx appsecret: xxx url: xxx

    RiskAspectConfig 配置类才会被加载,才会生成切点顾问 DefaultPointcutAdvisor,因此切点就会生效

    当需要的配置逐渐增多的时候,一条条添加进 @ConditionalOnProperty 显得比较冗长复杂,这时候该如何处理呢?

    五、使用对应的 Properties 配置类来封装配置

    在项目 fastdfs-spring-boot-starter 当中,像上述需要的配置有很多,那么它是怎么处理的呢?

    它把需要的配置放到了一个 Java 类里

    复制代码
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    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 ,上述配置相当于

    复制代码
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    yaml
    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 配置类来管理,大家可以想想为什么

    复制代码
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    java
    @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

    复制代码
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    java
    @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 文件,文件内容如下

    复制代码
    • 1
    properties
    org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.grjr.quartz.config.GjSchedulerAutoConfiguration

    然后在应用启动类当中删除已经无用的 @Component 注解即可

    复制代码
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    java
    @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属性需要设置,来看一下源码

    复制代码
    • 1
    • 2
    • 3
    java
    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 修饰自定义注解时候,通常会导入这个接口的实现类。

    来看一下接口定义

    复制代码
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    java
    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 的消息发送器:

    复制代码
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    java
    @SpringBootApplication @EnableMqProducer(group="xxx") public class App { public static void main(String[] args) { SpringApplication.run(App.class); } }

    这是一个服务项目的启动类,这个服务开启了 RocketMq 的一个发送器,并且分到 xxx 组里。

    来看一下 @EnableMqProducer 注解

    复制代码
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    java
    @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 使用

    复制代码
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    java
    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地址建议外部配置)。

    但是好消息是,我们已经可以通过注入来使用这个未完成的实例了。

    上面遗留的问题,就是第二个类接下来要做的事。

    来看第二个配置类

    复制代码
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    java
    @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的实现类

    首先看一下接口

    复制代码
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    java
    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 封装的技术,快去试一下吧

  • 相关阅读:
    Android开发基础——Activity基本用法
    基于Socket的MFC网络编程
    linux加密安全和时间同步
    Archlinux Gnome上解决N卡驱动安装和IBUS输入法两个小问题记录
    如何发表计算机SCI或者EI论文? - 易智编译EaseEditing
    深入浅出Java多线程(四):线程状态
    Pytest测试框架一键动态切换环境思路及方案
    Excel显示列号
    “MJiangSports“ app Tech Support(URL)
    【算法训练-贪心算法 一】买卖股票的最佳时机II
  • 原文地址:https://www.cnblogs.com/qnlcy/p/15905682.html