MiniSpring框架学习-整合 IoC 和 MVC(NPC)

MiniSpring框架学习-整合 IoC 和 MVC

  • [08. 整合 IoC 和 MVC:如何在 Web 环境中启动 IoC 容器?](#08. 整合 IoC 和 MVC:如何在 Web 环境中启动 IoC 容器?)
    • [web.xml 先看懂](#web.xml 先看懂)
    • [web.xml 常见标签](#web.xml 常见标签)
    • [ContextLoaderListener 做什么](#ContextLoaderListener 做什么)
    • [WebApplicationContext 是什么](#WebApplicationContext 是什么)
    • [ContextLoaderListener 代码](#ContextLoaderListener 代码)
    • [applicationContext.xml 放什么](#applicationContext.xml 放什么)
    • [DispatcherServlet 要改哪里](#DispatcherServlet 要改哪里)
    • 启动后发生了什么
    • 这一节到底整合了什么
    • 本节几个关键修正
    • 最后总结

教程: https://github.com/YaleGuo/minis
极客时间: 手把手带你写一个 MiniSpring

前言:这种源码学习,跟着敲代码没什么用,都不知道为什么这么写,写在什么地方,和什么地方有关系,改动在哪。AI时代,应该先用Codex直接生成对应阶段的源码。在学习教程的时候,直接到IDEA里面去看代码,还可以直接DeBug看相应的数据,比看纸面教程代码清晰明朗多了。另外,本节属于过渡章节,没有刷新三观,评价为NPC。

08. 整合 IoC 和 MVC:如何在 Web 环境中启动 IoC 容器?

上一节的 MVC Demo 已经能做到:

text 复制代码
请求进入 DispatcherServlet
    -> 根据 URL 找到 Controller 方法
    -> 通过反射调用方法
    -> 把返回值写回响应

但上一节还有一个明显问题:Controller 对象是 DispatcherServlet 自己通过反射 new 出来的。

这样一来,前面 IoC 容器里做好的能力,比如 XML 创建 Bean、@Autowired 注入、BeanPostProcessor 等,就用不上了。

这一节要解决的就是:

text 复制代码
Tomcat 启动 Web 应用时,先启动 IoC 容器。
DispatcherServlet 分派请求时,不再自己 new Controller,而是从 IoC 容器里拿 Controller。

整条主线可以先记成这样:

text 复制代码
Tomcat 启动 Web 应用
    -> 读取 web.xml
    -> 调用 ContextLoaderListener
    -> 创建 WebApplicationContext,也就是 Web 环境里的 IoC 容器
    -> 把 IoC 容器放到 ServletContext
    -> 创建 DispatcherServlet
    -> DispatcherServlet 从 ServletContext 取到 IoC 容器
    -> 扫描 Controller 方法,建立 URL 映射
    -> 请求进来后,从 IoC 容器拿 Controller 并调用方法

web.xml 先看懂

web.xml 这一节主要多了两个东西:

  1. context-param:给整个 Web 应用用的全局初始化参数。
  2. listener:监听 Web 应用启动,用来初始化根 IoC 容器。

示例配置如下:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
                             http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
         version="3.1">

    <!-- 给整个 Web 应用使用的初始化参数,ContextLoaderListener 会读取它。 -->
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>applicationContext.xml</param-value>
    </context-param>

    <!-- Tomcat 启动 Web 应用时,会回调这个监听器来创建 IoC 容器。 -->
    <listener>
        <listener-class>com.minis.web.ContextLoaderListener</listener-class>
    </listener>

    <!-- MVC 入口 Servlet,负责接收请求并分派到 Controller 方法。 -->
    <servlet>
        <servlet-name>minisMVC</servlet-name>
        <servlet-class>com.minis.web.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>/WEB-INF/minisMVC-servlet.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>minisMVC</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>
</web-app>

这里有一个容易混的点:

text 复制代码
context-param 是整个 Web 应用的参数。
servlet 里的 init-param 只属于当前这个 Servlet。

所以本节里有两个配置文件:

配置 谁读取 用来做什么
applicationContext.xml ContextLoaderListener 启动 IoC 容器,创建业务 Bean
/WEB-INF/minisMVC-servlet.xml DispatcherServlet 配置 MVC 扫描哪些 Controller 包

真实 SpringMVC 里,根容器和 DispatcherServlet 自己的 MVC 容器通常会分开。

本节 MiniSpring 为了先讲清楚主线,先简化成一个根 IoC 容器:业务 Bean 和 Controller 都放在 applicationContext.xml 创建,DispatcherServlet 只负责扫描映射并从这个容器里取 Controller。

load-on-startup 表示 Tomcat 启动 Web 应用时就创建这个 Servlet。

如果不配置它,DispatcherServlet 也可以在第一次请求进来时再初始化。这里配置成 1,是为了启动时就把 MVC 映射关系准备好。

web.xml 常见标签

原文里列了一批标签,这里整理成表格,方便看:

标签 作用
display-name Web 应用名称
description Web 应用描述
context-param Web 应用级别的初始化参数
listener 声明监听器,比如监听 Web 应用启动和销毁
filter 声明过滤器类
filter-mapping 声明过滤器拦截路径
servlet 声明 Servlet 类
servlet-mapping 声明 URL 由哪个 Servlet 处理
session-config Session 相关配置,比如超时时间
error-page 异常或 HTTP 状态码对应的错误页面

本节重点只看两个:

text 复制代码
listener         -> 启动 IoC 容器
servlet-mapping  -> 把请求交给 DispatcherServlet

ContextLoaderListener 做什么

ContextLoaderListener 实现的是 ServletContextListener

注意是实现接口,不是继承类。

它最重要的两个方法是:

方法 调用时机
contextInitialized(...) Web 应用启动时调用
contextDestroyed(...) Web 应用关闭时调用

本节主要看 contextInitialized(...)

它要做三件事:

text 复制代码
从 web.xml 读取 contextConfigLocation
    -> 根据 applicationContext.xml 创建 IoC 容器
    -> 把 IoC 容器保存到 ServletContext

为什么要放进 ServletContext

因为 ServletContext 是整个 Web 应用共享的上下文对象。ContextLoaderListener 创建了 IoC 容器以后,DispatcherServlet 后面也能从这里拿到同一个容器。

WebApplicationContext 是什么

普通 IoC 容器只需要能 getBean(...)

Web 环境里的 IoC 容器还需要知道自己属于哪个 ServletContext

所以可以定义一个 WebApplicationContext

java 复制代码
package com.minis.web;

import javax.servlet.ServletContext;

public interface WebApplicationContext extends ApplicationContext {

    /**
     * IoC 容器保存到 ServletContext 里时使用的 key。
     * DispatcherServlet 后面会用同一个 key 把容器取出来。
     */
    String ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE =
            WebApplicationContext.class.getName() + ".ROOT";

    ServletContext getServletContext();

    void setServletContext(ServletContext servletContext);
}

这个接口的核心就是:

text 复制代码
它还是一个 ApplicationContext。
只是额外绑定了 ServletContext。

如果当前 Demo 里的 AnnotationConfigWebApplicationContext 继承了 ClassPathXmlApplicationContext,那它大概可以这样写:

java 复制代码
package com.minis.web;

import javax.servlet.ServletContext;

public class AnnotationConfigWebApplicationContext
        extends ClassPathXmlApplicationContext
        implements WebApplicationContext {

    private ServletContext servletContext;

    public AnnotationConfigWebApplicationContext(String fileName) throws BeansException {
        super(fileName);
    }

    @Override
    public ServletContext getServletContext() {
        return this.servletContext;
    }

    @Override
    public void setServletContext(ServletContext servletContext) {
        this.servletContext = servletContext;
    }
}

这里类名叫 AnnotationConfigWebApplicationContext,但示例里还是通过 XML 文件启动容器。

如果后面要严格一点,名字可以改成 XmlWebApplicationContext。本节为了和原 Demo 保持一致,先继续沿用这个名字。

还有一个小边界:如果这个构造方法里 super(fileName) 会立刻触发 refresh(),那 setServletContext(...) 实际发生在 IoC 容器初始化之后。当前 Demo 一般没问题;如果后面 Bean 创建阶段就要用到 ServletContext,就需要改成"先创建上下文、设置 ServletContext、再 refresh"的流程。

ContextLoaderListener 代码

整理后的代码如下:

java 复制代码
package com.minis.web;

import javax.servlet.ServletContext;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;

public class ContextLoaderListener implements ServletContextListener {
    public static final String CONFIG_LOCATION_PARAM = "contextConfigLocation";

    private WebApplicationContext context;

    public ContextLoaderListener() {
    }

    public ContextLoaderListener(WebApplicationContext context) {
        this.context = context;
    }

    @Override
    public void contextInitialized(ServletContextEvent event) {
        initWebApplicationContext(event.getServletContext());
    }

    @Override
    public void contextDestroyed(ServletContextEvent event) {
        // Demo 里暂时没有 close/destroy 流程,先留空。
        // 如果后面容器支持关闭方法,可以在这里释放单例 Bean、线程池等资源。
    }

    private void initWebApplicationContext(ServletContext servletContext) {
        String contextLocation = servletContext.getInitParameter(CONFIG_LOCATION_PARAM);
        if (contextLocation == null || contextLocation.trim().length() == 0) {
            throw new IllegalStateException("contextConfigLocation is required");
        }

        WebApplicationContext wac = new AnnotationConfigWebApplicationContext(contextLocation);

        // 建立 Web 环境到 IoC 容器的关联。
        wac.setServletContext(servletContext);
        this.context = wac;

        // 建立 IoC 容器到 Web 环境的关联,后面的 DispatcherServlet 会从这里取容器。
        servletContext.setAttribute(
                WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE,
                this.context
        );
    }
}

这里所谓"双向关联",就是这两行:

java 复制代码
wac.setServletContext(servletContext);

servletContext.setAttribute(
        WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE,
        wac
);

第一行让 IoC 容器知道自己所在的 Web 环境。

第二行让 Web 环境保存 IoC 容器,方便其他组件取出来用。

applicationContext.xml 放什么

既然 Controller 要从 IoC 容器里拿,那 Controller 就必须先被 IoC 容器创建出来。

比如:

xml 复制代码
<beans>
    <bean id="aService" class="com.chenhai.test.AService"/>
    <bean id="helloWorldBean" class="com.chenhai.test.HelloWorldBean"/>
</beans>

如果 HelloWorldBean 里有 @Autowired 字段,也会在 IoC 容器创建它时完成注入。

这就是整合 IoC 和 MVC 的关键:

text 复制代码
Controller 不再是 DispatcherServlet 自己 new 的普通对象。
Controller 是 IoC 容器管理的 Bean。

DispatcherServlet 要改哪里

上一节里,DispatcherServlet 扫描到 Controller 类后,是这样做的:

text 复制代码
Class.forName(controllerName)
    -> getDeclaredConstructor().newInstance()
    -> 放进 controllerObjs

这一节要改成:

text 复制代码
Class.forName(controllerName)
    -> 根据类名推导 beanName
    -> 从 WebApplicationContext 里 getBean(beanName)
    -> 放进 controllerObjs

先在 DispatcherServlet 里保存根容器:

java 复制代码
private WebApplicationContext webApplicationContext;

初始化时,从 ServletContext 里取出来:

java 复制代码
@Override
public void init(ServletConfig config) throws ServletException {
    super.init(config);
    // 从 ServletContext 取出 Listener 启动时保存的根 IoC 容器。
    Object context = getServletContext().getAttribute(
            WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE
    );

    if (!(context instanceof WebApplicationContext)) {
        throw new ServletException("WebApplicationContext not found in ServletContext");
    }

    this.webApplicationContext = (WebApplicationContext) context;

    this.contextConfigLocation = config.getInitParameter("contextConfigLocation");
    if (this.contextConfigLocation == null) {
        throw new ServletException("contextConfigLocation is required");
    }

    URL xmlPath;
    try {
        xmlPath = this.getServletContext().getResource(this.contextConfigLocation);
    } catch (MalformedURLException e) {
        throw new ServletException("Invalid contextConfigLocation: " + this.contextConfigLocation, e);
    }

    if (xmlPath == null) {
        throw new ServletException("MVC config not found: " + this.contextConfigLocation);
    }

    this.packageNames = XmlScanComponentHelper.getNodeValue(xmlPath);
    refresh();
}

然后改 initController()

这里假设 IoC 容器里的 Bean id 使用类名首字母小写,比如:

text 复制代码
HelloWorldBean -> helloWorldBean

对应 applicationContext.xml 里的:

xml 复制代码
<bean id="helloWorldBean" class="com.chenhai.test.HelloWorldBean"/>

代码如下:

java 复制代码
protected void initController() throws ServletException {
    this.controllerNames = scanPackages(this.packageNames);

    for (String controllerName : this.controllerNames) {
        try {
            Class<?> clz = Class.forName(controllerName);
            String beanName = buildBeanName(clz);

            // 重点:这里从 IoC 容器拿 Bean,不再自己反射创建 Controller。
            Object obj = this.webApplicationContext.getBean(beanName);

            this.controllerClasses.put(controllerName, clz);
            this.controllerObjs.put(controllerName, obj);
        } catch (Exception e) {
            throw new ServletException("Init controller failed: " + controllerName, e);
        }
    }
}

private String buildBeanName(Class<?> clz) {
    String simpleName = clz.getSimpleName();
    return Character.toLowerCase(simpleName.charAt(0)) + simpleName.substring(1);
}

如果你的 Mini IoC 容器里 Bean id 不是这个规则,也可以换成自己的规则。

关键点不是 buildBeanName(...) 怎么写,而是:

text 复制代码
DispatcherServlet 不负责创建 Controller。
DispatcherServlet 只负责从 IoC 容器获取 Controller。

initMapping() 的主逻辑基本不用变。

它仍然是遍历 Controller 方法,找出带 @RequestMapping 的方法,建立 URL 到 Method 的映射:

java 复制代码
protected void initMapping() throws ServletException {
    for (String controllerName : this.controllerNames) {
        Class<?> clazz = this.controllerClasses.get(controllerName);
        Object obj = this.controllerObjs.get(controllerName);
        Method[] methods = clazz.getDeclaredMethods();

        for (Method method : methods) {
            if (!method.isAnnotationPresent(RequestMapping.class)) {
                continue;
            }

            String urlMapping = method.getAnnotation(RequestMapping.class).value();
            if (urlMapping == null || urlMapping.trim().length() == 0) {
                throw new ServletException("@RequestMapping value is empty: " + method);
            }

            if (this.mappingMethods.containsKey(urlMapping)) {
                throw new ServletException("Duplicate url mapping: " + urlMapping);
            }

            method.setAccessible(true);
            this.mappingObjs.put(urlMapping, obj);
            this.mappingMethods.put(urlMapping, method);
        }
    }
}

启动后发生了什么

Tomcat 启动时,顺序可以这样理解:

text 复制代码
1. Tomcat 加载 Web 应用
2. Tomcat 读取 web.xml
3. 发现 context-param:contextConfigLocation = applicationContext.xml
4. 发现 listener:ContextLoaderListener
5. 创建 ContextLoaderListener,并调用 contextInitialized(...)
6. ContextLoaderListener 创建 WebApplicationContext
7. WebApplicationContext 读取 applicationContext.xml,完成 IoC 容器初始化
8. ContextLoaderListener 把 WebApplicationContext 放进 ServletContext
9. Tomcat 根据 load-on-startup 创建 DispatcherServlet
10. DispatcherServlet 从 ServletContext 取到 WebApplicationContext
11. DispatcherServlet 读取 minisMVC-servlet.xml,扫描 Controller 包
12. DispatcherServlet 从 IoC 容器获取 Controller Bean
13. DispatcherServlet 建立 URL 到 Controller 方法的映射

初始化完成后,请求进来:

text 复制代码
GET /helloworld
    -> Tomcat 根据 servlet-mapping 交给 DispatcherServlet
    -> DispatcherServlet.doGet(...)
    -> 根据 /helloworld 找到 Method
    -> 根据 /helloworld 找到 IoC 容器里的 Controller 对象
    -> method.invoke(controller)
    -> 把返回值写回 response

这一节到底整合了什么

上一节:

text 复制代码
DispatcherServlet 扫描 Controller
    -> 自己 new Controller
    -> 调用 Controller 方法

这一节:

text 复制代码
ContextLoaderListener 先启动 IoC 容器
    -> IoC 容器创建 Controller
    -> DispatcherServlet 从 IoC 容器拿 Controller
    -> 调用 Controller 方法

差别就在 Controller 的来源。

这一步做完以后,Controller 就能享受 IoC 容器的能力了。

比如:

java 复制代码
public class HelloWorldBean {

    @Autowired
    private AService aService;

    @RequestMapping("/helloworld")
    public String doTest() {
        this.aService.sayHello();
        return "hello world for doGet!";
    }
}

只要 HelloWorldBeanAService 都被 applicationContext.xml 注册进容器,@Autowired 就有机会在创建 Bean 的过程中生效。

本节几个关键修正

第一,Tomcat 不会"自动调用之前 IoC 容器的初始化流程"。

更准确地说,是 Tomcat 按 web.xml 创建并回调 ContextLoaderListener,然后由 ContextLoaderListener 主动创建 IoC 容器。

第二,ContextLoaderListenerServletContextListener 的实现类。

它不是继承某个 JavaServlet 类,而是实现 Servlet 规范提供的监听器接口。

第三,contextConfigLocation 有两处。

context-param 里的 contextConfigLocationContextLoaderListener 用,通常指向 IoC 配置。

servletinit-param 里的 contextConfigLocationDispatcherServlet 用,通常指向 MVC 配置。

第四,ServletContext 是 Web 应用共享对象。

把 IoC 容器放进 ServletContext,是为了让 DispatcherServlet 这类 Web 组件能拿到同一个容器。

第五,整合的关键不是多写了一个 Listener。

真正的关键是:Controller 对象从 IoC 容器获取,不再由 DispatcherServlet 自己创建。

最后总结

这一节可以用两句话收住:

text 复制代码
ContextLoaderListener 负责在 Web 应用启动时创建 IoC 容器,并把它保存到 ServletContext。
text 复制代码
DispatcherServlet 负责从 ServletContext 取到 IoC 容器,再从容器里拿 Controller 来处理请求。

理解了这一步,MiniSpring 的 IoC 和 MVC 就连起来了。

前面 IoC 解决的是"对象怎么创建、依赖怎么注入"。

现在 MVC 解决的是"请求怎么找到对象的方法"。

两条线接上以后,Controller 才真正变成一个由容器管理的 Web 入口对象。

相关推荐
知识分享小能手4 小时前
Flask入门学习教程,从入门到精通,数据库操作 — 知识点详解与案例代码(4)
数据库·学习·flask
wubba lubba dub dub7505 小时前
第四十八周学习周报
学习
生成论实验室5 小时前
用事件关系网络重新理解AI(三):激活函数、微调与元学习
人工智能·学习·算法·语言模型·可信计算技术
辰海Coding5 小时前
MiniSpring框架学习-为什么一个请求访问 /helloworld,最后能调用到某个 Controller 方法?原始 MVC实现
java·学习·程序人生·spring·mvc
凉、介6 小时前
深入理解 ARMv7-A|异常/中断处理
笔记·学习·嵌入式·arm
wxytxdy6 小时前
通过猜数字游戏学习Shell脚本的分支、循环编写
linux·学习
我想我不够好。6 小时前
观察对方打野的动向,预判下一次gank的时机
学习
java小吕布7 小时前
Hermes Agent:自带学习闭环的开源 AI 智能体,一键部署全平台可用
人工智能·学习·开源
东风破1377 小时前
达梦DEM和DFM的介绍、搭建学习记录
数据库·学习·dm达梦数据库