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 这一节主要多了两个东西:
context-param:给整个 Web 应用用的全局初始化参数。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!";
}
}
只要 HelloWorldBean 和 AService 都被 applicationContext.xml 注册进容器,@Autowired 就有机会在创建 Bean 的过程中生效。
本节几个关键修正
第一,Tomcat 不会"自动调用之前 IoC 容器的初始化流程"。
更准确地说,是 Tomcat 按 web.xml 创建并回调 ContextLoaderListener,然后由 ContextLoaderListener 主动创建 IoC 容器。
第二,ContextLoaderListener 是 ServletContextListener 的实现类。
它不是继承某个 JavaServlet 类,而是实现 Servlet 规范提供的监听器接口。
第三,contextConfigLocation 有两处。
context-param 里的 contextConfigLocation 给 ContextLoaderListener 用,通常指向 IoC 配置。
servlet 的 init-param 里的 contextConfigLocation 给 DispatcherServlet 用,通常指向 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 入口对象。