feat(mq-starter-idempotent-redis): mq.idempotent.* 中立配置 + 不按 mq.type 门控的去重 store 自动装配
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
|
||||
com.njcn.mq.idempotent.redis.MqIdempotentRedisAutoConfiguration
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user