如何手写一个SpringBoot starter组件

2,820 阅读23分钟

前言

有接触过starter组件吗?

相信大家在接触Spring Boot的项目时,都遇见过像 spring-boot-starter-webspring-boot-starter-amqpmybatis-spring-boot-starter 等诸如此类的starter组件了吧。用过Spring Boot的会发现它最大的特点就是自动装配,凭借这一特点可以简化依赖,快速搭建项目。那么除了使用之外有没有想了解过如何自定义一个这样的starter组件呢?

为什么会想自定义starter组件呢?

在实际开发中经常会发现现在开发的功能好像之前项目也开发过呢,那么这些功能能不能封装在一起,让每个项目都可以使用呢?

基于这样的需求,为了不重复写同样的功能,可以将需要封装的功能做成一个starter组件,这样在每次需要使用时只需要使用Maven依赖进来即可。

文章说明

下面文章将会介绍的自定义starter组件是一个很简单很简单的starter组件实现。

这篇文章的目的在于让一些像我一样还没有接触过自定义starter组件的读者们,体验一下自定义starter组件的编写过程。对于后续内容如果不感兴趣可以直接跳过文章,最后希望这篇文章能对读者起到一点帮助😀。


目标

当前自定义stater组件的应用场景

image.png

如上图所示,这个自定义starter组件的目标是通过注解方式对指定的接口参数进行指定方式的校验

文章后续介绍的内容

  1. 如何自定义注解以及元注解的相关知识
  2. 如何结合注解与Spring AOP的切面方式完成校验
  3. 如何让starter组件自动装配,如何让starter组件可配置

实现

放一张项目总体结构图

image.png

项目中的完整代码后续文章都会有,仔细看项目代码-XXX加粗字体下方就是完整代码。


1. 创建工程

命名规范:对于SpringBoot自带的starter通常命名为spring-boot-starter-xxx,对于第三方提供(自定义)的starter通常命名为xxx-spring-boot-starter。所以工程名取为check-spring-boot-starter

项目代码-项目依赖pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>cn.lucas.check</groupId>
    <artifactId>check-spring-boot-starter</artifactId>
    <version>1.0</version>
    <name>check-spring-boot-starter</name>
    <description>接口参数校验组件</description>

    <packaging>jar</packaging>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.5.RELEASE</version>
        <relativePath/>
    </parent>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <!-- 生成配置元数据 支持 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
        </dependency>
        <!-- 自动配置 支持 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-autoconfigure</artifactId>
        </dependency>
        <!-- Spring AOP 支持 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
    </dependencies>

</project>

2. 自定义校验注解

自定义校验注解相关代码设计

项目代码-校验注解代码

/**
 * 接口参数校验注解
 * @author 单程车票
 */
@Target(ElementType.METHOD) // 目标作用在方法上
@Retention(RetentionPolicy.RUNTIME) // 运行时保留
public @interface DoCheck {

    // 校验方式 (枚举)
    Check value() default Check.Email;

    // 校验参数
    String arg() default "";

    // 错误信息
    String msg() default "";
}

其中的@Target@Retention是元注解,通过元注解限定自定义注解的修饰目标以及保留时间。(有关元注解的使用后续会讲解)

这里自定义的校验注解有三个属性,分别是枚举类型的校验方式String类型的需要校验的参数名String类型的校验失败后返回的错误信息

项目代码-校验方式枚举类代码

/**
 * 校验枚举类
 * @author 单程车票
 */
public enum Check {
    
    // 邮箱校验方式
    Email("参数应为Email地址", CheckUtil::isEmail);

    public String msg;

    // 函数式接口 Object为传入参数类型,Boolean为返回类型
    public Function<Object, Boolean> function;

    Check(String msg, Function<Object, Boolean> function) {
        this.msg = msg;
        this.function = function;
    }
}

这里为了简化代码,只实现了测试接口需要使用的校验方式(邮箱校验),有兴趣的可以拓展添加其他需要校验的枚举方式。

当前枚举类有两个属性,分别为 校验错误时返回的固定错误信息(也就是说如果注解中没有指定错误信息msg时,会使用枚举方式中自带的固定错误信息) 和 函数式接口(指定该接口入参为Object类型,返回参数为Boolean类型)

这样设计枚举类的原因是,通过实现函数式接口的方式定义校验逻辑(把Object类型的校验参数作为入参,校验完成后返回Boolean类型的返回结果)。这里实现方式封装在了校验工具类中。

项目代码-校验工具类代码

/**
 * 校验工具类
 * @author 单程车票
 */
public class CheckUtil {

    /**
     * 使用正则表达式判断是否是邮箱格式
     */
    public static Boolean isEmail(Object value) {
        if(value == null) {
            return Boolean.FALSE;
        }
        if(value instanceof String) {
            String regEx = "^([a-z0-9A-Z]+[-|\.]?)+[a-z0-9A-Z]@([a-z0-9A-Z]+(-[a-z0-9A-Z]+)?\.)+[a-zA-Z]{2,}$";
            Pattern p = Pattern.compile(regEx);
            Matcher m = p.matcher((String) value);
            if (m.matches()) {
                return Boolean.TRUE;
            }
        }
        return Boolean.FALSE;
    }
}

看完这个工具类,可以发现上述代码正是通过isEmail()方法实现的函数式接口,isEmail()方法内容就是邮箱校验逻辑。

总结

当需要使用校验逻辑时,会先通过注解信息(关于怎么获取注解信息后续会讲解)获取到校验方式枚举类,然后通过校验方式枚举类获取到函数式接口,进而使用函数式接口的apply()方法完成校验逻辑。

// 注解信息(annotation)获取校验方式枚举类(value())获取函数式接口(function)调用apply()
// 实际上就相当于调用了CheckUtil的isEmial()方法。
Boolean res = annotation.value().function.apply(argValue);

通过这样的设计方式实现动态地获取注解中枚举方式从而获取需要的校验逻辑


补充知识:元注解

元注解可以看作是修饰注解的注解类,列举如下:

元注解说明
@Target描述注解的使用范围:分为ElementType.TYPE(类)、ElementType.FIELD(变量)、ElementType.METHOD(方法)、ElementType.PARAMETER(参数)、ElementType.CONSTRUCTOR(构造方法)、ElementType.LOCAL_VARIABLE(局部变量)、ElementType.ANNOTATION_TYPE(注解类)、ElementType.PACKAGE(包)、ElementType.TYPE_PARAMETER(类型参数)、ElementType.TYPE_USE(使用类型的任何地方)。
@Retention描述注解保留的时间范围,分为:RetentionPolicy.SOURCE(源文件保留)、RetentionPolicy.CLASS(编译期保留)、RetentionPolicy.RUNTIME(运行期保留)。后者范围大于前者,一般使用运行期保留。
@Documented描述在使用javadoc工具为类生成帮组文档时是否需要保留注解信息。
@Inherited作用是被它修饰的Annotation将具有继承性。如果某个类使用了被@Inherited修饰的Annotation,则其子类将自动具有该注解。
@Repeatable作用是允许在同一申明类型(类,属性,或方法)的多次使用同一个注解。
@Native被其修饰的变量可以被本地代码引用。

补充说明一下元注解@Repeatable

Java8以后的新元注解(重复注解),如果想要在一个方法进行两次相同的注解会报错,可以通过该注解实现。接下来展示使用和不使用该注解的对比。

在Java8之前解决在一个方法上使用两次注解如下:

public @interface DoCheck {
    Check value() default Check.Email;
    String arg() default "";
}

public @interface DoChecks {
    DoCheck[] value();
}

// 使用@DoChecks包含@DoCheck的方式进行两次注解
@DoChecks({
    @DoCheck(value = Check.Email, argN = "email"),
    @DoCheck(value = Check.NotEmpty, arg = "email"),
})
@GetMapping("/test")
public R<String> sendEmail(@RequestParam("email") String email) {
    return R.success("发送成功");
}

在Java8后解决在一个方法上使用两次注解如下:

@Repeatable(DoChecks.class)
public @interface DoCheck {
    Check value() default Check.Email;
    String arg() default "";
}

public @interface DoChecks {
    DoCheck[] value();
}

// 只需要通过@Repeatable修饰@DoCheck即可直接在方法上进行多次注解
@DoCheck(value = Check.Email, argN = "email")
@DoCheck(value = Check.NotEmpty, arg = "email")
@GetMapping("/test")
public R<String> sendEmail(@RequestParam("email") String email) {
    return R.success("发送成功");
}

通过@Repeatable可以提高代码可读性,仅此而已,仍然需要声明@DoChecks存储@DoCheck注解。

考虑到项目的主要目的还是在于手写一个starter组件的过程,所以当前自定义的校验注解并未实现重复注解功能,有兴趣的可以自行拓展开发。


3. 结合Spring AOP切面方式实现注解校验

项目代码-切面代码

/**
 * aop切面方法(执行校验工作)
 * @author 单程车票
 */
@Aspect
public class DoCheckPoint {

    // 记录日志
    private final Logger log = LoggerFactory.getLogger(DoCheckPoint.class);

    /**
     * 自定义切入点
     * 切入点说明:这样指定的切入点是任何一个执行的方法有一个 @DoCheck 注解的连接点(这里连接点可以看是方法)
     */
    @Pointcut("@annotation(cn.lucas.check.annotation.DoCheck)")
    private void doCheckPoint() {}

    /**
     * 定义环绕通知
     */
    @Around("doCheckPoint()")
    public Object doCheck(ProceedingJoinPoint jp) throws Throwable {
        // 获取被修饰的方法信息
        Method method = getMethod(jp);
        // 获取方法的所有参数值
        Object[] args = jp.getArgs();
        // 获取方法的所有参数名
        String[] paramNames = getParamName(jp);

        // 获取注解信息
        DoCheck annotation = method.getAnnotation(DoCheck.class);
        // 获取需要校验的参数名
        String argName = annotation.arg();
        // 获取需要的校验方式枚举类
        Check value = annotation.value();
        // 获取需要返回的报错信息
        String msg = annotation.msg();

        // 判断是否未配置msg,未配置则直接使用枚举类的固定提示
        if ("".equals(msg)) msg = value.msg;

        // 获取需要校验的参数值
        Object argValue = getArgValue(argName, args, paramNames);

        // 记录日志
        log.info("校验方法:{} 校验值:{}", method.getName(), argValue);

        // 如果找不到需要校验的参数直接放行
        if (argValue == null) return jp.proceed();

        // 通过函数式接口传入需要校验的值, 内部会调用工具类的isEmail方法进行校验
        Boolean res = value.function.apply(argValue);

        if (res) {
            return jp.proceed(); // 校验成功则放行
        }else {
            // 校验失败抛出异常(带上错误信息msg)并交给调用方捕获(调用方:使用该注解的项目可以定义全局异常捕获,遇到IllegalArgumentException异常则返回对应报错信息)
            throw new IllegalArgumentException(msg);
        }
    }

    /**
     * 获取方法信息
     */
    public Method getMethod(ProceedingJoinPoint jp) throws NoSuchMethodException {
        MethodSignature methodSignature = (MethodSignature) jp.getSignature();
        return jp.getTarget().getClass() // 获取切入点的目标(被修饰方法)的Class对象
                .getMethod(methodSignature.getName(), methodSignature.getParameterTypes()); // 通过方法名和方法参数类型使用反射获取到方法对象
    }

    /**
     * 获取方法的所有参数名字
     */
    private String[] getParamName(ProceedingJoinPoint jp) {
        MethodSignature methodSignature = (MethodSignature) jp.getSignature();
        return methodSignature.getParameterNames();
    }

    /**
     * 获取需要检验的参数值
     * @param target 需要校验的参数名
     * @param args 被修饰的方法中所有的参数
     * @param paramNames 被修饰的方法中所有的参数名
     */
    private Object getArgValue(String target, Object[] args, String[] paramNames){
        // 标记当前遍历的索引(因为args和paramNames是一一对应的)
        int idx = 0;
        // 遍历参数名
        for (String name : paramNames) {
            // 匹配对应的参数名则直接返回对应的参数值
            if (name.equals(target)) {
                return args[idx];
            }
            idx++;
        }
        return null;
    }
}

一眼看到上面这么臭的代码估计读者们也会感到厌烦导致一时半会无法理解校验内容,接下来会通过一点点的深入慢慢理解。


需要了解的知识一:AOP几个重要的编程术语

术语介绍
切面(Aspect)切面指交叉业务逻辑。常用的切面是通知(Advice)。实际就是对主业务逻辑的一种增强。
连接点(JoinPoint)连接点指可以被切面织入的具体方法。通常业务接口中的方法均为连接点。
切入点(Ponitcut)切入点指声明的一个或多个连接点的集合。切入点指定切入的位置。通过切入点指定一组方法。被标记为final的方法是不能作为连接点与切入点的。因为最终的是不能被修改的,不能被增强的。
目标对象(Target)目标对象指将要被增强的对象 。 即包含主业务逻辑的类的对象。
通知(Advice)通知表示切面的执行时间,Advice也叫增强。通知定义了增强代码切入到目标代码的时间点,是目标方法执行之前执行,还是之后执行等。通知类型不同,切入时间不同。切入点定义切入的位置,通知定义切入的时间

需要了解的知识二:AOP的注解

注解说明
@Aspect用来定义一个切面(Aspect)
@Pointcut用于定义切入点(Ponitcut)表达式。在使用时还需要定义一个包含名字和任意参数的方法签名来表示切入点名称,这个方法签名就是一个返回值为void,且方法体为空的普通方法。
@Before用于定义前置通知,在使用时,通常需要指定一个value属性的切入点表达式。前置通知用于在某连接点执行之前执行的通知
@AfterReturning用于定义后置通知,在使用时可以指定value和returning属性,其中value用于指定切入点表达式。后置通知用于在某连接点正常执行完后执行的通知
@Around用于定义环绕通知,在使用时需要指定一个value属性,该属性用于指定切入点表达式。环绕通知用于包围一个连接点的通知,可以在方法调用前后完成自定义的行为。它也会选择是否继续执行连接点或直接返回它自己的返回值或抛出异常来结束执行。
@AfterThrowing用于定义异常通知来处理程序中未处理的异常。在使用时可指定value和throwing属性。其中value用于指定切入点表达式,而throwing属性值用于指定一个形参名来表示Advice方法中可定义与此同名的形参,该形参可用于访问目标方法抛出的异常。异常通知是在方法抛出异常退出时执行的通知。
@After用于定义最终通知,不管是否异常,在某连接点退出时执行的通知。使用时需要指定一个value属性用于指定切入点表达式。

通过上面两个AOP的知识结合代码,就可以理解代码中的 @Aspect修饰该类表明该类是一个切面@Pointcut注解用于自定义切入点表达式,方便后续使用,@Around("doCheckPoint()")用于定义环绕通知,也就是切面的主要执行逻辑。


自定义切入点代码理解

/**
 * 自定义切入点表达式
 * 切入点说明:这样指定的切入点是任何一个执行的方法有一个 @DoCheck 注解的连接点(这里连接点可以看是方法)
 */
@Pointcut("@annotation(cn.lucas.check.annotation.DoCheck)")
private void doCheckPoint() {}

这里使用@Pointcut自定义切入点表达式,在后续需要使用切入点表达式时只需要使用doCheckPoint()代替即可。@Pointcut中放入的是切入点表达式,这样自定义切入点就像是做一个全局切入点表达式,可以使得后续不用重复写切入点表达式。

这里自定义的切入点表达式指定的切入位置是每一个被@DoCheck注解修饰的方法都是切入位置,都会执行切面业务。

有关切入点表达式这里只能简单介绍,需要详细了解的可以查阅相关资料

// execution(访问权限 方法返回值 方法声明(参数) 异常类型)
execution(modifiers-pattern ret-type-pattern declaring-type-pattern name-pattern(param-pattern) throws-pattern)
/*
对应的参数说明:
    modifiers-pattern 访问权限类型(可选(即可以不写))
    ret-type-pattern 返回值类型
    declaring-type-pattern 包名类名(可选)
    name-pattern(param-pattern) 方法名(参数类型和参数个数)
    throws-pattern 抛出异常类型(可选)
*/

// 下面放一些表达式例子

// 指定切入点为:任意公共方法。
execution(public * *(..)) 
// 指定切入点为:任何一个以“set”开始的方法。
execution(* set*(..)) 
// 指定切入点为:定义在 service 包里的任意类的任意方法。
execution(* cn.lucas.service.*.*(..)) 
 // 指定所有包下的 serivce 子包下所有类(接口)中所有方法为切入点
execution(* *..service.*.*(..))
// 目标对象中有一个 @DoCheck 注解的任意连接点
@target(cn.lucas.check.annotation.DoCheck)
// 任何一个执行的方法有一个 @DoCheck 注解的连接点
@annotation(cn.lucas.check.annotation.DoCheck)
// 任何一个只接受一个参数,并且运行时所传入的参数类型具有@DoCheck 注解的连接点
@args(cn.lucas.check.annotation.DoCheck)

环绕通知代码理解

/**
 * 定义环绕通知
 */
@Around("doCheckPoint()")
public Object doCheck(ProceedingJoinPoint jp) throws Throwable {

首先可以看到环绕通知@Around()的value值是"doCheckPoint()",即使用自定义的切入点表示式声明切面代码的切入位置,使用环绕通知声明切面代码的切入时机

方法中携带一个参数ProceedingJoinPoint,可以理解成连接点(JoinPoint)的定义,在这里通过它可以拿到注解修饰的方法Method信息,也可以拿到方法的参数名信息,参数值信息等。

获取注解修饰的方法信息,返回的是Method对象。

/**
 * 获取方法信息
 */
public Method getMethod(ProceedingJoinPoint jp) throws NoSuchMethodException {
    MethodSignature methodSignature = (MethodSignature) jp.getSignature();
    return jp.getTarget().getClass() // 获取切入点的目标(被修饰方法)的Class对象
            .getMethod(methodSignature.getName(), methodSignature.getParameterTypes()); // 通过方法名和方法参数类型使用反射获取到方法对象
}

获取注解修饰的方法的所有参数名,返回的是String数组。

/**
 * 获取方法的所有参数名字
 */
private String[] getParamName(ProceedingJoinPoint jp) {
    MethodSignature methodSignature = (MethodSignature) jp.getSignature();
    return methodSignature.getParameterNames();
}

梳理校验逻辑的过程

  1. 通过参数ProceedingJoinPoint参数可以拿到注解修饰的方法Method信息方法的所有参数名方法的所有参数值

    // 获取被修饰的方法信息
    Method method = getMethod(jp);
    // 获取方法的所有参数值
    Object[] args = jp.getArgs();
    // 获取方法的所有参数名
    String[] paramNames = getParamName(jp);
    
  2. 通过Method对象可以拿到注解信息Annotation对象(这是因为Method接口源码中实现了反射包下的AnnotatedElement接口的getAnnotation()方法,此方法可以获取到修饰Method方法的注解Annotation信息)。拿到注解信息即可动态的获取注解中的属性信息:校验方式枚举类、校验参数名、校验错误返回信息。

    // 获取注解信息
    DoCheck annotation = method.getAnnotation(DoCheck.class);
    // 获取需要校验的参数名
    String argName = annotation.arg();
    // 获取需要的校验方式
    Check value = annotation.value();
    // 获取需要返回的报错信息
    String msg = annotation.msg();
    
  3. 通过拿到的方法所有参数名、所有参数值、以及需要校验的参数名即可获取到需要校验的参数值。这里通过循环对比参数名与需要校验的参数名一致时返回对应的参数值。逻辑很简单,后续读者想要拓展可以考虑当传入参数为对象,而需要校验参数名为对象的属性时,如何获取对应的参数值。

    /**
     * 获取需要检验的参数值
     * @param target 需要校验的参数名
     * @param args 被修饰的方法中所有的参数值
     * @param paramNames 被修饰的方法中所有的参数名
     */
    private Object getArgValue(String target, Object[] args, String[] paramNames){
        // 标记当前遍历的索引(因为args和paramNames是一一对应的)
        int idx = 0;
        // 遍历参数名
        for (String name : paramNames) {
            // 匹配对应的参数名则直接返回对应的参数值
            if (name.equals(target)) {
                return args[idx];
            }
            idx++;
        }
        return null;
    }
    
  4. 通过获取到需要校验的参数值,调用注解信息获取到的校验方式枚举类拿到对应的function函数式接口调用apply()即可进行参数值校验。最后通过是否校验成功进行判断是放行还是抛出异常。

    // 通过函数式接口传入需要校验的值, 内部会调用工具类的isEmail方法进行校验
    Boolean res = value.function.apply(argValue);
    
    if (res) {
        return jp.proceed(); // 校验成功则放行
    }else {
        // 校验失败抛出异常由调用方捕获
        throw new IllegalArgumentException(msg);
    }
    

以上就是所有校验的逻辑以及如何结合Spring AOP完成对注解的切面(校验)业务。


4. 实现starter组件自动装配以及可配置

Spring Boot自动装配原理

前面说过Spring Boot的特点就是自动装配,相信使用过Spring的读者们都体会过XML配置的复杂,而使用Spring Boot会发现除了导入依赖之外,无需过多的配置即可使用,就算需要配置时也只需要通过配置类或配置文件进行简单配置即可,这样的便利归功于Spring Boot的自动装配。接下来就了解一下Spring Boot究竟如何实现了自动装配。

学过Java的应该都听过SPI机制(JDK内置的一种服务发现机制),而Spring boot正是基于SPI机制的方式对外提供了一套接口规范(当Spring Boot项目启动时,会扫描外部引入的Jar中的META-INF/spring.factories文件,将文件中配置的类信息装配到Spring容器中),让引入的Jar实现这套规范即可自动装配进Spring Boot中。

所以想要自定义的starter组件实现自动装配,只需要在项目的 resources 中创建 META-INF 目录,并在此目录下创建一个 spring.factories 文件,将starter组件的自动配置类的类路径写在文件上即可。

项目代码-META-INF/spring.factories

// 添加项目的自动配置类的类路径
org.springframework.boot.autoconfigure.EnableAutoConfiguration=cn.lucas.check.config.CheckAutoConfigure

虽然已经解决了自定义starter实现自动装配,但是有兴趣的还是接着了解一下底层自动装配的过程。那么Spring Boot是如何找到 META-INF/spring.factories 的文件并进行自动配置的呢?

深入源码解析自动装配过程

  1. Spring Boot项目在启动类上都会有一个 @SpringBootApplication 注解,这个注解可以看作是@Configuration@EnableAutoConfiguration@ComponentScan的集合,而自动装配原理就在其中的 @EnableAutoConfiguration 中,这个注解作用是开启自动配置机制。
  2. @EnableAutoConfiguration 中实现自动装配核心功能的是@Import,通过加载自动装配类AutoConfigurationImportSelector实现自动装配功能。
  3. 深入 AutoConfigurationImportSelector 代码发现该类实现了ImportSelector接口同时实现了该接口的selectImports方法,这个方法用来获取所有符合条件的类的全限定名(为什么是符合条件,只在后续按需装配中说明),并且将这些类加载进Spring容器中。
  4. 再深入selectImports方法中会发现这个getAutoConfigurationEntry()方法才是最终获取到 META-INF/spring.factories 文件的方法。
    protected AutoConfigurationImportSelector.AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata annotationMetadata) {
        // 第一步:先判断是否开启了自动装配机制
        if (!this.isEnabled(annotationMetadata)) {
            return EMPTY_ENTRY;
        } else {
            // 第二步:获取@SpringBootApplication中需要排除的类(exclude和excludeName属性),通过这两个属性排除指定的不需要自动装配的类
            AnnotationAttributes attributes = this.getAttributes(annotationMetadata);
            List<String> configurations = this.getCandidateConfigurations(annotationMetadata, attributes);
            // 第三步:获取需要自动装配的所有配置类
            configurations = this.removeDuplicates(configurations);
            Set<String> exclusions = this.getExclusions(annotationMetadata, attributes);
            this.checkExcludedClasses(configurations, exclusions);
            configurations.removeAll(exclusions);
            configurations = this.getConfigurationClassFilter().filter(configurations);
            this.fireAutoConfigurationImportEvents(configurations, exclusions);
            return new AutoConfigurationImportSelector.AutoConfigurationEntry(configurations, exclusions);
        }
    }
    

到这里便是SpringBoot的自动装配原理整个过程(可能描述的不完整,有兴趣的可以去找找资料看看)。

抛出问题:刚刚提到的Spring Boot只会加载符合条件的类是怎么回事?

Spring Boot其实并不会加载 META-INF/spring.factories 文件下的所有类,而是按需加载,怎么个按需加载呢?

Spring Boot可以通过@ConditionalOnXXX满足条件时加载(即按需加载),下面列举一些常用的注解:

  • @ConditionalOnBean:当容器里存在指定Bean时,实例化(加载)当前Bean。
  • @ConditionalOnMissingBean:当容器里不存在指定Bean时,实例化(加载)当前Bean。
  • @ConditionalOnClass:当类路径下存在指定类时,实例化(加载)当前Bean。
  • @ConditionalOnMissingClass:当类路径下不存在指定类时,实例化(加载)当前Bean。
  • @ConditionalOnProperty:配置文件中指定的value属性是指定的havingValue属性值时,实例化(加载)当前Bean。

项目代码-自动配置类

/**
 * 自动配置类
 * @author 单程车票
 */
@Configuration
// 注意@ConditionalOnProperty注解要放在后面两个注解的前面,这样才会优先通过配置文件判断是否要开启自动装配。
@ConditionalOnProperty(value = "check.enabled", havingValue = "true") 
@ConditionalOnClass(CheckProperties.class)
@EnableConfigurationProperties(CheckProperties.class)
public class CheckAutoConfigure {

    /**
     * 使用配置Bean的方式使用DoCheckPoint切面
     */
    @Bean
    @ConditionalOnMissingBean
    public DoCheckPoint point() {
        return new DoCheckPoint();
    }

}

解释代码:

  1. @Configuration注解:标注这个注解的类可以看成是配置类(也可以看为是Bean对象的工厂),主要作用就是将Bean对象注入容器中。
  2. @ConditionalOnProperty(value = "check.enabled", havingValue = "true")注解:当引入当前starter的项目的配置文件中出现check.enabled=true(不出现也行,因为默认就是true)时,自动装配当前配置类。当配置文件中出现的是check.enabled=false时,则不装配该类,则无法使用切面,则starter组件失效。(坑点:该注解一定要放前面,优先通过配置文件判断是否需要开启自动装配,而后再判断其他条件)
  3. @ConditionalOnClass(CheckProperties.class)注解:上面解释过,就是当CheckProperties出现在类路径下时自动装配当前配置类。
  4. @EnableConfigurationProperties(CheckProperties.class)注解:让使用了@ConfigurationProperties注解的类生效。
  5. 这里使用@Bean的方式将切面注入Spring容器中,使得切面生效得以使用。(还有另外一种将切面注入容器的方式,即在切面类上加@Component注解,并且在自动配置类上添加@ComponentScan(basePackages = "cn.lucas.check.*")用于扫描切面类的@Component并将切面Bean添加进容器中)具体使用哪种方式注入切面类Bean都行。

项目代码-配置文件读取类

/**
 * 读取配置文件的信息类
 * @author 单程车票
 */
@ConfigurationProperties("check") // 用于读取配置文件前缀为check的属性
public class CheckProperties {
    // 默认为true,表示开启校验
    private boolean enabled = true;

    public boolean isEnabled() {
        return enabled;
    }

    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }
}

到此完成了对自定义Spring Boot starter的自动装配以及可通过配置文件的check.enabled控制是否开启starter组件(是否加载该starter配置类)。


测试

完成了对自定义starter的编写后,还需要对starter进行测试,确保功能能正常使用,所以接下来新建一个项目可以命名为check-spring-boot-starter-test

需要先将编写好的自定义的starter的jar包安装到本地maven仓库中。

image.png


测试项目编写步骤

  1. 引入依赖pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>cn.lucas.check</groupId>
    <artifactId>check-spring-boot-starter-test</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>check-spring-boot-starter-test</name>
    <description>校验测试服务</description>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.5.RELEASE</version>
        <relativePath/>
    </parent>

    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <!-- 引入自定义的starter -->
        <dependency>
            <groupId>cn.lucas.check</groupId>
            <artifactId>check-spring-boot-starter</artifactId>
            <version>1.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>
  1. 创建统一封装结果类
/**
 * 统一封装结果类
 * @author 单程车票
 */
@Data
public class R<T> implements Serializable {
    private Integer code;
    private String msg;
    private T data;

    public static <T> R<T> success(int code, String msg, T data) {
        R<T> result = new R<>();
        result.setCode(code);
        result.setMsg(msg);
        result.setData(data);
        return result;
    }

    public static <T> R<T> fail(int code, String msg, T data) {
        R<T> result = new R<>();
        result.setCode(code);
        result.setMsg(msg);
        result.setData(data);
        return result;
    }

    public static <T> R<T> success(T data) {
        return success(200, "操作成功", data);
    }

    public static <T> R<T> fail(String msg) {
        return fail(500, msg, null);
    }
}
  1. 创建全局异常处理类(用来处理检验失败捕获参数异常)
/**
 * 全局异常处理
 * @author 单程车票
 */
@Slf4j
@ResponseBody
@ControllerAdvice(annotations = {RestController.class, Controller.class})
public class GlobalExceptionHandler {
    /**
     * 捕获参数异常信息
     * @param ex 异常信息
     * @return R
     */
    @ExceptionHandler(IllegalArgumentException.class)
    public R<String> illegalArgumentException(IllegalArgumentException ex){
        log.info(ex.getMessage());
        return R.fail(ex.getMessage());
    }
}
  1. 创建TestController用于接口测试
/**
 * 测试
 * @author 单程车票
 */
@Slf4j
@RestController
public class TestController {

    @DoCheck(value = Check.Email, arg = "email", msg = "邮箱格式不正确!")
    @GetMapping("/test")
    public R<String> sendEmail(@RequestParam("email") String email) {
        log.info("校验参数:{}", email);
        return R.success("发送成功");
    }
}
  1. 配置文件application.yml
# 端口号
server:
  port: 8888

# 控制校验开关
check:
  enabled: true

测试一:配置文件中开启校验,传入接口参数为正确邮箱格式

通过postman工具测试接口,校验成功能够成功发送,预期结果一致。

postman结果图

image.png

控制台信息图

image.png

控制台打印starter的日志以及接口的日志,说明校验成功并且执行了接口方法。


测试二:配置文件中开启校验,传入接口参数为错误邮箱格式

通过postman工具测试接口,校验失败不能够成功发送,预期结果一致。

postman结果图

image.png

控制台信息图

image.png

控制台打印报错信息,说明校验失败。


测试三:配置文件中关闭校验,传入接口参数为错误邮箱格式

# 端口号
server:
  port: 8888

# 控制校验开关
check:
  enabled: false

通过postman工具测试接口,无法校验,直接发送成功,预期结果一致。

postman结果图

image.png

控制台信息图

Snipaste_2023-02-17_21-25-31.png

可以看到只打印了接口的日志,并没有打印starter中的日志,说明starter未启用。