Spring MVC 原理探秘 - 容器的创建过程

本文涉及的产品
容器镜像服务 ACR,镜像仓库100个 不限时长
简介: 1.简介在上一篇文章中,我向大家介绍了 Spring MVC 是如何处理 HTTP 请求的。Spring MVC 可对外提供服务时,说明其已经处于了就绪状态。再次之前,Spring MVC 需要进行一系列的初始化操作。

1.简介

在上一篇文章中,我向大家介绍了 Spring MVC 是如何处理 HTTP 请求的。Spring MVC 可对外提供服务时,说明其已经处于了就绪状态。再次之前,Spring MVC 需要进行一系列的初始化操作。正所谓兵马未动,粮草先行。这些操作包括创建容器,加载 DispatcherServlet 中用到的各种组件等。本篇文章就来和大家讨论一下这些初始化操作中的容器创建操作,容器的创建是其他一些初始化过程的基础。那其他的就不多说了,我们直入主题吧。

2.容器的创建过程

一般情况下,我们会在一个 Web 应用中配置两个容器。一个容器用于加载 Web 层的类,比如我们的接口 Controller、HandlerMapping、ViewResolver 等。在本文中,我们把这个容器叫做 web 容器。另一个容器用于加载业务逻辑相关的类,比如 service、dao 层的一些类。在本文中,我们把这个容器叫做业务容器。在容器初始化的过程中,业务容器会先于 web 容器进行初始化。web 容器初始化时,会将业务容器作为父容器。这样做的原因是,web 容器中的一些 bean 会依赖于业务容器中的 bean。比如我们的 controller 层接口通常会依赖 service 层的业务逻辑类。下面举个例子进行说明:

img_f26e42b0d641c01fb94572fa8a59314f.jpe

如上,我们将 dao 层的类配置在 application-dao.xml 文件中,将 service 层的类配置在 application-service.xml 文件中。然后我们将这两个配置文件通过 标签导入到 application.xml 文件中。此时,我们可以让业务容器去加载 application.xml 配置文件即可。另一方面,我们将 Web 相关的配置放在 application-web.xml 文件中,并将该文件交给 Web 容器去加载。

这里我们把配置文件进行分层,结构上看起来清晰了很多,也便于维护。这个其实和代码分层是一个道理,如果我们把所有的代码都放在同一个包下,那看起来会多难受啊。同理,我们用业务容器和 Web 容器去加载不同的类也是一种分层的体现吧。当然,如果应用比较简单,仅用 Web 容器去加载所有的类也不是不可以。

2.1 业务容器的创建过程

前面说了一些背景知识作为铺垫,那下面我们开始分析容器的创建过程吧。按照创建顺序,我们先来分析业务容器的创建过程。业务容器的创建入口是 ContextLoaderListener 的 contextInitialized 方法。顾名思义,ContextLoaderListener 是用来监听 ServletContext 加载事件的。当 ServletContext 被加载后,监听器的 contextInitialized 方法就会被 Servlet 容器调用。ContextLoaderListener Spring 框架提供的,它的配置方法如下:

<web-app>
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>
    
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>classpath:application.xml</param-value>
    </context-param>
    
    <!-- 省略其他配置 -->
</web-app>

如上,ContextLoaderListener 可通过 ServletContext 获取到 contextConfigLocation 配置。这样,业务容器就可以加载 application.xml 配置文件了。那下面我们来分析一下 ContextLoaderListener 的源码吧。

public class ContextLoaderListener extends ContextLoader implements ServletContextListener {

    // 省略部分代码

    @Override
    public void contextInitialized(ServletContextEvent event) {
        // 初始化 WebApplicationContext
        initWebApplicationContext(event.getServletContext());
    }
}

public WebApplicationContext initWebApplicationContext(ServletContext servletContext) {
    /*
     * 如果 ServletContext 中 ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE 属性值
     * 不为空时,表明有其他监听器设置了这个属性。Spring 认为不能替换掉别的监听器设置
     * 的属性值,所以这里抛出异常。
     */
    if (servletContext.getAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE) != null) {
        throw new IllegalStateException(
                "Cannot initialize context because there is already a root application context present - " +
                "check whether you have multiple ContextLoader* definitions in your web.xml!");
    }

    Log logger = LogFactory.getLog(ContextLoader.class);
    servletContext.log("Initializing Spring root WebApplicationContext");
    if (logger.isInfoEnabled()) {...}
    long startTime = System.currentTimeMillis();

    try {
        if (this.context == null) {
            // 创建 WebApplicationContext
            this.context = createWebApplicationContext(servletContext);
        }
        if (this.context instanceof ConfigurableWebApplicationContext) {
            ConfigurableWebApplicationContext cwac = (ConfigurableWebApplicationContext) this.context;
            if (!cwac.isActive()) {
                if (cwac.getParent() == null) {
                    /*
                     * 加载父 ApplicationContext,一般情况下,业务容器不会有父容器,
                     * 除非进行配置
                     */ 
                    ApplicationContext parent = loadParentContext(servletContext);
                    cwac.setParent(parent);
                }
                // 配置并刷新 WebApplicationContext
                configureAndRefreshWebApplicationContext(cwac, servletContext);
            }
        }

        // 设置 ApplicationContext 到 servletContext 中
        servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context);

        ClassLoader ccl = Thread.currentThread().getContextClassLoader();
        if (ccl == ContextLoader.class.getClassLoader()) {
            currentContext = this.context;
        }
        else if (ccl != null) {
            currentContextPerThread.put(ccl, this.context);
        }

        if (logger.isDebugEnabled()) {...}
        if (logger.isInfoEnabled()) {...}

        return this.context;
    }
    catch (RuntimeException ex) {
        logger.error("Context initialization failed", ex);
        servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, ex);
        throw ex;
    }
    catch (Error err) {
        logger.error("Context initialization failed", err);
        servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, err);
        throw err;
    }
}

如上,我们看一下上面的创建过程。首先 Spring 会检测 ServletContext 中 ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE 属性有没有被设置,若被设置过,则抛出异常。若未设置,则调用 createWebApplicationContext 方法创建容器。创建好后,再调用 configureAndRefreshWebApplicationContext 方法配置并刷新容器。最后,调用 setAttribute 方法将容器设置到 ServletContext 中。经过以上几步,整个创建流程就结束了。流程并不复杂,可简单总结为创建容器 → 配置并刷新容器 → 设置容器到 ServletContext 中。这三步流程中,最后一步就不进行分析,接下来分析一下第一步和第二步流程对应的源码。如下:

protected WebApplicationContext createWebApplicationContext(ServletContext sc) {
    // 判断创建什么类型的容器,默认类型为 XmlWebApplicationContext
    Class<?> contextClass = determineContextClass(sc);
    if (!ConfigurableWebApplicationContext.class.isAssignableFrom(contextClass)) {
        throw new ApplicationContextException("Custom context class [" + contextClass.getName() +
                "] is not of type [" + ConfigurableWebApplicationContext.class.getName() + "]");
    }
    // 通过反射创建容器
    return (ConfigurableWebApplicationContext) BeanUtils.instantiateClass(contextClass);
}

protected Class<?> determineContextClass(ServletContext servletContext) {
    /*
     * 读取用户自定义配置,比如:
     * <context-param>
     *     <param-name>contextClass</param-name>
     *     <param-value>XXXConfigWebApplicationContext</param-value>
     * </context-param>
     */
    String contextClassName = servletContext.getInitParameter(CONTEXT_CLASS_PARAM);
    if (contextClassName != null) {
        try {
            return ClassUtils.forName(contextClassName, ClassUtils.getDefaultClassLoader());
        }
        catch (ClassNotFoundException ex) {
            throw new ApplicationContextException(
                    "Failed to load custom context class [" + contextClassName + "]", ex);
        }
    }
    else {
        /*
         * 若无自定义配置,则获取默认的容器类型,默认类型为 XmlWebApplicationContext。
         * defaultStrategies 读取的配置文件为 ContextLoader.properties,
         * 该配置文件内容如下:
         * org.springframework.web.context.WebApplicationContext =
         *     org.springframework.web.context.support.XmlWebApplicationContext
         */
        contextClassName = defaultStrategies.getProperty(WebApplicationContext.class.getName());
        try {
            return ClassUtils.forName(contextClassName, ContextLoader.class.getClassLoader());
        }
        catch (ClassNotFoundException ex) {
            throw new ApplicationContextException(
                    "Failed to load default context class [" + contextClassName + "]", ex);
        }
    }
}

简单说一下 createWebApplicationContext 方法的流程,该方法首先会调用 determineContextClass 判断创建什么类型的容器,默认为 XmlWebApplicationContext。然后调用 instantiateClass 方法通过反射的方式创建容器实例。instantiateClass 方法就不跟进去分析了,大家可以自己去看看,比较简单。

继续往下分析,接下来分析一下 configureAndRefreshWebApplicationContext 方法的源码。如下:

protected void configureAndRefreshWebApplicationContext(ConfigurableWebApplicationContext wac, ServletContext sc) {
    if (ObjectUtils.identityToString(wac).equals(wac.getId())) {
        // 从 ServletContext 中获取用户配置的 contextId 属性
        String idParam = sc.getInitParameter(CONTEXT_ID_PARAM);
        if (idParam != null) {
            // 设置容器 id
            wac.setId(idParam);
        }
        else {
            // 用户未配置 contextId,则设置一个默认的容器 id
            wac.setId(ConfigurableWebApplicationContext.APPLICATION_CONTEXT_ID_PREFIX +
                    ObjectUtils.getDisplayString(sc.getContextPath()));
        }
    }

    wac.setServletContext(sc);
    // 获取 contextConfigLocation 配置
    String configLocationParam = sc.getInitParameter(CONFIG_LOCATION_PARAM);
    if (configLocationParam != null) {
        wac.setConfigLocation(configLocationParam);
    }
    
    ConfigurableEnvironment env = wac.getEnvironment();
    if (env instanceof ConfigurableWebEnvironment) {
        ((ConfigurableWebEnvironment) env).initPropertySources(sc, null);
    }

    customizeContext(sc, wac);

    // 刷新容器
    wac.refresh();
}

上面的源码不是很长,逻辑不是很复杂。下面简单总结 configureAndRefreshWebApplicationContext 方法主要做了事情,如下:

  1. 设置容器 id
  2. 获取 contextConfigLocation 配置,并设置到容器中
  3. 刷新容器

到此,关于业务容器的创建过程就分析完了,下面我们继续分析 Web 容器的创建过程。

2.2 Web 容器的创建过程

前面说了业务容器的创建过程,业务容器是通过 ContextLoaderListener。那 Web 容器是通过什么创建的呢?答案是通过 DispatcherServlet。我在上一篇文章介绍 HttpServletBean 抽象类时,说过该类覆写了父类 HttpServlet 中的 init 方法。这个方法就是创建 Web 容器的入口,那下面我们就从这个方法入手。如下:

// -- org.springframework.web.servlet.HttpServletBean
public final void init() throws ServletException {
    if (logger.isDebugEnabled()) {...}

    // 获取 ServletConfig 中的配置信息
    PropertyValues pvs = new ServletConfigPropertyValues(getServletConfig(), this.requiredProperties);
    if (!pvs.isEmpty()) {
        try {
            /*
             * 为当前对象(比如 DispatcherServlet 对象)创建一个 BeanWrapper,
             * 方便读/写对象属性。
             */ 
            BeanWrapper bw = PropertyAccessorFactory.forBeanPropertyAccess(this);
            ResourceLoader resourceLoader = new ServletContextResourceLoader(getServletContext());
            bw.registerCustomEditor(Resource.class, new ResourceEditor(resourceLoader, getEnvironment()));
            initBeanWrapper(bw);
            // 设置配置信息到目标对象中
            bw.setPropertyValues(pvs, true);
        }
        catch (BeansException ex) {
            if (logger.isErrorEnabled()) {...}
            throw ex;
        }
    }

    // 进行后续的初始化
    initServletBean();

    if (logger.isDebugEnabled()) {...}
}

protected void initServletBean() throws ServletException {
}

上面的源码主要做的事情是将 ServletConfig 中的配置信息设置到 HttpServletBean 的子类对象中(比如 DispatcherServlet),我们并未从上面的源码中发现创建容器的痕迹。不过如果大家注意看源码的话,会发现 initServletBean 这个方法稍显奇怪,是个空方法。这个方法的访问级别为 protected,子类可进行覆盖。HttpServletBean 子类 FrameworkServlet 覆写了这个方法,下面我们到 FrameworkServlet 中探索一番。

// -- org.springframework.web.servlet.FrameworkServlet
protected final void initServletBean() throws ServletException {
    getServletContext().log("Initializing Spring FrameworkServlet '" + getServletName() + "'");
    if (this.logger.isInfoEnabled()) {...}
    long startTime = System.currentTimeMillis();

    try {
        // 初始化容器
        this.webApplicationContext = initWebApplicationContext();
        initFrameworkServlet();
    }
    catch (ServletException ex) {
        this.logger.error("Context initialization failed", ex);
        throw ex;
    }
    catch (RuntimeException ex) {
        this.logger.error("Context initialization failed", ex);
        throw ex;
    }

    if (this.logger.isInfoEnabled()) {...}
}

protected WebApplicationContext initWebApplicationContext() {
    // 从 ServletContext 中获取容器,也就是 ContextLoaderListener 创建的容器
    WebApplicationContext rootContext =
            WebApplicationContextUtils.getWebApplicationContext(getServletContext());
    WebApplicationContext wac = null;

    /*
     * 若下面的条件成立,则需要从外部设置 webApplicationContext。有两个途径可以设置 
     * webApplicationContext,以 DispatcherServlet 为例:
     *    1. 通过 DispatcherServlet 有参构造方法传入 WebApplicationContext 对象
     *    2. 将 DispatcherServlet 配置到其他容器中,由其他容器通过 
     *       setApplicationContext 方法进行设置
     *       
     * 途径1 可参考 AbstractDispatcherServletInitializer 中的 
     * registerDispatcherServlet 方法源码。一般情况下,代码执行到此处,
     * this.webApplicationContext 为 null,大家可自行调试进行验证。
     */
    if (this.webApplicationContext != null) {
        wac = this.webApplicationContext;
        if (wac instanceof ConfigurableWebApplicationContext) {
            ConfigurableWebApplicationContext cwac = (ConfigurableWebApplicationContext) wac;
            if (!cwac.isActive()) {
                if (cwac.getParent() == null) {
                    // 设置 rootContext 为父容器
                    cwac.setParent(rootContext);
                }
                // 配置并刷新容器
                configureAndRefreshWebApplicationContext(cwac);
            }
        }
    }
    if (wac == null) {
        // 尝试从 ServletContext 中获取容器
        wac = findWebApplicationContext();
    }
    if (wac == null) {
        // 创建容器,并将 rootContext 作为父容器
        wac = createWebApplicationContext(rootContext);
    }

    if (!this.refreshEventReceived) {
        onRefresh(wac);
    }

    if (this.publishContext) {
        String attrName = getServletContextAttributeName();
        // 将创建好的容器设置到 ServletContext 中
        getServletContext().setAttribute(attrName, wac);
        if (this.logger.isDebugEnabled()) {...}
    }

    return wac;
}

protected WebApplicationContext createWebApplicationContext(ApplicationContext parent) {
    // 获取容器类型,默认为 XmlWebApplicationContext.class
    Class<?> contextClass = getContextClass();
    if (this.logger.isDebugEnabled()) {...}
    if (!ConfigurableWebApplicationContext.class.isAssignableFrom(contextClass)) {
        throw new ApplicationContextException(
                "Fatal initialization error in servlet with name '" + getServletName() +
                "': custom WebApplicationContext class [" + contextClass.getName() +
                "] is not of type ConfigurableWebApplicationContext");
    }

    // 通过反射实例化容器
    ConfigurableWebApplicationContext wac =
            (ConfigurableWebApplicationContext) BeanUtils.instantiateClass(contextClass);

    wac.setEnvironment(getEnvironment());
    wac.setParent(parent);
    wac.setConfigLocation(getContextConfigLocation());

    // 配置并刷新容器
    configureAndRefreshWebApplicationContext(wac);

    return wac;
}

protected void configureAndRefreshWebApplicationContext(ConfigurableWebApplicationContext wac) {
    if (ObjectUtils.identityToString(wac).equals(wac.getId())) {
        // 设置容器 id
        if (this.contextId != null) {
            wac.setId(this.contextId);
        }
        else {
            // 生成默认 id
            wac.setId(ConfigurableWebApplicationContext.APPLICATION_CONTEXT_ID_PREFIX +
                    ObjectUtils.getDisplayString(getServletContext().getContextPath()) + '/' + getServletName());
        }
    }

    wac.setServletContext(getServletContext());
    wac.setServletConfig(getServletConfig());
    wac.setNamespace(getNamespace());
    wac.addApplicationListener(new SourceFilteringListener(wac, new ContextRefreshListener()));

    ConfigurableEnvironment env = wac.getEnvironment();
    if (env instanceof ConfigurableWebEnvironment) {
        ((ConfigurableWebEnvironment) env).initPropertySources(getServletContext(), getServletConfig());
    }

    // 后置处理,子类可以覆盖进行一些自定义操作。在 Spring MVC 未使用到,是个空方法。
    postProcessWebApplicationContext(wac);
    applyInitializers(wac);
    // 刷新容器
    wac.refresh();
}

以上就是创建 Web 容器的源码,下面总结一下该容器创建的过程。如下:

  1. 从 ServletContext 中获取 ContextLoaderListener 创建的容器
  2. 若 this.webApplicationContext != null 条件成立,仅设置父容器和刷新容器即可
  3. 尝试从 ServletContext 中获取容器,若容器不为空,则无需执行步骤4
  4. 创建容器,并将 rootContext 作为父容器
  5. 设置容器到 ServletContext 中

到这里,关于 Web 容器的创建过程就讲完了。总的来说,Web 容器的创建过程和业务容器的创建过程大致相同,但是差异也是有的,不能忽略。

3.总结

本篇文章对 Spring MVC 两种容器的创建过程进行了较为详细的分析,总的来说两种容器的创建过程并不是很复杂。大家在分析这两种容器的创建过程时,看的不明白的地方,可以进行调试,这对于理解代码逻辑还是很有帮助的。当然阅读 Spring MVC 部分的源码最好有 Servlet 和 Spring IOC 容器方面的知识,这些是基础,Spring MVC 就是在这些基础上构建的。

限于个人能力,文章叙述有误,还望大家指明。也请多多指教,在这里说声谢谢。好了,本篇文章就到这里了。感谢大家的阅读。

参考

附录:Spring 源码分析文章列表

Ⅰ. IOC

更新时间 标题
2018-05-30 Spring IOC 容器源码分析系列文章导读
2018-06-01 Spring IOC 容器源码分析 - 获取单例 bean
2018-06-04 Spring IOC 容器源码分析 - 创建单例 bean 的过程
2018-06-06 Spring IOC 容器源码分析 - 创建原始 bean 对象
2018-06-08 Spring IOC 容器源码分析 - 循环依赖的解决办法
2018-06-11 Spring IOC 容器源码分析 - 填充属性到 bean 原始对象
2018-06-11 Spring IOC 容器源码分析 - 余下的初始化工作

Ⅱ. AOP

更新时间 标题
2018-06-17 Spring AOP 源码分析系列文章导读
2018-06-20 Spring AOP 源码分析 - 筛选合适的通知器
2018-06-20 Spring AOP 源码分析 - 创建代理对象
2018-06-22 Spring AOP 源码分析 - 拦截器链的执行过程

Ⅲ. MVC

更新时间 标题
2018-06-29 Spring MVC 原理探秘 - 一个请求的旅行过程
2018-06-30 Spring MVC 原理探秘 - 容器的创建过程

本文在知识共享许可协议 4.0 下发布,转载需在明显位置处注明出处
作者:coolblog.xyz
本文同步发布在我的个人博客:http://www.coolblog.xyz

cc
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。

目录
相关文章
|
1月前
|
XML Java 测试技术
《深入理解Spring》:IoC容器核心原理与实战
Spring IoC通过控制反转与依赖注入实现对象间的解耦,由容器统一管理Bean的生命周期与依赖关系。支持XML、注解和Java配置三种方式,结合作用域、条件化配置与循环依赖处理等机制,提升应用的可维护性与可测试性,是现代Java开发的核心基石。
|
1月前
|
前端开发 Java 微服务
《深入理解Spring》:Spring、Spring MVC与Spring Boot的深度解析
Spring Framework是Java生态的基石,提供IoC、AOP等核心功能;Spring MVC基于其构建,实现Web层MVC架构;Spring Boot则通过自动配置和内嵌服务器,极大简化了开发与部署。三者层层演进,Spring Boot并非替代,而是对前者的高效封装与增强,适用于微服务与快速开发,而深入理解Spring Framework有助于更好驾驭整体技术栈。
|
1月前
|
XML Java 应用服务中间件
【SpringBoot(一)】Spring的认知、容器功能讲解与自动装配原理的入门,带你熟悉Springboot中基本的注解使用
SpringBoot专栏开篇第一章,讲述认识SpringBoot、Bean容器功能的讲解、自动装配原理的入门,还有其他常用的Springboot注解!如果想要了解SpringBoot,那么就进来看看吧!
364 2
|
4月前
|
前端开发 Java API
Spring Cloud Gateway Server Web MVC报错“Unsupported transfer encoding: chunked”解决
本文解析了Spring Cloud Gateway中出现“Unsupported transfer encoding: chunked”错误的原因,指出该问题源于Feign依赖的HTTP客户端与服务端的`chunked`传输编码不兼容,并提供了具体的解决方案。通过规范Feign客户端接口的返回类型,可有效避免该异常,提升系统兼容性与稳定性。
335 0
|
4月前
|
SQL Java 数据库连接
Spring、SpringMVC 与 MyBatis 核心知识点解析
我梳理的这些内容,涵盖了 Spring、SpringMVC 和 MyBatis 的核心知识点。 在 Spring 中,我了解到 IOC 是控制反转,把对象控制权交容器;DI 是依赖注入,有三种实现方式。Bean 有五种作用域,单例 bean 的线程安全问题及自动装配方式也清晰了。事务基于数据库和 AOP,有失效场景和七种传播行为。AOP 是面向切面编程,动态代理有 JDK 和 CGLIB 两种。 SpringMVC 的 11 步执行流程我烂熟于心,还有那些常用注解的用法。 MyBatis 里,#{} 和 ${} 的区别很关键,获取主键、处理字段与属性名不匹配的方法也掌握了。多表查询、动态
157 0
|
4月前
|
JSON 前端开发 Java
第05课:Spring Boot中的MVC支持
第05课:Spring Boot中的MVC支持
267 0
|
XML Java 数据格式
京东一面:spring ioc容器本质是什么? ioc容器启动的步骤有哪些?
京东一面:spring ioc容器本质是什么? ioc容器启动的步骤有哪些?
|
6月前
|
XML Java 数据格式
Spring IoC容器的设计与实现
Spring 是一个功能强大且模块化的 Java 开发框架,其核心架构围绕 IoC 容器、AOP、数据访问与集成、Web 层支持等展开。其中,`BeanFactory` 和 `ApplicationContext` 是 Spring 容器的核心组件,分别定位为基础容器和高级容器,前者提供轻量级的 Bean 管理,后者扩展了事件发布、国际化等功能。
|
4月前
|
Java Spring 容器
SpringBoot自动配置的原理是什么?
Spring Boot自动配置核心在于@EnableAutoConfiguration注解,它通过@Import导入配置选择器,加载META-INF/spring.factories中定义的自动配置类。这些类根据@Conditional系列注解判断是否生效。但Spring Boot 3.0后已弃用spring.factories,改用新格式的.imports文件进行配置。
889 0
|
5月前
|
人工智能 Java 测试技术
Spring Boot 集成 JUnit 单元测试
本文介绍了在Spring Boot中使用JUnit 5进行单元测试的常用方法与技巧,包括添加依赖、编写测试类、使用@SpringBootTest参数、自动装配测试模块(如JSON、MVC、WebFlux、JDBC等),以及@MockBean和@SpyBean的应用。内容实用,适合Java开发者参考学习。
632 0