fix(mq-starter): 修复启动 fail-fast 校验 review 发现的缺陷(relaxed binding / 逃生开关 / redis 假地址)
workflow 多智能体 review 后修复: - rocketmq 探测改用 Binder 读 rocketmq.name-server,与 driver(RocketMQProperties)同源、 享受 relaxed binding,修复 camelCase(rocketmq.nameServer) 被误判为「未配置」而错误 fail-fast - MqStartupChecks 逃生开关只认严格 false(忽略大小写/空白),缺省或非法值一律继续校验, 不再因非法布尔值(如 enabled=disabled)抛类型转换异常炸掉启动 - redis 探测 target() 对 cluster/sentinel 回退到指向真实配置项的文案, 不再假报使用者从未配过的 localhost:6379 - 补齐回归测试:camelCase 键 / 非法开关值继续校验 / host:port 提示分支 / cluster 回退 (core 5、redis-stream 5、rocketmq 5,全绿) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,13 @@ package com.njcn.mq.autoconfig;
|
||||
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/** MQ 启动校验的公共工具:统一读取逃生开关 {@code mq.startup-check.enabled}(默认 true)。 */
|
||||
/**
|
||||
* MQ 启动校验的公共工具:统一读取逃生开关 {@code mq.startup-check.enabled}(默认 true)。
|
||||
*
|
||||
* <p>只接受严格的 {@code true}/{@code false}(忽略大小写、去首尾空白)。仅当显式配为
|
||||
* {@code false} 时才关闭校验;缺省或任何非法值(如 {@code disabled})都按默认 {@code true}
|
||||
* 继续校验——避免逃生开关自身因非法布尔值在启动期抛类型转换异常,反而把启动炸掉。
|
||||
*/
|
||||
public final class MqStartupChecks {
|
||||
|
||||
private MqStartupChecks() {
|
||||
@@ -10,7 +16,7 @@ public final class MqStartupChecks {
|
||||
|
||||
/** @return true 表示应执行启动校验;false 表示使用者主动关闭。 */
|
||||
public static boolean enabled(Environment env) {
|
||||
return Boolean.TRUE.equals(
|
||||
env.getProperty("mq.startup-check.enabled", Boolean.class, Boolean.TRUE));
|
||||
String value = env.getProperty("mq.startup-check.enabled");
|
||||
return value == null || !"false".equalsIgnoreCase(value.trim());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,17 @@ class MqDriverPresenceAutoConfigurationTest {
|
||||
.run(ctx -> assertThat(ctx).hasNotFailed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void illegalEnabledValue_stillChecks_insteadOfCrashing() {
|
||||
// 逃生开关填非法布尔值不应炸在类型转换上,而应按默认继续校验:
|
||||
// 此处无 driver,应走到中文③提示,而不是 Spring 的「Invalid boolean」类型转换异常。
|
||||
runner.withPropertyValues("mq.type=redis-stream", "mq.startup-check.enabled=disabled")
|
||||
.run(ctx -> {
|
||||
assertThat(ctx).hasFailed();
|
||||
assertThat(ctx).getFailure().hasMessageContaining("没有装配任何 MqDriver");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabled_skipsCheck() {
|
||||
runner.withPropertyValues("mq.type=redis-stream", "mq.startup-check.enabled=false")
|
||||
|
||||
@@ -46,11 +46,19 @@ public class RedisStreamStartupProbeAutoConfiguration implements InitializingBea
|
||||
}
|
||||
}
|
||||
|
||||
/** 尽力取 host:port 用于提示;取不到(非 Lettuce / cluster / sentinel)则回退 unknown,不再抛新异常。 */
|
||||
/** 尽力取 host:port 用于提示;取不到(非 Lettuce / cluster / sentinel)则回退到有意义的文案,不再抛新异常。 */
|
||||
private String target() {
|
||||
try {
|
||||
if (connectionFactory instanceof LettuceConnectionFactory) {
|
||||
LettuceConnectionFactory lf = (LettuceConnectionFactory) connectionFactory;
|
||||
// cluster/sentinel 模式下 getHostName()/getPort() 返回的是 standalone 默认值 localhost:6379
|
||||
//(使用者从未配置过的假地址),会把排障引偏,故回退到指向真实配置项的文案。
|
||||
if (lf.isClusterAware()) {
|
||||
return "集群模式,见 spring.data.redis.cluster.nodes";
|
||||
}
|
||||
if (lf.isRedisSentinelAware()) {
|
||||
return "哨兵模式,见 spring.data.redis.sentinel";
|
||||
}
|
||||
return lf.getHostName() + ":" + lf.getPort();
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
|
||||
@@ -3,8 +3,11 @@ package com.njcn.mq.driver.redis;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.data.redis.connection.RedisClusterConfiguration;
|
||||
import org.springframework.data.redis.connection.RedisConnection;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -44,6 +47,47 @@ class RedisStreamStartupProbeAutoConfigurationTest {
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void pingFails_messageContainsHostPort() {
|
||||
// 用真实 LettuceConnectionFactory(standalone) 覆盖 target() 的 host:port 提示分支;
|
||||
// override getConnection() 让探测立即失败,无需真实 Redis / 网络。
|
||||
LettuceConnectionFactory factory =
|
||||
new LettuceConnectionFactory(new RedisStandaloneConfiguration("myhost", 1234)) {
|
||||
@Override
|
||||
public RedisConnection getConnection() {
|
||||
throw new RuntimeException("Connection refused");
|
||||
}
|
||||
};
|
||||
|
||||
runner.withPropertyValues("mq.type=redis-stream")
|
||||
.withBean(RedisConnectionFactory.class, () -> factory)
|
||||
.run(ctx -> {
|
||||
assertThat(ctx).hasFailed();
|
||||
assertThat(ctx).getFailure().hasMessageContaining("myhost:1234");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterMode_avoidsFakeLocalhostInMessage() {
|
||||
// cluster 模式下 getHostName()/getPort() 会返回 standalone 默认值 localhost:6379(假地址);
|
||||
// 修复后应回退到指向 cluster 配置项的文案,而不是打印使用者从未配过的 localhost:6379。
|
||||
LettuceConnectionFactory factory =
|
||||
new LettuceConnectionFactory(
|
||||
new RedisClusterConfiguration(java.util.Collections.singletonList("127.0.0.1:7000"))) {
|
||||
@Override
|
||||
public RedisConnection getConnection() {
|
||||
throw new RuntimeException("Connection refused");
|
||||
}
|
||||
};
|
||||
|
||||
runner.withPropertyValues("mq.type=redis-stream")
|
||||
.withBean(RedisConnectionFactory.class, () -> factory)
|
||||
.run(ctx -> {
|
||||
assertThat(ctx).hasFailed();
|
||||
assertThat(ctx).getFailure().hasMessageContaining("集群模式");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabled_skipsProbe() {
|
||||
RedisConnectionFactory factory = mock(RedisConnectionFactory.class);
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.njcn.mq.driver.rocketmq;
|
||||
import com.njcn.mq.autoconfig.MqStartupChecks;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -31,7 +32,10 @@ public class RocketMqStartupProbeAutoConfiguration implements InitializingBean {
|
||||
if (!MqStartupChecks.enabled(env)) {
|
||||
return;
|
||||
}
|
||||
String nameServer = env.getProperty("rocketmq.name-server");
|
||||
// 与 driver 同源取值:driver 走 RocketMQProperties(@ConfigurationProperties)的 getNameServer(),
|
||||
// 享受 Spring relaxed binding;这里也用 Binder,避免 env.getProperty 精确键查找漏掉
|
||||
// camelCase(rocketmq.nameServer)等 relaxed 变体,从而把「其实配好了」误判成「未配置」而错误 fail-fast。
|
||||
String nameServer = Binder.get(env).bind("rocketmq.name-server", String.class).orElse(null);
|
||||
if (!StringUtils.hasText(nameServer)) {
|
||||
throw new IllegalStateException(
|
||||
"MQ 启动失败:mq.type=rocketmq,但未配置 rocketmq.name-server。"
|
||||
|
||||
@@ -25,6 +25,19 @@ class RocketMqStartupProbeAutoConfigurationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void reachableNameServer_camelCaseKey_startsUp() throws Exception {
|
||||
// driver 通过 RocketMQProperties 走 relaxed binding,camelCase 的 rocketmq.nameServer 也能生效;
|
||||
// 探测取值必须与之同源,否则会把配好的 NameServer 误判为未配置而错误 fail-fast。
|
||||
try (ServerSocket server = new ServerSocket(0)) {
|
||||
int port = server.getLocalPort();
|
||||
runner.withPropertyValues(
|
||||
"mq.type=rocketmq",
|
||||
"rocketmq.nameServer=127.0.0.1:" + port)
|
||||
.run(ctx -> assertThat(ctx).hasNotFailed());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableNameServer_startupFails() {
|
||||
runner.withPropertyValues(
|
||||
|
||||
Reference in New Issue
Block a user