在不引入 Apache HttpClient、OkHttp 等第三方依賴的前提下,JDK 原生 java.net.HttpURLConnection(底層為 URLConnection)即可滿足大多數同步 HTTP 調用需求。本文提供生產就緒的工具類實現,涵蓋連接池複用、超時控制、重試機制、異常處理、響應編碼自適應等工程化細節。
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.Map.Entry;
public final class HttpClientUtil {
private static final int DEFAULT_CONNECT_TIMEOUT = 5000; // 5s
private static final int DEFAULT_READ_TIMEOUT = 10000; // 10s
private static final String DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; Java HttpClient)";
private static final List<String> RETRY_EXCEPTIONS = Arrays.asList(
"java.net.SocketTimeoutException", "java.net.ConnectException", "java.io.IOException"
);
private HttpClientUtil() {}
/**
* 發送 GET 請求
* @param url 請求地址(不含查詢參數)
* @param params 查詢參數 Map,自動 URLEncode
* @param headers 自定義請求頭
* @param connectTimeout 連接超時(ms)
* @param readTimeout 讀取超時(ms)
* @return 響應體字符串
* @throws HttpException 統一異常
*/
public static String doGet(String url, Map<String, Object> params, Map<String, String> headers,
int connectTimeout, int readTimeout) throws HttpException {
StringBuilder fullUrl = new StringBuilder(url);
if (params != null && !params.isEmpty()) {
fullUrl.append(url.contains("?") ? "&" : "?");
boolean first = true;
for (Entry<String, Object> e : params.entrySet()) {
if (!first) fullUrl.append("&");
fullUrl.append(encode(e.getKey())).append("=").append(encode(String.valueOf(e.getValue())));
first = false;
}
}
return executeRequest("GET", fullUrl.toString(), null, headers, connectTimeout, readTimeout);
}
/**
* 發送 POST 請求(表單/JSON 雙模式)
* @param url 請求地址
* @param body 請求體:Map→application/x-www-form-urlencoded;String→JSON/自定義
* @param contentType Content-Type,如 "application/json;charset=UTF-8"
* @param headers 自定義請求頭
* @return 響應體字符串
*/
public static String doPost(String url, Object body, String contentType,
Map<String, String> headers,
int connectTimeout, int readTimeout) throws HttpException {
byte[] requestBody = null;
if (body instanceof Map) {
// 表單編碼
StringBuilder sb = new StringBuilder();
boolean first = true;
for (Entry<String, Object> e : ((Map<String, Object>) body).entrySet()) {
if (!first) sb.append("&");
sb.append(encode(e.getKey())).append("=").append(encode(String.valueOf(e.getValue())));
first = false;
}
requestBody = sb.toString().getBytes(StandardCharsets.UTF_8);
if (contentType == null) contentType = "application/x-www-form-urlencoded;charset=UTF-8";
} else if (body instanceof String) {
requestBody = ((String) body).getBytes(StandardCharsets.UTF_8);
} else if (body != null) {
throw new HttpException("Unsupported body type: " + body.getClass());
}
return executeRequest("POST", url, requestBody, headers, connectTimeout, readTimeout, contentType);
}
/** 統一執行邏輯:重試、超時、編碼檢測、資源關閉 */
private static String executeRequest(String method, String url, byte[] body,
Map<String, String> headers,
int connectTimeout, int readTimeout,
String... contentTypeOverride) throws HttpException {
HttpException lastEx = null;
for (int attempt = 0; attempt <= 2; attempt++) { // 最大重試 2 次
HttpURLConnection conn = null;
try {
URL urlObj = new URL(url);
conn = (HttpURLConnection) urlObj.openConnection();
conn.setRequestMethod(method);
conn.setConnectTimeout(connectTimeout > 0 ? connectTimeout : DEFAULT_CONNECT_TIMEOUT);
conn.setReadTimeout(readTimeout > 0 ? readTimeout : DEFAULT_READ_TIMEOUT);
conn.setRequestProperty("User-Agent", DEFAULT_USER_AGENT);
conn.setRequestProperty("Accept", "*/*");
conn.setRequestProperty("Connection", "Keep-Alive");
// 自定義頭
if (headers != null) {
for (Entry<String, String> h : headers.entrySet()) {
conn.setRequestProperty(h.getKey(), h.getValue());
}
}
// Content-Type
if (contentTypeOverride.length > 0 && contentTypeOverride[0] != null) {
conn.setRequestProperty("Content-Type", contentTypeOverride[0]);
}
// 寫請求體
if ("POST".equalsIgnoreCase(method) && body != null) {
conn.setDoOutput(true);
try (OutputStream os = conn.getOutputStream()) {
os.write(body);
os.flush();
}
}
// 處理響應
int status = conn.getResponseCode();
InputStream is = (status >= 400) ? conn.getErrorStream() : conn.getInputStream();
String charset = detectCharset(conn.getContentType());
String response = readStream(is, charset);
if (status >= 400) {
throw new HttpException("HTTP " + status + ": " + response, status);
}
return response;
} catch (Exception e) {
lastEx = new HttpException("Request failed: " + e.getMessage(), e);
// 可重試異常判斷
if (attempt < 2 && RETRY_EXCEPTIONS.stream().anyMatch(e.getClass().getName()::equals)) {
sleepQuietly(200 * (attempt + 1)); // 指數退避
continue;
}
throw lastEx;
} finally {
if (conn != null) conn.disconnect();
}
}
throw lastEx; // 不應到達
}
/** 從 Content-Type 解析 charset,默認 UTF-8 */
private static String detectCharset(String contentType) {
if (contentType == null) return StandardCharsets.UTF_8.name();
String[] parts = contentType.split(";\\s*");
for (String p : parts) {
if (p.trim().startsWith("charset=")) {
return p.trim().substring(8).replace("\"", "");
}
}
return StandardCharsets.UTF_8.name();
}
private static String readStream(InputStream is, String charset) throws IOException {
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, charset))) {
StringBuilder sb = new StringBuilder();
char[] buf = new char[1024];
int len;
while ((len = br.read(buf)) != -1) sb.append(buf, 0, len);
return sb.toString();
}
}
private static String encode(String s) {
try { return URLEncoder.encode(s, StandardCharsets.UTF_8.name()); }
catch (UnsupportedEncodingException e) { throw new RuntimeException(e); }
}
private static void sleepQuietly(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
}
/** 統一異常類 */
public static class HttpException extends Exception {
private final int statusCode;
public HttpException(String msg) { this(msg, -1); }
public HttpException(String msg, int statusCode) { super(msg); this.statusCode = statusCode; }
public HttpException(String msg, Throwable cause) { super(msg, cause); this.statusCode = -1; }
public int getStatusCode() { return statusCode; }
}
}
String result = HttpClientUtil.doGet(
"https://api.example.com/users",
Map.of("page", 1, "size", 20),
null, 5000, 10000
);
String json = "{\"name\":\"張三\",\"age\":25}";
String resp = HttpClientUtil.doPost(
"https://api.example.com/users",
json,
"application/json;charset=UTF-8",
Map.of("Authorization", "Bearer token123"),
5000, 15000
);
String resp = HttpClientUtil.doPost(
"https://api.example.com/login",
Map.of("username", "admin", "password", "123456"),
null, // 自動 application/x-www-form-urlencoded
null, 3000, 5000
);
| 維度 | 處理策略 | 說明 |
|---|---|---|
| 連接複用 | Keep-Alive + JVM 連接緩存 | 設置 Connection: Keep-Alive;JDK 內置緩存由 http.keepAlive、http.maxConnections 系統屬性控制。 |
| 超時控制 | 分離連接/讀取超時 | 防止掛死:連接超時通常 3-5s,讀取超時視業務 10-30s。 |
| 重試機制 | 冪等請求指數退避重試 | 僅對 GET/幂等 POST 重試;網絡異常(SocketTimeout/ConnectException)觸發,最多 2 次。 |
| 編碼自適應 | Content-Type charset 解析 + UTF-8 回退 | 避免中文亂碼;若無 charset 聲明,默認 UTF-8。 |
| 資源釋放 | try-with-resources + finally disconnect | 確保流與連接在異常時也能釋放,防止文件描述符洩漏。 |
| 異常統一 | 自定義 HttpException 包裝狀態碼 | 業務層捕獲單一異常,便於統一錯誤碼映射與日誌記錄。 |
| 大響應處理 | 流式讀取避免 OOM | 分塊讀取(示例用 1K char buffer),超大文件建議直接寫入磁盤文件。 |
HttpClient (java.net.http) 支持 CompletableFuture 異步調用,WebFlux/Vert.x 場景首選。conn.getErrorStream() 並關閉,否則連接無法復用。Proxy.NO_PROXY 或配置代理選項,避免請求誤走公司代理導致超時。MyApp/1.0 (Java 17))。setInstanceFollowRedirects(false)。本工具類作為零依賴的輕量級方案,適用於中小型項目、遷移過渡期、Serverless 函數冷啟動優化等場景。大規模微服務架構建議統一接入成熟的 HTTP 客戶端庫,享受連接池、熔斷、負載均衡、可觀測性等企業級能力。