2a2d23e0dc
- 自建最小 HTTP/1.1 keep-alive 计数服务:连续顺序请求复用同一连接、多客户端工厂共享连接池、短暂空闲后复用、顺序突发不新建 - 不同 host 各自建连、非共享池客户端不复用(暴露不复用场景)、并发平息后顺序追加复用既有连接 - 记录平台边界:JDK HTTP/1.1 无多路复用,并发在途各自建连;复用体现在顺序/间歇复用空闲连接 - 纯测试,零生产代码改动,全本地确定性
287 lines
11 KiB
Java
287 lines
11 KiB
Java
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();
|
|
}
|
|
}
|
|
}
|
|
}
|