2025年7月

/**

  • HTTP客户端工具类
  • 特性:
    1. 连接池管理优化
    1. 空闲连接自动清理
    1. 自动重试机制
    1. 更安全的TLS配置
    1. 资源自动释放
      */

public class HttpClientUtil {

private static final Logger logger = LoggerFactory.getLogger(HttpClientUtil.class);

public static final String METHOD_POST = "POST";
public static final String METHOD_GET = "GET";
public static final String DEFAULT_CHARSET = "UTF-8";
public static final String DEFAULT_CONTENT_TYPE = "application/json;charset=UTF-8";

// 超时配置
public static final int DEFAULT_CONNECT_TIMEOUT = 10000;         // 10秒连接超时
public static final int DEFAULT_READ_TIMEOUT = 30000;            // 30秒读取超时
public static final int DEFAULT_CONNECT_REQUEST_TIMEOUT = 10000; // 10秒请求超时

// 连接池配置
private static final int MAX_TOTAL = 200;         // 最大连接数
private static final int MAX_PER_ROUTE = 100;     // 每路由最大连接数
private static final int VALIDATE_AFTER_INACTIVITY = 5000; // 5秒空闲后验证
private static final int EVICT_IDLE_CONNECTION_TIME = 30; // 30秒空闲连接清理
private static final int CONNECTION_TTL = 60;     // 连接存活时间(秒)
private static final int MAX_RETRIES = 3;         // 最大重试次数

private static final RequestConfig requestConfig;
private static final PoolingHttpClientConnectionManager connectionManager;
private static final CloseableHttpClient httpClient;
private static final CloseableHttpClient httpsClient;

static {
    // 初始化SSLContext
    SSLContext sslContext = initSSLContext();

    // 配置请求参数
    requestConfig = RequestConfig.custom()
            .setSocketTimeout(DEFAULT_READ_TIMEOUT)
            .setConnectTimeout(DEFAULT_CONNECT_TIMEOUT)
            .setConnectionRequestTimeout(DEFAULT_CONNECT_REQUEST_TIMEOUT)
            .build();

    // 配置连接池
    Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create()
            .register("http", PlainConnectionSocketFactory.getSocketFactory())
            .register("https", new SSLConnectionSocketFactory(
                    sslContext,
                    new String[]{"TLSv1.2", "TLSv1.3"}, // 安全协议
                    null,
                    NoopHostnameVerifier.INSTANCE))
            .build();

    connectionManager = new PoolingHttpClientConnectionManager(
            socketFactoryRegistry,
            null,
            null,
            null,
            VALIDATE_AFTER_INACTIVITY,
            TimeUnit.MILLISECONDS);

    connectionManager.setMaxTotal(MAX_TOTAL);
    connectionManager.setDefaultMaxPerRoute(MAX_PER_ROUTE);

    // 创建HttpClient
    HttpClientBuilder builder = HttpClients.custom()
            .setDefaultRequestConfig(requestConfig)
            .setConnectionManager(connectionManager)
            .evictExpiredConnections()
            .evictIdleConnections(EVICT_IDLE_CONNECTION_TIME, TimeUnit.SECONDS)
            .setConnectionTimeToLive(CONNECTION_TTL, TimeUnit.SECONDS)
            .disableAutomaticRetries()
            .setRetryHandler((exception, executionCount, context) -> {
                if (executionCount > MAX_RETRIES) {
                    return false;
                }
                if (exception instanceof org.apache.http.NoHttpResponseException) {
                    return true;
                }
                return false;
            });

    httpClient = builder.build();
    httpsClient = builder.build();
}

private static SSLContext initSSLContext() {
    try {
        return new SSLContextBuilder()
                .loadTrustMaterial(null, (X509Certificate[] chain, String authType) -> true)
                .build();
    } catch (KeyStoreException | NoSuchAlgorithmException | KeyManagementException ex) {
        logger.error("Failed to initialize SSL context", ex);
        throw new RuntimeException("Failed to initialize SSL context", ex);
    }
}

private HttpClientUtil() {
    // 私有构造器防止实例化
}

/**
 * 执行GET请求
 * @param url 请求URL
 * @param headers 请求头
 * @return 响应内容
 */
public static String get(String url, Map<String, String> headers) {
    HttpGet request = new HttpGet(url);
    try {
        wrapHeader(request, headers);
        return isHttps(url) ? execute(request, httpsClient) : execute(request, httpClient);
    } catch (Exception e) {
        logger.error("GET request failed: {}", e.getMessage(), e);
        throw new RuntimeException("HTTP GET request failed", e);
    } finally {
        request.releaseConnection();
    }
}

/**
 * 执行POST请求(JSON body)
 * @param url 请求URL
 * @param body 请求体(JSON)
 * @param headers 请求头
 * @return 响应内容
 */
public static String postBody(String url, String body, Map<String, String> headers) {
    HttpPost request = new HttpPost(url);
    try {
        wrapHeader(request, headers);
        wrapStringEntity(request, body);
        return isHttps(url) ? execute(request, httpsClient) : execute(request, httpClient);
    } catch (Exception e) {
        logger.error("POST request failed: {}", e.getMessage(), e);
        throw new RuntimeException("HTTP POST request failed", e);
    } finally {
        request.releaseConnection();
    }
}

/**
 * 执行POST请求(form表单)
 * @param url 请求URL
 * @param params 表单参数
 * @param headers 请求头
 * @return 响应内容
 */
public static String postForm(String url, Map<String, String> params, Map<String, String> headers) {
    HttpPost request = new HttpPost(url);
    try {
        wrapHeader(request, headers);
        wrapFormEntity(request, params);
        return isHttps(url) ? execute(request, httpsClient) : execute(request, httpClient);
    } catch (Exception e) {
        logger.error("POST form request failed: {}", e.getMessage(), e);
        throw new RuntimeException("HTTP POST form request failed", e);
    } finally {
        request.releaseConnection();
    }
}

/**
 * 清理连接池中的无效连接
 */
public static void cleanup() {
    connectionManager.closeExpiredConnections();
    connectionManager.closeIdleConnections(0, TimeUnit.SECONDS);
    logger.debug("HTTP connection pool cleaned up");
}

private static String execute(HttpRequestBase request, CloseableHttpClient httpClient) {
    String respJson = null;
    int retryCount = 0;

    while (retryCount <= MAX_RETRIES) {
        try (CloseableHttpResponse response = httpClient.execute(request)) {
            if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
                HttpEntity httpEntity = response.getEntity();
                respJson = EntityUtils.toString(httpEntity, DEFAULT_CHARSET);
                EntityUtils.consume(httpEntity);
                break;
            }
        } catch (IOException e) {
            retryCount++;
            if (retryCount > MAX_RETRIES) {
                logger.error("Request failed after {} retries: {}", MAX_RETRIES, e.getMessage());
                throw new RuntimeException("HTTP request failed after retries", e);
            }

            try {
                // 指数退避
                Thread.sleep((long) Math.pow(2, retryCount) * 100);
            } catch (InterruptedException ie) {
                Thread.currentThread().interrupt();
                throw new RuntimeException("Interrupted during retry", ie);
            }

            // 清理无效连接
            cleanup();
        }
    }
    return respJson;
}

private static void wrapHeader(HttpRequestBase request, Map<String, String> headers) {
    if (headers != null) {
        for (Map.Entry<String, String> entry : headers.entrySet()) {
            request.addHeader(entry.getKey(), entry.getValue());
        }
    }
}

private static void wrapStringEntity(HttpPost request, String body) {
    if (body != null) {
        StringEntity entity = new StringEntity(body, DEFAULT_CHARSET);
        entity.setContentEncoding(DEFAULT_CHARSET);
        entity.setContentType(DEFAULT_CONTENT_TYPE);
        request.setEntity(entity);
    }
}

private static void wrapFormEntity(HttpPost request, Map<String, String> params) throws UnsupportedEncodingException {
    if (params != null) {
        List<NameValuePair> nvps = new ArrayList<>();
        for (Map.Entry<String, String> entry : params.entrySet()) {
            nvps.add(new BasicNameValuePair(entry.getKey(), entry.getValue()));
        }
        request.setEntity(new UrlEncodedFormEntity(nvps, DEFAULT_CHARSET));
    }
}

private static boolean isHttps(String url) {
    return StringUtils.startsWithIgnoreCase(url, "https:");
}

}