ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Apache HttpClient 4.x 实现 DELETE 请求携带 Body 的完整方案

Apache HttpClient 4.x 实现 DELETE 请求携带 Body 的完整方案

1. 从一个实际需求说起:DELETE 请求为什么需要带 Body

1.1 问题的起源:一个批量删除接口引发的思考

前段时间在对接一个内部服务时,遇到了一个挺有意思的问题。对方提供的接口是一个批量删除的语义,路径是类似/api/resource/batch-delete这种,但要求把待删除的资源 ID 列表放在请求体里传过去,而不是拼在 URL 上。原因也很实在:ID 数量可能上百个,拼在 URL 里长度会爆掉,而且 URL 编码后也不好看。

我第一反应是用 Apache HttpClient 来发这个请求,毕竟项目里一直在用它。结果写代码的时候发现,HttpDelete这个类压根就没有提供带HttpEntity的构造函数。翻了一下源码,HttpDelete继承自HttpRequestBase,而HttpRequestBase本身是支持setEntity()的,但HttpDelete在构造时把 entity 相关的逻辑给限制住了——准确地说,是HttpDelete没有像HttpPost、HttpPut那样提供接收HttpEntity的构造方法,而且它的getMethod()返回的是"DELETE",在默认的请求执行链路里,DELETE 方法通常被当作"无实体"来处理。

这就引出了一个很经典的矛盾:HTTP 规范本身并没有禁止 DELETE 携带请求体,RFC 7231 里对 DELETE 的描述是"请求体没有定义的语义",但也没说不能有。可现实中的很多 HTTP 客户端库、服务端框架、甚至中间的代理层,都会默认忽略 DELETE 的 body。所以这个问题不是"能不能做",而是"怎么绕过客户端的限制把它做出来,同时还要保证服务端能正确解析"。

1.2 谁需要看这篇内容

如果你正在做以下几类事情,这篇内容应该能帮到你:

  • 对接的第三方或内部接口,明确要求 DELETE 请求携带 JSON body 传参;
  • 批量删除场景,ID 列表太长不适合放 URL query;
  • 使用 Apache HttpClient 4.x 版本,发现HttpDelete不支持 entity;
  • 想搞清楚"为什么 HttpClient 要这么设计"以及"绕过它的正确姿势是什么"。

需要说明的是,这里讨论的是 Apache HttpClient 4.x 系列(4.3 到 4.5 这几个版本行为基本一致),如果你用的是 HttpClient 5.x,情况会有所不同,后面也会提一嘴。另外,我假设你对 Java 和 HttpClient 的基本用法不陌生,至少写过CloseableHttpClient发 GET/POST 请求。

1.3 先给结论:三条可行路径

在展开细节之前,先把结论摆出来,方便你快速判断哪条路适合自己:

方案核心思路适用场景风险点
方案一:自定义 HttpEntityEnclosingRequestBase 子类继承并重写 getMethod() 返回 DELETE最推荐,通用性强需要自己写一个类
方案二:用 HttpPost 改方法名构造 POST 后通过 setMethod 或反射改临时应急反射脆弱,不推荐生产
方案三:升级到 HttpClient 5.x新版本原生支持新项目或可升级的老项目迁移成本

下面我会把方案一作为主线详细拆解,因为它是实际项目里最稳、最可控的做法。方案二和方案三作为补充说明,帮你理解不同取舍。

2. 为什么 HttpDelete 不支持 Entity:源码层面的原因

2.1 HttpDelete 的类结构分析

要理解怎么绕过,先得理解为什么会被挡住。Apache HttpClient 4.x 里,请求类的继承体系大致是这样的:

  • HttpRequestBase:所有请求方法的基类,实现了HttpUriRequest接口,内部持有一个HttpEntity字段,提供了setEntity()和getEntity()方法。
  • HttpPost、HttpPut、HttpPatch:继承自HttpEntityEnclosingRequestBase,这个中间类实现了HttpEntityEnclosingRequest接口,明确表示"我是一个可以带实体的请求"。
  • HttpGet、HttpDelete、HttpHead、HttpOptions、HttpTrace:直接继承自HttpRequestBase,没有实现HttpEntityEnclosingRequest接口。

关键就在这个接口上。HttpClient 在执行请求时,InternalHttpClient或CloseableHttpClient的doExecute方法会判断当前请求是否instanceof HttpEntityEnclosingRequest。如果是,才会去处理 entity 的写入、Content-Length 的计算、Content-Type 头的设置等。如果不是,即使你通过setEntity()塞了一个 entity 进去,执行链路里也不会去读它,最终发出去的请求就是没有 body 的。

所以问题的本质不是"HttpDelete 不能 setEntity",而是"HttpDelete 没有被标记为 EntityEnclosing,执行器不会处理它的 entity"。

2.2 HTTP 规范对 DELETE body 的态度

这里稍微展开一下规范层面的背景,因为这直接影响到你和服务端联调时会不会踩坑。

RFC 7231 第 4.3.5 节对 DELETE 的描述原文大意是:DELETE 方法请求源服务器删除由 Request-URI 标识的资源。请求体没有定义的语义,客户端不应该在 DELETE 请求里生成请求体。注意用词是"不应该"(SHOULD NOT),不是"禁止"(MUST NOT)。这意味着规范层面留了口子,但明确表达了"不推荐"的态度。

这个态度带来的实际后果是:

  • 一些服务端框架(比如某些版本的 Spring MVC)默认不会去读 DELETE 的 body,需要额外配置才能拿到;
  • 一些反向代理或网关可能会丢弃 DELETE 的 body;
  • 一些 HTTP 客户端库干脆不支持。

所以你在决定用 DELETE 带 body 之前,一定要先和服务端确认他们能正确解析。否则你客户端这边折腾半天,服务端读不到,白忙活。

2.3 为什么 HttpClient 要这么设计

有人可能会觉得 HttpClient 这个设计很"死板",但站在库作者的角度其实很合理:既然规范不推荐 DELETE 带 body,那客户端库就不主动提供这个能力,避免开发者滥用。这是一种"用 API 设计引导正确用法"的思路。

但现实是,很多业务场景确实需要批量操作语义,而 RESTful 风格里又倾向于用 DELETE 表示删除。于是就产生了这个矛盾。理解了这一点,你就知道我们接下来做的事情本质上是"在库的限制下,用继承和重写的方式表达我们的意图",而不是"破解"什么。

3. 方案一详解:自定义 EntityEnclosing 的 Delete 请求类

3.1 核心思路与代码实现

方案一的核心就一句话:写一个继承HttpEntityEnclosingRequestBase的类,把getMethod()重写成返回"DELETE"。这样它既具备 EntityEnclosing 的能力(执行器会处理 entity),又会被识别为 DELETE 方法。

代码不长,直接上:

import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; import java.net.URI; public class HttpDeleteWithBody extends HttpEntityEnclosingRequestBase { public static final String METHOD_NAME = "DELETE"; public HttpDeleteWithBody() { super(); } public HttpDeleteWithBody(final URI uri) { super(); setURI(uri); } public HttpDeleteWithBody(final String uri) { super(); setURI(URI.create(uri)); } @Override public String getMethod() { return METHOD_NAME; } }

就这么简单。HttpEntityEnclosingRequestBase已经帮你实现了setEntity、getEntity以及HttpEntityEnclosingRequest接口,你唯一需要做的就是告诉它"我的方法名是 DELETE"。

3.2 为什么继承 HttpEntityEnclosingRequestBase 而不是 HttpRequestBase

这是这个方案里最关键的一个选择,值得说清楚。

如果你继承HttpRequestBase,那么即使你重写了getMethod()返回 DELETE,这个类也不会被识别为HttpEntityEnclosingRequest。执行链路里那个instanceof HttpEntityEnclosingRequest的判断依然会失败,entity 依然不会被发送。

而HttpEntityEnclosingRequestBase实现了HttpEntityEnclosingRequest接口,继承它就自动获得了这个"身份"。所以正确的做法是继承HttpEntityEnclosingRequestBase,而不是HttpRequestBase。

我见过有人在网上分享的代码是继承HttpRequestBase然后自己实现 entity 相关方法,那样也能跑,但等于把HttpEntityEnclosingRequestBase已经做好的事情重做一遍,没必要。

3.3 完整的使用示例

光有类还不够,得看怎么用。下面是一个完整的调用示例,包含构造 JSON body、设置请求头、执行请求、处理响应:

import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.entity.ContentType; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; public class DeleteWithBodyDemo { public static void main(String[] args) throws Exception { String url = "http://your-service/api/resource/batch-delete"; String jsonBody = "{\"ids\":[1001,1002,1003]}"; try (CloseableHttpClient client = HttpClients.createDefault()) { HttpDeleteWithBody delete = new HttpDeleteWithBody(url); StringEntity entity = new StringEntity( jsonBody, ContentType.APPLICATION_JSON ); delete.setEntity(entity); delete.setHeader("Accept", "application/json"); try (CloseableHttpResponse response = client.execute(delete)) { int statusCode = response.getStatusLine().getStatusCode(); String responseBody = EntityUtils.toString( response.getEntity(), "UTF-8" ); System.out.println("Status: " + statusCode); System.out.println("Body: " + responseBody); } } } }

这段代码里有几个细节值得注意:

  • StringEntity的第二个参数用ContentType.APPLICATION_JSON,它会自动帮你设置Content-Type: application/json; charset=UTF-8。如果你手动setHeader("Content-Type", "application/json"),可能会丢掉 charset,服务端解析中文时容易出问题。
  • EntityUtils.toString要指定字符集,否则默认用 ISO-8859-1,中文会乱码。
  • 用 try-with-resources 管理 client 和 response,避免连接泄漏。

3.4 参数计算与 Content-Length 的处理

有人会问:DELETE 带 body 时,Content-Length 是怎么算出来的?会不会有问题?

答案是:只要你用的是HttpEntityEnclosingRequestBase的子类,HttpClient 在执行时会自动处理。具体来说,RequestContent拦截器会调用 entity 的getContentLength(),如果返回非负数,就设置Content-Length头;如果返回 -1(比如流式 entity 长度未知),就改用Transfer-Encoding: chunked。

StringEntity的getContentLength()返回的是字节数组的长度,是确定的,所以会走 Content-Length 这条路。你不需要手动设置这个头,手动设置反而可能因为编码问题算错。

这里有个坑:如果你用的是InputStreamEntity且没指定长度,会走 chunked 编码。有些服务端或网关对 chunked 的 DELETE 支持不好,可能读不到 body。所以能用 StringEntity 就用 StringEntity,长度确定,兼容性最好。

4. 方案二与方案三:应急手段与升级路径

4.1 方案二:用 HttpPost 改方法名(不推荐)

网上流传的另一种做法是:构造一个HttpPost,设置好 entity 和 URL,然后通过反射把它的 method 字段改成 DELETE。代码大概长这样:

HttpPost post = new HttpPost(url); post.setEntity(entity); // 通过反射修改 method Field methodField = HttpRequestBase.class.getDeclaredField("method"); methodField.setAccessible(true); methodField.set(post, "DELETE");

这个做法能跑通,因为HttpPost是 EntityEnclosing 的,执行器会处理 entity,而 method 被改成 DELETE 后,实际发出的请求行就是DELETE /path HTTP/1.1。

但我不推荐在生产环境用,原因有三:

  1. 反射脆弱:不同版本的 HttpClient 内部字段名可能变,JDK 版本升级也可能导致反射失败(模块化之后setAccessible可能被拒)。
  2. 语义混乱:代码里明明写的是 HttpPost,实际发的是 DELETE,后来维护的人会一脸懵。
  3. 可能触发意外逻辑:某些拦截器或重试策略会根据请求类的类型做判断,HttpPost 和真正的 DELETE 请求在重试语义上是不一样的(POST 通常不自动重试,DELETE 的幂等性判断也不同)。

所以这个方案只适合临时验证接口,别往正式代码里放。

4.2 方案三:升级到 HttpClient 5.x

如果你用的是 HttpClient 5.x,这个问题会简单很多。5.x 里HttpDelete依然不直接支持 entity,但提供了更灵活的ClassicHttpRequest体系,你可以用HttpUriRequestBase直接构造:

HttpUriRequestBase delete = new HttpUriRequestBase("DELETE", URI.create(url)); delete.setEntity(new StringEntity(jsonBody, ContentType.APPLICATION_JSON));

HttpUriRequestBase本身就支持 entity,不需要你再写子类。这是 5.x 相比 4.x 的一个改进。

不过升级 4.x 到 5.x 不是小工程,包名从org.apache.http变成了org.apache.hc.client5.http和org.apache.hc.core5.http,很多 API 签名也变了。如果你的项目里 HttpClient 用得比较重,升级成本要提前评估。如果只是零星几处,倒是可以考虑。

4.3 三种方案的选型建议

给一个简单的决策表:

你的情况推荐方案
新项目,还没选型直接用 HttpClient 5.x
老项目用 4.x,只有这一处需求方案一,写个 HttpDeleteWithBody 类
老项目用 4.x,多处需要,且可升级评估后升级到 5.x
只是临时验证接口方案二凑合一下,别提交

我个人在项目里的做法是:只要还在用 4.x,就统一放一个HttpDeleteWithBody工具类在 common 包里,谁需要谁用。这样既不用升级,也不用反射,代码清晰。

5. 实操中的坑与排查技巧

5.1 服务端读不到 body 怎么办

这是最常见的联调问题。客户端这边代码写对了,抓包也能看到 body 发出去了,但服务端就是拿不到参数。排查思路按顺序来:

第一步,确认服务端框架是否支持。以 Spring MVC 为例,早期版本对 DELETE 的 body 处理是有问题的,因为HiddenHttpMethodFilter和FormContentFilter的默认行为会干扰。较新的版本(Spring 5.x 之后)基本没问题,但如果你用的是老版本,可能需要在配置里显式开启对 DELETE body 的读取。

第二步,确认中间有没有代理或网关。有些网关(尤其是基于 Nginx 的)默认会丢弃 DELETE 的 body,或者对 chunked 编码的 DELETE 处理异常。这时候要么改网关配置,要么客户端改用 Content-Length 而非 chunked(也就是前面说的用 StringEntity)。

第三步,抓包确认。用 Wireshark 或者服务端的访问日志,确认请求行是DELETE,且 body 确实到达了服务端。如果 body 在网关那一层就没了,那客户端怎么改都没用。

5.2 Content-Type 丢失的排查

有时候服务端报 415 Unsupported Media Type,或者解析 body 时报格式错误,多半是 Content-Type 没设对。

用StringEntity时,如果你这样写:

new StringEntity(jsonBody)

那么 Content-Type 默认是text/plain; charset=ISO-8859-1,服务端按 JSON 解析就会失败。正确写法是显式传 ContentType:

new StringEntity(jsonBody, ContentType.APPLICATION_JSON)

或者:

new StringEntity(jsonBody, "UTF-8"); delete.setHeader("Content-Type", "application/json; charset=UTF-8");

两种都行,但前者更简洁,推荐。

5.3 连接复用与超时设置

DELETE 带 body 的请求,在连接管理上和普通请求没区别,但有几个点容易忽略:

  • 超时:批量删除可能耗时较长,默认的 socket timeout 可能不够。建议显式设置:
RequestConfig config = RequestConfig.custom() .setConnectTimeout(5000) .setSocketTimeout(30000) .setConnectionRequestTimeout(5000) .build(); delete.setConfig(config);
  • 连接池:如果你用PoolingHttpClientConnectionManager,注意 DELETE 请求如果服务端处理慢,会占用连接。批量删除场景下建议控制并发数,别一次性发几百个。

  • 重试:DELETE 语义上是幂等的,理论上可以重试。但带 body 的 DELETE 重试要小心,如果服务端已经处理了一半,重试可能导致重复删除或报错。建议对这类请求关闭自动重试,或者用幂等 token 保证。

5.4 常见问题速查表

现象可能原因解决方向
服务端收不到 body用了 HttpDelete 而非自定义类换成 HttpDeleteWithBody
服务端报 415Content-Type 不对用 ContentType.APPLICATION_JSON
中文乱码字符集没指定StringEntity 指定 UTF-8
网关丢弃 body网关不支持 DELETE body改网关配置或换 POST 语义
chunked 解析失败用了 InputStreamEntity改用 StringEntity
反射改 method 失败JDK 模块化限制放弃方案二,用方案一
请求超时默认超时太短显式设置 RequestConfig

5.5 几个实操心得

心得一:优先确认服务端能力,再动手写客户端。我踩过一次坑,客户端折腾了半天,最后发现服务端框架根本不读 DELETE body,白忙。现在我的习惯是,拿到接口文档先问一句"DELETE 的 body 你们能解析吗",确认了再写。

心得二:把 HttpDeleteWithBody 做成工具方法。不要每次用都 new 一遍再 setEntity,封装一个HttpClientUtil.deleteWithBody(url, jsonBody)之类的静态方法,内部处理 entity、header、超时。这样调用方一行代码搞定,也统一了行为。

心得三:日志里打印请求行和 body。联调阶段,在客户端加一个拦截器,把实际发出的请求行、headers、body 打到日志里。这样出问题时不用抓包就能定位。HttpClient 的HttpRequestInterceptor可以做到:

client = HttpClients.custom() .addInterceptorFirst((HttpRequest request, HttpContext context) -> { System.out.println("Method: " + request.getRequestLine().getMethod()); System.out.println("URI: " + request.getRequestLine().getUri()); }) .build();

注意这个拦截器拿不到 body(body 是在后面才写入的),要打 body 得用HttpRequestExecutor层面的钩子,或者干脆在业务代码里打。

心得四:批量删除考虑分批。如果 ID 列表特别大(比如上万),即使 body 能传,服务端处理也可能超时。建议客户端分批,每批几百个,串行或小并发发送。这样既避免超时,也方便失败重试。

6. 延伸思考:DELETE body 之外的选择

6.1 用 POST 表达批量删除语义

如果你的服务端对 DELETE body 支持不好,其实还有一个更"正统"的选择:用 POST 来表达批量删除。比如POST /api/resource/batch-delete,body 里放 ID 列表。虽然从 RESTful 纯粹主义角度看不够优雅,但胜在兼容性好,所有框架、网关、代理都支持 POST body。

很多大厂的开放 API 就是这么做的,批量操作一律用 POST,避免 DELETE body 的各种坑。所以如果你有选择权,不妨和服务端商量一下,用 POST 语义,省去客户端这边的折腾。

6.2 用 query 参数传 ID 列表的边界

如果 ID 数量不多(比如几十个),其实拼在 URL 上也行,比如DELETE /api/resource?ids=1,2,3。但要注意 URL 长度限制,不同浏览器和服务器不一样,一般 2KB 到 8KB 之间。超过这个长度就得用 body 或分批。

另外,URL 里的 ID 列表需要 URL 编码,逗号、方括号这些字符在某些框架里可能被特殊处理,联调时要注意。

6.3 幂等性与重试的权衡

DELETE 语义上是幂等的,但带 body 的批量删除,如果服务端实现不当,可能不是幂等的。比如"删除 ID 列表里的资源",如果第一次删了一半失败了,重试时那一半已经不存在,服务端可能报错也可能忽略。这取决于服务端实现。

客户端这边能做的:要么保证服务端幂等(删除不存在的资源返回成功),要么客户端记录已成功的批次,重试时只发失败的。后者复杂一些,但更可靠。

7. 一个完整的工具类封装参考

最后给一个我在项目里实际用的封装,把前面的要点都整合进去,你可以直接抄:

public class HttpDeleteWithBody extends HttpEntityEnclosingRequestBase { public static final String METHOD_NAME = "DELETE"; public HttpDeleteWithBody() { super(); } public HttpDeleteWithBody(final URI uri) { super(); setURI(uri); } public HttpDeleteWithBody(final String uri) { super(); setURI(URI.create(uri)); } @Override public String getMethod() { return METHOD_NAME; } }

配套的工具方法:

public class HttpUtil { private static final CloseableHttpClient CLIENT = HttpClients.custom() .setDefaultRequestConfig(RequestConfig.custom() .setConnectTimeout(5000) .setSocketTimeout(30000) .setConnectionRequestTimeout(5000) .build()) .setMaxConnTotal(100) .setMaxConnPerRoute(20) .build(); public static String deleteWithJsonBody(String url, String jsonBody) throws IOException { HttpDeleteWithBody delete = new HttpDeleteWithBody(url); delete.setEntity(new StringEntity(jsonBody, ContentType.APPLICATION_JSON)); delete.setHeader("Accept", "application/json"); try (CloseableHttpResponse response = CLIENT.execute(delete)) { return EntityUtils.toString(response.getEntity(), "UTF-8"); } } }

调用方就一行:

String result = HttpUtil.deleteWithJsonBody( "http://your-service/api/resource/batch-delete", "{\"ids\":[1001,1002,1003]}" );

这个封装里,client 是单例复用的,超时统一设置,entity 和 header 都处理好了。实际项目里你可能还需要加异常处理、日志、重试策略,但骨架就是这样。

我个人在实际操作中的体会是,HttpClient 4.x 这个"DELETE 不支持 entity"的限制,本质上是一个 API 设计上的取舍,不是技术做不到。理解了HttpEntityEnclosingRequest这个接口的作用,绕过它就是十几行代码的事。真正花时间的,反而是和服务端确认他们能不能正确解析 DELETE body,以及处理各种网关、代理带来的兼容性问题。所以我的建议始终是:能用 POST 语义就用 POST,非要用 DELETE body,先把服务端和链路确认清楚,再动手写客户端。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进