task-174: 连接复用测试(HttpClientConnectionReuseTest2,本机计数 TCP 服务观测真实复用)+ 8 条测试

- 自建最小 HTTP/1.1 keep-alive 计数服务:连续顺序请求复用同一连接、多客户端工厂共享连接池、短暂空闲后复用、顺序突发不新建
- 不同 host 各自建连、非共享池客户端不复用(暴露不复用场景)、并发平息后顺序追加复用既有连接
- 记录平台边界:JDK HTTP/1.1 无多路复用,并发在途各自建连;复用体现在顺序/间歇复用空闲连接
- 纯测试,零生产代码改动,全本地确定性
This commit is contained in:
2026-09-04 23:24:26 +08:00
parent 19121f6239
commit 2a2d23e0dc
@@ -0,0 +1,286 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.Test;
import org.springframework.web.client.RestClient;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-174:连接复用测试(plan 10)。
*
* 用本机计数 HTTP/1.1 服务统计真实 TCP 连接数,验证 HttpClientPool 共享池的 keep-alive
* 复用:连续顺序请求复用同一连接、不同客户端工厂共用同一连接池、同 host 突发不新建连接、
* 不同 host 各自建连、非共享池客户端不复用。全部本地确定性,不发外部网络请求。
*
* 平台边界:JDK HttpClient HTTP/1.1 无多路复用,并发在途请求会各自建连;复用体现在
* 顺序/间歇请求复用空闲连接上,故并发后追加的顺序请求应复用已建立的连接而不新增。
*/
class HttpClientConnectionReuseTest2 {
/** 极小的本地 HTTP/1.1 keep-alive 计数服务。 */
static final class CountingServer implements AutoCloseable {
private final ServerSocket serverSocket;
private final AtomicInteger accepted = new AtomicInteger();
private final AtomicInteger requests = new AtomicInteger();
private final List<Socket> sockets = new CopyOnWriteArrayList<>();
private final Thread acceptThread;
private volatile boolean running = true;
CountingServer() throws IOException {
serverSocket = new ServerSocket(0, 256, InetAddress.getByName("127.0.0.1"));
acceptThread = new Thread(this::acceptLoop, "conn-reuse-srv");
acceptThread.setDaemon(true);
acceptThread.start();
}
int port() {
return serverSocket.getLocalPort();
}
int accepted() {
return accepted.get();
}
int requests() {
return requests.get();
}
private void acceptLoop() {
while (running) {
try {
Socket socket = serverSocket.accept();
sockets.add(socket);
accepted.incrementAndGet();
Thread worker = new Thread(() -> handle(socket), "conn-reuse-worker");
worker.setDaemon(true);
worker.start();
} catch (IOException e) {
if (running) {
// accept 被关闭时退出
}
return;
}
}
}
private void handle(Socket socket) {
try (socket) {
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
while (running && readRequest(in)) {
requests.incrementAndGet();
byte[] body = "ok".getBytes(StandardCharsets.US_ASCII);
String head = "HTTP/1.1 200 OK\r\nContent-Length: " + body.length
+ "\r\nConnection: keep-alive\r\n\r\n";
out.write(head.getBytes(StandardCharsets.US_ASCII));
out.write(body);
out.flush();
}
} catch (IOException ignored) {
// 客户端断开/连接被关闭,属预期
} finally {
sockets.remove(socket);
}
}
/** 读取到空行分隔的请求头结束;EOF 返回 false。 */
private static boolean readRequest(InputStream in) throws IOException {
final byte[] end = {'\r', '\n', '\r', '\n'};
int matched = 0;
while (true) {
int b = in.read();
if (b == -1) {
return false;
}
if ((byte) b == end[matched]) {
matched++;
} else {
matched = (b == '\r') ? 1 : 0;
}
if (matched == 4) {
return true;
}
}
}
@Override
public void close() {
running = false;
try {
serverSocket.close();
} catch (IOException ignored) {
// ignore
}
for (Socket socket : sockets) {
try {
socket.close();
} catch (IOException ignored) {
// ignore
}
}
}
}
private static RestClient pooledClient() {
return RestClient.builder()
.requestFactory(HttpClientPool.requestFactory(5_000))
.build();
}
private static void get(RestClient client, int port) {
client.get().uri("http://127.0.0.1:" + port + "/").retrieve().toBodilessEntity();
}
@Test
void sequentialRequestsReuseSingleConnection() throws Exception {
try (CountingServer server = new CountingServer()) {
RestClient client = pooledClient();
for (int i = 0; i < 6; i++) {
get(client, server.port());
}
assertEquals(6, server.requests());
assertEquals(1, server.accepted(), "连续 6 次请求应复用同一连接");
}
}
@Test
void sharedAcrossClientFactoriesReuseOneConnection() throws Exception {
try (CountingServer server = new CountingServer()) {
// 三个独立 RestClient(各自 JdkClientHttpRequestFactory)共享底层连接池
RestClient brand = pooledClient();
RestClient ziniao = pooledClient();
RestClient llm = pooledClient();
get(brand, server.port());
get(ziniao, server.port());
get(llm, server.port());
assertEquals(3, server.requests());
assertEquals(1, server.accepted(), "不同客户端工厂共用同一连接池,应复用同一连接");
}
}
@Test
void reuseSurvivesShortIdleGap() throws Exception {
try (CountingServer server = new CountingServer()) {
RestClient client = pooledClient();
for (int i = 0; i < 3; i++) {
get(client, server.port());
}
Thread.sleep(80);
for (int i = 0; i < 3; i++) {
get(client, server.port());
}
assertEquals(6, server.requests());
assertEquals(1, server.accepted(), "短暂空闲后连接应被 keep-alive 复用");
}
}
@Test
void manySequentialRequestsDoNotBurstConnections() throws Exception {
try (CountingServer server = new CountingServer()) {
RestClient client = pooledClient();
for (int i = 0; i < 12; i++) {
get(client, server.port());
}
assertEquals(12, server.requests());
assertEquals(1, server.accepted(), "顺序突发不应新建连接");
}
}
@Test
void poolIsSingletonAcrossAccesses() {
// 共享池单例;不同 RestClient 各自持工厂,但底层 HttpClient 唯一(Task77 已测实例级)
assertSame(HttpClientPool.sharedHttpClient(), HttpClientPool.sharedHttpClient(),
"共享池单例");
}
@Test
void distinctHostsGetDistinctConnections() throws Exception {
try (CountingServer a = new CountingServer(); CountingServer b = new CountingServer()) {
RestClient client = pooledClient();
get(client, a.port());
get(client, b.port());
get(client, a.port());
assertEquals(1, a.accepted(), "host A 两次请求复用");
assertEquals(1, b.accepted(), "host B 独立建连");
}
}
@Test
void nonPooledHttpClientDoesNotReusePoolConnection() throws Exception {
try (CountingServer server = new CountingServer()) {
RestClient pool = pooledClient();
get(pool, server.port());
assertEquals(1, server.accepted());
// 独立 HttpClient(不参与共享池)访问同一 host → 新建连接,不复用池连接
HttpClient standalone = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
org.springframework.http.client.JdkClientHttpRequestFactory factory =
new org.springframework.http.client.JdkClientHttpRequestFactory(standalone);
RestClient standaloneRest = RestClient.builder().requestFactory(factory).build();
get(standaloneRest, server.port());
assertEquals(2, server.accepted(), "非共享池客户端应另建连接(不复用时暴露)");
}
}
@Test
void concurrentRequestsSucceedAndSettledConnectionsAreReused() throws Exception {
try (CountingServer server = new CountingServer()) {
RestClient client = pooledClient();
int concurrency = 6;
ExecutorService executor = Executors.newFixedThreadPool(concurrency);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(concurrency);
try {
IntStream.range(0, concurrency).forEach(i -> executor.submit(() -> {
try {
start.await();
get(client, server.port());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
}));
start.countDown();
assertTrue(done.await(10, TimeUnit.SECONDS), "并发请求应在时限内完成");
assertEquals(concurrency, server.requests());
int acceptedAfterConcurrent = server.accepted();
assertTrue(acceptedAfterConcurrent >= 1 && acceptedAfterConcurrent <= concurrency,
"并发建连数应在合理范围,实际 " + acceptedAfterConcurrent);
// 并发平息后追加顺序请求应复用已建立的空闲连接,不新增
for (int i = 0; i < 3; i++) {
get(client, server.port());
}
assertEquals(concurrency + 3, server.requests());
assertEquals(acceptedAfterConcurrent, server.accepted(),
"顺序追加应复用既有连接,不新增");
} finally {
executor.shutdownNow();
}
}
}
}