当前位置: 首页 > news >正文

宽容老好人 vs 严格完美主义者:HttpURLConnection 迁 HttpClient 的 4 个隐藏陷阱

博客主页:https://tomcat.blog.csdn.net
博主昵称:农民工老王
主要领域:Java、Linux、K8S
期待大家的关注💖点赞👍收藏⭐留言💬
阅读时长:约 15 分钟

目录

    • 一、一个真实的故事
    • 二、前置知识(可跳过)
      • 2.1 HTTP 请求头(Request Header)
      • 2.2 HTTP 状态码
      • 2.3 超时(Timeout)
    • 三、四个 bug 的故事
      • Bug 1:把"0"当成"无限等待"——超时的语义陷阱
        • 错误现场
        • 出错的代码
        • 为什么错了
        • 根因
        • 修复
        • 影响范围
      • Bug 2:客户端请求头复制引发的 `restricted header name`——`Connection` 头
        • 错误现场
        • 出错的代码
        • 为什么 `Connection` 头让 `HttpClient` 炸了
        • 为什么 `HttpURLConnection` 时代没这问题
        • 修复
        • 进阶做法:抽公共常量
      • Bug 3:配置后端业务系统 B 时报 `{302}`——重定向不跟随
        • 错误现场
        • 出错的代码
        • 302 是什么
        • 为什么 `HttpURLConnection` 时代没这问题
        • 修复
        • 为什么"最后还是代理成功"了
        • 影响范围
      • Bug 4:代理后端业务系统 B 的页面又报 `restricted header name: "connection"`
        • 错误现场
        • 根因
        • 修复
    • 四、元教训:默认值差异是迁移的隐形大坑
    • 五、给初级工程师的 7 条避坑清单
      • 5.1 改完之后,立刻 grep 这 4 个字符串
      • 5.2 关于超时:能不设就不设
      • 5.3 关于请求头复制:永远用 5 元素 skip-list
      • 5.4 关于重定向:分清"代理"和"普通调用"
      • 5.5 关于 HTTP 版本:HTTP/1.1 显式指定
      • 5.6 关于 SSL 证书:别再用 `setDefaultSSLSocketFactory`
      • 5.7 关于测试:必测 5 个真实场景
    • 六、结语
    • 附录:参考链接
    • 附录:本次修复涉及的 4 个改动

一、一个真实的故事

前段时间我把公司内部的一个老项目从HttpURLConnection升级到HttpClient。改完以后:

  • ✅ 本地mvn clean compile通过
  • mvn package -DskipTests通过
  • ✅ 测试环境访问http://localhost/backendA/admin返回 200
  • 生产环境首次配置后端业务系统 A 失败:报Invalid duration: PT0S
  • 修复后访问任意 API 失败:报restricted header name: "connection"
  • 再修复后配置后端业务系统 B 失败:报{302}状态码错误
  • 再再修复后代理请求失败:又是restricted header name: "connection"

四个 bug,每一个都只在生产环境第一次访问特定接口时才暴露。没有一条在编译期被捕获,也没有一条在测试环境触发。

这篇文章想用最通俗的方式,把这四个 bug 讲清楚——重点不是"怎么修",而是为什么 HttpURLConnection 时代没出过这种问题。一句话总结:

HttpURLConnection是"宽容的老好人",HttpClient是"严格的完美主义者"。迁移到 HttpClient 最大的坑不是 API 变了,而是默认值变了


二、前置知识(可跳过)

如果你是初级工程师,下面三个概念会反复出现,先用 3 分钟过一遍。

2.1 HTTP 请求头(Request Header)

HTTP 请求长这样(简化版):

GET /api/users/123 HTTP/1.1 ← 请求行 Host: api.example.com ← 请求头开始 User-Agent: Mozilla/5.0 Accept: application/json Connection: keep-alive ← 浏览器自动加的 Cookie: session=abc123 ← 空行 ← 请求体(GET 没有)

请求头就是附加在请求上的"元数据"——告诉服务器"我是谁、我要什么格式、我要不要保持连接"。客户端和服务器都可以加请求头,而 JDK 内部有一组"不允许用户手动设置"的请求头(叫受限请求头),因为它们由 JDK 自己管理。

2.2 HTTP 状态码

服务器收到请求后会返回一个数字状态码:

状态码含义类比
200成功餐厅上菜了
301/302/303“换个地址吧”餐厅告诉你"我们搬家了,新地址在 Location 头里"
400你请求格式错了你点菜用外语,服务员听不懂
404资源不存在你点了菜单上没有的菜
500服务器内部错误厨房着火了

2.3 超时(Timeout)

HTTP 请求可能挂死(网络断了、对方服务器卡了)。客户端一般设两个超时保护自己:

  • 连接超时(connect timeout):建 TCP 连接最多等多久
  • 读超时(read timeout):建立连接后等响应数据最多等多久

一般会设个上限(比如 30 秒),但有时候业务需要"无限等"——比如长轮询、WebSocket、视频流。


三、四个 bug 的故事

Bug 1:把"0"当成"无限等待"——超时的语义陷阱

错误现场

生产环境首次配置后端业务系统 A 时报:

java.lang.Exception: 管理员请求失败 Invalid duration: PT0S at com.example.proxy.util.HttpUtils.sendRequest(HttpUtils.java:737) at com.example.proxy.util.BackendAUtil.getToken(...)
出错的代码
// 原 HttpURLConnection 版本(运行多年无 bug)URLConnectionconn=url.openConnection();conn.setReadTimeout(0);// 0 在这里表示"无限等"conn.setConnectTimeout(0);// 0 在这里也表示"无限等"
// 迁移到 HttpClient 后的版本HttpRequestrequest=HttpRequest.newBuilder(URI.create(urlString)).timeout(Duration.ZERO)// ← 报错的源头.header("Content-Type","application/x-www-form-urlencoded").POST(HttpRequest.BodyPublishers.ofByteArray(postBytes)).build();

我天真地以为Duration.ZEROsetReadTimeout(0)等价,结果client.send()在构造请求阶段就抛了IllegalArgumentException

为什么错了

来看 JDK 17 官方文档是怎么写的(来源):

Throws:IllegalArgumentException— if the duration is non-positive.

也就是说:

  • Duration.ZERO❌ 抛异常
  • Duration.ofSeconds(-1)❌ 也抛异常
  • Duration.ofMillis(0)❌ 还抛异常
  • 不调用.timeout()✅ 走默认行为
根因

HttpURLConnectionint 0这个魔法数字表示"无限等"(源自 Unix socket 的语义)。而HttpClient引入java.time.Duration类型后拒绝任何"非正值",要"无限等"请干脆别设

修复
// 修复:直接删掉 .timeout(Duration.ZERO)HttpRequestrequest=HttpRequest.newBuilder(URI.create(urlString)).header("Content-Type","application/x-www-form-urlencoded").POST(HttpRequest.BodyPublishers.ofByteArray(postBytes)).build();
影响范围

我们在项目的工具类与两个代理 Servlet 里一共找到了9 处Duration.ZERO,全部删除。


Bug 2:客户端请求头复制引发的restricted header name——Connection

错误现场

修复 Bug 1 后,配置后端业务系统 A 终于能进了。但访问https://host/api/rest/services时报:

java.lang.IllegalArgumentException: restricted header name: "connection" at java.net.http/jdk.internal.net.http.HttpRequestBuilderImpl.checkNameAndValue(...) at com.example.proxy.servlet.BackendProxyAServlet.service(BackendProxyAServlet.java:333)
出错的代码

代理服务是反向代理,会把"客户端浏览器发来的请求头"原样复制到"转发给后端业务系统的请求"上:

// 复制请求头Enumeration<String>reqhs=request.getHeaderNames();while(reqhs.hasMoreElements()){Stringh=reqhs.nextElement();if(h==null||h.equalsIgnoreCase("host")||h.equalsIgnoreCase("content-length")){continue;}reqBuilder.header(h,request.getHeader(h));// ← 第一次栽在 "connection" 上}
为什么Connection头让HttpClient炸了

来看 JDK 文档(来源):

Throws:IllegalArgumentException— if the header name is not a valid header token, or if the name is one of:connection,content-length,expect,host,upgrade.

HttpClient把这 5 个请求头列为受限请求头(Restricted Headers),显式设置会立刻抛异常

受限请求头原因
hostURI自动派生
content-lengthBodyPublisher自动计算
connection控制 keep-alive,由 JDK 底层管理
expect用于Expect: 100-continue协商,由 JDK 内部处理
upgrade用于协议升级(如 WebSocket)

为什么限制?因为这 5 个头是"连接级"语义,JDK 需要自己管理它们才能正确处理 HTTP/2 多路复用、keep-alive、100-continue 等复杂场景。让你手动设置,连接池会乱套。

为什么HttpURLConnection时代没这问题

HttpURLConnection.setRequestProperty("Connection", "...")允许你这么干,运行时悄悄忽略。相当于"宽容的老好人"——你写错了不报错,默默给你兜底。

HttpClient是"严格的完美主义者"——你写了它不喜欢的,立刻报错让你改。

修复

把 skip-list 扩展为包含所有 5 个受限请求头:

// 修复后if(h==null||h.equalsIgnoreCase("host")||h.equalsIgnoreCase("content-length")||h.equalsIgnoreCase("connection")||h.equalsIgnoreCase("expect")||h.equalsIgnoreCase("upgrade")){continue;}

注:本项目 WebSocket 走 Jakarta WebSocket API(@ServerEndpoint),不通过HttpRequest.Builder设置Upgrade,所以把upgrade加进 skip-list 不会影响 WebSocket 功能。

进阶做法:抽公共常量

我后来发现另一个代理 Servlet(用于后端业务系统 B)也复制了客户端头,但 skip-list 里只跳了Expecthostcontent-length漏掉了Connection。结果/backendB/home又报了一次同样的错。

所以最佳做法是把 5 个受限头抽成共享常量:

// HttpClientProvider.java(公共 HttpClient 工厂类)publicstaticfinalSet<String>RESTRICTED_HEADERS=Set.of("host","content-length","connection","expect","upgrade");// 两个代理 Servlet 共用if(h==null||HttpClientProvider.RESTRICTED_HEADERS.contains(h.toLowerCase(Locale.ROOT))){continue;}

Bug 3:配置后端业务系统 B 时报{302}——重定向不跟随

错误现场

修复 Bug 2 后再次访问后端业务系统 B 的配置页,又报:

java.lang.Exception: 管理员请求失败 http_error : {302} at com.example.proxy.util.HttpUtils.sendGetRequest(HttpUtils.java:879) at com.example.proxy.util.BackendBUtil.getVersion(BackendBUtil.java:465) at com.example.proxy.util.BackendBUtil.registerBackendB(BackendBUtil.java:231)
出错的代码
// HttpUtils.sendGetRequest —— HTTP GET 拿到响应后只认 200HttpResponse<String>response=client.send(request,BodyHandlers.ofString(StandardCharsets.UTF_8));intstatusCode=response.statusCode();if(statusCode==200){returnresponse.body();}thrownewException("http_error : {"+statusCode+"}");// ← 302 就炸这里
302 是什么

302 Found是 HTTP 的"重定向"状态码——服务端告诉客户端"你想要的东西搬到Location头里的地址了,请重发请求过去"。常见触发场景:

  • 用户用http://访问,服务端强制跳https://
  • 网站换了域名,旧的 URL 跳新的
  • 负载均衡器返回"规范 URL"(canonical URL)
  • 未登录访问需要鉴权的页面,302 跳登录页

我们的场景:后端业务系统 B 部署在内网 HTTP 上,但代理配置里写的是https://backendB.company.com。代理服务用 HTTP 协议去访问 B,B 强制 302 跳到 HTTPS。

为什么HttpURLConnection时代没这问题

HttpURLConnection.setInstanceFollowRedirects(true)默认值——HTTP 客户端自动帮你跟随 3xx 重定向,对调用方完全透明。

HttpClientHttpClientProvider在我们项目里显式设了followRedirects(Redirect.NEVER)永不跟随),原因是为了让反向代理场景下能拦截并重写Location头(把内网地址改成对外的代理地址)。但HttpUtils.sendGetRequest这种工具类也用了同一个 HttpClient,结果它也不跟随重定向了。

修复

参考已有的BackendAUtil.sendAdminRequest(POST 版本已有手动重定向处理),在HttpUtils.sendGetRequest里加上同样的 302/303 处理:

// 修复后publicstaticStringsendGetRequest(StringurlString,Stringreferer)throwsException{try{HttpResponse<String>response=sendGet(urlString,referer);intstatusCode=response.statusCode();if(statusCode==200){returnresponse.body();}// 处理HTTP重定向(302/303):对齐 HttpURLConnection 默认 setInstanceFollowRedirects(true)if(statusCode==302||statusCode==303){Stringlocation=response.headers().firstValue("Location").orElse(null);if(location!=null){HttpResponse<String>redirected=sendGet(location,referer);if(redirected.statusCode()==200){returnredirected.body();}thrownewException("http_error : {"+redirected.statusCode()+"}");}}thrownewException("http_error : {"+statusCode+"}");}catch(Exceptione){thrownewException("http_error"+e.getMessage());}}// 私有助手:纯发 GET,返回完整 HttpResponse,方便重发privatestaticHttpResponse<String>sendGet(StringurlString,Stringreferer)throwsException{URLurl=newURL(urlString);booleanisHttps="https".equalsIgnoreCase(url.getProtocol());HttpClientclient=HttpClientProvider.get(isHttps);HttpRequest.Builderbuilder=HttpRequest.newBuilder(URI.create(urlString)).GET();if(!Util.isEmpty(referer)){builder.header("Referer",referer);}returnclient.send(builder.build(),HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));}
为什么"最后还是代理成功"了

BackendBUtil.getVersion捕获异常后返回""registerBackendB的版本校验被跳过,所以 B 仍能注册。但版本校验被静默吞了——如果后端业务系统 B 和代理服务大版本不匹配,应该报错阻断配置,反而被这条WARNING掩盖。

影响范围

sendGetRequest16 个调用点(多个缓存刷新任务、版本查询、健康检查等)。所有这些调用都正确跟随 302,与HttpURLConnection行为一致。


Bug 4:代理后端业务系统 B 的页面又报restricted header name: "connection"

错误现场

访问https://host/backendB/home(后端 B 代理)时报:

java.lang.IllegalArgumentException: restricted header name: "connection" at com.example.proxy.servlet.BackendProxyBServlet.service(BackendProxyBServlet.java:233)
根因

这是 Bug 2 的"姊妹篇"——BackendProxyBServlet的 skip-list 在 HttpClient 改造时被改成"跳过Expecthostcontent-length",但漏了Connection。Bug 2 修复的是BackendProxyAServlet,没有顺手改 B 端。

为什么先没暴露?后端 B 的代理平时不常被直接访问,且/backendB/home是 SPA 首页,浏览器要带Connection: keep-alive头才会触发——某些内部调用碰巧没经过完整请求头,所以侥幸逃过。

修复

和 Bug 2 一起抽公共常量,参见上面的"进阶做法"小节。


四、元教训:默认值差异是迁移的隐形大坑

把四个 bug 摆一起看:

维度HttpURLConnection(老好人)HttpClient(完美主义者)
超时设 0setReadTimeout(0)= 无限等.timeout(Duration.ZERO)= 抛异常
连接超时设 0setConnectTimeout(0)= 无限等.connectTimeout(Duration.ZERO)= 抛异常
设受限请求头运行时悄悄忽略立刻抛IllegalArgumentException
3xx 重定向默认followRedirects=true默认Redirect.NEVER
HTTPS 证书信任setDefaultSSLSocketFactory(全局污染)HttpClient.Builder.sslContext()注入(局部)
HTTP/2 多路复用不支持JDK 17 默认启用,会改变headers().map()的多值合并行为
响应体读取InputStream手动读BodyHandlers.ofString(StandardCharsets.UTF_8)自动解码

核心规律

HttpURLConnection是"permissive with hidden behavior"——你写错它兜底,你偷懒它帮你做。
HttpClient是"strict and explicit"——你写错它报错,你偷懒它就当没这回事。

迁移的本质,是把"运行时隐式行为"翻译成"代码显式表达"。每个 bug 都是在某个角落,依赖了"老好人"的兜底。


五、给初级工程师的 7 条避坑清单

读完故事,送你一份可以直接用的 checklist——任何时候你从 HttpURLConnection 迁到 HttpClient,挨个对一遍

5.1 改完之后,立刻 grep 这 4 个字符串

# 1. 时长是 0 或负数(任何 .timeout/.connectTimeout 调用都该看一眼)grep-rn"Duration.ZERO\|Duration.ofSeconds(0)\|Duration.ofMillis(0)"src/# 2. 设置请求头的地方 —— 看 skip-list 够不够全grep-rn"requestBuilder.header\|HttpRequest\.newBuilder"src/# 3. 期望 200 的地方 —— 看是否处理了 3xxgrep-rn"statusCode() == 200"src/# 4. 跟随重定向的设置点grep-rn"followRedirects\|setInstanceFollowRedirects"src/

5.2 关于超时:能不设就不设

// ❌ 错:表达"无限等"HttpRequestrequest=HttpRequest.newBuilder(uri).timeout(Duration.ZERO).build();// ✅ 对:不设,让 HttpClient 用默认HttpRequestrequest=HttpRequest.newBuilder(uri).build();// ✅ 想要有限超时:明确写出.timeout(Duration.ofSeconds(30))

判别口诀:在 HttpClient 里,没设 = 用默认 = 通常等于"无限等"。想要有限超时才显式设。

5.3 关于请求头复制:永远用 5 元素 skip-list

不管你复制的是浏览器请求头还是别的请求,都要跳过这 5 个:

privatestaticfinalSet<String>RESTRICTED_HEADERS=Set.of("host","content-length","connection","expect","upgrade");

把这个常量抽出来放在HttpClientProviderHttpUtils这类共用类里,所有代理 Servlet 共用,杜绝漏改。

5.4 关于重定向:分清"代理"和"普通调用"

  • 代理场景(Servlet 转发用户请求):用Redirect.NEVER,自己拦截Location头并改写。
  • 普通工具调用(访问别人的 API):用Redirect.NORMAL(自动跟随 3xx),或者像我上面那样手动处理 302。

不要图省事在HttpClientProvider里把 followRedirects 设成ALWAYSNEVER,要让调用方按需选择

5.5 关于 HTTP 版本:HTTP/1.1 显式指定

HttpClient默认 HTTP/2。多路复用会改变headers().map()的多值合并行为,可能影响下游的LocationSet-Cookie处理。代理场景强烈建议显式.version(HTTP_1_1)

5.6 关于 SSL 证书:别再用setDefaultSSLSocketFactory

HttpURLConnection时代的"全局信任所有证书"是用HttpsURLConnection.setDefaultSSLSocketFactory()实现的——全局污染,所有HttpsURLConnection都受影响。HttpClient时代在HttpClient.Builder.sslContext()注入是局部的,更安全。

5.7 关于测试:必测 5 个真实场景

HttpClient的多数校验都在build()send()阶段(请求生命周期的早期),单测覆盖不到。集成测试必须覆盖

  1. 配置页POST /proxy/backendA/config(触发getToken+ 版本查询)
  2. 普通GET /api/rest/services(触发Connection头复制)
  3. POST到 HTTP 协议的后端(触发 302 重定向)
  4. WebSocket 升级(如果支持)
  5. 大文件 / 慢接口(触发超时)

六、结语

写代码多年,最怕的不是写不出能跑的代码,而是写出一份本地能跑、生产跑挂的代码HttpURLConnection给了我们多年"岁月静好"的错觉——你写错、漏写、超时设成 0,它都默默兜底。等你换成HttpClient,它把所有的兜底都撤掉,每一处偷懒都变成生产事故。

但话说回来,HttpClient这种"严格"才是好的设计。错误越早暴露(最好在build()那一刻就抛),修复成本越低;越晚暴露(生产环境第一次访问),代价越大。

希望读完这篇,下次你做类似迁移时,能少踩几个坑。如果有其他迁移故事想分享,欢迎留言。


附录:参考链接

  • JDK 17HttpRequest.Builder官方文档 —— 每个方法的Throws条款都值得读
  • JDK 17HttpClient官方文档
  • JDK 17HttpResponse官方文档
  • RFC 7230 §3.3.2 - Content-Length vs Transfer-Encoding —— 为什么 skip-list 必须包含content-length
  • RFC 7231 §6.4 - 3xx Redirection —— 302 语义

附录:本次修复涉及的 4 个改动

出于公司项目保密考虑,commit hash 与具体类名不列出,仅给出每个改动的主题:

  1. 删除 9 处.timeout(Duration.ZERO)—— 解决Invalid duration: PT0S
  2. 后端业务系统 A 的代理 Servlet 复制请求头时跳过connection/expect
  3. 后端业务系统 B 的代理 Servlet 补上 Connection 跳过 + 抽公共RESTRICTED_HEADERS常量
  4. HttpUtils.sendGetRequest手动跟随 302/303 重定向

TL;DR:HttpURLConnection 是"宽容的老好人",HttpClient 是"严格的完美主义者"。迁移的 4 个坑(Duration.ZERO、受限请求头、302 重定向、另一端漏改)都源于"默认值变了"。避坑口诀:不设超时 = 无限等复制请求头必跳 5 个受限头工具方法手动跟 302/303代理场景显式Redirect.NEVER+HTTP_1_1


如需转载,请注明本文的出处:农民工老王的CSDN博客https://blog.csdn.net/monarch91 。

http://www.jsqmd.com/news/1076936/

相关文章:

  • 回归模型评估:从R²陷阱到业务对齐的实战指南
  • 豆包2.0四大实用功能:语音即指令、文档秒读、灵感转待办、格式一键净化
  • Transformers模型实战指南:从代码加载到推理部署
  • 云手机技术解析与实战:用 Python 远程操控云手机实现自动化挂机
  • 达梦数据库重启方法
  • 计算机毕业设计之基于JSP的校园宿舍电费缴纳系统
  • 拦了百万次攻击还是被入侵?逐包核验揪出藏在流量里的3次“漏网之鱼”
  • Poly Haven Assets:如何在Blender中一键获取数千个专业3D资源?
  • Python毕设项目:基于 Python+Vue 的可视化数据购物管理系统设计与实现 基于 Python+Vue 的校园线上购物管理系统 (源码+文档,讲解、调试运行,定制等)
  • 智造未来:从全生命周期视角,看蓝色星球造价机器人如何重塑工程造价
  • ONNX模型封装与生产级API服务实战指南
  • 从 Copilot 到 Agent 集群:我的开发工作流正在被重塑
  • qmcdump:QQ音乐加密音频文件的高效本地解码解决方案
  • 从单调到惊艳:用Blue-Topaz主题彻底改造你的Obsidian笔记界面
  • IntelliJ IDEA安装卡在“Loading Plugins”?一线架构师亲授4步诊断法+底层ClassLoader日志分析法
  • 计算机毕业设计之基于文本画像的研究与实现
  • 从零手写注意力机制:可调试的QKV计算与数值稳定性实践
  • excel操作技巧 ,新手 教程
  • 手写自编码器实战:从信息论到工业级异常检测
  • Composer:PHP 项目的依赖管理工具
  • 鸿蒙进程模型与IPC机制详解
  • 线上投票工具的实用性
  • 2024十大AI落地论文实操指南:QLoRA、FlashAttention-3与StreamingLLM工程化落地
  • AI历史人物重绘:技术史可视化实战指南
  • 第【33】期--基于SVD和注水算法的MIMO自适应调制系统性能研究 --matlab完整代码
  • 130、 PCIE调试笔记:ARI这个“小开关”惹出的麻烦
  • Mistral Small 2409 实战指南:本地部署与 OpenHands 编程代理集成
  • CPT Markets:把长期一致性做扎实,注重效率的使用者更容易感受到的要点
  • 抖音视频下载终极方案:开源工具实现无水印保存与批量管理实战手册
  • HDMI数据的接收发送实验(十五)