深入剖析Spring Boot3.0自动配置原理,核心概念以及Tomcat自动启动原理

导言

现在许多项目都广泛采用了Spring Boot,你只需要引入相应的starter,例如spring-boot-starter-web,然后启动应用程序,就会自动启动Tomcat Web服务器并开始接收HTTP请求。那么,这是如何实现的呢?它是如何知道要启动Tomcat而不是Undertow? 另外,如果我希望使用Undertow吗,要如何切换?本文将深入剖析背后的原理。

简单的说,就是Spring Boot提供了一种自动配置(auto-configuration)机制:当项目引入一个包含自动配置的jar包时,根据特定的条件和规则,它会注册不同的Bean到Spring容器中,从而启动不同的功能特性。

那么,具体什么是自动配置,它是如何工作的?有哪些条件和规则?这些条件和规则又是如何匹配和应用的?本文将分三个部分帮你全面了解自动配置的工作原理:

  • 核心概念:@AutoConfiguration(自动配置类)和@Conditional注解(条件匹配)
  • 案例分析:Spring Boot是怎么自动启动Tomcat服务器的?
  • 常见问题和FAQ

本文基于Spring Boot 3.0.x版本,同时也适用于Spring Boot 2.7.x版本。

核心概念 - 自动配置类@AutoConfiguration

什么是自动配置类

使用过Spring框架的开发者应该对@Configuration注解非常熟悉了。在项目中,我们经常使用它来进行自定义的Bean配置。

@AutoConfiguration是专门用于自动配置类的注解,而这些加了AutoConfiguration注解的自动配置类就是自动配置的入口。@AutoConfiguration本身也使用了@Configuration注解,表明自动配置类也是一个标准的配置类。

与标准的配置类相同,自动配置类的核心内容也是配置Bean,但是它会在此基础上,添加各种条件和规则,只有满足特定的条件和规则,这些Bean才会生效。另外,这些条件规则也可以应用到自动配置类本身,控制整个自动配置类的开启与否。

通常一个特定的自包含特性功能会对应一个自动配置类,但是配置本身不一定要都要全写在这一个类里,可以分解为多个普通的@Configuration配置类,然后通过@Import引入。

例如,Servlet Web服务器相关功能的自动配置,入口就是一个自动配置类ServletWebServerFactoryAutoConfiguration,它将每个可选的Web服务器配置都拆分到各自的@Configuration配置类中,部分代码如下所示:

Java 复制代码
@AutoConfiguration(after = SslAutoConfiguration.class)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)  
@ConditionalOnClass(ServletRequest.class)  
@ConditionalOnWebApplication(type = Type.SERVLET)  
@EnableConfigurationProperties(ServerProperties.class)  
@Import({
ServletWebServerFactoryAutoConfiguration.BeanPostProcessorsRegistrar.class,
ServletWebServerFactoryConfiguration.EmbeddedTomcat.class,  
ServletWebServerFactoryConfiguration.EmbeddedJetty.class,  
ServletWebServerFactoryConfiguration.EmbeddedUndertow.class })  
public class ServletWebServerFactoryAutoConfiguration {
    // ... 其它Bean配置 ...
}

我们来详细分析下这个自动配置类上的注解。

  • @AutoConfiguration(after = SslAutoConfiguration.class):告诉Spring框架这个类是用于自动配置的。有些自动配置的初始化是有先后依赖关系,可以通过afterbefore来声明这种依赖关系。
  • @AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE):设置自动配置类加载的顺序。
  • @ConditionalOnClass@ConditionalOnWebApplication:这两个注解就是自动配置生效的条件和规则,后面会详细说明。
  • @EnableConfigurationProperties(ServerProperties.class):自动配置提供的自定义参数,比如server.port等。
  • @Import({...}):自动配置类一般是作为入口,简单的配置可以直接写在自动配置类里。而复杂的配置建议按功能或范围拆分成子配置,然后通过@Import引入。注意,引入的顺序会影响条件的匹配,尤其是选项类的配置(比如选择Tomcat,Jetty还是Undertow)。

查找自动配置类

我们现在有了自动配置类,那么Spring Boot是如何知道要加载这个自动配置类的呢?要知道,我们只是单纯引入了一个jar包而已,并没有做任何设置。

答案是,Spring定义了一套自动配置专用的发现机制,就是jar包里的META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件。该文件的每一行就是一个自动配置类的完全限定名,比如下面是spring-boot-autoconfigure包里该文件的部分内容:

shell 复制代码
## 其它自动配置类
org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration  
org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration  
org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
## 其它自动配置类

可以看到ServletWebServerFactoryAutoConfiguration就在这个文件里。具体的加载逻辑可以查看源码AutoConfigurationImportSelector#getCandidateConfigurations

imports文件是Spring 2.7新引入的发现机制,之前版本使用的是spring.factories文件。实际上,Spring 2.7.x版本两种方式都支持,而在Spring 3.0中完全删除了对spring.factories文件的兼容支持。
【自动配置的模块组织】一般简单的自动配置模块,只有一个starter模块。而复杂的配置,都会拆成两个模块:starter和autoconfigure。例如,Spring Boot将其内置的所有自动配置类都放在了spring-boot-autoconfigure包里,包括imports文件。而为每个单独的功能特性提供了独立的starter包,比如spring-boot-starter-webspring-boot-starter-jdbc等。这些starter没有任何Java代码,唯一作用是引入所有需要的依赖。

核心概念 - 条件@Conditional

自动配置的核心是条件匹配,不同的条件加载不同的Bean,从而启用不同的功能特性。在Spring Boot中,使用了一系列的@ConditionalXXX注解来定义条件。其中,最常用的包括:

  • 类条件@ConditionalOnClass@ConditionalOnMissingClass,用于检测类的存在与否。简单的说就是,应用程序有没有直接或者间接的引用了包含这个类的jar包。比如,你要开启Undertow服务器的自动配置,就要引入Undertow相关的jar包。
  • Bean条件@ConditionalOnBean@ConditionalOnMissingBean,用于检测Spring容器中是否已经注册了指定的Bean。通过使用这些条件注解,开发者可以根据需要注册自定义的Bean,以覆盖默认的配置。比如Spring提供了多种DataSource,不过不包含Druid,你就可以自定义一个基于DruidDataSource Bean,覆盖Spring默认提供的DataSource实现。
  • 属性条件@ConditionalOnProperty,用于检测当前的Environment中是否配置了指定的属性,这些属性可以来自配置文件,JVM系统属性,操作系统的环境变量等。比如Hikari的其中一个条件是@ConfigurationProperties(prefix = "spring.datasource.hikari")
  • 资源条件@ConditionalOnResource,用于检查是否存在特定的资源,比如是否存在某个配置文件,这种条件用到的很少。
  • Web特定条件@ConditionalOnWebApplication@ConditionalOnNotWebApplication,用于检测应用类型是否为Web应用。@ConditionalOnWarDeployment@ConditionalOnNotWarDeployment注解用于判断是否是一个部署在Servlet容器上的传统WAR应用,而使用内嵌的web服务器的应用就不符合此条件。
  • SpEL表达式条件@ConditionalOnExpression可以用SpEL表达式指定条件规则。要注意, 如果在表达式中引入了其它bean,会导致提早初始化这些bean。此时,这些Bean的状态可能是不完整的,因为它还没有经过Post Processor(比如属性绑定)的处理。建议先用上面的几种条件,无法满足再考虑这种。

我们分析一个实际案例,ServletWebServerFactoryAutoConfiguration条件注解如下:

Java 复制代码
@AutoConfiguration(after = SslAutoConfiguration.class)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)  
@ConditionalOnClass(ServletRequest.class) // (1)
@ConditionalOnWebApplication(type = Type.SERVLET) // (2)
@EnableConfigurationProperties(ServerProperties.class)  
@Import({ ServletWebServerFactoryAutoConfiguration.BeanPostProcessorsRegistrar.class,  
ServletWebServerFactoryConfiguration.EmbeddedTomcat.class,  
ServletWebServerFactoryConfiguration.EmbeddedJetty.class,  
ServletWebServerFactoryConfiguration.EmbeddedUndertow.class })  
public class ServletWebServerFactoryAutoConfiguration {
    // ... 其它Bean配置 ...
}
  1. @ConditionalOnClass(ServletRequest.class):要求类路径下必须存在ServletRequest类,这个很好理解,如果都没用到Servlet相关的类和库,说明你不需要Servlet Web服务相关的功能,也就没必要启动相关配置了。
  2. @ConditionalOnWebApplication(type = Type.SERVLET):只是引入了Servlet相关类和库,也不能表明这就是一个Servlet Web服务应用。这个条件就能确保当前启动的应用是一个Servlet Web服务。

这两个条件注解是应用在自动配置类上的,是一种总开关,如果不满足,这个自动配置类就会被完全禁用。

如果满足了类级别上的条件,就会继续加载具体的配置,包括自动配置类里定义的@Bean方法和@Import的配置类。

假设自动配置类的开关条件满足了,我们看下Tomcat的具体配置,也就是@Import里的ServletWebServerFactoryConfiguration.EmbeddedTomcat配置类,它的核心代码如下:

Java 复制代码
@Configuration(proxyBeanMethods = false) // (1)
@ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class }) // (2) 
@ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT) // (3)
static class EmbeddedTomcat {
    @Bean // (4)
    TomcatServletWebServerFactory tomcatServletWebServerFactory(...) {  
        TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();  
        // 其它初始化代码
        return factory;  
    }
}

我们详细分析下这个配置类:

  1. @Configuration(proxyBeanMethods = false):表明它是一个配置类,proxyBeanMethods=false表示这个配置类不需要用CGLIB增强@Bean方法,CGLIB增强后,可以以直接调用@Bean方法的方式,定义Bean之间的依赖关系。
  2. @ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class }) :这些都是Tomcat的核心类。简单的说,就是要求你引入Tomcat相关的jar包。同理,EmbeddedUndertow的条件就要求引入Jetty的核心类。
  3. @ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT) :字面意思是,只有当前Spring容器中没有ServletWebServerFactory类型的Bean,才会注册这个Bean。换种说法就是,目前还没有加载其它Web服务器。其它可选的服务器配置类,比如EmbeddedJettyEmbeddedUndertow也是这个条件。你也可以注册自定义的ServletWebServerFactory,覆盖Spring Boot自带的Web服务器。
  4. tomcatServletWebServerFactory:这个配置类只有这一个@Bean方法,返回的是一个工厂类Bean,它的作用是实例化,初始化一个Tomcat Web Server。只有EmbeddedTomcat类上的条件注解都满足之后,这个@Bean方法才会生效。

案例分析:Spring Boot是怎么自动启动Tomcat服务器的?

上面讲述了自动配置的基本原理和概念,接下来我们来回答文章开头提出的问题:"我们只是引入了spring-boot-starter-web包,Spring Boot是怎么知道要自动启动Tomcat服务器的?具体是如何启动的呢?"

第一个问题其实简单,因为spring-boot-starter-web引入了spring-boot-starter-tomcat

而关于其中的决策和启动过程,上面讲原理的时候其实已经提到了核心部分,无非就是条件匹配,不过前面部分侧重原理,知识点比较分散,这里通过案例分析的方式,把整个过程串起来,再详细说明下Spring Boot的整个决策过程。

第一步:扫描和注册用户自定义的Bean配置

这是所有Spring Boot启动的标准步骤,这里没有什么特殊的地方。只需要知道自动配置类的解析和加载是在用户自定义的Bean配置之后的。只有这样,自动配置才能根据用户的自定义配置做调整。

第二步:查找自动配置类

在这一步,Spring会扫描类路径下的所有jar包,查找自动配置类的注册文件META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,然后加载文件里的自动配置类。

我们的应用只引入了spring-boot-starter-web包,但是这个包引入了spring-boot-starter,继而引入了spring-boot-autoconfigure,我们可以从spring-boot-autoconfigure包下找到这个imports文件,该文件配置了Spring Boot内置的大量自动配置类,这里我们只关心Servlet Web服务器相关的自动配置类org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration

我们再回顾下这个类的源码,后续会解析具体的条件匹配过程。

Java 复制代码
@AutoConfiguration(after = SslAutoConfiguration.class)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)  
@ConditionalOnClass(ServletRequest.class)
@ConditionalOnWebApplication(type = Type.SERVLET)
@EnableConfigurationProperties(ServerProperties.class)  
@Import({ ServletWebServerFactoryAutoConfiguration.BeanPostProcessorsRegistrar.class,  
ServletWebServerFactoryConfiguration.EmbeddedTomcat.class,  
ServletWebServerFactoryConfiguration.EmbeddedJetty.class,  
ServletWebServerFactoryConfiguration.EmbeddedUndertow.class })  
public class ServletWebServerFactoryAutoConfiguration {
    // ... 其它Bean配置 ...
}

第三步:条件匹配@ConditionalOnClass(ServletRequest.class)

spring-boot-starter-web包引入了spring-boot-starter-tomcat,继而引入了tomcat-embed-core,这个包打包了JavaEE(Spring Boot 3.x之后是Jakarta EE)的类,其中就包含了ServletRequest类,这样就满足了该条件。

第四步:条件匹配@ConditionalOnWebApplication(type = Type.SERVLET)

SpringApplication类的构造函数会调用下面这段代码,判断Web应用的类型。

Java 复制代码
static WebApplicationType deduceFromClasspath() {  
    if (ClassUtils.isPresent(WEBFLUX_INDICATOR_CLASS, null) && !ClassUtils.isPresent(WEBMVC_INDICATOR_CLASS, null)  
    && !ClassUtils.isPresent(JERSEY_INDICATOR_CLASS, null)) {  
        return WebApplicationType.REACTIVE;  
    }  
    for (String className : SERVLET_INDICATOR_CLASSES) {
        if (!ClassUtils.isPresent(className, null)) {  
            return WebApplicationType.NONE;  
        }  
    }  
    return WebApplicationType.SERVLET;  
}

从这段代码可以看出,当前应用是Servlet Web服务的前提,是存在相关的类SERVLET_INDICATOR_CLASSES,这个值在Spring Boot 3.x和之前的版本有些微差别,具体如下:

Java 复制代码
// Spring Boot 3.x
String[] SERVLET_INDICATOR_CLASSES = { "jakarta.servlet.Servlet",  
"org.springframework.web.context.ConfigurableWebApplicationContext" };

// Spring Boot 2.7.x
String[] SERVLET_INDICATOR_CLASSES = { "javax.servlet.Servlet",  
"org.springframework.web.context.ConfigurableWebApplicationContext" };

差异就是Servlet类的包名改了,因为Spring 3.x从JavaEE升级到了Jakarta EE,Servlet跟第三步要求的ServletRequest类在同一个包下,因此这个条件也满足了。剩下的就是org.springframework.web.context.ConfigurableWebApplicationContext类。从包名可以看出,它是spring-web中的一个类,我们分析下包的依赖关系,发现spring-web包是由spring-boot-starter-web包引入的。它其实是个接口,具体的实现类是ServletWebServerApplicationContext

至此,ServletWebServerFactoryAutoConfiguration的两个条件注解都满足了。Spring Boot就会开始加载这个配置类以及它@Import的配置类。

第五步:加载@Import的EmbeddedTomcat

我们先看下EmbeddedTomcat的源码:

Java 复制代码
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class })
@ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT)
static class EmbeddedTomcat {
    @Bean
    TomcatServletWebServerFactory tomcatServletWebServerFactory(...) {  
        // ...
    }
}

spring-boot-starter-web引入了spring-boot-starter-tomcat,继而引入了Tomcat相关的依赖包,因此满足了第一个条件@ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class })

由于我们只是引入了spring-boot-starter-web包,没有做任何配置,此时容器肯定没有ServletWebServerFactory类型的Bean,因此满足了第二个条件@ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT)

自此,EmbeddedTomcat配置的所有条件满足,配置生效,@Bean方法tomcatServletWebServerFactory会被注册到Spring容器中,在合适的阶段用于创建TomcatServletWebServerFactory类型的Bean实例。

此外,剩下两个被@ImportEmbeddedJettyEmbeddedUndertow也还是会被处理的,但是由于我们没有引入相应的Jetty或Undertow的包,因此条件都不满足,它们的配置也就不会生效。其实,就算引入了需要的jar包,由于EmbeddedTomcat已经注册了ServletWebServerFactory,这两个配置类也不会生效,它们的源码如下:

Java 复制代码
@Configuration(proxyBeanMethods = false)  
@ConditionalOnClass({ Servlet.class, Server.class, Loader.class, WebAppContext.class })  // 这些类都是Jetty核心包下的类
@ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT) // EmbeddedTomcat已经注册了,这个条件无法满足
static class EmbeddedJetty {
    @Bean  
    JettyServletWebServerFactory jettyServletWebServerFactory(...) {  
        // ...
    }
}

@Configuration(proxyBeanMethods = false)  
@ConditionalOnClass({ Servlet.class, Undertow.class, SslClientAuthMode.class })  // 这些类都是Undertow核心包下的类
@ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT) // EmbeddedTomcat已经注册了,这个条件无法满足
static class EmbeddedUndertow {
    @Bean  
    UndertowServletWebServerFactory undertowServletWebServerFactory(...) {
        // ...
    }
}

到目前位置,Web服务器的选择决策部分已经结束了,剩下的就是其它依赖Bean的配置,这里就不再详细展开了。

第六步:启动内嵌的Tomcat服务器

在容器初始化完毕后,会调用AbstractApplicationContext#onRefresh方法,而ServletWebServerApplicationContext会重写该方法,在重写的方法中调用createWebServer方法来创建一个WebServer实例。而具体要创建哪个WebServer实例,就是看容器中注册的ServletWebServerFactory类型Bean。具体代码如下:

scss 复制代码
protected ServletWebServerFactory getWebServerFactory() {
    String[] beanNames = getBeanFactory().getBeanNamesForType(ServletWebServerFactory.class);
    return getBeanFactory().getBean(beanNames[0], ServletWebServerFactory.class);
    }

从实际效果来看,就是调用了第五步注册的tomcatServletWebServerFactory创建的工厂Bean,然后用这个工厂Bean创建了真正的Tomcat实例。

需要提一下,此时还只是创建和初始化Tomcat实例,并没有真正启动服务。在SpringApplication启动的最后一步,会触发WebServerStartStopLifecyclestart()回调,这个回调触发WebServer.start()方法,从而真正启动一个Web服务器,开始接收请求。

FAQ

1. 如何排除特定的自动配置类?

我们以排除自动数据源配置类为例,第一种方法是通过@SpringBootApplicationexclude字段:

Java 复制代码
// 第一种方法,用exclude字段。
// 如果不想对类有依赖,可以用excludeName字段。
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class)
public class Application {

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

第二种方法是在配置文件中排除:

Java 复制代码
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration

2. 不想用Tomcat,如何换成Undertow?

只需要排除spring-boot-starter-tomcat,并引入spring-boot-starter-undertow

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <!-- 排除Tomcat的依赖 -->
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<!-- 替换成Undertow的依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-undertow</artifactId>
</dependency>

3. 如果同时直接或间接地引入了Tomcat,Jetty和Undertow的依赖包,最终启动的是哪个?

实际测试发现,三个都引入的话,最终启动的是Tomcat。而如果只有Jetty和Undertow,实际启动的是Jetty。没有找到官方的优先级文档,我猜测这跟@Import的顺序有关,@Import就是按照Tomcat,Jetty和Undertow的顺序引用的,Spring先看到了import的EmbeddedTomcat配置类,发现满足条件,于是注册了ServletWebServerFactory类型的Bean TomcatServletWebServerFactory,然后继续检查Jetty和Undertown,此时由于已经注册了TomcatServletWebServerFactory,就不满足条件@ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT)了。

4. 某个配置类为什么没生效?要怎么排查?

启动应用的时候,加上-Ddebug参数,Spring就会打印出每个配置类的条件匹配的细节。

作为案例,我们看下没有exclude掉tomcat,同时又引入undertow的情况下,看看为什么undertow没有生效。从下面这个输出可以看出,虽然匹配了@ConditionalOnClass条件,但是没有匹配到@ConditionalOnMissingBean条件,具体原因是已经存在了tomcatServletWebServerFactory

Java 复制代码
ServletWebServerFactoryConfiguration.EmbeddedUndertow:
      Did not match:
         - @ConditionalOnMissingBean (types: org.springframework.boot.web.servlet.server.ServletWebServerFactory; SearchStrategy: current) found beans of type 'org.springframework.boot.web.servlet.server.ServletWebServerFactory' tomcatServletWebServerFactory (OnBeanCondition)
      Matched:
         - @ConditionalOnClass found required classes 'jakarta.servlet.Servlet', 'io.undertow.Undertow', 'org.xnio.SslClientAuthMode' (OnClassCondition)

总结

Spring Boot自动配置的核心思想就是将自动配置类和条件匹配相结合,使得我们能够快速集成各种功能和组件,而无需手动进行繁琐的配置。

而从使用者的角度看,就是通过引入或者排除特定jar包的依赖,配置特定属性和Bean,来影响条件的匹配,从而灵活地配置和定制特定功能的开关和选项。

Spring Boot支持的所有自动配置类都配置在了spring-boot-autoconfigure包的META-INF\spring\org.springframework.boot.autoconfigure.AutoConfiguration.imports,引入一个新的Starter包的时候,强烈建议去看下相关的自动配置类。

相关推荐
2401_85743969几秒前
社团管理新工具:SpringBoot框架
java·spring boot·后端
2401_857610032 分钟前
Spring Boot OA:企业办公自动化的创新之路
spring boot·后端·mfc
ThetaarSofVenice12 分钟前
Java从入门到放弃 之 泛型
java·开发语言
嘟嘟Listing20 分钟前
jenkins docker记录
java·运维·jenkins
WHabcwu26 分钟前
统⼀异常处理
java·开发语言
zaim126 分钟前
计算机的错误计算(一百六十三)
java·c++·python·matlab·错数·等价算式
枫叶丹427 分钟前
【在Linux世界中追寻伟大的One Piece】多线程(一)
java·linux·运维
2401_8543910828 分钟前
Spring Boot OA:企业数字化转型的利器
java·spring boot·后端
山山而川粤35 分钟前
废品买卖回收管理系统|Java|SSM|Vue| 前后端分离
java·开发语言·后端·学习·mysql
武昌库里写JAVA37 分钟前
Vue3与Vue2 对比
spring boot·spring·毕业设计·layui·课程设计