MiniSpring框架学习-为什么一个请求访问 /helloworld,最后能调用到某个 Controller 方法?原始 MVC实现
- [07. 原始 MVC:如何通过单一的 Servlet 拦截请求分派任务?](#07. 原始 MVC:如何通过单一的 Servlet 拦截请求分派任务?)
-
- [先说清楚 MVC 是什么](#先说清楚 MVC 是什么)
- [Tomcat 和 DispatcherServlet 分别负责什么](#Tomcat 和 DispatcherServlet 分别负责什么)
- [webapp、web.xml 和 minisMVC-servlet.xml](#webapp、web.xml 和 minisMVC-servlet.xml)
- [第一版:通过 XML 建立路径到方法的映射](#第一版:通过 XML 建立路径到方法的映射)
- [DispatcherServlet 初始化做了什么](#DispatcherServlet 初始化做了什么)
- [doGet 怎么分派请求](#doGet 怎么分派请求)
- [第二版:引入 @RequestMapping](#第二版:引入 @RequestMapping)
- [定义 RequestMapping 注解](#定义 RequestMapping 注解)
- [Controller 示例](#Controller 示例)
- [读取 component-scan 配置](#读取 component-scan 配置)
- [DispatcherServlet 改成扫描注解](#DispatcherServlet 改成扫描注解)
- [注解版 doGet](#注解版 doGet)
- 总结一下调用流程
- 本节几个关键修正
- 最后收住
教程: https://github.com/YaleGuo/minis
极客时间: 手把手带你写一个 MiniSpring
前言:看教程很抽象无助,用CodeX生成Demo代码很爽,写笔记理清逻辑的时候抓耳挠腮,知道为什么这样后原地高潮。
07. 原始 MVC:如何通过单一的 Servlet 拦截请求分派任务?
本节目标是初步理解 SpringMVC 的 Demo 实现。
这一节不追求完整复刻 SpringMVC,而是先看清楚一件事:
text
为什么一个请求访问 /helloworld,最后能调用到某个 Controller 方法?
先把结论放前面:
text
浏览器/Postman 发请求
-> Tomcat 接收并封装请求
-> 根据 web.xml 找到 DispatcherServlet
-> DispatcherServlet 根据请求路径找到处理方法
-> 通过反射调用方法
-> 把方法返回值写回响应
先说清楚 MVC 是什么
SpringMVC 里的 MVC,可以先简单理解成:
| 名称 | 含义 | 在 Web 请求里大概负责什么 |
|---|---|---|
| Model | 模型 | 保存业务数据 |
| View | 视图 | 展示结果 |
| Controller | 控制器 | 接收请求,调用业务逻辑,决定返回什么 |
真实 SpringMVC 会涉及 HandlerMapping、HandlerAdapter、ViewResolver 等组件。
本节 Demo 会简化很多:Controller 方法直接返回字符串,DispatcherServlet 直接把字符串写回浏览器。也就是说,这里先不做真正的视图解析和模板渲染。
Tomcat 和 DispatcherServlet 分别负责什么
访问这个接口:
text
http://localhost:8080/helloworld
首先,8080 端口必须有服务监听,这个工作由 Tomcat 完成。
Tomcat 做的大概是这些事:
text
监听 8080 端口
-> 接收 TCP 连接
-> 按 HTTP 协议解析请求报文
-> 封装成 HttpServletRequest / HttpServletResponse
-> 根据 web.xml 找到应该由哪个 Servlet 处理
这里注意一个小修正:HTTP 不是从 TCP "升级"来的。
更准确的说法是:HTTP 是跑在 TCP 之上的应用层协议。Tomcat 收到 TCP 连接里的字节流后,会按 HTTP 协议格式去解析它。
接下来才轮到我们的 DispatcherServlet。
DispatcherServlet 做的是:
text
读取请求路径
-> 找到路径对应的 Controller 方法
-> 反射调用方法
-> 把返回值写到 response
所以要分清楚:
text
Tomcat 负责把请求交给哪个 Servlet。
DispatcherServlet 负责把请求交给哪个 Controller 方法。
webapp、web.xml 和 minisMVC-servlet.xml
新增 webapp 目录,是为了放 Web 应用资源,比如 WEB-INF/web.xml 和 MVC 配置文件。
web.xml 是 Java Web 的配置文件,它告诉 Tomcat:
text
要创建哪个 Servlet
哪些 URL 请求交给这个 Servlet
Servlet 初始化时要读哪个配置文件
一个最小配置大概是这样:
xml
<web-app>
<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>
</servlet>
<servlet-mapping>
<servlet-name>minisMVC</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
</web-app>
这里的重点是:
text
url-pattern="/" 表示把当前 Web 应用下的大部分请求都交给 minisMVC 这个 Servlet。
minisMVC 对应的类是 DispatcherServlet。
minisMVC-servlet.xml 不是定义 Servlet 的地方。
Servlet 是在 web.xml 里定义的。minisMVC-servlet.xml 是 DispatcherServlet 自己启动时读取的配置文件。
第一版可以先把 URL、类、方法都写死在 XML 里:
xml
<beans>
<bean id="/helloworld" class="com.chenhai.test.HelloWorldBean" value="doTest"/>
</beans>
这里约定:
text
id 表示请求路径
class 表示处理这个请求的类
value 表示要调用的方法名
第一版:通过 XML 建立路径到方法的映射
先定义一个 MappingValue,用来保存 XML 里读出来的映射信息。
java
public class MappingValue {
private final String path;
private final String clz;
private final String method;
public MappingValue(String path, String clz, String method) {
this.path = path;
this.clz = clz;
this.method = method;
}
public String getPath() {
return this.path;
}
public String getClz() {
return this.clz;
}
public String getMethod() {
return this.method;
}
}
再写 XmlConfigReader,把 XML 节点解析成 Map<String, MappingValue>。
java
import java.util.HashMap;
import java.util.Map;
import org.dom4j.Element;
public class XmlConfigReader {
public Map<String, MappingValue> loadConfig(Resource resource) {
Map<String, MappingValue> mappings = new HashMap<>();
while (resource.hasNext()) {
Element element = (Element) resource.next();
String path = element.attributeValue("id");
String className = element.attributeValue("class");
String methodName = element.attributeValue("value");
// path 约定成 /helloworld 这种请求路径,后面 doGet 会直接用它查找。
mappings.put(path, new MappingValue(path, className, methodName));
}
return mappings;
}
}
这里的 Resource 是前面教程里已有的资源读取抽象。它负责遍历 XML 里的节点,XmlConfigReader 只负责把节点转成映射对象。
DispatcherServlet 初始化做了什么
DispatcherServlet 是 MVC 的核心入口类。
Tomcat 读取 web.xml 后,会通过反射创建 DispatcherServlet 对象,然后调用它的 init(...) 方法。
第一版里,DispatcherServlet 需要维护三份信息:
java
private Map<String, MappingValue> mappingValues = new HashMap<>();
private Map<String, Class<?>> mappingClz = new HashMap<>();
private Map<String, Object> mappingObjs = new HashMap<>();
可以这样理解:
| 字段 | 作用 |
|---|---|
mappingValues |
保存 /helloworld -> 类名 + 方法名 |
mappingClz |
保存 /helloworld -> Class对象 |
mappingObjs |
保存 /helloworld -> Controller实例 |
初始化代码如下:
java
@Override
public void init(ServletConfig config) throws ServletException {
super.init(config);
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);
}
Resource resource = new ClassPathXmlResource(xmlPath);
XmlConfigReader reader = new XmlConfigReader();
this.mappingValues = reader.loadConfig(resource);
refresh();
}
refresh() 做的事很简单:根据配置里的类名创建对象,并把它们缓存起来。
java
protected void refresh() throws ServletException {
for (Map.Entry<String, MappingValue> entry : this.mappingValues.entrySet()) {
String path = entry.getKey();
String className = entry.getValue().getClz();
try {
Class<?> clz = Class.forName(className);
Object obj = clz.getDeclaredConstructor().newInstance();
this.mappingClz.put(path, clz);
this.mappingObjs.put(path, obj);
} catch (Exception e) {
throw new ServletException("Create handler failed: " + className, e);
}
}
}
这里还没有接入 IoC 容器,Controller 实例是 DispatcherServlet 自己反射创建的。
doGet 怎么分派请求
HttpServlet 里有模板方法逻辑。
请求进来后,Tomcat 调用 service(request, response),HttpServlet.service(...) 会根据请求方法判断是 GET、POST 还是别的 HTTP 方法。
如果是 GET,就会调用我们重写的 doGet(...)。
java
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String path = request.getServletPath();
MappingValue mappingValue = this.mappingValues.get(path);
if (mappingValue == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
Class<?> clz = this.mappingClz.get(path);
Object obj = this.mappingObjs.get(path);
String methodName = mappingValue.getMethod();
Object result;
try {
Method method = clz.getMethod(methodName);
result = method.invoke(obj);
} catch (Exception e) {
throw new ServletException("Invoke handler failed: " + path, e);
}
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().append(String.valueOf(result));
}
这段代码的主线是:
text
从 request 拿路径 /helloworld
-> 从 mappingValues 找到类名和方法名
-> 从 mappingClz / mappingObjs 找到 Class 和对象
-> 通过反射调用方法
-> 写回响应
如果默认访问 /,而 XML 里只配置了 /helloworld,那就应该返回 404。
不要直接 return,否则浏览器可能收到一个空的 200 响应,不利于排查问题。
第二版:引入 @RequestMapping
第一版的问题是:URL、类名、方法名都写在 XML 里,太死了。
现在改成用注解声明 URL。
也就是从:
xml
<bean id="/helloworld" class="com.chenhai.test.HelloWorldBean" value="doTest"/>
改成:
java
@RequestMapping("/helloworld")
public String doTest() {
return "hello world for doGet!";
}
这样 XML 里就不需要写每一个方法了,只需要告诉框架扫描哪个包。
xml
<beans>
<component-scan base-package="com.chenhai.test"/>
</beans>
定义 RequestMapping 注解
注解本身不会自动建立映射。
它只是把 URL 信息留在方法上,后面还要靠 DispatcherServlet 扫描方法、读取注解、建立映射。
java
package com.minis.web;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestMapping {
String value() default "";
}
这里有两个关键点:
@Target(ElementType.METHOD):这个注解只能标在方法上。@Retention(RetentionPolicy.RUNTIME):程序运行时还能通过反射读到它。
因为属性名叫 value,所以可以简写成:
java
@RequestMapping("/helloworld")
不用写成:
java
@RequestMapping(value = "/helloworld")
Controller 示例
java
public class HelloWorldBean {
/**
* DispatcherServlet 启动时会扫描这个方法,
* 读到 @RequestMapping 后建立 /helloworld -> doTest 的映射。
*/
@RequestMapping("/helloworld")
public String doTest() {
return "hello world for doGet!";
}
/**
* 这里的 /post 只是请求路径。
* 当前 Demo 只实现 doGet,所以它仍然是通过 GET /post 访问。
*/
@RequestMapping("/post")
public String post() {
return "hello world for /post!";
}
}
注意:本节的 @RequestMapping 只表示 URL 映射,不区分 GET、POST。
如果后面要支持请求方法,还需要给注解增加类似 method 的属性,或者实现 @GetMapping、@PostMapping 这种更细的注解。
读取 component-scan 配置
现在 XML 只配置要扫描的包,所以新建一个 XmlScanComponentHelper,用来读取所有 base-package。
java
package com.minis.web;
import java.net.URL;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import org.dom4j.Document;
import org.dom4j.DocumentException;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;
public class XmlScanComponentHelper {
public static List<String> getNodeValue(URL xmlPath) {
List<String> packages = new ArrayList<>();
SAXReader saxReader = new SAXReader();
try {
Document document = saxReader.read(xmlPath);
Element root = document.getRootElement();
Iterator<Element> iterator = root.elementIterator();
while (iterator.hasNext()) {
Element element = iterator.next();
String basePackage = element.attributeValue("base-package");
if (basePackage != null && basePackage.trim().length() > 0) {
packages.add(basePackage.trim());
}
}
} catch (DocumentException e) {
throw new IllegalStateException("Load MVC config failed: " + xmlPath, e);
}
return packages;
}
}
这里删掉了没用到的 HashMap、Map、Node 等 import。
另外,不建议只 e.printStackTrace() 然后继续往下走。配置文件解析失败时,应该直接抛异常,不然后面很容易变成空指针。
DispatcherServlet 改成扫描注解
改成注解版以后,DispatcherServlet 不再需要保存 XML 里的每个 URL 映射。
它需要保存的是:
java
private List<String> packageNames = new ArrayList<>();
private List<String> controllerNames = new ArrayList<>();
private Map<String, Class<?>> controllerClasses = new HashMap<>();
private Map<String, Object> controllerObjs = new HashMap<>();
private Map<String, Object> mappingObjs = new HashMap<>();
private Map<String, Method> mappingMethods = new HashMap<>();
含义是:
| 字段 | 作用 |
|---|---|
packageNames |
XML 里配置的扫描包 |
controllerNames |
扫描出来的类全限定名 |
controllerClasses |
类名到 Class 对象 |
controllerObjs |
类名到 Controller 实例 |
mappingObjs |
URL 到 Controller 实例 |
mappingMethods |
URL 到 Method 对象 |
初始化时,先读取扫描包,再刷新映射。
java
@Override
public void init(ServletConfig config) throws ServletException {
super.init(config);
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();
}
refresh() 分两步:
java
protected void refresh() throws ServletException {
initController();
initMapping();
}
第一步,扫描包并实例化 Controller。
java
protected void initController() throws ServletException {
this.controllerNames = scanPackages(this.packageNames);
for (String controllerName : this.controllerNames) {
try {
Class<?> clz = Class.forName(controllerName);
Object obj = clz.getDeclaredConstructor().newInstance();
this.controllerClasses.put(controllerName, clz);
this.controllerObjs.put(controllerName, obj);
} catch (Exception e) {
throw new ServletException("Create controller failed: " + controllerName, e);
}
}
}
扫描包的代码要注意一个小坑:递归扫描子目录时,要把子目录扫描结果 addAll 回来。
原来如果只调用 scanPackage(...),但不接收返回值,子包里的类就会丢失。
java
private List<String> scanPackages(List<String> packages) throws ServletException {
List<String> controllerNames = new ArrayList<>();
for (String packageName : packages) {
controllerNames.addAll(scanPackage(packageName));
}
return controllerNames;
}
private List<String> scanPackage(String packageName) throws ServletException {
List<String> controllerNames = new ArrayList<>();
String packagePath = "/" + packageName.replace('.', '/');
URL resource = this.getClass().getResource(packagePath);
if (resource == null) {
throw new ServletException("Package not found: " + packageName);
}
File dir;
try {
URI uri = resource.toURI();
dir = new File(uri);
} catch (Exception e) {
throw new ServletException("Resolve package failed: " + packageName, e);
}
File[] files = dir.listFiles();
if (files == null) {
return controllerNames;
}
for (File file : files) {
if (file.isDirectory()) {
controllerNames.addAll(scanPackage(packageName + "." + file.getName()));
continue;
}
String fileName = file.getName();
if (fileName.endsWith(".class") && !fileName.contains("$")) {
String controllerName = packageName + "." + fileName.replace(".class", "");
controllerNames.add(controllerName);
}
}
return controllerNames;
}
这个扫描方式适合教学 Demo:它扫描的是编译后的 class 文件目录。
如果类被打进 jar 包里,这段代码还不够用,后面需要更完整的 classpath 扫描逻辑。
第二步,遍历 Controller 方法,找到带 @RequestMapping 的方法。
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;
}
RequestMapping requestMapping = method.getAnnotation(RequestMapping.class);
String urlMapping = requestMapping.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);
}
}
}
这里建立的是:
text
/helloworld -> HelloWorldBean 对象
/helloworld -> doTest 方法
有了这两个 Map,后面请求进来时就能直接按 URL 查方法。
注解版 doGet
最后修改 doGet(...)。
java
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String path = request.getServletPath();
Method method = this.mappingMethods.get(path);
if (method == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
Object obj = this.mappingObjs.get(path);
try {
Object result = method.invoke(obj);
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().append(String.valueOf(result));
} catch (Exception e) {
throw new ServletException("Invoke handler failed: " + path, e);
}
}
这里用 mappingMethods.get(path) 判断有没有映射,就不需要再额外维护一个 urlMappingNames 列表了。
这样状态更少,也不容易出现"列表里有 URL,但 Map 里没有 Method"的不一致问题。
总结一下调用流程
最后把整条链路串起来:
text
浏览器/Postman 发送 HTTP 请求
↓
Tomcat 接收请求
↓
Tomcat 解析 HTTP 报文
↓
封装 HttpServletRequest / HttpServletResponse
↓
根据 web.xml 的 servlet-mapping 找到 DispatcherServlet
↓
调用 DispatcherServlet.service(request, response)
↓
HttpServlet.service() 判断请求方法是 GET
↓
调用 DispatcherServlet.doGet(request, response)
↓
DispatcherServlet 读取请求路径,比如 /helloworld
↓
从 mappingMethods 里找到对应 Method
↓
从 mappingObjs 里找到对应 Controller 对象
↓
反射调用 Controller 方法
↓
response.getWriter().append(...) 写回响应
↓
Tomcat 把响应内容返回给浏览器
这一节最核心的点就一句话:
text
Tomcat 只负责把请求交给 DispatcherServlet,真正的 Controller 方法分派,是 DispatcherServlet 自己根据路径映射完成的。
本节几个关键修正
第一,minisMVC-servlet.xml 不是定义 Servlet 的地方。
Servlet 在 web.xml 里定义,minisMVC-servlet.xml 是 DispatcherServlet 自己读取的 MVC 配置。
第二,Tomcat 不会直接知道 /helloworld 对应哪个 Controller 方法。
Tomcat 只知道请求应该交给 DispatcherServlet。Controller 方法映射是 DispatcherServlet 读 XML 或注解后自己维护的。
第三,HTTP 不是 TCP 升级来的。
更准确的说法是:Tomcat 接收 TCP 连接后,按 HTTP 协议解析请求内容。
第四,注解只是元信息。
@RequestMapping 本身不会自动生效。必须由框架启动时扫描方法、读取注解、建立 URL 到 Method 的映射。
第五,Demo 里的 @RequestMapping 只处理路径,不处理 HTTP 方法。
所以 @RequestMapping("/post") 里的 post 只是路径名,不代表它只能处理 POST 请求。
第六,异常不要吞掉。
扫描类、创建对象、调用方法这些地方,如果失败了要抛出带原因的异常。只写空 catch 会让问题变成很难排查的空指针或空响应。
最后收住
这一节可以这样记:
text
web.xml 让所有请求先进入 DispatcherServlet。
DispatcherServlet 启动时建立 URL 和方法的映射。
请求进来后,DispatcherServlet 根据路径找到方法并反射调用。
这就是"通过单一 Servlet 拦截请求并分派任务"的最小模型。
后面真正的 SpringMVC,只是在这个模型上继续补齐更多能力:参数绑定、返回值处理、异常处理、视图解析、拦截器、请求方法匹配等等。