从Controller到Tomcat底层请求链路全解析

一次请求的旅程:从 URL 到你的 Controller 方法

写 Spring 的时候,我经常有一种错觉:好像只要在 Controller 里放一个方法,浏览器敲个地址,参数就会自己进来,返回值就会自己变成 JSON 还给用户。好像 Tomcat 是某个神秘组织,在幕后默默替我打理一切。

直到有一天我认真问自己:@GetMapping("/users/{id}") 里的 {id} 是怎么被提取出来的?方法里那个 Long id 又是谁塞进来的?如果我想拦一下请求,到底该往哪一层加代码?

带着这几个问题往下挖,才发现所谓"框架"根本不是什么魔法。它就是一层一层很朴素的机制叠起来的,每一层做的事都简单,只是层与层的衔接被框架藏起来了。

目录

  • [Tomcat 根本不认识你的 Controller](#Tomcat 根本不认识你的 Controller "#tomcat-%E6%A0%B9%E6%9C%AC%E4%B8%8D%E8%AE%A4%E8%AF%86%E4%BD%A0%E7%9A%84-controller")
  • [进入 Spring 的世界:doDispatch 干了什么](#进入 Spring 的世界:doDispatch 干了什么 "#%E8%BF%9B%E5%85%A5-spring-%E7%9A%84%E4%B8%96%E7%95%8Cdodispatch-%E5%B9%B2%E4%BA%86%E4%BB%80%E4%B9%88")
  • [第一步:URL 是怎么找到你的方法的](#第一步:URL 是怎么找到你的方法的 "#%E7%AC%AC%E4%B8%80%E6%AD%A5url-%E6%98%AF%E6%80%8E%E4%B9%88%E6%89%BE%E5%88%B0%E4%BD%A0%E7%9A%84%E6%96%B9%E6%B3%95%E7%9A%84")
  • [第二步:@PathVariable 里的参数是谁塞进去的](#第二步:@PathVariable 里的参数是谁塞进去的 "#%E7%AC%AC%E4%BA%8C%E6%AD%A5pathvariable-%E9%87%8C%E7%9A%84%E5%8F%82%E6%95%B0%E6%98%AF%E8%B0%81%E5%A1%9E%E8%BF%9B%E5%8E%BB%E7%9A%84")
  • [第三步:@RequestBody 是怎么把 JSON 变成对象的](#第三步:@RequestBody 是怎么把 JSON 变成对象的 "#%E7%AC%AC%E4%B8%89%E6%AD%A5requestbody-%E6%98%AF%E6%80%8E%E4%B9%88%E6%8A%8A-json-%E5%8F%98%E6%88%90%E5%AF%B9%E8%B1%A1%E7%9A%84")
  • [第四步:返回值又是怎么变成 JSON 还回去的](#第四步:返回值又是怎么变成 JSON 还回去的 "#%E7%AC%AC%E5%9B%9B%E6%AD%A5%E8%BF%94%E5%9B%9E%E5%80%BC%E5%8F%88%E6%98%AF%E6%80%8E%E4%B9%88%E5%8F%98%E6%88%90-json-%E8%BF%98%E5%9B%9E%E5%8E%BB%E7%9A%84")
  • 把整条链串起来
  • [Spring Boot 到底干了什么](#Spring Boot 到底干了什么 "#spring-boot-%E5%88%B0%E5%BA%95%E5%B9%B2%E4%BA%86%E4%BB%80%E4%B9%88")
  • 写在最后

Tomcat 根本不认识你的 Controller

这是整件事的起点,也是最容易搞反的一点。

Tomcat 是一个 Servlet 容器 。它的职责就四件事:监听端口、接收字节流、解析成 HTTP 请求、把请求转交给"某个 Servlet"。它认识的东西只有一个,就是 Servlet 这个接口:

java 复制代码
public interface Servlet {
    void init(ServletConfig config);
    void service(ServletRequest req, ServletResponse res);
    void destroy();
}

在你的应用里,真正被注册到 Tomcat 的 Servlet 只有一个,名字叫 DispatcherServlet。你的 UserController 从头到尾都不在 Tomcat 的视野里。

那 Tomcat 怎么把请求交给 Spring?答案是:Spring 写了一个实现了 Servlet 接口的类 ,把它注册到了 Tomcat 的根路径 / 上。于是 Tomcat 收到任何请求,都只会调用这一个 Servlet 的 service() 方法------它以为自己只是在跟一个普通 Servlet 打交道,完全不知道这个 Servlet 背后藏着一整个 Spring 容器。

打个比方:Tomcat 是医院的导诊台,只知道"所有病人先到我这儿,我按科室分诊",它不直接认识任何一位医生。而 DispatcherServlet 就是这个导诊台,它按照你挂的号(URL),把你带到对应的科室(Controller)。

进入 Spring 的世界:doDispatch 干了什么

DispatcherServlet 继承自 HttpServlet,所以对 Tomcat 来说就是一个普通 Servlet。Tomcat 调它的 service(),最终会进入它自己重写的方法 doDispatch()

这个方法是整个 Spring MVC 的心脏,但做的事其实不多,就五步:

java 复制代码
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) {
    // 1. 根据 URL 找到处理这个请求的 Handler(其实就是你的方法)
    HandlerExecutionChain mappedHandler = getHandler(request);

    // 2. 找到能"执行"这个 Handler 的适配器
    HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());

    // 3. 执行拦截器链(如果有的话)
    mappedHandler.applyPreHandle(...);

    // 4. 真正调用你的 Controller 方法
    ModelAndView mv = ha.handle(request, response, mappedHandler.getHandler());

    // 5. 处理返回值(JSON 序列化或视图渲染)
    processDispatchResult(processedRequest, response, mappedHandler, mv);
}

注意第 4 步的措辞------是"适配器执行"你的方法,不是 DispatcherServlet 直接调用。这里藏着一个设计上的讲究:DispatcherServlet 不想知道"你的处理器到底长什么样"。它可能是带 @RequestMapping 注解的方法,也可能是老式的 Controller 接口,还可能是别的什么。为了不让自己被任何具体形态绑死,它只面向 HandlerMappingHandlerAdapter 这两个接口编程。

这就是适配器模式:DispatcherServlet 说"我不管你是哪种处理器,反正你告诉我怎么执行就行"。

第一步:URL 是怎么找到你的方法的

getHandler(request) 这一步,背后是 RequestMappingHandlerMapping

Spring 容器在启动的时候,会扫描所有带 @Controller@RestController 的类,解析每个方法上的 @RequestMapping@GetMapping 这类注解,构建出一张映射表:

bash 复制代码
GET  /users/{id}  →  UserController.getUser(Long)
POST /users       →  UserController.createUser(User)

这张表在启动时构建一次,之后每次请求来了直接查表。查到的结果不是简单的方法引用,而是一个叫 HandlerMethod 的东西------它同时携带了"这个方法是哪个类上的"、"是哪个 Bean 实例的"、"方法的反射对象是什么"这些信息。最后再包上一层拦截器链,形成一个 HandlerExecutionChain

所以你看,这一步的本质就是查字典。URL 是词条,Controller 方法是释义。

第二步:@PathVariable 里的参数是谁塞进去的

到了执行阶段,HandlerAdapter 需要反射调用你的方法。但在调用之前,它得先解决一个问题:方法签名上的参数,值从哪来?

你写 getUser(@PathVariable Long id),Spring 总不能自己脑补一个 id 出来。于是它引入了一组叫 HandlerMethodArgumentResolver 的接口:

java 复制代码
public interface HandlerMethodArgumentResolver {
    boolean supportsParameter(MethodParameter parameter);
    Object resolveArgument(MethodParameter parameter, ...);
}

每个解析器只回答两个问题:这个参数我能不能处理?如果能,值是什么。Spring 维护了一个解析器列表,执行到某个参数时,从列表里找到第一个能处理它的解析器。

@PathVariable Long id 举例。PathVariableMethodArgumentResolver 看到参数上有 @PathVariable 注解,就知道这事归自己管。它会从 URL 模板匹配的结果里,把 {id} 对应的那一段字符串取出来------比如请求是 GET /users/1,取到的就是 "1"。然后再通过类型转换器,把字符串 "1" 转成 Long 类型的 1L。到这里,参数齐了,反射调用 getUser(1L)

你会发现,不同的注解对应不同的解析器,各管各的:

  • @RequestParam → 从查询串 ?page=2 里取
  • @RequestHeader → 从 HTTP 头里取
  • @PathVariable → 从 URL 路径里取
  • @CookieValue → 从 Cookie 里取
  • @RequestBody → 从请求体里取(这个最特殊,后面单独说)

第三步:@RequestBody 是怎么把 JSON 变成对象的

@RequestBody 是参数解析里最特殊的一个,因为它涉及格式转换。

请求体的原始形态是一段 JSON 字符串:

json 复制代码
{"name":"张三","age":25}

而你的方法参数是一个 User 对象。字符串到对象,中间必须有一个"翻译"的过程。这个翻译官叫 HttpMessageConverter------HTTP 消息转换器。

处理 @RequestBody 参数的解析器,内部会调用 HttpMessageConverter.read()。它会看请求头里的 Content-Type,如果是 application/json,就从注册表里找到 MappingJackson2HttpMessageConverter,然后委托给 Jackson 的 ObjectMapper 去反序列化,把 JSON 变成 User 对象。

这也顺便解释了一个困扰过很多人的现象:为什么引入 spring-boot-starter-web 之后,Jackson 就自动可用了。因为 @RequestBody / @ResponseBody 需要它,Spring Boot 的自动配置就把 MappingJackson2HttpMessageConverter 默认注册好了。

第四步:返回值又是怎么变成 JSON 还回去的

方法返回一个 User 对象,但响应只能承载字节。这个反向翻译,还是 HttpMessageConverter 干的------只不过这次调用的是 write() 方向。

方法执行完,返回值交给 HandlerMethodReturnValueHandler 处理。如果方法上标了 @ResponseBody@RestController 自带这个注解),就会走 RequestResponseBodyMethodProcessor,它调用转换器的 write(),把 User 对象序列化成 JSON 字符串,写进 HttpServletResponse 的响应体里。

所以 HttpMessageConverter 是个双向翻译官:read() 负责把请求里的 JSON 变成 Java 对象,write() 负责把 Java 对象变成响应里的 JSON。你平时感觉不到它,是因为它一直在你根本看不见的地方默默工作。

把整条链串起来

一个 GET /users/1,从浏览器敲下回车到拿到 JSON,实际上是这么一路走下来的:

scss 复制代码
浏览器发 HTTP 请求
  → Tomcat 的 NIO 连接器接收字节流,解析 HTTP 报文
  → 封装成 HttpServletRequest 对象
  → 根据 URL 匹配到 DispatcherServlet(根路径 /)
  → 调用 service() → 进入 doDispatch()
  → HandlerMapping 查表,找到 UserController.getUser 这个方法
  → HandlerAdapter 准备执行
  → 参数解析器从 URL 里取出 "1",转成 Long 1L
  → 反射调用 getUser(1L)
  → 返回值交给返回值处理器,Jackson 序列化成 JSON
  → 写进响应对象
  → Tomcat 把响应报文发回浏览器

中间那条"Tomcat 把控制权交给 Spring"的界线,发生在 DispatcherServlet.service() 被调用的那一刻。界线之前是 Servlet 容器的事,界线之后是 Spring MVC 的事,两条世界线靠一个实现了 Servlet 接口的类接上了。

Spring Boot 到底干了什么

很多人以为是 IDEA 把 Tomcat 封装了。其实不是。

Spring Boot 的 SpringApplication.run(...) 里,有一批自动配置类在幕后干活。其中 ServletWebServerFactoryAutoConfiguration 会在类路径里发现 tomcat-embed-core 之后,创建出一个内嵌的 Tomcat 实例------对,你运行 main() 的时候,程序里已经悄悄启动了一个完整的 Tomcat。然后 DispatcherServletAutoConfiguration 会创建 DispatcherServlet,把它注册到 Tomcat 的根路径 / 上,最后 tomcat.start() 开始监听端口。

IDEA 做的唯一一件事,是帮你执行了 main(),外加帮你管理了 tomcat-embed-core 这个依赖。所谓的"Tomcat 被封装",真相是 Spring Boot 用几行代码把 Tomcat 当作一个库给内嵌启动了

写在最后

回头看我一开始的那三个问题,答案其实都很朴素:

  • {id}HandlerMapping 的 URL 模板匹配结果,PathVariableMethodArgumentResolver 从里面抠出来的。
  • Long id 是类型转换器把字符串转出来的。
  • 想拦请求,除了 Servlet 规范里的 Filter,Spring 的 HandlerInterceptor 就是插在 doDispatch 第 3 步那个位置的钩子。

框架之所以显得像魔法,不过是因为层与层的衔接被藏起来了。每一层本身都简单到不值得惊讶:Tomcat 只会解析报文和调 Servlet;DispatcherServlet 只会查表、找适配器、反射调用;参数解析器只会从请求里取一段数据;转换器只会做字符串和对象之间的翻译。七层简单的机制叠在一起,就成了你每天面对的那个"写个方法就能跑"的 Spring。

而这一切的起点,是 main() 里那一声毫不起眼的 SpringApplication.run(...)

相关推荐
月光有害1 小时前
理解 Spring 依赖注入:从构造器注入到集合与条件 Bean
java·后端·spring
码农进化录1 小时前
Java 程序员的 AI 进化论 | 用 AI 生成 Spring Boot 脚手架,省下两小时重复劳动
java·spring boot·openai
名字还没想好☜1 小时前
Java 用 MethodHandle 替代反射:调用性能实测、invokeExact 的坑与缓存
java·开发语言·缓存·反射·methodhandle
SMF19192 小时前
【Linux】完美解决缩略图工具gm调用java.io.FileNotFoundException: gm问题
java·开发语言·python
小当家.1052 小时前
LLM服务缓存与连接池管理:三层缓存架构与ChatClient池化
java·后端·spring·缓存·llm·agent
万岳科技系统开发2 小时前
医疗诊所小程序如何实现患者管理和会员运营?
java·小程序·apache
小当家.1052 小时前
Token成本控制实战:语义缓存、上下文压缩与工具缓存
java·spring·缓存·token·上下文
乐观勇敢坚强的老彭2 小时前
C++ STL 常用容器的速查表
java·c++·算法
wwwzhouzy2 小时前
SpringBoot 响应式编程
java·spring boot·后端·响应式编程