前言

我们都知道可以使用SpringBoot快速的开发基于Spring框架的项目。由于围绕SpringBoot存在很多开箱即用的Starter依赖,使得我们在开发业务代码时能够非常方便的、不需要过多关注框架的配置,而只需要关注业务即可。

例如我想要在SpringBoot项目中集成web,那么我只需如下两步:

第一步 要加入spring-boot-starter-web的依赖并简单配置一下信息

<!-- spring-boot-starter-web -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

第二步 在启动类或配置类上增加一个注解即可完成

@GetMapping("/user/user_id")

这为我们省去了之前很多的配置操作,有些功能的开启只需关心业务逻辑
是不是特别方便?
也许,你可以对面试官说你熟(略)练(懂)使(皮)用(毛)SpringBoot了。

但是你有没有想过自己开发一个炫酷功能的starter被别人拿来引用呢?比如 spring-boot-starter-cache

@Cache(key = "user_id", action = "用户查询", type = "redis")

只要加上这个注解,系统便会自动利用redis对数据库进行缓存查询优化,而不是在业务中进行代码优化。

我相信只要大家花三分钟看完这篇文章一定可以开发出来的。

原理浅谈

从spring boot启动类说起,众所周知启动类会有@SpringBootApplication注解

@SpringBootApplication
public class EdgeApplication {

    public static void main(String[] args) {
        SpringApplication.run(EdgeApplication.class, args);
    }
    
}

ctrl+alt+鼠标左键查看注解源码

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(
    excludeFilters = {@Filter(
    type = FilterType.CUSTOM,
    classes = {TypeExcludeFilter.class}
), @Filter(
    type = FilterType.CUSTOM,
    classes = {AutoConfigurationExcludeFilter.class}
)}
)
public @interface SpringBootApplication {

我们看下@EnableAutoConfiguration这个注解:

@AutoConfigurationPackage
@Import({AutoConfigurationImportSelector.class})
public @interface EnableAutoConfiguration {

通过@AutoConfiguration启用Spring应用程序上下文的自动配置,这个注解会导入一个AutoConfigurationImportSelector的类,而这个类会去读取一个spring.factories下key为EnableAutoConfiguration对应的全限定名的值。

protected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) {
        List<String> configurations = SpringFactoriesLoader.loadFactoryNames(this.getSpringFactoriesLoaderFactoryClass(), this.getBeanClassLoader());
        Assert.notEmpty(configurations, "No auto configuration classes found in META-INF/spring.factories. If you are using a custom packaging, make sure that file is correct.");
        return configurations;
}

这个spring.factories里面配置的那些类,主要作用是告诉Spring Boot这个stareter所需要加载的那些xxxAutoConfiguration类,也就是你真正的要自动注册的那些bean或功能。然后,我们实现一个spring.factories指定的类,标上@Configuration注解,一个starter就定义完了。

概括 SpringBoot 在启动时会去依赖的 starter 包中寻找 /META-INF/spring.factories 文件,然后根据文件中配置的路径去扫描项目所依赖的 Jar 包,这类似于 Java 的 SPI 机制。

实战说明

需求

之前系统前后端传参使用VO,如果微服务场景下各个服务频繁发版,其他服务调用方改动会很大,因此后端改用了Map<String, String> paramMap数据类型来传递参数,请求进入到controller层后要在每一个方法内进行参数校验,如果我们可以开发一个starter,只需加上注解即可实现参数校验,就会减少很多冗余代码。

效果

通过数组注解实现参数校验使用示例

@GetMapping("/product")
@ParamCheck(name = "paramMap", params = {
        @ParamCheck.Param(name = "requireProductCategory", type = Integer.class),
        @ParamCheck.Param(name = "requireProductDetail", type = Integer.class, required = false),
        @ParamCheck.Param(name = "productName", type = String.class, required = true)})
public ResultModel query(@RequestParam Map<String, String> paramMap) {
    // 业务逻辑
    ResultModel resultModel = new ResultModel();
    resultModel.setMsg("success");
    return resultModel;
}

验证效果

请求

http://localhost:8080/product

响应

{
    code: 10001,
    msg: "参数校验失败"
}

请求

http://localhost:8080/product?productName=%E8%8B%B9%E6%9E%9C

响应

{
    code: 0,
    msg: "success"
}

实战开发

为了方便,大家可以先把demo代码克隆下来https://github.com/aaa081215/parametercheck-spring-boot-starter.git

命名规则

官方命名:spring-boot-starter-xxxx

非官方命名:xxxx-spring-boot-starter

这里我们使用非官方命名 parametercheck-spring-boot-starter (避免将来spring-boot官方使用你的starter而重名)

开发步骤

第一步 新建 maven 工程

相信各位小伙都非常熟练,不多说了,省略

第二步 添加 pom 依赖,完整 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 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>

    <artifactId>parametercheck-spring-boot-starter</artifactId>
    <groupId>9421.top</groupId>
    <version>1.0.1-SNAPSHOT</version>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>7</source>
                    <target>7</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-autoconfigure</artifactId>
            <version>2.2.2.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjrt</artifactId>
            <version>1.9.5</version>
        </dependency>
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
            <version>1.9.4</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>5.2.2.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>commons-lang</groupId>
            <artifactId>commons-lang</artifactId>
            <version>2.6</version>
        </dependency>
    </dependencies>

</project>

仅需引入spring-boot-autoconfigure即可,由于此项目用到了aop等,所以引用了多个依赖。

第三步 编写上文曾提到的spring.factories文件

  • resource 目录下,创建 META-INF 目录
  • META-INF 目录下创建 spring.factories 文件

完整spring.factories文件如下

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
hiweek.autoconfig.VerifyAnnotationAutoConfiguration

第四步 新建自动配置类,VerifyAnnotationAutoConfiguration 类如下
类名为spring.factories声明的类

@Aspect
@Configuration
public class VerifyAnnotationAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public ParamAspect paramAspect() {
        return new ParamAspect();
    }

    /**
     * aop切入点
     */
    @Around("@annotation(hiweek.verify.annotation.ParamCheck)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        return paramAspect().around(joinPoint);
    }

}

解释:

@Aspect声明切面

@Configuration 相当于把该类作为spring的xml配置文件中的,配置spring容器(应用上下文)

@ConditionalOnMissingBean 判断是否执行初始化代码,即如果用户已经创建了bean,则相关的初始化代码不再执行。

@Around("@annotation(hiweek.verify.annotation.ParamCheck)")
切入点为hiweek.verify.annotation.ParamCheck注解

也就是说当执行到有ParamCheck注解的方法时,会执行环绕通知paramAspect().around(joinPoint);

第五步 自定义元注解

现在starter配置基本完成了,接下来开发上面代码中的自定义注解hiweek.verify.annotation.ParamCheck

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ParamCheck {

    /**
     * 存放参数的变量名
     */
    String name();
    /**
     * 要校验的参数
     */
    Param[] params();
    /**
     * 自定义注解,用来描述要校验的参数信息
     */
    @Target({ElementType.METHOD})
    @Retention(RetentionPolicy.RUNTIME)
    @interface Param {

        /**
         * 参数名
         */
        String name();
        /**
         * 是否必须
         */
        boolean required() default false;
        /**
         * 参数类型
         */
        Class type();

        /**
         * 参数格式
         */
        String format() default "";
        
    }
}

解释:

@Target({ElementType.METHOD})

作用:用于描述注解的使用范围(即:被描述的注解可以用在什么地方)

取值(ElementType)有:

  • 1.CONSTRUCTOR:用于描述构造器
  • 2.FIELD:用于描述域
  • 3.LOCAL_VARIABLE:用于描述局部变量
  • 4.METHOD:用于描述方法
  • 5.PACKAGE:用于描述包
  • 6.PARAMETER:用于描述参数
  • 7.TYPE:用于描述类、接口(包括注解类型) 或enum声明

@Retention(RetentionPolicy.RUNTIME)

  • 1、RetentionPolicy.SOURCE:注解只保留在源文件,当Java文件编译成class文件的时候,注解被遗弃
  • 2、RetentionPolicy.CLASS:注解被保留到class文件,但jvm加载class文件时候被遗弃,这是默认的生命周期
  • 3、RetentionPolicy.RUNTIME:注解不仅被保存到class文件中,jvm加载class文件之后,仍然存在

第六步 ParamAspect 业务逻辑开发(配置环绕通知,校验参数是否合法)

ParamAspect
完整代码

public class ParamAspect {
    /**
     * 配置环绕通知。
     * 校验参数是否合法
     *
     * @param joinPoint 切入点
     * @return 执行结果
     */
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        ResultModel resultModel = new ResultModel();
        // 1 对包含ParamCheck注解的方法进行操作
        if (method.isAnnotationPresent(ParamCheck.class)) {
            // 2 获取注解paramCheckName要校验的参数名
            ParamCheck paramCheck = method.getAnnotation(ParamCheck.class);
            String paramCheckName = paramCheck.name();
            // 3 从joinPoint中获得获取方法的所有参数名
            String[] parameterNames = methodSignature.getParameterNames();
            // 4 根据注解中要校验的参数名与joinPoint中所有的参数名,获取到要校验的数据的下标
            int paramMapIndex = ArrayUtils.indexOf(parameterNames, paramCheckName);
            if (paramMapIndex == -1) {
                resultModel.setFailed(CommonStatusCode.PARAM_INVALID, "要校验的方法参数名应与注解字段相同");
                return resultModel;
            }
            // 5 根据joinPoint获取方法的所有元素与下标,拿到真实值
            Object[] args = joinPoint.getArgs();
            Map<String, String> realParamMap = (Map<String, String>) args[paramMapIndex];
            int index = 0;
            ParamCheck.Param[] params = paramCheck.params();
            String[] paramArray = new String[params.length];
            boolean[] requiredArray = new boolean[params.length];
            Class[] classArray = new Class[params.length];
            String[] format = new String[params.length];
            // 6 获得注解中定义的校验规则
            for (ParamCheck.Param paramItem : params) {
                paramArray[index] = paramItem.name();
                requiredArray[index] = paramItem.required();
                classArray[index] = paramItem.type();
                format[index] = paramItem.format();
                index++;
            }
            try {
                StringUtils.checkParam(realParamMap, paramArray, requiredArray, classArray, format);
            } catch (ParamException e) {
                resultModel.setFailed(CommonStatusCode.PARAM_INVALID, e.getMessage());
                return resultModel;
            }
        }
        return joinPoint.proceed();
    }
}

至此开发以及完成。