From 39e52858d102c24854e3678123c146870fd30fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sun, 30 Aug 2026 21:08:28 +0800 Subject: [PATCH] =?UTF-8?q?task-77:=20=E7=BB=9F=E4=B8=80=20Coze=E3=80=81?= =?UTF-8?q?=E5=93=81=E7=89=8C=E6=A3=80=E6=9F=A5=E5=92=8C=E7=B4=AB=E9=B8=9F?= =?UTF-8?q?=20HTTP=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=9A=84=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E5=A4=8D=E7=94=A8=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nanri/aiimage/config/HttpClientPool.java | 45 +++++ .../brand/client/BrandCheckClient.java | 9 +- .../client/SimilarAsinCozeClient.java | 9 +- .../ziniao/client/ZiniaoClientImpl.java | 26 ++- .../config/HttpClientConnectionReuseTest.java | 176 ++++++++++++++++++ 5 files changed, 253 insertions(+), 12 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java b/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java new file mode 100644 index 00000000..45767368 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java @@ -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; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/client/BrandCheckClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/client/BrandCheckClient.java index 91f54a9a..161b2a60 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/client/BrandCheckClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/client/BrandCheckClient.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java index b6419e00..7d70c2f9 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java index 837e2a3b..581c9883 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java @@ -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 items, JsonNode itemNode) { diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java new file mode 100644 index 00000000..a4db64b2 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java @@ -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 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(), + "多次构造后共享连接池实例不变"); + } +}