尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于JDK HttpClient封装轻量级HTTP请求工具类的实战指南

基于JDK HttpClient封装轻量级HTTP请求工具类的实战指南 很多年以前我在一个对接第三方支付的老项目里第一次写HTTP请求工具类。那时候网上能搜到的版本绝大多数是拿HttpURLConnection包一层接口简单到只有两个方法get()和post()。放到小项目里凑合能用一旦落到生产环境连接超时、字符乱码、连接池耗尽各种问题轮着来。后来项目升级到Java 11我开始切java.net.http.HttpClient踩过的坑不比之前少。最近我把这套HTTP请求工具类重新整理了一遍沉淀成项目里固定的基础组件今天就从设计思路讲到代码实现再把我实际踩过的坑一个个扒给你看。不管你是刚入门的Java新人还是维护老系统的后端开发只要写过HttpURLConnection或者在HttpClient的一堆配置参数面前发怵这篇文章都能给你一套能直接抄回去用的方案。我的原则很简单说人话给能跑的代码讲清楚为什么这么设计。1. 为什么最终还是自己写了套工具类先聊点实在的。现在Java生态里HTTP客户端并不少但真到了选型的时候你会发现每一款都有点“不够顺手”。HttpURLConnection是JDK自带的零依赖Java 8之前的老项目基本只能用它。坏处也很明显API设计老套没有连接池超时控制全靠系统参数遇到重定向、cookie这些高级功能写起来极其别扭。我见过太多人直接在业务代码里写(HttpURLConnection) url.openConnection()然后一个InputStream读到底异常处理忘得干干净净连接也不释放请求一多系统就卡死。Apache HttpClient功能全拦截器、连接池都有了但依赖一堆httpcore、commons-logging之类的包在老项目里引入容易产生依赖冲突。性能表现我倒不担心可它太重了为了发一个GET请求引入几兆的依赖很多人接受不了。OkHttp是现代一点的方案性能和易用性都很好Android圈尤其流行。但放到纯Java后端项目里它需要额外引入依赖而且默认的线程池、超时参数不一定贴合服务端场景调优还要另花时间。Spring RestTemplate在Spring生态里很方便但它底层默认还是HttpURLConnection性能一般。加上Spring项目通常是全家桶你没法只为了一个HTTP工具类拖一个Spring核心进来。聊回到java.net.http.HttpClient这是JDK 11开始官方提供的HTTP客户端支持HTTP/2、WebSocket、异步请求设计比HttpURLConnection现代太多。但它的原生API是“Builder模式到底”每个请求都要自己搭HttpRequest.Builder在业务代码里直接使用样板代码依然不少。最常见的问题是新手不知道HttpClient实例应该复用每次请求都new HttpClient()本地端口很快被耗尽。我的选择是底层用java.net.http.HttpClient外面包一层轻量的工具类提供统一入口、统一异常、统一超时与重试策略。这样既保住了官方客户端的性能和现代API又把业务开发时的使用成本降了下来。表格总结一下市面上方案的差别方案依赖连接池API友好度适合场景HttpURLConnection无无低Java 8老项目、简单脚本Apache HttpClient较多样有中老项目需要复杂HTTP能力OkHttp单个有高移动端、新后端项目Spring RestTemplateSpring取决于底层中Spring全家桶java.net.http.HttpClient无有中上JDK 11项目自研轻量封装无有高我推荐你这样做实际情况是每个团队的依赖管理、部署环境、Java版本都不一样你没法保证项目里一定引了OkHttp或Apache HttpClient。自研一个基于官方HttpClient的工具类零额外依赖约束最少这对我来说是性价比最高的方案。2. 先把设计定下来接口、返回体、异常体系很多人一上来就写工具类先定义public static String get(String url)调通了就收工。这思路没错但生产环境里不够用。我这边要求的“好用”至少包含四层请求参数能灵活组装、响应体能自动解析、异常能分类、超时重试能被统一控制。2.1 方法签名怎么设计才够用第一步把核心方法列出来。业务里用得最多的是GET和POST JSON工具类至少要覆盖这些场景带查询参数的GET请求携带请求头的GET请求提交JSON body的POST请求提交表单body的POST请求PUT和DELETE请求异步请求用于并发调用带重试的请求方法的重载不要太多否则调用方看花眼。这里我建议用Map来承载请求头和查询参数配合一个小体量的Builder来收敛可选配置public class HttpRequests { private static final HttpClient HTTP_CLIENT HttpClientHolder.get(); public static String get(String url, MapString, String queryParams, MapString, String headers, Duration timeout) { // ... } public static String postJson(String url, String jsonBody, MapString, String headers, Duration timeout) { // ... } public static CompletableFutureString getAsync(String url, MapString, String queryParams, MapString, String headers, Duration timeout) { // ... } }看到参数列表里都带Duration timeout了吗这个是关键设计。我不赞成你写一个全局超时因为不同接口的耗时要求差异很大有的内部接口要求500毫秒内返回有的第三方回调需要30秒。把超时下放到每个请求上调用方才有控制权。2.2 统一返回体和异常分级刚开始我的工具类直接返回String字符串看起来省事后面就后悔了。原因很简单调用方永远不止需要body内容他可能还要判断HTTP状态码、响应耗时、是否需要重试。如果你只返回String这些信息全部丢失。所以我额外定义了一个HttpResultpublic class HttpResult { private final int statusCode; private final String body; private final long costMillis; public HttpResult(int statusCode, String body, long costMillis) { this.statusCode statusCode; this.body body; this.costMillis costMillis; } public boolean isSuccess() { return statusCode 200 statusCode 300; } public int getStatusCode() { return statusCode; } public String getBody() { return body; } public long getCostMillis() { return costMillis; } }异常也是同样思路。HttpClient在请求失败时抛出IOException这信息太粗糙了调用方只知道“连接错了”完全不知道是连不上、超时还是读取失败。我这里的做法是统一包装成HttpRequestException再通过内部的错误码区分类型public class HttpRequestException extends RuntimeException { private final HttpErrorType errorType; public HttpRequestException(HttpErrorType errorType, String message, Throwable cause) { super(message, cause); this.errorType errorType; } public HttpErrorType getErrorType() { return errorType; } public enum HttpErrorType { CONNECT_TIMEOUT, READ_TIMEOUT, CONNECTION_FAILED, INVALID_RESPONSE, CALLBACK_FAILED } }这样做的好处是业务代码里可以精确捕获某一种错误类型做降级处理。比如CONNECT_TIMEOUT时走缓存兜底INVALID_RESPONSE时记录告警日志。如果只有一个笼统的RuntimeException你就只能在catch里写字符串判断。2.3 用链式Builder还是静态方法这里有个取舍问题。有人喜欢OkHttp那种new Request.Builder().url(...).build()的链式风格觉得可读性好也有人喜欢RestTemplate那种一行一个调用的静态方式。我的建议是工具类对外提供静态方法内部可以使用链式构建HttpRequest对象。理由是静态方法对新人最友好调用成本最低不需要了解Builder的细节而java.net.http.HttpClient原生就支持Builder内部组装时直接用完全不用额外造轮子。使用方看到的效果是这样的HttpResult result HttpRequests.get( https://api.example.com/user/list, Map.of(page, 1, size, 20), Map.of(Authorization, Bearer xxx), Duration.ofSeconds(5) ); if (result.isSuccess()) { // 处理 result.getBody() } else { // 处理异常状态 }3. 核心实现基于JDK HttpClient的封装现在到了动手写代码的环节。下面的代码都在JDK 11环境下运行核心类我会拆开讲。3.1 客户端初始化与单例管理HttpClient实例不是轻量级对象它内部维护了连接池、SSL上下文和线程调度资源。如果你每次请求前都HttpClient.newBuilder().build()连接池就无法复用大量处于TIME_WAIT状态的连接会堆积最终导致端口耗尽。正确的做法是全局单例这一点要毫不犹豫。看这个HttpClientHolder容器类public class HttpClientHolder { private static final ExecutorService EXECUTOR Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2); private static final HttpClient INSTANCE createHttpClient(); private HttpClientHolder() {} private static HttpClient createHttpClient() { return HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .version(HttpClient.Version.HTTP_2) .followRedirects(HttpClient.Redirect.NORMAL) .executor(EXECUTOR) .build(); } public static HttpClient get() { return INSTANCE; } }我特意给HttpClient配了一个独立线程池。原因说两句JDK默认的HttpClient如果没收executor异步调用时底层会用ForkJoinPool但它不够稳定特别是在处理大量阻塞IO和并发异步任务时容易被打满。自己定义一个定长线程池即便配置取值不合理也至少你能看到线程数量和队列情况方便排查。连接超时这里设为5秒在你自己的场景里可以调整。注意connectTimeout只负责TCP建立连接阶段和请求的整体超时是两回事后面我会细讲。3.2 GET/POST请求构建有了单例HttpClient接下来组装请求。GET请求的难点在查询参数拼接。很多人直接拼字符串容易忽略已有查询参数的情况。比如你传入的url本身就是https://example.com/api?fromapp再用url ?page1就会拼出两个问号。我下面这个buildUrl方法就是为了防止这种情况private static String buildUrl(String url, MapString, String queryParams) { if (queryParams null || queryParams.isEmpty()) { return url; } StringBuilder sb new StringBuilder(url); boolean hasQuery url.contains(?); if (!hasQuery) { sb.append(?); } else { if (!url.endsWith(?) !url.endsWith()) { sb.append(); } } for (Map.EntryString, String entry : queryParams.entrySet()) { sb.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)) .append() .append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)) .append(); } sb.deleteCharAt(sb.length() - 1); return sb.toString(); }URLEncoder.encode这一步不能省略。真实场景里查询参数可能带中文、带空格、带符号不编码会导致服务端解析错误甚至引发参数注入风险。POST JSON请求的关键点是设置Content-Type和body。JDK的HttpRequest.BodyPublishers.ofString默认按UTF-8编码但如果你不显式指定某些环境下会走平台默认字符集中文body很容易变成乱码。所以我在工具类内部强制用StandardCharsets.UTF_8private static HttpRequest buildPostJsonRequest(String url, String jsonBody, MapString, String headers, Duration timeout) { HttpRequest.Builder builder HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(timeout) .header(Content-Type, application/json;charsetUTF-8) .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)); if (headers ! null) { headers.forEach(builder::header); } return builder.build(); }这里有个细节我先给builder加了默认的Content-Type如果调用方在headers里又传了自定义的Content-Type后加的值会覆盖默认值。这个顺序我是故意为之保证默认策略在又不拦截调用方的自定义需求。3.3 响应读取与状态码校验HttpClient发送请求之后响应体的读取有两种常见方式BodyHandlers.ofString()和BodyHandlers.ofByteArray()。我推荐固定用BodyHandlers.ofString(StandardCharsets.UTF_8)。如果你不指定charsetJDK内部会用UTF-8但很多老服务返回GBK编码此时必须换一种方式先把响应读成字节数组再按服务端返回的编码手动解码。工具类层面我做了个折中默认UTF-8同时暴露一个charset参数给特殊场景用。另一个非常关键的点HttpClient.send()默认不会对4xx、5xx响应抛异常它只是正常返回一个HttpResponse对象。很多新手第一次用它时看到200以外的状态码却没有任何异常以为接口调用成功了这是最常见的误用。所以封装层必须自己检查状态码public static String request(String url, MapString, String queryParams, MapString, String headers, String jsonBody, Duration timeout) { long start System.currentTimeMillis(); try { HttpRequest request buildRequest(url, queryParams, headers, jsonBody, timeout); HttpResponseString response HTTP_CLIENT.send( request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8) ); int statusCode response.statusCode(); if (statusCode 400) { log.error(HTTP请求返回异常状态码, url{}, status{}, body{}, url, statusCode, response.body()); throw new HttpRequestException( HttpRequestException.HttpErrorType.INVALID_RESPONSE, HTTP状态码异常: statusCode, null); } return response.body(); } catch (HttpTimeoutException e) { throw new HttpRequestException( HttpRequestException.HttpErrorType.READ_TIMEOUT, 读取响应超时: url, e); } catch (ConnectException e) { throw new HttpRequestException( HttpRequestException.HttpErrorType.CONNECTION_FAILED, 连接失败: url, e); } catch (IOException e) { throw new HttpRequestException( HttpRequestException.HttpErrorType.CALLBACK_FAILED, IO异常: url, e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new HttpRequestException( HttpRequestException.HttpErrorType.CALLBACK_FAILED, 请求被中断: url, e); } finally { long cost System.currentTimeMillis() - start; if (cost 1000) { log.warn(HTTP请求耗时过长, url{}, cost{}ms, url, cost); } } }这里捕获异常的层次很讲究HttpTimeoutException是InterruptedException的子类吗不是但它是IOException的子类所以catch顺序必须是更具体的异常在前。我把HttpTimeoutException放在最前接着是ConnectException最后才接IOException兜底。耗时超过1秒就打印WARN日志这个习惯帮我定位过很多第三方接口变慢的问题强烈建议你保留。3.4 异步请求扩展工具类不只要支持同步调用高并发场景下异步调用同样常见。JDK的HttpClient原生就提供sendAsync返回CompletableFutureHttpResponseString。封装的时候我建议保留future的返回让调用方决定何时joinpublic static CompletableFutureHttpResult getAsync(String url, MapString, String queryParams, MapString, String headers, Duration timeout) { HttpRequest request buildGetRequest(url, queryParams, headers, timeout); return HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) .thenApply(response - { int statusCode response.statusCode(); if (statusCode 400) { throw new HttpRequestException( HttpRequestException.HttpErrorType.INVALID_RESPONSE, HTTP状态码异常: statusCode, null); } return new HttpResult(statusCode, response.body(), 0); }); }用异步方式批量调用多个接口时记得用CompletableFuture.allOf()来等待所有请求完成而不是在主线程里逐个join。否则异步就没意义了。4. 连接复用、超时与重试的实战经验代码到位只是第一步生产环境里真正让系统崩溃的往往是几个看起来不起眼的细节。4.1 连接复用是性能分水岭HTTP协议基于TCP而TCP建连是一次完整的三次握手耗时通常在几十到几百毫秒。如果每个请求都重新建连不仅浪费网络RTT还会让操作系统的端口表快速耗尽。HttpClient内部维护的连接池默认空闲5分钟过期每次请求时优先复用池里的连接这是它比HttpURLConnection优秀的重要原因。但前提是你必须复用HttpClient实例。我在生产环境排查过一个案例某服务上线后运行不到一周SocketException和Connection refused报错不断。查了半天发现代码里每次调用都HttpClient.newBuilder().build()服务端积累了成千上万的TIME_WAIT连接最终触发系统源端口不足。改回单例HttpClient后故障立刻消失。你在自测时可以执行下面这段命令观察TIME_WAIT状态ss -ant | grep TIME_WAIT | wc -l如果这个数字持续增长基本可以断定连接没有被复用。4.2 两层超时配置到底该怎么设HTTP请求的超时不是只有一个。至少包含两层第一层是连接超时connectTimeout在HttpClient.Builder上配置代表TCP建立连接的最长等待时间。我通常设5秒。第二层是请求超时request timeout在HttpRequest.Builder上配置代表从发送请求到收到响应体的整个时间。这个值要按业务接口的实际情况来比如查询类接口设5秒复杂报表接口设30秒。我见过很多团队只在HttpClient上设置了connectTimeout结果一个慢接口把线程池占满了所有请求堆在一起整个服务雪崩。记住connectTimeout保护不了慢接口只有request timeout能限制每次请求的总时长。如果你的工具类允许每个调用方传自己需要的超时时间那么默认值建议在常量区定义并在方法注释里写明单位。避免有人把Duration.ofSeconds(1)错看成1000秒。4.3 重试必须想清楚这三件事有句老话是“能用一次搞定的事就不要重试”但在真实网络环境下瞬时抖动是常态不加重试反而容易被误杀。不过无脑重试是大忌我总结了三个要点第一只对幂等请求重试。GET、PUT、DELETE这类请求通常幂等重试没问题POST请求改变服务端状态重试可能导致重复下单、重复发货。如果你一定要对POST做重试服务端接口必须提供幂等键比如订单号、UUID并且服务端要按幂等键去重。第二只对特定错误重试。连接失败、读取超时、503/502/504这类瞬时错误可以重试401鉴权失败、400参数错误、404路由缺失这类业务错误重试一万次也一样没必要浪费时间。第三重试要有间隔而且要逐渐放大。固定间隔会让被调用方在恢复瞬间收到并发重试风暴指数退避是更稳的策略。我在工具类里留了个简单实现public static String getWithRetry(String url, MapString, String queryParams, MapString, String headers, Duration timeout, int maxRetries) { long baseDelayMillis 200; for (int attempt 0; attempt maxRetries; attempt) { try { return get(url, queryParams, headers, timeout); } catch (HttpRequestException e) { if (attempt maxRetries) { throw e; } boolean retryable isRetryable(e); if (!retryable) { throw e; } long delayMillis baseDelayMillis * (1L attempt); sleepQuietly(delayMillis); } } throw new IllegalStateException(不可达的代码路径); } private static boolean isRetryable(HttpRequestException e) { HttpRequestException.HttpErrorType type e.getErrorType(); return type HttpRequestException.HttpErrorType.CONNECT_TIMEOUT || type HttpRequestException.HttpErrorType.READ_TIMEOUT || type HttpRequestException.HttpErrorType.CONNECTION_FAILED; }重试不等于一直重试。maxRetries我建议默认2到3次再多就不合适了。线上系统最怕重试风暴你在这里重试对方服务端也在做同样的事两边互相加重负载结果只会更糟。4.4 日志记录要看哪些信息日志价值永远大于代码本身。我在工具类里打了三个维度的日志请求维度url、method、queryParams敏感信息脱敏结果维度statusCode、耗时异常维度错误类型、完整堆栈千万别把Authorization头或者body里的密码明文写在日志里。脱敏处理可以用正则替换比如把:passwordxxx替换成:password***。日志级别上正常请求打DEBUG耗时过长打WARN业务失败打ERROR不要一刀切全打INFO。5. 常见问题与排查速查最后把我这几年攒下来的排查经验整理成一份速查表你遇到问题时直接对照找思路。症状可能原因处理方案本地端口耗尽TIME_WAIT堆积HttpClient实例每次新建连接无法复用改为单例复用连接池请求总是读取超时只配了connectTimeout没配request timeout在HttpRequest.Builder里设置timeout响应body中文乱码服务端返回GBK工具类按UTF-8解码先读字节数组按服务端charset解码4xx/5xx状态码但程序没报错HttpClient不会自动对非2xx抛异常封装层检查statusCodeserver选择HTTP/1.1但客户端请求HTTP/2某些网关/TLS配置不支持HTTP/2降级为HttpClient.Version.HTTP_1_1SSLHandshakeException服务端证书自签名或证书链不完整导入信任库或临时信任所有证书生产慎用重试导致重复提交POST请求未加幂等键请求头加幂等键服务端做去重DNS解析慢JVM默认永久缓存DNS设置sun.net.inetaddr.ttl或定期刷新日志文件疯狂刷屏把全部响应体打进了日志日志截断到200字符5.1 一个典型连接泄漏排查实录有一次接口压测时发现线程数不断上涨但QPS却上不去。我dump了一份线程栈发现大量线程阻塞在socketRead0方法上。进一步看运行日志发现不少请求的响应时间是30秒和配置的request timeout一致。这说明请求一直读不到响应但连接又没被及时关闭占着线程池不放。排查下来底层原因是调用方修改了对方的服务端口但工具类里没有对连接读写失败做快速失败所有请求都傻傻等到超时。修复方法其实很简单把request timeout从30秒下调到10秒同时加上了自动重试。这样慢请求会被快速切断并重新发起单次请求的可靠性反而提升了。5.2 关于自签名证书的一个良心建议测试环境经常遇到自签名证书导致的SSLHandshakeException。网上大多数人会给你一段“信任所有证书”的代码我不建议直接复制。信任所有证书等于关闭了TLS的安全校验万一代码误上生产就是巨大的安全隐患。我的做法是把测试环境的证书导出为.cer文件加到JDK的cacerts信任库里keytool -importcert -alias test-cert -file test.cer \ -keystore $JAVA_HOME/lib/security/cacerts \ -storepass changeit -noprompt这样既能过掉证书校验又不影响生产安全策略。5.3 别忘了优雅停机时的问题线程池在应用关闭时如果没有释放会导致请求发不出去、资源无法回收甚至出现僵尸进程。我建议给自定义的ExecutorService加上JVM关闭钩子或者通过Spring的PreDestroy来shutdown()。这不是什么高深技术但做了之后你的工具类才算真正完整。我个人的习惯是在工具类的HttpClientHolder里增加一个静态的shutdown()方法里面先shutdown()线程池再关闭HttpClient内部的资源。这样在容器优雅停机时可以避免一些偶发的告警和连接泄漏。如果你刚开始封装自己的工具类记住一条底线不要为了赶进度跳过状态码校验不要忽略字符集更不要重复创建HttpClient。这三个坑我全踩过哪一个都能让你在线上环境焦头烂额。先把这版工具类跑通后面再根据业务慢慢加拦截器、加监控、加限流这条路比我一开始直接照抄别人的完整框架要稳得多。
返回列表