task-67: 复用 RustFS/MinIO 客户端与 HTTP 连接池

- buildClient 改为 double-check 懒加载缓存共享 MinioClient(volatile 单例),
  同一实例持有同一 OkHttpClient,连接池随实例共享,不再每次操作新建客户端
- supplier 注入路径优先且不写共享缓存,测试隔离不受污染
- 未配置/空白 endpoint 时不创建实例,拒绝操作并保持缓存为空
This commit is contained in:
2026-08-30 19:04:32 +08:00
parent f15d2e1c22
commit 2819fb3c6c
2 changed files with 238 additions and 6 deletions
@@ -49,6 +49,11 @@ public class RustfsObjectStorageService {
private volatile long failureWindowStartedAtMillis; private volatile long failureWindowStartedAtMillis;
private volatile long circuitOpenUntilMillis; private volatile long circuitOpenUntilMillis;
private volatile OkHttpClient httpClient; private volatile OkHttpClient httpClient;
/**
* 懒加载缓存的共享 MinioClientdouble-check 单例):复用同一实例与同一
* OkHttpClient(连接池随实例共享),避免每次操作创建客户端。
*/
private volatile MinioClient sharedMinioClient;
@Autowired @Autowired
public RustfsObjectStorageService(TransientStorageProperties properties, public RustfsObjectStorageService(TransientStorageProperties properties,
@@ -275,12 +280,21 @@ public class RustfsObjectStorageService {
if (minioClientSupplier != null) { if (minioClientSupplier != null) {
return minioClientSupplier.get(); return minioClientSupplier.get();
} }
return MinioClient.builder() MinioClient shared = sharedMinioClient;
.endpoint(properties.getEndpoint()) if (shared != null) {
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret()) return shared;
.region(properties.getRegion()) }
.httpClient(getHttpClient(deadlineNanos)) synchronized (this) {
.build(); if (sharedMinioClient == null) {
sharedMinioClient = MinioClient.builder()
.endpoint(properties.getEndpoint())
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
.region(properties.getRegion())
.httpClient(getHttpClient(deadlineNanos))
.build();
}
return sharedMinioClient;
}
} }
OkHttpClient getHttpClient() { OkHttpClient getHttpClient() {
@@ -0,0 +1,218 @@
package com.nanri.aiimage.modules.file.service.object;
import com.nanri.aiimage.config.TransientStorageProperties;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 67:复用 RustFS/MinIO 客户端与 HTTP 连接池,减少每次操作创建客户端。
* RustfsObjectStorageService 懒加载缓存共享 MinioClientdouble-check 单例),
* 后续操作复用同一实例与同一 OkHttpClient(连接池随实例共享),
* 不再每次操作新建客户端;supplier 注入路径优先且不污染共享缓存;
* 未配置/非法配置时不创建实例。
*/
class RustfsClientReuseTest {
private TransientStorageProperties properties;
private SimpleMeterRegistry simpleRegistry;
private MinioClient client;
private ObjectProvider<MeterRegistry> meterRegistryProvider;
private ObjectProvider<RustfsDeleteRetryService> deleteRetryProvider;
private RustfsObjectStorageService service;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
properties = new TransientStorageProperties();
simpleRegistry = new SimpleMeterRegistry();
meterRegistryProvider = mock(ObjectProvider.class);
when(meterRegistryProvider.getIfAvailable()).thenReturn(simpleRegistry);
deleteRetryProvider = mock(ObjectProvider.class);
client = mock(MinioClient.class);
// 默认无 supplier → 走共享 MinioClient 缓存路径
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider, null);
}
@AfterEach
void tearDown() {
simpleRegistry.clear();
}
private static void setConfigured(TransientStorageProperties properties) {
properties.setEndpoint("http://rustfs.local:9000");
properties.setBucket("bucket");
properties.setAccessKeyId("ak");
properties.setAccessKeySecret("sk");
properties.setRegion("us-east-1");
}
private Object sharedMinioClient() {
return ReflectionTestUtils.getField(service, "sharedMinioClient");
}
private Object buildClient(long deadlineNanos) {
return ReflectionTestUtils.invokeMethod(service, "buildClient", deadlineNanos);
}
@Test
void test_task_067_rustfs_normal_default_path() {
// 默认路径:共享缓存懒加载创建 MinioClient,第二次调用复用同一实例。
setConfigured(properties);
Object first = buildClient(Long.MAX_VALUE);
Object second = buildClient(Long.MAX_VALUE);
assertNotNull(first, "首次构建出客户端");
assertSame(first, second, "复用同一 MinioClient 实例");
assertSame(first, sharedMinioClient(), "共享缓存字段已填充且一致");
}
@Test
void test_task_067_rustfs_normal_multiple_items() throws Exception {
// 批量场景:并发多操作同时构建客户端只产生一个共享实例,无实例爆炸。
setConfigured(properties);
int concurrency = 16;
CountDownLatch start = new CountDownLatch(1);
Set<Object> instances = ConcurrentHashMap.newKeySet();
Thread[] threads = new Thread[concurrency];
for (int i = 0; i < concurrency; i++) {
threads[i] = new Thread(() -> {
try {
start.await(2, TimeUnit.SECONDS);
instances.add(buildClient(Long.MAX_VALUE));
} catch (Exception ignored) {
}
});
threads[i].setDaemon(true);
threads[i].start();
}
start.countDown();
for (Thread thread : threads) {
thread.join(5_000);
}
assertEquals(1, instances.size(), "并发构建只产生一个共享实例");
assertNotNull(sharedMinioClient());
}
@Test
void test_task_067_rustfs_normal_repeated_operation_is_idempotent() {
// 幂等:重复调用不创建重复客户端,共享实例引用稳定。
setConfigured(properties);
Object first = buildClient(Long.MAX_VALUE);
Object second = buildClient(Long.MAX_VALUE);
Object third = buildClient(Long.MAX_VALUE);
assertSame(first, second);
assertSame(second, third);
assertSame(first, sharedMinioClient());
}
@Test
void test_task_067_rustfs_boundary_empty_input() {
// 空输入:未配置时操作直接拒绝,不创建任何客户端实例。
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> service.uploadText("k", "v"));
org.junit.jupiter.api.Assertions.assertTrue(ex.getMessage().contains("not configured"));
assertNull(sharedMinioClient(), "未配置不创建客户端");
}
@Test
void test_task_067_rustfs_boundary_single_item() {
// 单元素:单次构建创建一次并缓存,不依赖批量路径。
setConfigured(properties);
Object built = buildClient(Long.MAX_VALUE);
assertNotNull(built);
assertSame(built, sharedMinioClient());
}
@Test
void test_task_067_rustfs_boundary_limit_and_overflow() throws Exception {
// 上限/超限:更大并发下仍只创建一个共享实例;
// HTTP 客户端(含连接池)同样复用同一实例。
setConfigured(properties);
int concurrency = 32;
CountDownLatch start = new CountDownLatch(1);
Set<Object> instances = ConcurrentHashMap.newKeySet();
Thread[] threads = new Thread[concurrency];
for (int i = 0; i < concurrency; i++) {
threads[i] = new Thread(() -> {
try {
start.await(2, TimeUnit.SECONDS);
instances.add(buildClient(Long.MAX_VALUE));
} catch (Exception ignored) {
}
});
threads[i].setDaemon(true);
threads[i].start();
}
start.countDown();
for (Thread thread : threads) {
thread.join(5_000);
}
assertEquals(1, instances.size(), "高并发下不产生客户端实例爆炸");
assertSame(service.getHttpClient(), service.getHttpClient(), "HTTP 客户端复用同一实例(连接池共享)");
}
@Test
void test_task_067_rustfs_invalid_input_rejected() {
// 非法参数:endpoint 为空白时视为未配置,拒绝操作且不创建客户端。
properties.setEndpoint(" ");
properties.setBucket("bucket");
properties.setAccessKeyId("ak");
properties.setAccessKeySecret("sk");
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> service.uploadText("k", "v"));
org.junit.jupiter.api.Assertions.assertTrue(ex.getMessage().contains("not configured"));
assertNull(sharedMinioClient());
}
@Test
void test_task_067_rustfs_dependency_failure_releases_resources() throws Exception {
// 依赖失败:supplier 注入路径优先且失败后可恢复,
// 不污染共享缓存(sharedMinioClient 保持未初始化)。
RustfsObjectStorageService supplierService = new RustfsObjectStorageService(
properties, meterRegistryProvider, deleteRetryProvider, () -> client);
setConfigured(properties);
properties.setUploadMaxRetries(1);
when(client.putObject(ArgumentMatchers.any(PutObjectArgs.class)))
.thenThrow(new java.io.IOException("rustfs down"));
assertThrows(Exception.class, () -> supplierService.uploadText("k", "v"));
when(client.putObject(ArgumentMatchers.any(PutObjectArgs.class)))
.thenReturn(mock(io.minio.ObjectWriteResponse.class));
when(client.statObject(ArgumentMatchers.any(io.minio.StatObjectArgs.class))).thenReturn(null);
assertEquals("k", supplierService.uploadText("k", "v"));
verify(client, times(2)).putObject(ArgumentMatchers.any(PutObjectArgs.class));
assertNull(ReflectionTestUtils.getField(supplierService, "sharedMinioClient"),
"supplier 路径不写共享缓存");
}
}