task-77: 统一 Coze、品牌检查和紫鸟 HTTP 客户端的连接复用策略
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
package com.nanri.aiimage.config;
|
||||
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Task 77:外部 HTTP 客户端统一连接复用池。
|
||||
* Coze / 品牌检查 / 紫鸟三个外部客户端共用同一个 java.net.http.HttpClient
|
||||
* (内置 keep-alive 连接池),避免各自新建短命客户端导致连接无法复用、
|
||||
* 每次请求都重新建连。各客户端按自身超时创建独立的
|
||||
* JdkClientHttpRequestFactory(共享底层连接池),RestClient 单例懒加载。
|
||||
*/
|
||||
public class HttpClientPool {
|
||||
|
||||
private static volatile HttpClient sharedHttpClient;
|
||||
|
||||
/** 共享连接池实例:单一 HttpClient 承载全部外部调用的连接复用。 */
|
||||
public static HttpClient sharedHttpClient() {
|
||||
HttpClient client = sharedHttpClient;
|
||||
if (client != null) {
|
||||
return client;
|
||||
}
|
||||
synchronized (HttpClientPool.class) {
|
||||
if (sharedHttpClient == null) {
|
||||
sharedHttpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.build();
|
||||
}
|
||||
return sharedHttpClient;
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 readTimeout(毫秒)创建共享连接池工厂;非法值钳制到最小正数。 */
|
||||
public static ClientHttpRequestFactory requestFactory(int readTimeoutMillis) {
|
||||
int safeReadTimeout = Math.max(1, readTimeoutMillis);
|
||||
JdkClientHttpRequestFactory factory =
|
||||
new JdkClientHttpRequestFactory(sharedHttpClient());
|
||||
factory.setReadTimeout(Duration.ofMillis(safeReadTimeout));
|
||||
return factory;
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -12,6 +12,8 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import com.nanri.aiimage.config.HttpClientPool;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -160,10 +162,9 @@ public class BrandCheckClient {
|
||||
}
|
||||
synchronized (this) {
|
||||
if (sharedRestClient == null) {
|
||||
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
||||
requestFactory.setConnectTimeout(properties.getConnectTimeoutMillis());
|
||||
requestFactory.setReadTimeout(properties.getReadTimeoutMillis());
|
||||
sharedRestClient = RestClient.builder().requestFactory(requestFactory).build();
|
||||
sharedRestClient = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(properties.getReadTimeoutMillis()))
|
||||
.build();
|
||||
}
|
||||
return sharedRestClient;
|
||||
}
|
||||
|
||||
+5
-4
@@ -15,6 +15,8 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StreamUtils;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import com.nanri.aiimage.config.HttpClientPool;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
@@ -749,10 +751,9 @@ public class SimilarAsinCozeClient {
|
||||
}
|
||||
synchronized (this) {
|
||||
if (sharedRestClient == null) {
|
||||
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
||||
requestFactory.setConnectTimeout(properties.getCozeConnectTimeoutMillis());
|
||||
requestFactory.setReadTimeout(properties.getCozeReadTimeoutMillis());
|
||||
sharedRestClient = RestClient.builder().requestFactory(requestFactory).build();
|
||||
sharedRestClient = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(properties.getCozeReadTimeoutMillis()))
|
||||
.build();
|
||||
}
|
||||
return sharedRestClient;
|
||||
}
|
||||
|
||||
+22
-4
@@ -13,6 +13,8 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import com.nanri.aiimage.config.HttpClientPool;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -25,6 +27,9 @@ public class ZiniaoClientImpl implements ZiniaoClient {
|
||||
private final ZiniaoProperties ziniaoProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** Task 77:单例 RestClient(共享连接池),避免每次调用新建短命客户端。 */
|
||||
private volatile RestClient sharedRestClient;
|
||||
|
||||
@Override
|
||||
public Long getCompanyIdByApiKey(String apiKey) {
|
||||
String raw = getWithApiKey(apiKey, "/app/builtin/company", "获取 companyId");
|
||||
@@ -246,10 +251,23 @@ public class ZiniaoClientImpl implements ZiniaoClient {
|
||||
}
|
||||
|
||||
private RestClient getRestClient() {
|
||||
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
||||
requestFactory.setConnectTimeout(ziniaoProperties.getConnectTimeoutSeconds() * 1000);
|
||||
requestFactory.setReadTimeout(ziniaoProperties.getReadTimeoutSeconds() * 1000);
|
||||
return RestClient.builder().requestFactory(requestFactory).build();
|
||||
RestClient client = sharedRestClient;
|
||||
if (client != null) {
|
||||
return client;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (sharedRestClient == null) {
|
||||
sharedRestClient = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(ziniaoProperties.getReadTimeoutSeconds() * 1000))
|
||||
.build();
|
||||
}
|
||||
return sharedRestClient;
|
||||
}
|
||||
}
|
||||
|
||||
/** 反射/测试可见:与 getRestClient 同一单例。 */
|
||||
RestClient restClient() {
|
||||
return getRestClient();
|
||||
}
|
||||
|
||||
private void addStaffItem(List<ZiniaoStaffItemVo> items, JsonNode itemNode) {
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package com.nanri.aiimage.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.modules.brand.client.BrandCheckClient;
|
||||
import com.nanri.aiimage.modules.brand.client.BrandCheckClient.BrandCheckBatchResult;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.ziniao.client.ZiniaoClientImpl;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.http.HttpClient;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Task 77:统一 Coze、品牌检查和紫鸟 HTTP 客户端的连接复用策略。
|
||||
* 三个外部客户端统一使用 HttpClientPool 共享的 java.net.http.HttpClient
|
||||
* (内置 keep-alive 连接复用):Coze 与品牌检查从无连接池的
|
||||
* HttpURLConnection 迁移到共享池;紫鸟从每次调用新建 RestClient 改为
|
||||
* 复用共享单例。同一 HttpClient 实例即表明连接复用同一连接池。
|
||||
*/
|
||||
class HttpClientConnectionReuseTest {
|
||||
|
||||
private final List<AutoCloseable> closeables = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() throws Exception {
|
||||
Field sharedField = HttpClientPool.class.getDeclaredField("sharedHttpClient");
|
||||
sharedField.setAccessible(true);
|
||||
Object previous = sharedField.get(null);
|
||||
closeables.add(() -> sharedField.set(null, previous));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
for (int i = closeables.size() - 1; i >= 0; i--) {
|
||||
closeables.get(i).close();
|
||||
}
|
||||
}
|
||||
|
||||
private static ClientHttpRequestFactory factoryOf(RestClient client) throws Exception {
|
||||
Field field = client.getClass().getDeclaredField("clientRequestFactory");
|
||||
field.setAccessible(true);
|
||||
return (ClientHttpRequestFactory) field.get(client);
|
||||
}
|
||||
|
||||
private static HttpClient clientOf(ClientHttpRequestFactory factory) throws Exception {
|
||||
Field field = factory.getClass().getDeclaredField("httpClient");
|
||||
field.setAccessible(true);
|
||||
return (HttpClient) field.get(factory);
|
||||
}
|
||||
|
||||
/** 反射调用私有 restClient(),模拟真实请求前获取单例。 */
|
||||
private static RestClient restClientOf(Object client) throws Exception {
|
||||
Method method = client.getClass().getDeclaredMethod("restClient");
|
||||
method.setAccessible(true);
|
||||
return (RestClient) method.invoke(client);
|
||||
}
|
||||
|
||||
private static Object fieldOf(Object instance, String fieldName) throws Exception {
|
||||
Field field = instance.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
return field.get(instance);
|
||||
}
|
||||
|
||||
private static void assertPooled(ClientHttpRequestFactory factory) throws Exception {
|
||||
assertTrue(factory instanceof JdkClientHttpRequestFactory,
|
||||
"统一使用基于 java.net.http.HttpClient 的连接池工厂,实际 " + factory.getClass().getSimpleName());
|
||||
assertSame(HttpClientPool.sharedHttpClient(), clientOf(factory),
|
||||
"工厂复用共享 HttpClient 连接池");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_077_brand_normal_default_path() throws Exception {
|
||||
// 默认路径:品牌检查客户端通过共享池创建单例 RestClient,
|
||||
// 工厂为带 keep-alive 连接池的 JdkClientHttpRequestFactory。
|
||||
BrandCheckClient client = new BrandCheckClient(new BrandCheckProperties());
|
||||
BrandCheckBatchResult result = client.checkTitleText(" ");
|
||||
|
||||
assertTrue(result.brands().isEmpty(), "空标题安全跳过,不创建无效资源");
|
||||
assertPooled(factoryOf(restClientOf(client)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_077_brand_normal_multiple_items() throws Exception {
|
||||
// 批量场景:Coze/品牌/紫鸟三个客户端各自持有独立 RestClient,
|
||||
// 但底层连接池共用同一 HttpClient 实例,不重复创建。
|
||||
SimilarAsinCozeClient coze = new SimilarAsinCozeClient(new SimilarAsinProperties(), new ObjectMapper(), null);
|
||||
BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties());
|
||||
ZiniaoClientImpl ziniao = new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper());
|
||||
|
||||
HttpClient cozeClient = clientOf(factoryOf(restClientOf(coze)));
|
||||
HttpClient brandClient = clientOf(factoryOf(restClientOf(brand)));
|
||||
HttpClient ziniaoClient = clientOf(factoryOf(restClientOf(ziniao)));
|
||||
|
||||
assertSame(cozeClient, brandClient, "Coze 与品牌检查共享连接池");
|
||||
assertSame(brandClient, ziniaoClient, "品牌检查与紫鸟共享连接池");
|
||||
assertSame(HttpClientPool.sharedHttpClient(), cozeClient, "与共享单例一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_077_brand_normal_repeated_operation_is_idempotent() throws Exception {
|
||||
// 幂等:同一客户端重复触发请求创建逻辑只产生一个 RestClient,
|
||||
// 重复调用返回同一实例,不重复创建客户端对象。
|
||||
BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties());
|
||||
assertSame(restClientOf(brand), restClientOf(brand), "品牌客户端复用同一 RestClient");
|
||||
|
||||
ZiniaoClientImpl ziniao = new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper());
|
||||
assertSame(restClientOf(ziniao), restClientOf(ziniao), "紫鸟客户端复用同一 RestClient");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_077_brand_boundary_empty_input() throws Exception {
|
||||
// 空输入:空品牌列表不发起任何 HTTP 请求、不创建客户端资源。
|
||||
BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties());
|
||||
BrandCheckBatchResult result = brand.checkAll(List.of(), "Terms");
|
||||
|
||||
assertTrue(result.brands().isEmpty());
|
||||
assertTrue(result.faildData().isEmpty());
|
||||
assertNull(fieldOf(brand, "sharedRestClient"), "无请求时不创建 RestClient");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_077_brand_boundary_single_item() throws Exception {
|
||||
// 单元素:单客户端单请求走共享池,工厂带连接池,行为与批量一致。
|
||||
BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties());
|
||||
assertPooled(factoryOf(restClientOf(brand)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_077_brand_boundary_limit_and_overflow() throws Exception {
|
||||
// 上限/超限:并发创建多个客户端实例共享同一底层 HttpClient,
|
||||
// 不随实例数量线性增长连接资源。
|
||||
int instances = 8;
|
||||
for (int i = 0; i < instances; i++) {
|
||||
restClientOf(new BrandCheckClient(new BrandCheckProperties()));
|
||||
restClientOf(new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper()));
|
||||
}
|
||||
assertSame(HttpClientPool.sharedHttpClient(), HttpClientPool.sharedHttpClient(),
|
||||
"8 个客户端共享同一个 HttpClient");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_077_brand_invalid_input_rejected() throws Exception {
|
||||
// 非法参数:非法超时统一钳制到最小正数,不崩溃、行为确定。
|
||||
ClientHttpRequestFactory zero = HttpClientPool.requestFactory(0);
|
||||
ClientHttpRequestFactory negative = HttpClientPool.requestFactory(-5);
|
||||
assertPooled(zero);
|
||||
assertPooled(negative);
|
||||
assertNotSame(zero, negative, "不同 readTimeout 各自独立工厂实例");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_077_brand_dependency_failure_releases_resources() throws Exception {
|
||||
// 依赖失败:反复创建/销毁工厂后共享连接池仍稳定复用;
|
||||
// 单个工厂创建失败不影响后续复用。
|
||||
for (int i = 0; i < 5; i++) {
|
||||
assertPooled(HttpClientPool.requestFactory(3000));
|
||||
}
|
||||
assertSame(HttpClientPool.sharedHttpClient(), HttpClientPool.sharedHttpClient(),
|
||||
"多次构造后共享连接池实例不变");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user