当前位置:   article > 正文

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

springboot3.0自动配置

导言

现在许多项目都广泛采用了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配置类中,部分代码如下所示:

  1. Java复制代码@AutoConfiguration(after = SslAutoConfiguration.class)
  2. @AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
  3. @ConditionalOnClass(ServletRequest.class)
  4. @ConditionalOnWebApplication(type = Type.SERVLET)
  5. @EnableConfigurationProperties(ServerProperties.class)
  6. @Import({
  7. ServletWebServerFactoryAutoConfiguration.BeanPostProcessorsRegistrar.class,
  8. ServletWebServerFactoryConfiguration.EmbeddedTomcat.class,
  9. ServletWebServerFactoryConfiguration.EmbeddedJetty.class,
  10. ServletWebServerFactoryConfiguration.EmbeddedUndertow.class })
  11. public class ServletWebServerFactoryAutoConfiguration {
  12. // ... 其它Bean配置 ...
  13. }

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

  • @AutoConfiguration(after = SslAutoConfiguration.class):告诉Spring框架这个类是用于自动配置的。有些自动配置的初始化是有先后依赖关系,可以通过after,before来声明这种依赖关系。
  • @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包里该文件的部分内容:

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

可以看到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-web,spring-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,你就可以自定义一个基于Druid的DataSource 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条件注解如下:

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

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

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

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

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

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

  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服务器。其它可选的服务器配置类,比如EmbeddedJetty和EmbeddedUndertow也是这个条件。你也可以注册自定义的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。

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

  1. Java复制代码@AutoConfiguration(after = SslAutoConfiguration.class)
  2. @AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
  3. @ConditionalOnClass(ServletRequest.class)
  4. @ConditionalOnWebApplication(type = Type.SERVLET)
  5. @EnableConfigurationProperties(ServerProperties.class)
  6. @Import({ ServletWebServerFactoryAutoConfiguration.BeanPostProcessorsRegistrar.class,
  7. ServletWebServerFactoryConfiguration.EmbeddedTomcat.class,
  8. ServletWebServerFactoryConfiguration.EmbeddedJetty.class,
  9. ServletWebServerFactoryConfiguration.EmbeddedUndertow.class })
  10. public class ServletWebServerFactoryAutoConfiguration {
  11. // ... 其它Bean配置 ...
  12. }

第三步:条件匹配@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应用的类型。

  1. Java复制代码static WebApplicationType deduceFromClasspath() {
  2. if (ClassUtils.isPresent(WEBFLUX_INDICATOR_CLASS, null) && !ClassUtils.isPresent(WEBMVC_INDICATOR_CLASS, null)
  3. && !ClassUtils.isPresent(JERSEY_INDICATOR_CLASS, null)) {
  4. return WebApplicationType.REACTIVE;
  5. }
  6. for (String className : SERVLET_INDICATOR_CLASSES) {
  7. if (!ClassUtils.isPresent(className, null)) {
  8. return WebApplicationType.NONE;
  9. }
  10. }
  11. return WebApplicationType.SERVLET;
  12. }

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

  1. Java复制代码// Spring Boot 3.x
  2. String[] SERVLET_INDICATOR_CLASSES = { "jakarta.servlet.Servlet",
  3. "org.springframework.web.context.ConfigurableWebApplicationContext" };
  4. // Spring Boot 2.7.x
  5. String[] SERVLET_INDICATOR_CLASSES = { "javax.servlet.Servlet",
  6. "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的源码:

  1. Java复制代码@Configuration(proxyBeanMethods = false)
  2. @ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class })
  3. @ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT)
  4. static class EmbeddedTomcat {
  5. @Bean
  6. TomcatServletWebServerFactory tomcatServletWebServerFactory(...) {
  7. // ...
  8. }
  9. }

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实例。

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

  1. Java复制代码@Configuration(proxyBeanMethods = false)
  2. @ConditionalOnClass({ Servlet.class, Server.class, Loader.class, WebAppContext.class }) // 这些类都是Jetty核心包下的类
  3. @ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT) // EmbeddedTomcat已经注册了,这个条件无法满足
  4. static class EmbeddedJetty {
  5. @Bean
  6. JettyServletWebServerFactory jettyServletWebServerFactory(...) {
  7. // ...
  8. }
  9. }
  10. @Configuration(proxyBeanMethods = false)
  11. @ConditionalOnClass({ Servlet.class, Undertow.class, SslClientAuthMode.class }) // 这些类都是Undertow核心包下的类
  12. @ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT) // EmbeddedTomcat已经注册了,这个条件无法满足
  13. static class EmbeddedUndertow {
  14. @Bean
  15. UndertowServletWebServerFactory undertowServletWebServerFactory(...) {
  16. // ...
  17. }
  18. }

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

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

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

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

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

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

FAQ

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

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

  1. Java复制代码// 第一种方法,用exclude字段。
  2. // 如果不想对类有依赖,可以用excludeName字段。
  3. @SpringBootApplication(exclude = {DataSourceAutoConfiguration.class)
  4. public class Application {
  5. public static void main(String[] args) {
  6. SpringApplication.run(PayPalApplication.class, args);
  7. }
  8. }

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

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

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

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

  1. xml复制代码<dependency>
  2. <groupId>org.springframework.boot</groupId>
  3. <artifactId>spring-boot-starter-web</artifactId>
  4. <exclusions>
  5. <!-- 排除Tomcat的依赖 -->
  6. <exclusion>
  7. <groupId>org.springframework.boot</groupId>
  8. <artifactId>spring-boot-starter-tomcat</artifactId>
  9. </exclusion>
  10. </exclusions>
  11. </dependency>
  12. <!-- 替换成Undertow的依赖 -->
  13. <dependency>
  14. <groupId>org.springframework.boot</groupId>
  15. <artifactId>spring-boot-starter-undertow</artifactId>
  16. </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。

  1. Java复制代码ServletWebServerFactoryConfiguration.EmbeddedUndertow:
  2. Did not match:
  3. - @ConditionalOnMissingBean (types: org.springframework.boot.web.servlet.server.ServletWebServerFactory; SearchStrategy: current) found beans of type 'org.springframework.boot.web.servlet.server.ServletWebServerFactory' tomcatServletWebServerFactory (OnBeanCondition)
  4. Matched:
  5. - @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包的时候,强烈建议去看下相关的自动配置类。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Guff_9hys/article/detail/796461
推荐阅读
相关标签
  

闽ICP备14008679号