相近的请求为什么得到不同错误码

GET /hello 成功,POST /hello 返回 405,GET /hello/ 却返回 404。向 /json 发送正文也不保证进入控制器:请求声明 text/plain,而处理器只消费 JSON 时,结果为 415。路径只是匹配条件之一;几个失败发生在不同条件上,状态码正好保留了这种差别。

如果把这些错误全转成“接口不存在”,调用者无法判断该换路径、换方法还是换 Content-Type。排查时也容易把网关改写、容器解码、MVC 匹配和消息转换混在一起。必须先确定请求在哪一层被拒绝,再解释其状态码。

实验使用 JDK 21、Spring Framework 6.2.11、Tomcat 10.1.46,固定源码提交 4c134254642d88e058aa004bdaf44168e1be7bb2。MvcLab 的 26 分支通过真实 HTTP 验证七种请求行为,另直接操作真实 RequestMappingHandlerMapping 验证重复注册失败。后一个场景属于注册阶段,不伪装成客户端请求结果。

从容器解码到映射条件检查

映射在启动时建立

RequestMappingHandlerMapping 会发现控制器处理方法,把类型与方法级注解合并成 RequestMappingInfo。这个信息包括路径模式、HTTP 方法、参数条件、请求头、consumes 和 produces 等。请求到来时比较的是这些已注册条件,不是每次都重新扫描整个应用的所有注解。

注册表需要区分“多个方法可能匹配某些相近请求”和“同一个映射信息注册了不同处理方法”。例如 /orders/{id} 与 /orders/new 可以同时存在,请求时再根据匹配规则选择;两个方法拥有同样的 /same 与 GET 条件,则在重复注册时抛出 Ambiguous mapping 异常,不能等流量到来后随机选一个。

实验构造同一个 RequestMappingInfo,先为 Duplicate.first() 注册,再为 Duplicate.second() 注册。第二次注册必须抛出 IllegalStateException,且信息包含 Ambiguous mapping。这验证了映射注册表的冲突检测,不包含组件扫描发现顺序的实验;正常应用的启动扫描最终也会进入该注册机制。AbstractHandlerMethodMapping 注册实现

这类失败与运行时歧义需要分开。两个不同条件集合仍可能在某个请求上得到同等优先级;运行时匹配会比较候选。一个只验证“应用启动成功”的检查不能证明所有可能请求都不存在歧义。本篇实际验收的是重复注册冲突,未穷举全部 PathPattern 优先级组合。

404、405 与 415 的分界

RequestMappingInfoHandlerMapping.handleNoMatch() 处理完全匹配失败的情况。它不会立刻把所有结果归为 404,而是检查仍有路径匹配的部分候选。没有这种候选时可以返回无处理器;存在候选但方法不匹配时,抛出 HttpRequestMethodNotSupportedException;方法符合而 consumes 不满足时,再抛出 HttpMediaTypeNotSupportedException。固定版本部分匹配分支

本实验 /hello 只注册 GET。POST 到同一路径得到 405,响应 Allow 包含 GET。Allow 是可操作的信息,客户端能够据此判断服务器接受的方法。本例不把 Allow 的完整字符串冻结成唯一顺序,因为 HEAD、OPTIONS 等支持细节需要结合映射规则;断言只要求目标方法 GET 可见。

/absent 没有任何映射,也没有额外配置静态资源处理器,因而得到 404。若应用启用了兜底资源映射,同一路径可能先选中资源处理器,再在资源查找阶段产生 404。最终状态码相同,不表示失败阶段相同。查看处理器身份与分派日志,才能判断是否真的“没有 Handler”。

POST /json 的 consumes 为 application/json。发送 text/plain 会在映射条件阶段得到 415,此时 JSON 解析尚未开始。发送 application/json 但正文语法损坏,则可以通过 consumes 条件,进入转换器后产生 400。这两个场景应使用不同修复:一个纠正媒体类型声明,一个纠正正文内容。

响应协商还存在另一个方向。consumes 对照请求 Content-Type,produces 对照客户端 Accept 与处理器能够生成的表示。Content-Type 描述已发送正文,Accept 描述希望收到的表示,它们可以同时出现且值不同。第 29 篇从返回值转换器继续验证 406,避免把协商全归结为路由注解。

尾斜杠不是可以忽略的字符

在当前 MVC 默认的 PathPattern 配置下,/hello 与 /hello/ 是不同路径。实际请求前者得到 200,后者得到 404。旧版本经验或网关自动重定向会掩盖这个差别,因此升级后出现“只有某些客户端 404”,应同时检查 URL 生成与上游规范化。

本实验不配置尾斜杠重定向,也不另注册第二个路径。若产品要求两种形式都有效,可以显式注册两种形式,或在边界处制定规范化规则。使用重定向时还需要验证方法、正文与客户端行为,不能随意把携带正文的请求转换成 GET。请求路径模式文档

路径规范属于接口契约。调用方缓存键、签名算法、反向代理路由与应用匹配器若采用不同规范,同一资源可能出现两种身份。修复方法应落在明确的一层,并用穿过真实代理和容器的测试确认。只给控制器增加宽泛通配符,可能把原本应该失败的拼写错误一起接收。

URL 解码先经过容器

GET /path/a%20b 进入 /path/{id},返回值为 a b。它说明当前容器与 MVC 配置下,编码空格可以作为该路径段的一部分被解析,控制器得到解码后的变量。URI 中的编码形式与 Java 参数值不是同一个表示。

GET /path/a%2Fb 得到 400。这里不能归因于 PathPattern 没有匹配:Tomcat 默认拒绝编码斜杠,请求在进入 MVC 前已失败。证据中没有该请求的 MVC preHandle。若只使用模拟请求对象测试,往往绕过容器对原始请求目标的校验,难以观察这个边界。Tomcat HTTP Connector 的 encodedSolidusHandling

即使修改容器允许编码斜杠,也不能简单推出控制器会得到哪个变量值。容器的 decode、passthrough 等策略、代理预解码以及 MVC 的分段解析都可能改变结果;重复解码还会带来路径语义不一致。本文只证明默认配置的 400,不给未执行的自定义配置补写结果。

允许特殊路径字符前,应该先明确业务是否真的需要把分隔符放进标识符。若只是外部任意字符串,放入查询参数或正文通常能减少路径层歧义;这不免除输入校验,而是让各层对路径段边界保持一致。若协议已固定必须走路径,则需要对代理、容器、应用进行端到端验证。

用请求矩阵定位失败阶段

实测结果如下,HTTP 行与映射异常信息都保存在本章 run.txt 中。

请求或操作 结果 已定位的边界
GET /path/abc 200,正文 abc 路径变量匹配
GET /absent 404 当前配置无处理器
POST /hello 405,Allow 含 GET 方法条件
POST /json,text/plain 415 consumes 条件
GET /hello/ 404 尾斜杠不同路径
GET /path/a%20b 200,正文 a b 路径段解码
GET /path/a%2Fb 400 Tomcat 编码斜杠校验
重复注册 GET /same IllegalStateException 映射注册冲突

排障时先保存原始请求方法、目标 URI、Content-Type 与 Accept,再查当前请求是否进入 Filter、是否选中处理器,最后查看参数和返回值处理。只保存控制器日志会遗漏拒绝在它之前的请求。另一方面,直接记录含令牌的全部请求头或正文也不是必要条件;保留决定匹配结果的字段即可。

复现与练习

完整可编译代码在下载工程 mvc-lab。以下命令执行 12 条断言,其中四条检查执行器、上下文与监听端口关闭;证据目录是 evidence/26/local-20261002/。

1
2
./mvnw -f mvc-lab/pom.xml -q compile exec:java \
-Dexec.mainClass=blog.spring.mvc.MvcLab -Dexec.args=26

推导题:把 /json 请求头改成 application/json,但保留正文 name=a,是否仍然得到 415?预期通过 consumes 检查后在 JSON 读取阶段失败,得到 400。状态变化证明失败阶段已经向后移动,并不代表请求更接近业务成功。

改动练习:给 /hello 显式增加 /hello/ 映射,重跑矩阵,要求只有尾斜杠的预期结果改变。另在chapter26为第二次registerMapping单独创建RequestMappingInfo.paths("/same").methods(RequestMethod.POST).build(),把second方法注册到新映射,并将原冲突断言改为检查注册表有GET、POST两个条目。预期两条映射可以共存。这里手动传入映射信息,不解析方法注解;只把Duplicate.second上的@GetMapping改为@PostMapping仍会冲突。该练习未执行,正文已运行控制组仍保留重复GET冲突检查。

完整工程下载见 深入 Spring(00):从手动组装到可验证的容器实验。六章共用独立的 mvc-lab 模块,单章参数决定执行场景;正文中的改动练习未计入已通过断言。

参考资料