Spring MVC 表单提交 PUT/DELETE 的 _method 机制

发布时间:2026/10/13 1:21:12
Spring MVC 表单提交 PUT/DELETE 的 _method 机制
老项目里最常见的尴尬场景Controller 上 PutMapping、DeleteMapping 全写好了Postman 里也测通了结果页面一接 form 表单要么 405要么请求根本进不了方法。这个场景在 Spring Boot Thymeleaf 这类服务端渲染项目里太典型了本质原因是浏览器原生 form 根本不认 PUT、DELETE能用的只有 GET 和 POST。Spring MVC 从很早的版本就留了一个补丁方案HiddenHttpMethodFilter。配合一个约定好的隐藏参数_method表单用 POST 把请求发出去过滤器在到达 Controller 之前把请求方法改写成 PUT 或 DELETE后面的接口映射、参数绑定都照常工作。这篇文章会把这条链路的原理、配置、form 写法、Thymeleaf 联动、Spring Boot 2.x 和 3.x 的行为差异以及实际开发中踩得最多的坑完整过一遍。适合正在维护服务端渲染项目、想把表单操作改成 RESTful 风格的后端开发参考。1. 为什么原生 form 只能发 GET 和 POST硬伤在哪1.1 浏览器的限制与 RESTful 语义的错位HTML 规范里form 表单的 method 属性长期只支持两个值get 和 post。HTML5 也没有为 form 扩展 PUT、DELETE更不用说 PATCH。这不是 Spring Boot 的问题是所有服务端渲染框架共同面对的约束。如果你直接在 HTML 里写methodput浏览器不会主动报错而是会当成非法值处理按 GET 请求提交或者干脆忽略这个属性。换句话说原生 form 根本没有表达 PUT 和 DELETE 语义的通道。RESTful 风格偏要把操作语义放在 HTTP 方法上PUT 代表更新DELETE 代表删除PATCH 代表局部更新。浏览器做不到后端又想按这套规范来就产生了错位。你当然可以在 Controller 里继续写 PostMapping 然后靠路径区分操作但这又回到了老式接口风格写多了自己都分不清这个 POST 到底是新增还是更新。1.2 常见绕行思路Ajax、自定义 header、_method 约定解决这个错位实际项目里无非三条路。第一条是全部用 JavaScript 发送异步请求。fetch、axios 可以直接指定 methodREST 语义完整也不需要任何隐藏参数。代价是页面里所有表单都要改成 JS 提交原生表单的页面跳转、同步提交带来的刷新逻辑全要重写在老后台项目里改造成本很高。第二条是后端自己定一套约定比如 POST /books/3/update、POST /books/3/delete。界面开发简单了但接口命名越来越随意REST 风格被打折扣同类资源多了以后这种带动作的路径很容易堆成一团。第三条就是_method参数约定表单仍然用 POST 提交但在表单里放一个隐藏字段name_method valueput或valuedelete。后端的过滤器读取到这个参数后把当前请求包装成对应的 HTTP 方法。这样前端表单不用动Controller 依然可以写 PutMapping、DeleteMapping语义和服务端都保住了。1.3 为什么偏偏是_method这个约定Spring MVC 的 HiddenHttpMethodFilter 里有个常量public static final String DEFAULT_METHOD_PARAM _method;它读取请求参数的方式是request.getParameter(_method)。也就是说只要 POST 请求带着一个名为_method的参数无论这个参数是放在 form 表单 body 里还是拼在 URL 查询串上过滤器都能拿到。拿到之后会把值转成大写比如put变成PUT然后包装请求对象。选择隐藏字段而不是自定义 Header是因为浏览器 form 提交时根本没法主动设置 Headeraction 和 method 是 form 唯一能控制的东西。请求体里带参数是原生表单唯一可用的信息通道_method就占住了这个位置。如果你用 fetch 这类工具发请求完全可以把_method放在 URL query 上比如POST /books/3?_methodPUT效果一样这个点后续还会再提。2. HiddenHttpMethodFilter 到底改了什么2.1 过滤器核心逻辑只改 getMethod()HiddenHttpMethodFilter 不是真的把底层 TCP 报文改了它做的事情非常轻量。核心逻辑大致是这样的protected HttpServletRequest wrapHttpRequest(HttpServletRequest request) { String paramValue request.getParameter(this.methodParam); if (POST.equals(request.getMethod()) StringUtils.hasLength(paramValue)) { return new HttpMethodRequestWrapper(request, paramValue); } return request; }如果当前请求是 POST并且能从参数里拿到非空的_method值就创建一个内部包装类 HttpMethodRequestWrapper。这个包装类继承自 HttpServletRequestWrapper只覆盖一个方法public String getMethod() { return this.method; }请求到了 DispatcherServlet、HandlerMapping 那边一调用 getMethod()得到的是 PUT 或 DELETE于是路由匹配自然就落到了 PutMapping、DeleteMapping 上。其他的东西一概不动header、cookie、请求体、参数列表全都原封不动。所以这个方案的正确表述是请求从浏览器发出时依然是 POST但后端内部认为它是 PUT 或 DELETE。你在浏览器 F12 里看到的始终是 POST只有服务端逻辑能感知方法被改写。2.2 Spring Boot 2.x 与 3.x 的默认配置差异这一点是最容易踩的版本坑。在 Spring Boot 2.x 里WebMvcAutoConfiguration 默认注册了 OrderedHiddenHttpMethodFilter开发者什么也不用配置form 表单里带上_method就能用。很多人从这个版本上手习惯了开箱即用。到 Spring Boot 3.0 之后行为变了HiddenHttpMethodFilter 的自动配置默认关闭必须显式开启。spring.mvc.hiddenmethod.filter.enabledtrue漏配这条配置典型现象是form 的 method 写的是 post隐藏字段_method也写了但 Controller 里的 PutMapping 始终不触发日志里看到的请求方法仍然是 POST。这不是你代码写错了是过滤器根本没注册。需要说明的是Spring Boot 3 把默认行为关掉是有安全考量的这个机制本质上允许客户端用请求参数覆盖 HTTP 方法在网关鉴权、访问日志、限流策略都基于原始请求行的场景下一个 POST 请求实际执行了 DELETE 操作容易产生审计盲区。所以在缺少额外防护的项目里默认关闭反而更稳妥。2.3 关键前置条件方法必须是 POST过滤器源码第一道判断就是POST.equals(request.getMethod())。这句其实挡掉了大量使用错误。form 的 method 必须写成 post。如果你写成form methodget哪怕 URL 上带了_methodput过滤器看到 getMethod() 返回 GET直接放行最终到达 Controller 的还是一个普通 GET 请求对应的 PutMapping 当然不会执行。还有一点_method参数值如果为空字符串StringUtils.hasLength 判断过不去请求也会保持 POST。所以表单里这个隐藏字段要么别写要写就写准确值。另外_method参数名是区分大小写的必须严格写成_method写成Method或者_METHOD都读不到。3. 完整实操一个图书管理页面改造3.1 环境与依赖准备示例用 Spring Boot 3.2 Thymeleaf依赖只需要 starter-web 和 starter-thymeleafdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency配置文件里加上spring.mvc.hiddenmethod.filter.enabledtrue server.port8080如果是 Spring Boot 2.x前面的 enabled 属性不写也能工作写上也不会出问题。下面所有例子都假设已经开启了这个过滤器。3.2 原生 HTML 表单的写法更新图书的表单标准写法如下form action/books/3 methodpost input typehidden name_method valueput/ input typehidden nameid value3/ label书名/label input typetext nametitle value/ button typesubmit保存修改/button /form删除图书的表单加一个确认弹窗避免误操作form action/books/3 methodpost onsubmitreturn confirm(确定删除这本图书); input typehidden name_method valuedelete/ button typesubmit删除/button /form两个容易被忽略的细节隐藏字段必须放在 form 内部否则提交时根本不会带上form 的 action 路径上的/3和表单里的id3各自独立Controller 里一个用 PathVariable 拿一个用表单绑定拿不要为了省事只在 action 里放 id 却忘了表单字段后面会发现更新时对象里的 id 是 null。3.3 Thymeleaf 的便捷写法如果你用的是 Thymeleaf可以少写一个隐藏字段form th:action{/books/{id}(id${book.id})} th:methodput input typetext nametitle th:value${book.title}/ button typesubmit保存修改/button /form这里th:methodput在服务端渲染时做了两件事form 标签的 method 属性被输出为 postform 内部自动追加了一个隐藏字段name_method valueput。你打开浏览器开发者工具切到 Form Data 面板看到的依然是一堆参数里混着_methodput。Thymeleaf 只是省去了手写隐藏字段底层原理没有任何变化。th:methoddelete、th:methodpatch同理配合 DeleteMapping、PatchMapping 都能用。3.4 Controller 与表单数据绑定Controller 这端不需要做任何特殊处理直接按标准 REST 语义写Controller public class BookController { PutMapping(/books/{id}) public String update(PathVariable Long id, ModelAttribute BookForm form) { // 调用 service 执行更新 return redirect:/books; } DeleteMapping(/books/{id}) public String delete(PathVariable Long id) { // 调用 service 执行删除 return redirect:/books; } }注意这里用的是 ModelAttribute 接收表单字段不是 RequestBody。原生 form 提交的 Content-Type 是application/x-www-form-urlencoded字段按 URL 编码方式放在请求体里ModelAttribute 能自动完成字段绑定。RequestBody 默认期待 JSON 或 XML直接用于 urlencoded 表单数据往往会拿到空对象。BookForm 就是一个普通 POJOpublic class BookForm { private Long id; private String title; // getter / setter }3.5 用 curl 验证请求链路页面不好调试的时候可以用 curl 模拟这个完整链路。下面这条命令等价于 form 表单提交 PUT 更新curl -i -X POST http://localhost:8080/books/3 \ -d _methodPUTtitleSpringBoot实战服务端如果打日志或加了拦截器能看到 request.getMethod() 返回的是 PUT并且 PutMapping 接口被执行。想验证一下过滤器的存在感可以先去掉_methodPUT再发一次curl -i -X POST http://localhost:8080/books/3 \ -d titleSpringBoot实战这次返回多半是 405因为 POST 没有匹配 PutMapping 的映射。这一正一反两个请求能帮你快速确认问题到底出在配置还是页面。4. 高频坑与排查实录4.1 方法被改写成非法值导致 405_method参数的值会被转大写然后直接作为 getMethod() 的返回值。如果值是put、delete、patch没问题。但你要是写了别的值比如input typehidden name_method valueupdate请求方法就变成了UPDATE这不是标准 HTTP 方法后端也没有对应的 UpdateMapping结果就是 405。还有一个更隐蔽的版本form 里某个按钮的 name 恰好叫_method。input typehidden name_method valueput/ button name_method valueupdate保存修改/button按钮点击提交时它自身的 name-value 也会作为一个请求参数发出去而且隐藏字段的put被按钮的update覆盖了最终方法被改写为 UPDATE。这种问题肉眼很难盯出来。经验就是所有表单控件里name 一律不要用_method这个参数名让给隐藏字段专用。4.2 Spring Security 的 CSRF 拦截导致的 403项目里只要集成了 Spring Security 且开启了 CSRF 防护POST、PUT、DELETE、PATCH 这些修改类请求默认都会校验 CSRF token。form 表单里没有带 token就会收到 403。如果你用了 Thymeleaf 和 Spring Security 的整合包表单里通常会由模板自动注入_csrf。纯手写 HTML 表单时要手动加一个隐藏字段input typehidden name_csrf th:value${_csrf.token}/不要为了让表单能跑通就直接把 CSRF 关掉。后台管理类系统被 CSRF 打中的代价远高于多写一个字段的成本。4.3 RequestBody 和 ModelAttribute 选错导致的字段全 null这个问题在前后端配合时特别典型。后端开发在 Postman 里测接口用的是 JSON body测试通过。到了页面联调form 表单提交的是 urlencoded 数据请求虽然被改写成了 PUTbody 格式没变Controller 里写的 RequestBody 却解析不出任何东西所有字段都是 null。遇到这种情况先看 Content-Type。form 表单就是 urlencoded用 ModelAttribute 或显式 RequestParam如果是前端用 fetch 发 JSONbody 是 JSON 字符串那么用 RequestBody 才对。两者并不冲突关键是请求体格式和方法参数接收方式要匹配。4.4 文件上传场景别混用_methodform 如果带着文件上传Content-Type 会变成multipart/form-data同时还夹带一个_method隐藏字段。这种组合在不同容器下的行为不太一致因为过滤器读取_method时会触发 getParameter()在 multipart 请求上这可能导致请求体被提前解析后续 MultipartResolver 再去读取时拿不到完整的输入流或者解析异常。实际表现就是文件偶尔能传上去偶尔为 null或者直接报 multipart 解析相关的错误。这种接口我建议干脆保持 POST 语义不要强行伪装成 PUT/DELETE或者让前端用 FormData 配合 fetch 显式指定 PUT 请求。4.5 常见问题速查表现象可能原因处理方法form 里写了_method但请求方法还是 POSTSpring Boot 3.x 未开启过滤器配置spring.mvc.hiddenmethod.filter.enabledtrue请求变成了 GETform method 写成了 get改成 methodpost返回 405 Method Not Allowed_method值不是标准方法名或按钮覆盖了隐藏字段检查_method值按钮 name 避开_method返回 403 ForbiddenSpring Security CSRF 校验失败form 内添加_csrf隐藏字段接口进去了但字段全 nullRequestBody 接收了 urlencoded 数据换 ModelAttribute 或 RequestParam文件上传接口异常multipart 和_method混用改用 POST 语义或 fetch 显式 PUT5. 什么时候别用这套方案5.1 前后端分离项目如果前端是 Vue、React 这类项目接口完全通过 fetch、axios 调用浏览器对 XHR 没有 GET/POST 限制PUT、DELETE 直接就能发_method这套机制根本没有存在必要。硬要套上去反而是给团队找麻烦一个请求在浏览器显示 POST在后端变成 PUT接口文档、监控日志、排障过程都要多绕一层。这种场景下Controller 直接保持 PutMapping、DeleteMapping前端发对应方法后端收对应方法整个链路干净利落。5.2 对外开放的 API 或经过网关转发的接口网关鉴权、限流、日志分析很多都依赖请求行的原始方法。你一个 POST 请求经过过滤器后半路变成 DELETE网关如果已经按 POST 完成了鉴权后面实际执行了删除安全边界就被绕过了。开放平台、第三方对接、移动端服务这类接口都不要依赖_method参数标准方法直接走标准请求。5.3 认清边界这层伪装只服务表单这套方案的价值范围很明确解决服务端渲染页面里原生 form 无法表达 PUT/DELETE 的问题。它适合内部后台、管理端、传统 MVC 项目并且在删除成功后返回 redirect 还附带一个额外的好处——浏览器刷新列表页时发起的是 GET不会重复提交删除操作。但别把这种伪装延伸到所有接口设计上。大文件上传、流式请求、二进制数据这类场景老老实实按实际能力和语义来设计接口比强行凑 REST 方法重要得多。我个人在实际项目里的习惯是所有 form 表单的隐藏字段统一放在第一行值固定用小写并且约定按钮 name 不允许命名为_method。后端接口本身始终按标准 PUT/DELETE 实现用 Postman 直接发标准方法测通表单里的_method只是最后一道适配层。这样一来将来换成前后端分离或者接入第三方客户端时Controller 一行代码都不用改前端直接换成真正的 PUT/DELETE 就完全兼容。