MiniSpring框架学习-为什么一个请求访问 /helloworld,最后能调用到某个 Controller 方法?原始 MVC实现

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 会涉及 HandlerMappingHandlerAdapterViewResolver 等组件。

本节 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.xmlDispatcherServlet 自己启动时读取的配置文件。

第一版可以先把 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 "";
}

这里有两个关键点:

  1. @Target(ElementType.METHOD):这个注解只能标在方法上。
  2. @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;
    }
}

这里删掉了没用到的 HashMapMapNode 等 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.xmlDispatcherServlet 自己读取的 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,只是在这个模型上继续补齐更多能力:参数绑定、返回值处理、异常处理、视图解析、拦截器、请求方法匹配等等。

相关推荐
武子康2 小时前
Java-05 深入浅出 MyBatis动态SQL与参数拼接完全指南
java·spring boot·后端
生成论实验室2 小时前
用事件关系网络重新理解AI(三):激活函数、微调与元学习
人工智能·学习·算法·语言模型·可信计算技术
过期动态2 小时前
【LeetCode 热题 100】字母异位分组
java·算法·leetcode·职场和发展·哈希算法
驭渊的小故事2 小时前
多线程01(线程状态和线程的sleep,线程终止(Interrupt)的小关联)
java·jvm·算法
凉、介3 小时前
深入理解 ARMv7-A|异常/中断处理
笔记·学习·嵌入式·arm
山甫aa3 小时前
Java的包和import
java·开发语言
星轨zb3 小时前
JUC 到 Redis 分布式锁:一次关于高并发的性能压测实验
java·redis·分布式·jmeter
深蓝轨迹3 小时前
Java 集合框架超全解 · 底层源码|集合对比|HashMap 扩容原理
java·hashmap·集合框架·arraylist·linkedlist
wxytxdy3 小时前
通过猜数字游戏学习Shell脚本的分支、循环编写
linux·学习