feat(mq-starter-idempotent-redis): mq.idempotent.* 中立配置 + 不按 mq.type 门控的去重 store 自动装配

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 03:12:06 +01:00
parent c2a31c9339
commit f558f6303e
5 changed files with 197 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
package com.njcn.mq.idempotent.redis;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* 消费去重配置(driver 中立,前缀 {@code mq.idempotent})。
*
* <p>自 mq.redis-stream.idempotent.* 中立化迁移而来,<b>默认值与迁移前完全一致</b>,不改变现有行为。
* 旧键仍向后兼容(见 MqIdempotentRedisAutoConfiguration 的 Binder 回退),但会打弃用告警。
*
* <pre>
* mq:
* idempotent:
* enabled: true # 逃生开关:显式 false 关闭去重 store 装配
* key-prefix: "mq:idem:"
* processing-ttl: 60s # 应大于单条业务处理时延
* success-ttl: 300s # 防重复消费窗口
* fail-ttl: 300s # 允许重新消费前的保留窗口
* </pre>
*/
@Data
@ConfigurationProperties(prefix = "mq.idempotent")
public class MqIdempotentProperties {
/** 逃生开关:显式 false 关闭去重 store 装配。默认开。 */
private boolean enabled = true;
/** 去重键前缀。 */
private String keyPrefix = "mq:idem:";
/** processing 标记 TTL:应大于单条业务处理时延。 */
private Duration processingTtl = Duration.ofSeconds(60);
/** success 标记 TTL:防重复消费窗口。 */
private Duration successTtl = Duration.ofSeconds(300);
/** fail 标记 TTL:允许重新消费前的保留窗口。 */
private Duration failTtl = Duration.ofSeconds(300);
}

View File

@@ -0,0 +1,44 @@
package com.njcn.mq.idempotent.redis;
import com.njcn.mq.autoconfig.MqCoreAutoConfiguration;
import com.njcn.mq.spi.MqIdempotentStore;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
/**
* 消费去重 store 自动装配(driver 中立)。
*
* <p><b>刻意不按 mq.type 门控</b>,只按「Redis 在场」门控 —— rocketmq / redis-stream / 未来任何
* driver 部署下,只要 classpath 有 spring-data-redis 且上下文有 StringRedisTemplate 就装配。
*
* <p><b>启动安全约束</b>:本装配与 store 构造均不得 eager 连接 / PING Redis(保持 lettuce 懒连接)。
* classpath 有 lettuce 时 Spring Boot 会用 localhost:6379 默认值自动造出 StringRedisTemplate,
* 因此 @ConditionalOnBean 几乎恒真,它不代表 Redis 已正确配置;真正的安全网是懒连接 +
* core 仅在 idempotent=true 时才调用 store(未开去重时 bean 造了也无人调用,零副作用)。
*/
@Configuration
@AutoConfigureAfter(RedisAutoConfiguration.class)
@AutoConfigureBefore(MqCoreAutoConfiguration.class)
@ConditionalOnClass(StringRedisTemplate.class)
@ConditionalOnProperty(name = "mq.idempotent.enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(MqIdempotentProperties.class)
public class MqIdempotentRedisAutoConfiguration {
@Bean
@ConditionalOnBean(StringRedisTemplate.class)
@ConditionalOnMissingBean(MqIdempotentStore.class)
public MqIdempotentStore mqIdempotentStore(StringRedisTemplate redis, MqIdempotentProperties props) {
return new RedisMqIdempotentStore(redis, props.getKeyPrefix(),
props.getProcessingTtl().getSeconds(), props.getSuccessTtl().getSeconds(),
props.getFailTtl().getSeconds());
}
}

View File

@@ -0,0 +1,2 @@
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.njcn.mq.idempotent.redis.MqIdempotentRedisAutoConfiguration

View File

@@ -0,0 +1,24 @@
package com.njcn.mq.idempotent.redis;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link MqIdempotentProperties} 默认值守护:必须与迁移前 RedisStreamMqProperties.Idempotent
* 完全一致(processing 60s / success 300s / fail 300s / 前缀 mq:idem:),保证迁移不改变现有行为。
*/
class MqIdempotentPropertiesTest {
@Test
void defaults_matchLegacyRedisStreamIdempotentDefaults() {
MqIdempotentProperties p = new MqIdempotentProperties();
assertTrue(p.isEnabled(), "去重装配默认开启,仅显式 false 关闭");
assertEquals("mq:idem:", p.getKeyPrefix());
assertEquals(Duration.ofSeconds(60), p.getProcessingTtl());
assertEquals(Duration.ofSeconds(300), p.getSuccessTtl());
assertEquals(Duration.ofSeconds(300), p.getFailTtl());
}
}

View File

@@ -0,0 +1,85 @@
package com.njcn.mq.idempotent.redis;
import com.njcn.mq.autoconfig.MqCoreAutoConfiguration;
import com.njcn.mq.spi.MqIdempotentStore;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.core.io.support.SpringFactoriesLoader;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
/**
* 装配条件矩阵(ApplicationContextRunner,不连真实 Redis):
* 关键契约是「不按 mq.type 门控、只按 Redis 在场门控」—— rocketmq 部署也能装出 store。
*/
class MqIdempotentRedisAutoConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(MqIdempotentRedisAutoConfiguration.class));
@Test
void withStringRedisTemplate_storeAutoConfigured_regardlessOfMqType() {
runner.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class))
// 故意设 mq.type=rocketmq:装配必须与 mq.type 无关(这正是 rocketmq 用上去重的关键)
.withPropertyValues("mq.type=rocketmq")
.run(ctx -> {
assertThat(ctx).hasSingleBean(MqIdempotentStore.class);
assertThat(ctx.getBean(MqIdempotentStore.class)).isInstanceOf(RedisMqIdempotentStore.class);
});
}
@Test
void withoutRedisBean_noStoreAndNoStartupFailure() {
runner.run(ctx -> {
assertThat(ctx).hasNotFailed();
assertThat(ctx).doesNotHaveBean(MqIdempotentStore.class);
});
}
@Test
void enabledFalse_disablesStore() {
runner.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class))
.withPropertyValues("mq.idempotent.enabled=false")
.run(ctx -> assertThat(ctx).doesNotHaveBean(MqIdempotentStore.class));
}
@Test
void userProvidedStore_backsOff() {
MqIdempotentStore custom = mock(MqIdempotentStore.class);
runner.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class))
.withBean("customStore", MqIdempotentStore.class, () -> custom)
.run(ctx -> {
assertThat(ctx).hasSingleBean(MqIdempotentStore.class);
assertThat(ctx.getBean(MqIdempotentStore.class)).isSameAs(custom);
});
}
@Test
void declaresAutoConfigureOrderContract() {
// 真实 Spring Boot 按类名字母序处理 autoconfig:com.njcn.mq.autoconfig(core) 先于
// com.njcn.mq.idempotent(本模块)。显式声明顺序契约,守护 spec §4.4 的装配语义。
AutoConfigureBefore before = MqIdempotentRedisAutoConfiguration.class.getAnnotation(AutoConfigureBefore.class);
assertThat(before).as("必须声明 @AutoConfigureBefore(MqCoreAutoConfiguration)").isNotNull();
assertThat(before.value()).contains(MqCoreAutoConfiguration.class);
AutoConfigureAfter after = MqIdempotentRedisAutoConfiguration.class.getAnnotation(AutoConfigureAfter.class);
assertThat(after).as("必须声明 @AutoConfigureAfter(RedisAutoConfiguration),否则 @ConditionalOnBean(StringRedisTemplate) 求值时模板尚未注册").isNotNull();
assertThat(after.value()).contains(RedisAutoConfiguration.class);
}
@Test
void registeredInSpringFactories() {
List<String> names = SpringFactoriesLoader.loadFactoryNames(EnableAutoConfiguration.class,
getClass().getClassLoader());
assertThat(names).contains(MqIdempotentRedisAutoConfiguration.class.getName());
}
}