SpringBoot-原理篇

2023-01-04 15:12:37 浏览数 (1)

原理篇包含自动配置工作流程、自定义starter开发、springboot程序启动流程、掌握SpringBoot内部工作流程、理解SpringBoot整合第三方技术的原理

小简从 0 开始学 Java 知识之 Java-学习路线 中的《SpringBoot-原理篇》,不定期更新所学笔记,期待一年后的蜕变吧!

  • 学习目标

课程单元

学习目标

原理篇

掌握SpringBoot内部工作流程理解SpringBoot整合第三方技术的原理实现自定义开发整合第三方技术的组件

  • 前置知识

课程单元

前置知识

要求

原理篇

Spring

了解Spring加载bean的各种方式知道Spring容器底层工作原理,能够阅读简单的Spring底层源码

1.自动配置工作流程

①bean的加载方式

❶配置文件 <bean/>标签

最初级的bean的加载方式其实可以直击spring管控bean的核心思想,就是提供类名,然后spring就可以管理了。所以第一种方式就是给出bean的类名,内部通过反射机制加载类名后创建对象,对象就是spring管控的bean。

代码语言:javascript复制
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
    <!--xml方式声明自己开发的bean-->
    <bean id="cat" class="Cat"/>
    <bean class="Dog"/>

    <!--xml方式声明第三方开发的bean-->
    <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"/>
    <bean class="com.alibaba.druid.pool.DruidDataSource"/>
    <bean class="com.alibaba.druid.pool.DruidDataSource"/>
</beans>

❷配置文件扫描 注解

由于❶中需要将spring管控的bean全部写在xml文件中,非常不友好。现在哪一个类要受到spring管控加载成bean,就在这个类的上面加一个注解,还可以顺带起一个bean的名字(id)。这里可以使用的注解有@Component以及三个衍生注解@Service@Controller@Repository

代码语言:javascript复制
@Component("tom")
public class Cat {}

由于我们无法在第三方提供的技术源代码中去添加上述4个注解,因此当你需要加载第三方开发的bean的时候可以使用下列方式定义注解式的bean。定义一个方法添加@Bean,当前方法的返回值就可以交给spring管控,记得这个方法所在的类添加@Component或者@Configuration

代码语言:javascript复制
@Component
public class DbConfig {
    @Bean
    public DruidDataSource dataSource(){
        DruidDataSource ds = new DruidDataSource();
        return ds;
    }
}

上面提供的仅仅是bean的声明,spring并没有感知到这些东西,像极了上课积极回答问题的你,手举的非常高,可惜老师都没有往你的方向看上一眼。可以通过下列xml配置设置spring去检查哪些包,发现定了对应注解,就将对应的类纳入spring管控范围,声明成bean。

代码语言:javascript复制
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="
       http://www.springframework.org/schema/beans
       http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/context
       http://www.springframework.org/schema/context/spring-context.xsd
    ">
    <!--指定扫描加载bean的位置-->
    <context:component-scan base-package="com.jianjian.bean,com.jianjian.config"/>
</beans>

❸纯注解

定义一个配置类并使用@ComponentScan替代❷中的包扫描这个动作。

代码语言:javascript复制
@Configuration
@ComponentScan({"com.itheima.bean","com.itheima.config"})
public class SpringConfig {
    @Bean
    public DruidDataSource dataSource(){
        DruidDataSource ds = new DruidDataSource();
        return ds;
    }
}

❹@Import注解

由于使用@ComponentScan扫描的时候范围太大了,使用@Import注解它可以只加载你需要的bean即可。只需要在注解的参数中写上加载的类对应的.class,而且加载的bean可以不用@Component修饰

代码语言:javascript复制
@Import({Dog.class,DbConfig.class})
public class SpringConfig {
}

❺编程形式

前面介绍的加载bean的方式都是在容器启动阶段完成bean的加载,下面这种方式就比较特殊了,可以在容器初始化完成后手动加载bean。通过这种方式可以实现编程式控制bean的加载。

代码语言:javascript复制
public class App {
    public static void main(String[] args) {
        AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(SpringConfig.class);
        //上下文容器对象已经初始化完毕后,手工加载bean
        ctx.register(Cat.class);
    }
}

❻ImportSelector接口

是否可以在容器初始化过程中进行控制呢?答案是必须的。实现ImportSelector接口的类可以设置加载的bean的全路径类名,记得一点,只要能编程就能判定,能判定意味着可以控制程序的运行走向,进而控制一切。

代码语言:javascript复制
public class MyImportSelector implements ImportSelector {
    @Override
    public String[] selectImports(AnnotationMetadata metadata) {
        //各种条件的判定,判定完毕后,决定是否装载指定的bean
        boolean flag = metadata.hasAnnotation("org.springframework.context.annotation.Configuration");
        if(flag){
            return new String[]{"com.itheima.bean.Dog"};
        }
        return new String[]{"com.itheima.bean.Cat"};
    }
}

❼ImportBeanDefinitionRegistrar接口

❻中提供了给定类全路径类名控制bean加载的形式,其实bean的加载不是一个简简单单的对象,spring中定义了一个叫做BeanDefinition的东西,它才是控制bean初始化加载的核心。BeanDefinition接口中给出了若干种方法,可以控制bean的相关属性。说个最简单的,创建的对象是单例还是非单例,在BeanDefinition中定义了scope属性就可以控制这个。如果你感觉❻中没有给你开放出足够的对bean的控制操作,那么可以通过定义一个类实现ImportBeanDefinitionRegistrar接口的方式定义bean,并且还可以让你对bean的初始化进行更加细粒度的控制

代码语言:javascript复制
public class MyRegistrar implements ImportBeanDefinitionRegistrar {
    @Override
    public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
        BeanDefinition beanDefinition =     
            BeanDefinitionBuilder.rootBeanDefinition(BookServiceImpl2.class).getBeanDefinition();
        registry.registerBeanDefinition("bookService",beanDefinition);
    }
}

❽BeanDefinitionRegistryPostProcessor接口

当某种类型的bean被接二连三的使用各种方式加载后,在你对所有加载方式的加载顺序没有完全理解清晰之前,你不知道最后谁说了算。BeanDefinitionRegistryPostProcessor,全称bean定义后处理器,它在所有bean注册都折腾完后,把最后一道关,它是最后一个运行的。

代码语言:javascript复制
public class MyPostProcessor implements BeanDefinitionRegistryPostProcessor {
    @Override
    public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
        BeanDefinition beanDefinition = 
            BeanDefinitionBuilder.rootBeanDefinition(BookServiceImpl4.class).getBeanDefinition();
        registry.registerBeanDefinition("bookService",beanDefinition);
    }
}

②bean的加载控制

企业级开发中不可能在spring容器中进行bean的饱和式加载的。合理的加载方式是用什么加载什么。所以在spring容器中,通过判断一个类的全路径名是否能够成功加载来控制bean的加载是一种常见操作。

代码语言:javascript复制
public class MyImportSelector implements ImportSelector {
    @Override
    public String[] selectImports(AnnotationMetadata importingClassMetadata) {
        try {
            Class<?> clazz = Class.forName("com.jianjian.bean.Mouse");
            if(clazz != null) {
                return new String[]{"com.jianjian.bean.Cat"};
            }
        } catch (ClassNotFoundException e) {
                        //e.printStackTrace();
            return new String[0];
        }
        return null;
    }
}

通过上述的分析,可以看到此类操作将成为企业级开发中的常见操作,于是springboot将把这些常用操作给我们做了一次封装。通过注解实现上面的操作。

  • @ConditionalOnClass注解实现了当虚拟机中加载了指定类时才加载对应的bean。
代码语言:javascript复制
@Bean
@ConditionalOnClass(name = "com.jianjian.bean.Wolf")
public Cat tom(){
    return new Cat();
}
  • @ConditionalOnMissingClass注解实现了虚拟机中没有加载指定的类才加载对应的bean。
代码语言:javascript复制
@Bean
@ConditionalOnMissingClass("com.jianjian.bean.Dog")
public Cat tom(){
    return new Cat();
}

这种条件还可以做并且的逻辑关系,写2个就是2个条件都成立,写多个就是多个条件都成立。

代码语言:javascript复制
@Bean
@ConditionalOnClass(name = "com.jianjian.bean.Wolf")
@ConditionalOnMissingClass("com.jianjian.bean.Mouse")
public Cat tom(){
    return new Cat();
}
  • @ConditionalOnWebApplication判定当前容器环境是否是web环境。
代码语言:javascript复制
@Bean
@ConditionalOnWebApplication
public Cat tom(){
    return new Cat();
}
  • @ConditionalOnNotWebApplication判定容器环境是否是非web环境。
代码语言:javascript复制
@Bean
@ConditionalOnNotWebApplication
public Cat tom(){
    return new Cat();
}
  • @ConditionalOnBean判定是否加载了指定名称的bean
代码语言:javascript复制
@Bean
@ConditionalOnBean(name="jerry")
public Cat tom(){
    return new Cat();
}

总结

springboot定义了若干种控制bean加载的条件设置注解,由spring固定加载bean变成了可以根据情况选择性的加载bean

③bean的依赖属性

bean的加载及加载控制已经搞完了,下面研究一下bean内部的事情。bean在运行的时候,实现对应的业务逻辑时有可能需要开发者提供一些设置值,有就是属性了。如果使用构造方法将参数固定,灵活性不足,这个时候就可以使用bean的属性配置进行灵活的配置了。

步骤①:先通过yml配置文件,设置bean运行需要使用的配置信息。

代码语言:javascript复制
cartoon:
  cat:
    name: "图多盖洛"
    age: 5
  mouse:
    name: "泰菲"
    age: 1

步骤②:然后定义一个封装属性的专用类,加载配置属性,读取对应前缀相关的属性值。

代码语言:javascript复制
@ConfigurationProperties(prefix = "cartoon")
@Data
public class CartoonProperties {
    private Cat cat;
    private Mouse mouse;
}

步骤③:最后在使用的位置注入对应的配置即可。

代码语言:javascript复制
@EnableConfigurationProperties(CartoonProperties.class)
public class CartoonCatAndMouse{
    @Autowired
    private CartoonProperties cartoonProperties;
}

建议在业务类上使用@EnableConfigurationProperties声明bean,这样在不使用这个类的时候,也不会无故加载专用的属性配置类CartoonProperties,减少spring管控的资源数量。

④自动装配原理

啥叫自动配置呢?简单说就是springboot根据我们开发者的行为猜测你要做什么事情,然后把你要用的bean都给你准备好。springboot咋做到的呢?就是看你导入了什么类,就知道你想干什么了。然后把你有可能要用的bean都给你加载好,你直接使用就行了,springboot把所需要的一切工作都做完了。整体过程分为2个阶段:

阶段一:准备阶段

  1. springboot的开发人员先大量收集Spring开发者的编程习惯,整理开发过程每一个程序经常使用的技术列表,形成一个技术集A
  2. 收集技术集A的最常用的设置,把这些设置作为默认值直接设置好,得到开发过程中每一个技术的常用设置,形成每一个技术对应的设置集B

阶段二:加载阶段

  1. springboot初始化Spring容器基础环境,读取用户的配置信息,加载用户自定义的bean和导入的坐标,形成初始化环境
  2. springboot将技术集A包含的所有技术在SpringBoot启动时默认全部加载,这时肯定加载的东西有一些是无效的,没有用的
  3. springboot会对技术集A中每一个技术约定启动这个技术对应的条件,并设置成按条件加载,由于开发者导入了一些bean和坐标,也就是与初始化环境,这个时候就可以根据这个初始化环境与springboot的技术集A进行比对了,哪个匹配上加载哪个
  4. 因为有些技术不做配置就无法工作,所以springboot先加载设置集B中的默认值,这样可以减少开发者配置参数的工作量
  5. 但是默认配置不一定能解决问题,于是springboot开放修改设置集B的接口,可以由开发者根据需要决定是否覆盖默认配置

假定我们想自己实现自动配置的功能,都要做哪些工作呢?

  • 首先指定一个技术X,我们打算让技术X具备自动配置的功能,这个技术X可以是任意功能,这个技术隶属于上面描述的技术集A
代码语言:javascript复制
public class CartoonCatAndMouse{
}
  • 然后找出技术X使用过程中的常用配置Y,这个配置隶属于上面表述的设置集B
代码语言:javascript复制
cartoon:
  cat:
    name: "图多盖洛"
    age: 5
  mouse:
    name: "泰菲"
    age: 1
  • 然后定义一个属性类封装对应的配置属性,这个过程其实就是上一节咱们做的bean的依赖属性管理,一模一样
代码语言:javascript复制
@ConfigurationProperties(prefix = "cartoon")
@Data
public class CartoonProperties {
    private Cat cat;
    private Mouse mouse;
}
  • 最后做一个配置类,当这个类加载的时候就可以初始化对应的功能bean,并且可以加载到对应的配置
代码语言:javascript复制
@EnableConfigurationProperties(CartoonProperties.class)
public class CartoonCatAndMouse implements ApplicationContextAware {
    private CartoonProperties cartoonProperties;
}
  • 当然,你也可以为当前自动配置类设置上激活条件,例如使用@CondtionOn为其设置加载条件
代码语言:javascript复制
@ConditionalOnClass(name="org.springframework.data.redis.core.RedisOperations")
@EnableConfigurationProperties(CartoonProperties.class)
public class CartoonCatAndMouse implements ApplicationContextAware {
    private CartoonProperties cartoonProperties;
}

做到这里都已经做完了,但是遇到了一个全新的问题,如何让springboot启动的时候去加载这个类呢?如果不加载的话,我们做的条件判定,做的属性加载这些全部都失效了。springboot为我们开放了一个配置入口,在类路径(resources/)下创建META-INF目录,并创建spring.factories文件,在其中添加设置,说明哪些类要启动自动配置就可以了。

代码语言:javascript复制
# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=
com.jianjian.bean.CartoonCatAndMouse

其实这个文件就做了一件事,通过这种配置的方式加载了指定的类。那自动配置的核心究竟是什么呢?自动配置其实是一个小的生态,可以按照如下思想理解:

  1. 自动配置从根本上来说就是一个bean的加载
  2. 通过bean加载条件的控制给开发者一种感觉,自动配置是自适应的,可以根据情况自己判定,但实际上就是最普通的分支语句的应用,这是蒙蔽我们双眼的第一层面纱
  3. 使用bean的时候,如果不设置属性,就有默认值,如果不想用默认值,就可以自己设置,也就是可以修改部分或者全部参数,也是一种自适应的形式,其实还是需要使用分支语句来做判断的,这是蒙蔽我们双眼的第二层面纱
  4. springboot技术提前将大量开发者有可能使用的技术提前做好了,条件也写好了,用的时候你导入了一个坐标,对应技术就可以使用了,其实就是提前帮我们把spring.factories文件写好了,这是蒙蔽我们双眼的第三层面纱

​ 你在不知道自动配置这个知识的情况下,经过上面这一二三,你当然觉得自动配置是一种特别牛的技术,但是一窥究竟后发现,也就那么回事。而且现在springboot程序启动时,在后台偷偷的做了这么多次检测,这么多种情况判定,不用问了,效率一定是非常低的,毕竟它要检测100余种技术是否在你程序中使用。

总结

  1. springboot启动时先加载spring.factories文件中的org.springframework.boot.autoconfigure.EnableAutoConfiguration配置项,将其中配置的所有的类都加载成bean
  2. 在加载bean的时候,bean对应的类定义上都设置有加载条件,因此有可能加载成功,也可能条件检测失败不加载bean
  3. 对于可以正常加载成bean的类,通常会通过@EnableConfigurationProperties注解初始化对应的配置属性类并加载对应的配置
  4. 配置属性类上通常会通过@ConfigurationProperties加载指定前缀的配置,当然这些配置通常都有默认值。如果没有默认值,就强制你必须配置后使用了

⑤变更自动配置

springboot的自动配置并不是必然运行的,可以通过配置的形式干预是否启用对应的自动配置功能。系统默认会加载100多种自动配置的技术,我们可以先手工干预此工程,禁用一些自动配置

方式一:通过yaml配置设置排除指定的自动配置类

代码语言:javascript复制
spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration

方式二:通过注解参数排除自动配置类

代码语言:javascript复制
@EnableAutoConfiguration(excludeName = "",exclude = {})

方式三:排除坐标(应用面较窄)

例如web程序启动时会自动启动tomcat服务器,可以通过排除坐标的方式,让加载tomcat服务器的条件失效。不过需要提醒一点,你把tomcat排除掉,记得再加一种可以运行的服务器。

代码语言:javascript复制
        org.springframework.boot
        spring-boot-starter-web
        
        
            
                org.springframework.boot
                spring-boot-starter-tomcat
            
        
    
    
    
        org.springframework.boot
        spring-boot-starter-jetty

2.自定义starter开发

自动配置学习完后,我们就可以基于自动配置的特性,开发springboot技术中最引以为傲的功能了,starter。其实通过前期学习,我们发现用什么技术直接导入对应的starter,然后就实现了springboot整合对应技术,再加上一些简单的配置,就可以直接使用了。这种设计方式对开发者非常友好,本章就通过一个案例的制作,开发自定义starter来实现自定义功能的快捷添加。

⓪案例:网站访问次数

本案例的功能是统计网站独立IP访问次数的功能,并将访问信息在后台持续输出。整体功能是在后台每10秒输出一次监控信息(格式:IP 访问次数) ,当用户访问网站时,对用户的访问行为进行统计。 例如:张三访问网站功能15次,IP地址:192.168.0.135,李四访问网站功能20次,IP地址:61.129.65.248。那么在网站后台就输出如下监控信息,此信息每10秒刷新一次。 IP访问监控 -----ip-address----- --num-- | 192.168.0.135 | 15 | | 61.129.65.248 | 20 | -------------------- ------- 本功能最终要实现的效果是在现有的项目中导入一个starter,对应的功能就添加上了,删除掉对应的starter,功能就消失了,要求功能要与原始项目完全解耦。因此需要开发一个独立的模块,制作对应功能。

在进行具体制作之前,先对功能做具体的分析

  1. 数据记录在什么位置

最终记录的数据是一个字符串(IP地址)对应一个数字(访问次数),此处可以选择的数据存储模型可以使用java提供的map模型,也就是key-value的键值对模型,或者具有key-value键值对模型的存储技术,例如redis技术。本案例使用map作为实现方案。

  1. 统计功能运行位置

因为每次web请求都需要进行统计,因此使用拦截器会是比较好的方案,本案例使用拦截器来实现。不过在制作初期,先使用调用的形式进行测试,等功能完成了,再改成拦截器的实现方案。

  1. 为了提升统计数据展示的灵活度,为统计功能添加配置项。
  • 输出频度,默认10秒
  • 数据特征:累计数据 / 阶段数据,默认累计数据
  • 输出格式:详细模式 / 极简模式

在开发中我们分成若干个步骤实现。先完成最基本的统计功能的制作,然后开发出统计报表,接下来把所有的配置都设置好,最后将拦截器功能实现,整体功能就做完了。

最终实现代码:https://jwt1399.lanzouv.com/iG0LW0buql7g

项目文件结构

代码语言:javascript复制
ipcount-spring-boot-starter
├── pom.xml
└── src
    └── main
        ├── java
        │   └── com
        │       └── jianjian
        │           ├── IpcountApplication.java
        │           ├── autoconfigure
        │           │   └── IpAutoConfiguration.java
        │           ├── interceptor
        │           │   ├── IpCountInterceptor.java
        │           │   └── SpringMvcConfig.java
        │           ├── properties
        │           │   └── IpProperties.java
        │           └── service
        │               └── IpCountService.java
        └── resources
            ├── META-INF
            │   ├── spring-configuration-metadata.json
            │   └── spring.factories
            └── application.yml

①IP计数业务功能开发

步骤一:创建全新的模块,定义业务功能类

创建web模块ipcount-spring-boot-starter,定义一个业务类,声明一个Map对象,用于记录ip访问次数,key是ip地址,value是访问次数

代码语言:javascript复制
public class IpCountService {
    private Map<String,Integer> ipCountMap = new HashMap<String,Integer>();
}

有些小伙伴可能会有疑问,不设置成静态的,如何在每次请求时进行数据共享呢?记得,当前类加载成bean以后是一个单例对象,对象都是单例的,哪里存在多个对象共享变量的问题。

步骤二:制作统计功能

制作统计操作对应的方法,每次访问后对应ip的记录次数 1。需要分情况处理,如果当前没有对应ip的数据,新增一条数据,否则就修改对应key的值 1即可

代码语言:javascript复制
public class IpCountService {
    private Map<String,Integer> ipCountMap = new HashMap<String,Integer>();
    public void count(){
        //每次调用当前操作,就记录当前访问的IP,然后累加访问次数
        //1.获取当前操作的IP地址
        String ip = null;
        //2.根据IP地址从Map取值,并递增
        Integer count = ipCountMap.get(ip);
        if(count == null){
            ipCountMap.put(ip,1);
        }else{
            ipCountMap.put(ip,count   1);
        }
    }
}

因为当前功能最终导入到其他项目中进行,而导入当前功能的项目是一个web项目,因此可以从容器中直接获取请求对象,获取IP地址的操作可以通过自动装配得到请求对象,然后获取对应的访问IP

代码语言:javascript复制
public class IpCountService {
    private Map<String,Integer> ipCountMap = new HashMap<String,Integer>();
    @Autowired
    //当前的request对象的注入工作由使用当前web-starter的工程提供自动装配
    private HttpServletRequest httpServletRequest;
    public void count(){
        //每次调用当前操作,就记录当前访问的IP,然后累加访问次数
        //1.获取当前操作的IP地址
        String ip = httpServletRequest.getRemoteAddr();
        //2.根据IP地址从Map取值,并递增
        Integer count = ipCountMap.get(ip);
        if(count == null){
            ipCountMap.put(ip,1);
        }else{
            ipCountMap.put(ip,count   1);
        }
        //打印出信息
        System.out.println(ipCountMap);
    }
}

//优化后代码
public class IpCountService {
    private Map<String, Integer> ipCountMap = new HashMap<>();
    @Autowired
    //当前的request对象的注入工作由使用当前starter的工程提供自动装配
    private HttpServletRequest httpServletRequest;
    public void count(){
        String ip = httpServletRequest.getRemoteAddr();
        ipCountMap.put(ip,ipCountMap.getOrDefault(ip,0)   1);
        System.out.println(ipCountMap);
    }
}

步骤三:定义自动配置类

我们需要做到的效果是导入当前模块即开启此功能,因此使用自动配置实现功能的自动装载,需要开发自动配置类在启动项目时加载当前功能。

代码语言:javascript复制
@Configuration
public class IpAutoConfiguration {
    @Bean
    public IpCountService ipCountService(){
        return new IpCountService();
    }
}

自动配置类需要在spring.factories文件中做配置方可自动运行。

代码语言:javascript复制
# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=
  com.jianjian.autoconfigure.IpAutoConfiguration

步骤四:在其它项目中模拟调用,测试功能

在其它项目调用本项目,导入当前开发的starter,切记使用之前先clean后install安装到maven仓库,确保资源更新

代码语言:javascript复制
<dependency>
    <groupId>com.jianjian</groupId>
    <artifactId>ipcount-spring-boot-starter</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>

推荐选择调用方便的功能做测试,推荐使用分页操作,当然也可以换其他功能位置进行测试。

代码语言:javascript复制
@RestController
@RequestMapping("/books")
public class BookController {
    @Autowired
    private IpCountService ipCountService; //引入对象
    @GetMapping("{currentPage}/{pageSize}")
    public R getPage(@PathVariable int currentPage,@PathVariable int pageSize,Book book){
        ipCountService.count(); //测试功能
        IPage<Book> page = bookService.getPage(currentPage, pageSize,book);
        if( currentPage > page.getPages()){
            page = bookService.getPage((int)page.getPages(), pageSize,book);
        }
        return new R(true, page);
    }
}

当前效果:每次调用分页操作后,可以在控制台输出当前访问的IP地址

温馨提示

由于当前制作的功能需要在对应的调用位置进行坐标导入,因此必须保障仓库中具有当前开发的功能,所以每次原始代码修改后,需要重新编译并安装到仓库中。为防止问题出现,建议每次安装之前先clean然后install,保障资源进行了更新。切记切记!!

②定时任务报表开发

当前已经实现了在业务功能类中记录访问数据,但是还没有定时输出监控的信息到控制台。由于监控信息需要每10秒输出1次,因此需要使用定时器功能。此处选用Spring内置的task作为实现方案。

步骤一:开启定时任务功能

定时任务功能开启需要在当前功能的总配置中设置,结合现有业务设定,比较合理的位置是设置在自动配置类上。加载自动配置类即启用定时任务功能。

代码语言:javascript复制
@EnableScheduling
public class IpAutoConfiguration {
    @Bean
    public IpCountService ipCountService(){
        return new IpCountService();
    }
}

步骤二:制作显示统计数据功能

定义显示统计功能的操作print(),并设置定时任务,当前设置每5秒运行一次统计数据。

代码语言:javascript复制
public class IpCountService {
  
    private Map ipCountMap = new HashMap();

    @Scheduled(cron = "0/5 * * * * ?")
    public void print(){
        System.out.println("         IP访问监控");
        System.out.println(" -----ip-address----- --num-- ");
        for (Map.Entry entry : ipCountMap.entrySet()) {
            String key = entry.getKey();
            Integer value = entry.getValue();
            System.out.println(String.format("|s  |]  |",key,value));
        }
        System.out.println(" -------------------- ------- ");
      }
}

显示样式

代码语言:javascript复制
         IP访问监控
 -----ip-address----- --num-- 
|         127.0.0.1  |    1  |
 -------------------- ------- 

当前效果:每次调用分页操作后,可以在控制台看到统计数据,到此基础功能已经开发完毕。

③使用属性设置功能参数

由于当前报表显示的信息格式固定,为提高报表信息显示的灵活性,需要通过yml文件设置参数,控制报表的显示格式。

步骤一:定义参数格式

设置3个属性,分别用来控制显示周期(cycle),阶段数据是否清空(cycleReset),数据显示格式(model)

代码语言:javascript复制
tools:
  ip:
    cycle: 10
    cycleReset: false
    model: "detail"

步骤二:定义封装参数的属性类,读取配置参数

为防止项目组定义的参数种类过多,产生冲突,通常设置属性前缀会至少使用两级属性作为前缀进行区分。

代码语言:javascript复制
@Data
@ConfigurationProperties(prefix = "tools.ip")
public class IpProperties {
    /**
     * 日志显示周期
     */
    private Long cycle = 5L; //默认值
    /**
     * 是否周期内重置数据
     */
    private Boolean cycleReset = false;
    /**
     * 日志输出模式  detail:详细模式  simple:极简模式
     */
    private String model = LogModel.DETAIL.value;
    public enum LogModel{
        DETAIL("detail"),
        SIMPLE("simple");
        private String value;
        LogModel(String value) {
            this.value = value;
        }
        public String getValue() {
            return value;
        }
    }
}

日志输出模式是在若干个类别选项中选择某一项,对于此种分类性数据建议制作枚举定义分类数据,当然使用字符串也可以。

步骤三:加载属性类

代码语言:javascript复制
@EnableScheduling
@EnableConfigurationProperties(IpProperties.class)
public class IpAutoConfiguration {
    @Bean
    public IpCountService ipCountService(){
        return new IpCountService();
    }
}

步骤四:应用配置属性

在应用配置属性的功能类中,使用自动装配加载对应的配置bean,然后使用配置信息做分支处理。

注意:清除数据的功能一定要在输出后运行,否则每次查阅的数据均为空白数据。

代码语言:javascript复制
public class IpCountService {
    private Map<String,Integer> ipCountMap = new HashMap<String,Integer>();
    @Autowired
    private IpProperties ipProperties;
    @Scheduled(cron = "0/5 * * * * ?")
    public void print(){
if(ipProperties.getModel().equals(IpProperties.LogModel.DETAIL.getValue())){
            System.out.println("         IP访问监控");
            System.out.println(" -----ip-address----- --num-- ");
            for (Map.Entry<String, Integer> entry : ipCountMap.entrySet()) {
                String key = entry.getKey();
                Integer value = entry.getValue();
                System.out.println(String.format("|s  |]  |",key,value));
            }
            System.out.println(" -------------------- ------- ");
        }else if(ipProperties.getModel().equals(IpProperties.LogModel.SIMPLE.getValue())){
            System.out.println("     IP访问监控");
            System.out.println(" -----ip-address----- ");
            for (String key: ipCountMap.keySet()) {
                System.out.println(String.format("|s  |",key));
            }
            System.out.println(" -------------------- ");
        }
        //阶段内统计数据归零
        if(ipProperties.getCycleReset()){
            ipCountMap.clear();
        }
    }
}

当前效果:在web程序端可以通过控制yml文件中的配置参数对统计信息进行格式控制。但是数据显示周期还未进行控制。

④使用属性设置定时器参数

在使用属性配置中的显示周期数据时,遇到了一些问题。由于无法在@Scheduled注解上直接使用配置数据,改用曲线救国的方针,放弃使用@EnableConfigurationProperties注解对应的功能,改成最原始的bean定义格式。

步骤一:属性类定义bean并指定bean的访问名称

如果此处不设置bean的访问名称,spring会使用自己的命名生成器生成bean的长名称,无法实现属性的读取

代码语言:javascript复制
@Component("ipProperties")
@ConfigurationProperties(prefix = "tools.ip")
public class IpProperties {
}

步骤二:@Scheduled注解使用#{}读取bean属性值

此处读取bean名称为ipProperties的bean的cycle属性值

代码语言:javascript复制
@Scheduled(cron = "0/#{ipProperties.cycle} * * * * ?")
public void print(){
}

步骤三:弃用@EnableConfigurationProperties注解对应的功能,改为导入bean的形式加载配置属性类

代码语言:javascript复制
@EnableScheduling
//@EnableConfigurationProperties(IpProperties.class)
@Import(IpProperties.class)
public class IpAutoConfiguration {
    @Bean
    public IpCountService ipCountService(){
        return new IpCountService();
    }
}

当前效果:在web程序端可以通过控制yml文件中的配置参数对统计信息的显示周期进行控制

⑤使用拦截器开发

目前基础功能基本上已经完成了制作,下面进行拦截器的开发。使用拦截器后使用时不需要再引入对象调用方法了,导入 starter 即可实现功能。

步骤一:开发拦截器

使用自动装配加载统计功能的业务类,并在拦截器中调用对应功能

代码语言:javascript复制
public class IpCountInterceptor implements HandlerInterceptor {
    @Autowired
    private IpCountService ipCountService;
    @Override
    public boolean preHandle(HttpServletRequest request, 
                             HttpServletResponse response, Object handler) throws Exception {
        ipCountService.count();
        return true;
    }
}

步骤二:配置拦截器

配置mvc拦截器,设置拦截对应的请求路径。此处拦截所有请求,用户可以根据使用需要设置要拦截的请求。甚至可以在此处加载IpCountProperties中的属性,通过配置设置拦截器拦截的请求。

代码语言:javascript复制
@Configuration
public class SpringMvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(ipCountInterceptor()).addPathPatterns("/**");
    }
    @Bean
    public IpCountInterceptor ipCountInterceptor(){
        return new IpCountInterceptor();
    }
}

步骤三:去除之前的方法

代码语言:javascript复制
@RestController
@RequestMapping("/books")
public class BookController {
    //@Autowired 去掉
    //private IpCountService ipCountService; 去掉
    @GetMapping("{currentPage}/{pageSize}")
    public R getPage(@PathVariable int currentPage,@PathVariable int pageSize,Book book){
        //ipCountService.count();  去掉
        IPage<Book> page = bookService.getPage(currentPage, pageSize,book);
        if( currentPage > page.getPages()){
            page = bookService.getPage((int)page.getPages(), pageSize,book);
        }
        return new R(true, page);
    }
}

当前效果:在web程序端导入对应的starter后功能开启,去掉坐标后功能消失,实现自定义starter的效果。

⑥开启yml提示功能

我们在使用springboot的配置属性时,都可以看到提示,导入了对应的starter后,也会有对应的提示信息出现。但是现在我们的starter没有对应的提示功能,这种设定就非常的不友好,本节解决自定义starter功能如何开启配置提示的问题。springboot提供有专用的工具实现此功能,仅需导入下列坐标。

代码语言:javascript复制
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
</dependency>

程序编译后,在target/META-INF目录中会生成对应的json提示文件,然后拷贝json文件到自己开发的META-INF目录中,并对其进行自定义编辑。生成的信息来自于属性类的注释信息。打开生成的json文件,可以看到如下信息。

代码语言:javascript复制
{
  "groups": [
    {
      "name": "tools.ip",
      "type": "com.jianjian.properties.IpProperties",
      "sourceType": "com.jianjian.properties.IpProperties"
    }
  ],
  "properties": [
    {
      "name": "tools.ip.cycle",
      "type": "java.lang.Long",
      "description": "日志显示周期",
      "sourceType": "com.jianjian.properties.IpProperties",
      "defaultValue": 5
    },
    {
      "name": "tools.ip.cycle-reset",
      "type": "java.lang.Boolean",
      "description": "是否周期内重置数据",
      "sourceType": "com.jianjian.properties.IpProperties",
      "defaultValue": false
    },
    {
      "name": "tools.ip.model",
      "type": "java.lang.String",
      "description": "日志输出模式  detail:详细模式  simple:极简模式",
      "sourceType": "com.jianjian.properties.IpProperties"
    }
  ],
  "hints": [
    {
      "name": "tools.ip.model",
      "values": [
        {
          "value": "detail",
          "description": "详细模式."
        },
        {
          "value": "simple",
          "description": "极简模式."
        }
      ]
    }
  ]
}
  • groups属性定义了当前配置的提示信息总体描述,当前配置属于哪一个属性封装类,
  • properties属性描述了当前配置中每一个属性的具体设置,包含名称、类型、描述、默认值等信息。
  • hints属性默认是空白的,没有进行设置。hints属性可以参考springboot源码中的制作,设置当前属性封装类专用的提示信息,上面为日志输出模式属性model设置了两种可选提示信息。

总结

  1. 自定义starter其实就是做一个独立的功能模块,核心技术是利用自动配置的效果在加载模块后加载对应的功能
  2. 通常会为自定义starter的自动配置功能添加足够的条件控制,而不会做成100%加载对功能的效果
  3. 本例中使用map保存数据,如果换用redis方案,在starter开发模块中就要导入redis对应的starter
  4. 对于配置属性务必开启提示功能,否则使用者无法感知配置应该如何书写

到此当前案例全部完成,自定义stater的开发其实在第一轮开发中就已经完成了,就是创建独立模块导出独立功能,需要使用的位置导入对应的starter即可。如果是在企业中开发,记得不仅需要将开发完成的starter模块install到自己的本地仓库中,开发完毕后还要deploy到私服上,否则别人无法使用。

代码语言:javascript复制

3.SpringBoot程序启动

①启动过程分析

不管是springboot程序还是spring程序,启动过程本质上都是在做容器的初始化,并将对应的bean初始化出来放入容器。在spring环境中,每个bean的初始化都要开发者自己添加设置,但是切换成springboot程序后,自动配置功能的添加帮助开发者提前预设了很多bean的初始化过程,加上各种各样的参数设置,使得整体初始化过程显得更简单,但是核心本质还是在做一件事,初始化容器。

作为开发者只要搞清楚springboot提供了哪些参数设置的环节,同时初始化容器的过程中都做了哪些事情就行了。springboot初始化的参数根据参数的提供方,划分成如下3个大类,每个大类的参数又被封装了各种各样的对象,具体如下:

  • 环境属性(Environment)
  • 系统配置(spring.factories)
  • 参数(Arguments、application.properties)

以下通过代码流向介绍了springboot程序启动时每一环节做的具体事情。

代码语言:javascript复制
Springboot30StartupApplication【10】->SpringApplication.run(Springboot30StartupApplication.class, args);
    SpringApplication【1332】->return run(new Class<?>[] { primarySource }, args);
        SpringApplication【1343】->return new SpringApplication(primarySources).run(args);
            SpringApplication【1343】->SpringApplication(primarySources)
            # 加载各种配置信息,初始化各种配置对象
                SpringApplication【266】->this(null, primarySources);
                    SpringApplication【280】->public SpringApplication(ResourceLoader resourceLoader, Class<?>... primarySources)
                        SpringApplication【281】->this.resourceLoader = resourceLoader;
                        # 初始化资源加载器
                        SpringApplication【283】->this.primarySources = new LinkedHashSet<>(Arrays.asList(primarySources));
                        # 初始化配置类的类名信息(格式转换)
                        SpringApplication【284】->this.webApplicationType = WebApplicationType.deduceFromClasspath();
                        # 确认当前容器加载的类型
                        SpringApplication【285】->this.bootstrapRegistryInitializers = getBootstrapRegistryInitializersFromSpringFactories();
                        # 获取系统配置引导信息
                        SpringApplication【286】->setInitializers((Collection) getSpringFactoriesInstances(ApplicationContextInitializer.class));
                        # 获取ApplicationContextInitializer.class对应的实例
                        SpringApplication【287】->setListeners((Collection) getSpringFactoriesInstances(ApplicationListener.class));
                        # 初始化监听器,对初始化过程及运行过程进行干预
                        SpringApplication【288】->this.mainApplicationClass = deduceMainApplicationClass();
                        # 初始化了引导类类名信息,备用
            SpringApplication【1343】->new SpringApplication(primarySources).run(args)
            # 初始化容器,得到ApplicationContext对象
                SpringApplication【323】->StopWatch stopWatch = new StopWatch();
                # 设置计时器
                SpringApplication【324】->stopWatch.start();
                # 计时开始
                SpringApplication【325】->DefaultBootstrapContext bootstrapContext = createBootstrapContext();
                # 系统引导信息对应的上下文对象
                SpringApplication【327】->configureHeadlessProperty();
                # 模拟输入输出信号,避免出现因缺少外设导致的信号传输失败,进而引发错误(模拟显示器,键盘,鼠标...)
                    java.awt.headless=true
                SpringApplication【328】->SpringApplicationRunListeners listeners = getRunListeners(args);
                # 获取当前注册的所有监听器
                SpringApplication【329】->listeners.starting(bootstrapContext, this.mainApplicationClass);
                # 监听器执行了对应的操作步骤
                SpringApplication【331】->ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
                # 获取参数
                SpringApplication【333】->ConfigurableEnvironment environment = prepareEnvironment(listeners, bootstrapContext, applicationArguments);
                # 将前期读取的数据加载成了一个环境对象,用来描述信息
                SpringApplication【333】->configureIgnoreBeanInfo(environment);
                # 做了一个配置,备用
                SpringApplication【334】->Banner printedBanner = printBanner(environment);
                # 初始化logo
                SpringApplication【335】->context = createApplicationContext();
                # 创建容器对象,根据前期配置的容器类型进行判定并创建
                SpringApplication【363】->context.setApplicationStartup(this.applicationStartup);
                # 设置启动模式
                SpringApplication【337】->prepareContext(bootstrapContext, context, environment, listeners, applicationArguments, printedBanner);
                # 对容器进行设置,参数来源于前期的设定
                SpringApplication【338】->refreshContext(context);
                # 刷新容器环境
                SpringApplication【339】->afterRefresh(context, applicationArguments);
                # 刷新完毕后做后处理
                SpringApplication【340】->stopWatch.stop();
                # 计时结束
                SpringApplication【341】->if (this.logStartupInfo) {
                # 判定是否记录启动时间的日志
                SpringApplication【342】->    new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch);
                # 创建日志对应的对象,输出日志信息,包含启动时间
                SpringApplication【344】->listeners.started(context);
                # 监听器执行了对应的操作步骤
                SpringApplication【345】->callRunners(context, applicationArguments);
                # 调用运行器
                SpringApplication【353】->listeners.running(context);
                # 监听器执行了对应的操作步骤

上述过程描述了springboot程序启动过程中做的所有的事情。

②干预启动过程

如果想干预springboot的启动过程,比如自定义一个数据库环境检测的程序,该如何将这个过程加入springboot的启动流程呢?遇到这样的问题,大部分技术是这样设计的,设计若干个标准接口,对应程序中的所有标准过程。当你想干预某个过程时,实现接口就行了。例如spring技术中bean的生命周期管理就是采用标准接口进行的。

代码语言:javascript复制
public class Abc implements InitializingBean, DisposableBean {
    public void destroy() throws Exception {
        //销毁操作
    }
    public void afterPropertiesSet() throws Exception {
        //初始化操作
    }
}

springboot启动过程由于存在着大量的过程阶段,如果设计接口就要设计十余个标准接口,这样对开发者不友好,同时整体过程管理分散,十余个过程各自为政,管理难度大,过程过于松散。那springboot如何解决这个问题呢?它采用了一种最原始的设计模式来解决这个问题,这就是监听器模式,使用监听器来解决这个问题。

springboot将自身的启动过程比喻成一个大的事件,该事件是由若干个小的事件组成的。例如:

  • org.springframework.boot.context.event.ApplicationStartingEvent
    • 应用启动事件,在应用运行但未进行任何处理时,将发送 ApplicationStartingEvent
  • org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent
    • 环境准备事件,当Environment被使用,且上下文创建之前,将发送 ApplicationEnvironmentPreparedEvent
  • org.springframework.boot.context.event.ApplicationContextInitializedEvent
    • 上下文初始化事件
  • org.springframework.boot.context.event.ApplicationPreparedEvent
    • 应用准备事件,在开始刷新之前,bean定义被加载之后发送 ApplicationPreparedEvent
  • org.springframework.context.event.ContextRefreshedEvent
    • 上下文刷新事件
  • org.springframework.boot.context.event.ApplicationStartedEvent
    • 应用启动完成事件,在上下文刷新之后且所有的应用和命令行运行器被调用之前发送 ApplicationStartedEvent
  • org.springframework.boot.context.event.ApplicationReadyEvent
    • 应用准备就绪事件,在应用程序和命令行运行器被调用之后,将发出 ApplicationReadyEvent,用于通知应用已经准备处理请求
  • org.springframework.context.event.ContextClosedEvent(上下文关闭事件,对应容器关闭)

上述列出的仅仅是部分事件,当应用启动后走到某一个过程点时,监听器监听到某个事件触发,就会执行对应的事件。除了系统内置的事件处理,用户就可以根据需要自定义开发当前事件触发时要做的动作。

代码语言:javascript复制
//设定监听器,在应用启动开始事件时进行功能追加
public class MyListener implements ApplicationListener {
    public void onApplicationEvent(ApplicationStartingEvent event) {
        //自定义事件处理逻辑
    }
}

按照上述方案处理,用户就可以干预springboot启动过程的所有工作节点,设置自己的业务系统中独有的功能点。

总结

  1. springboot启动流程是先初始化容器需要的各种配置,并加载成各种对象,初始化容器时读取这些对象,创建容器
  2. 整体流程采用事件监听的机制进行过程控制,开发者可以根据需要自行扩展,添加对应的监听器绑定具体事件,就可以在事件触发位置执行开发者的业务代码

4.Web开发

1.SpringMVC自动配置概览

  • 内容协商视图解析器和BeanName视图解析器
  • 静态资源(包括webjars)
  • 自动注册 Converter,GenericConverter,Formatter
  • 支持 HttpMessageConverters
  • 自动注册 MessageCodesResolver (国际化)
  • 静态index.html 页支持
  • 自定义 Favicon
  • 自动使用 ConfigurableWebBindingInitializer (DataBinder负责将请求数据绑定到JavaBean上)

2.静态资源访问

a.静态资源目录

默认静态资源路径/static or /public or /resources or /META-INF/resources

访问 : 当前项目根路径/静态资源名 例如:127.0.0.1/jianjian.png

原理:当请求进来时,先去找 Controller 看能不能处理。不能处理的所有请求又都交给静态资源处理器。静态资源也找不到则响应404页面。

修改默认的静态资源路径,/static,/public,resources,/META-INF/resources 将失效

代码语言:javascript复制
spring:
    web:
        resources:
          static-locations: [classpath:/jianjian/]

静态资源访问前缀

代码语言:javascript复制
spring:
  mvc:
    static-path-pattern: /res/**

访问 :当前项目根路径 static-path-pattern 静态资源名

例如:127.0.0.1/res/jianjian.png

webjar

可用jar方式添加css,js等资源文件,webjar 查找:https://www.webjars.org/,例如,添加jquery

代码语言:javascript复制
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>jquery</artifactId>
    <version>3.5.1</version>
</dependency>

访问:localhost:8080/webjars/jquery/3.5.1/jquery.js ,后面地址要按照依赖里面的包路径。

b.欢迎页支持

  • 静态资源路径下 index.html
    • 可以配置静态资源路径
    • 配置静态资源的访问前缀,会导致 index.html不能被默认访问
代码语言:javascript复制
spring:
#  mvc:
#    static-path-pattern: /res/**   这个会导致欢迎页功能失效
    web:
        resources:
            static-locations: [classpath:/haha/]
  • controller处理index
代码语言:javascript复制
@RequestMapping("/")
public String home(){
  return "index";
}

c.自定义Favicon

将 favicon.ico 放在静态资源目录下即可,但是配置静态资源访问前缀会导致 Favicon 功能失效

代码语言:javascript复制
spring:
#  mvc:
#    static-path-pattern: /res/**   这个会导致 Favicon 功能失效

d.静态资源配置原理

。。。

3.请求参数处理

a.rest使用与原理

  • @xxxMapping
    • @GetMapping
    • @PostMapping
    • @PutMapping
    • @DeleteMapping
  • Rest风格支持(使用HTTP请求方式动词来表示对资源的操作
    • 以前:
      • /getUser 获取用户
      • /deleteUser 删除用户
      • /editUser 修改用户
      • /saveUser 保存用户
    • 现在: /user
      • GET-获取用户
      • DELETE-删除用户
      • PUT-修改用户
      • POST-保存用户
  • 核心Filter:HiddenHttpMethodFilter

用法

  • 1.开启页面表单的Rest功能
代码语言:javascript复制
spring:
  mvc:
    hiddenmethod:
      filter:
        enabled: true   #开启页面表单的Rest功能
  • 2.页面 form的属性method=post,隐藏域 _method=put、delete等(如果直接get或post,无需隐藏域)
代码语言:javascript复制
<form action="/user" method="get">
    <input value="REST-GET提交" type="submit" />
</form>

<form action="/user" method="post">
    <input value="REST-POST提交" type="submit" />
</form>

<form action="/user" method="post">
    <input name="_method" type="hidden" value="DELETE"/>
    <input value="REST-DELETE 提交" type="submit"/>
</form>

<form action="/user" method="post">
    <input name="_method" type="hidden" value="PUT" />
    <input value="REST-PUT提交"type="submit" />
<form>
  • 3.编写请求映射
代码语言:javascript复制
    @RequestMapping(value = "/user",method = RequestMethod.GET)
    public String getUser(){
        return "GET-张三";
    }

    @RequestMapping(value = "/user",method = RequestMethod.POST)
    public String saveUser(){
        return "POST-张三";
    }


    @RequestMapping(value = "/user",method = RequestMethod.PUT)
    public String putUser(){
        return "PUT-张三";
    }

    @RequestMapping(value = "/user",method = RequestMethod.DELETE)
    public String deleteUser(){
        return "DELETE-张三";
    }

Rest原理(表单提交要使用REST的时候)

  • 表单提交会带上 _method=PUT
  • 请求过来被 HiddenHttpMethodFilter拦截,检测请求是否正常,并且是POST
    • 获取到 _method 的值
    • 兼容以下请求;PUTDELETEPATCH
    • 原生request(post),包装模式 requesWrapper 重写了getMethod方法,返回的是传入的值( _method)。
    • 过滤器链放行的时候用 wrapper。以后的方法调用getMethod是调用 requesWrapper

b.请求映射原理

0 人点赞