feat(mq-starter-idempotent-redis): 旧键 mq.redis-stream.idempotent.* Binder 逐键回退兼容 + 聚合弃用告警
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package com.njcn.mq.idempotent.redis;
|
|||||||
|
|
||||||
import com.njcn.mq.autoconfig.MqCoreAutoConfiguration;
|
import com.njcn.mq.autoconfig.MqCoreAutoConfiguration;
|
||||||
import com.njcn.mq.spi.MqIdempotentStore;
|
import com.njcn.mq.spi.MqIdempotentStore;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
|
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
|
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||||
@@ -10,10 +11,16 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
|||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
|
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.boot.context.properties.bind.Binder;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 消费去重 store 自动装配(driver 中立)。
|
* 消费去重 store 自动装配(driver 中立)。
|
||||||
*
|
*
|
||||||
@@ -24,7 +31,12 @@ import org.springframework.data.redis.core.StringRedisTemplate;
|
|||||||
* classpath 有 lettuce 时 Spring Boot 会用 localhost:6379 默认值自动造出 StringRedisTemplate,
|
* classpath 有 lettuce 时 Spring Boot 会用 localhost:6379 默认值自动造出 StringRedisTemplate,
|
||||||
* 因此 @ConditionalOnBean 几乎恒真,它不代表 Redis 已正确配置;真正的安全网是懒连接 +
|
* 因此 @ConditionalOnBean 几乎恒真,它不代表 Redis 已正确配置;真正的安全网是懒连接 +
|
||||||
* core 仅在 idempotent=true 时才调用 store(未开去重时 bean 造了也无人调用,零副作用)。
|
* core 仅在 idempotent=true 时才调用 store(未开去重时 bean 造了也无人调用,零副作用)。
|
||||||
|
*
|
||||||
|
* <p><b>旧键兼容</b>:mq.redis-stream.idempotent.* 已中立化为 mq.idempotent.*。用 Binder 逐键回退
|
||||||
|
* (而非 env.getProperty 精确键,后者会漏 relaxed binding 变体):中立键显式设置→中立;否则旧键
|
||||||
|
* 显式设置→旧键值+弃用告警;否则默认。
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Configuration
|
@Configuration
|
||||||
@AutoConfigureAfter(RedisAutoConfiguration.class)
|
@AutoConfigureAfter(RedisAutoConfiguration.class)
|
||||||
@AutoConfigureBefore(MqCoreAutoConfiguration.class)
|
@AutoConfigureBefore(MqCoreAutoConfiguration.class)
|
||||||
@@ -33,12 +45,42 @@ import org.springframework.data.redis.core.StringRedisTemplate;
|
|||||||
@EnableConfigurationProperties(MqIdempotentProperties.class)
|
@EnableConfigurationProperties(MqIdempotentProperties.class)
|
||||||
public class MqIdempotentRedisAutoConfiguration {
|
public class MqIdempotentRedisAutoConfiguration {
|
||||||
|
|
||||||
|
private static final String NEUTRAL_PREFIX = "mq.idempotent.";
|
||||||
|
private static final String LEGACY_PREFIX = "mq.redis-stream.idempotent.";
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnBean(StringRedisTemplate.class)
|
@ConditionalOnBean(StringRedisTemplate.class)
|
||||||
@ConditionalOnMissingBean(MqIdempotentStore.class)
|
@ConditionalOnMissingBean(MqIdempotentStore.class)
|
||||||
public MqIdempotentStore mqIdempotentStore(StringRedisTemplate redis, MqIdempotentProperties props) {
|
public MqIdempotentStore mqIdempotentStore(StringRedisTemplate redis,
|
||||||
return new RedisMqIdempotentStore(redis, props.getKeyPrefix(),
|
MqIdempotentProperties props,
|
||||||
props.getProcessingTtl().getSeconds(), props.getSuccessTtl().getSeconds(),
|
Environment environment) {
|
||||||
props.getFailTtl().getSeconds());
|
Binder binder = Binder.get(environment);
|
||||||
|
List<String> usedLegacyKeys = new ArrayList<>();
|
||||||
|
String keyPrefix = resolve(binder, "key-prefix", String.class, props.getKeyPrefix(), usedLegacyKeys);
|
||||||
|
Duration processingTtl = resolve(binder, "processing-ttl", Duration.class, props.getProcessingTtl(), usedLegacyKeys);
|
||||||
|
Duration successTtl = resolve(binder, "success-ttl", Duration.class, props.getSuccessTtl(), usedLegacyKeys);
|
||||||
|
Duration failTtl = resolve(binder, "fail-ttl", Duration.class, props.getFailTtl(), usedLegacyKeys);
|
||||||
|
if (!usedLegacyKeys.isEmpty()) {
|
||||||
|
log.warn("[mq] 检测到已弃用配置 mq.redis-stream.idempotent.*,请迁移到 mq.idempotent.*;本次已按旧键取值:{}", usedLegacyKeys);
|
||||||
|
}
|
||||||
|
return new RedisMqIdempotentStore(redis, keyPrefix,
|
||||||
|
processingTtl.getSeconds(), successTtl.getSeconds(), failTtl.getSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单字段回退:中立键显式设置则以中立为准(props 已按中立键绑定,字段值即中立值);
|
||||||
|
* 否则旧键显式设置则用旧键值并记入弃用清单;否则用默认(同样取 props 字段值)。
|
||||||
|
*/
|
||||||
|
private static <T> T resolve(Binder binder, String field, Class<T> type,
|
||||||
|
T neutralOrDefault, List<String> usedLegacyKeys) {
|
||||||
|
if (binder.bind(NEUTRAL_PREFIX + field, type).isBound()) {
|
||||||
|
return neutralOrDefault;
|
||||||
|
}
|
||||||
|
T legacy = binder.bind(LEGACY_PREFIX + field, type).orElse(null);
|
||||||
|
if (legacy != null) {
|
||||||
|
usedLegacyKeys.add(LEGACY_PREFIX + field);
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
return neutralOrDefault;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.njcn.mq.idempotent.redis;
|
||||||
|
|
||||||
|
import com.njcn.mq.spi.MqIdempotentStore;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||||
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||||
|
import org.springframework.boot.test.system.CapturedOutput;
|
||||||
|
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.ValueOperations;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧键 mq.redis-stream.idempotent.* 向后兼容(Binder 逐键回退)。
|
||||||
|
*
|
||||||
|
* <p>规则:中立键 mq.idempotent.* 显式设置则以中立为准;否则旧键显式设置则用旧键值并打一条
|
||||||
|
* 聚合弃用 WARN;否则用默认。store 字段私有(类内容不改),故通过 mock Redis 的行为断言验证生效值。
|
||||||
|
*/
|
||||||
|
@ExtendWith(OutputCaptureExtension.class)
|
||||||
|
class MqIdempotentLegacyKeyCompatTest {
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private final ValueOperations<String, String> ops = mock(ValueOperations.class);
|
||||||
|
private final StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||||
|
|
||||||
|
private ApplicationContextRunner runner() {
|
||||||
|
when(redis.opsForValue()).thenReturn(ops);
|
||||||
|
return new ApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(MqIdempotentRedisAutoConfiguration.class))
|
||||||
|
.withBean(StringRedisTemplate.class, () -> redis);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void legacyKeysOnly_takeEffect_andLogDeprecationWarn(CapturedOutput output) {
|
||||||
|
runner().withPropertyValues(
|
||||||
|
"mq.redis-stream.idempotent.key-prefix=legacy:idem:",
|
||||||
|
"mq.redis-stream.idempotent.processing-ttl=15s")
|
||||||
|
.run(ctx -> {
|
||||||
|
MqIdempotentStore store = ctx.getBean(MqIdempotentStore.class);
|
||||||
|
store.tryAcquire("k");
|
||||||
|
// 旧键前缀 + 旧键 processing TTL 均生效
|
||||||
|
verify(ops).setIfAbsent("legacy:idem:k", "processing", Duration.ofSeconds(15));
|
||||||
|
// 弃用告警必须点名旧前缀,给使用者迁移线索
|
||||||
|
assertThat(output).contains("mq.redis-stream.idempotent");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void neutralKeyWins_whenBothSet_noWarnForOverriddenLegacy(CapturedOutput output) {
|
||||||
|
runner().withPropertyValues(
|
||||||
|
"mq.idempotent.processing-ttl=20s",
|
||||||
|
"mq.redis-stream.idempotent.processing-ttl=15s")
|
||||||
|
.run(ctx -> {
|
||||||
|
MqIdempotentStore store = ctx.getBean(MqIdempotentStore.class);
|
||||||
|
store.tryAcquire("k");
|
||||||
|
// 中立键优先;key-prefix 未设,用默认 mq:idem:
|
||||||
|
verify(ops).setIfAbsent("mq:idem:k", "processing", Duration.ofSeconds(20));
|
||||||
|
// 没有任何字段实际落到旧键 → 不打弃用告警
|
||||||
|
assertThat(output).doesNotContain("mq.redis-stream.idempotent");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void successAndFailTtl_alsoFallBackToLegacy() {
|
||||||
|
runner().withPropertyValues(
|
||||||
|
"mq.redis-stream.idempotent.success-ttl=111s",
|
||||||
|
"mq.redis-stream.idempotent.fail-ttl=222s")
|
||||||
|
.run(ctx -> {
|
||||||
|
MqIdempotentStore store = ctx.getBean(MqIdempotentStore.class);
|
||||||
|
store.markSuccess("k");
|
||||||
|
verify(ops).set("mq:idem:k", "success", Duration.ofSeconds(111));
|
||||||
|
store.markFail("k");
|
||||||
|
verify(ops).set("mq:idem:k", "fail", Duration.ofSeconds(222));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user