From 9d6a7ad5c2e0c893bf5f4dc69bde1ca30b99342d Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Sat, 19 Sep 2026 12:54:55 +0800 Subject: [PATCH 1/5] chore: start metrics timer consolidation From ddd24ea3e586746597f449e4d0721035465984ce Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Sat, 19 Sep 2026 13:01:07 +0800 Subject: [PATCH 2/5] test(metrics): migrate timer contracts to registry seam --- .../RedisProCacheMetricsRegistryTest.java | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java index 96400366..43dc5d81 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java @@ -10,6 +10,7 @@ import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; @@ -406,6 +407,164 @@ void metricsSnapshot_isImmutable() { } } + // ==================== primitive contract at registry seam ==================== + + @Nested + @DisplayName("Primitive contract at registry seam") + class PrimitiveContractTests { + + @Test + @DisplayName("null MeterRegistry disables timer and counter registration") + void nullMeterRegistry_disablesPrimitiveMetrics() { + RedisProCacheMetricsRegistry emptyRegistry = + new RedisProCacheMetricsRegistry(null, CACHE_NAME); + AtomicInteger invocations = new AtomicInteger(); + + String result = emptyRegistry.recordGet(() -> { + invocations.incrementAndGet(); + return "hello"; + }); + emptyRegistry.recordClear(invocations::incrementAndGet); + emptyRegistry.recordHit(); + emptyRegistry.recordMiss(); + + assertThat(result).isEqualTo("hello"); + assertThat(invocations.get()).isEqualTo(2); + assertThat(emptyRegistry.metrics().hitCount()).isZero(); + assertThat(emptyRegistry.metrics().missCount()).isZero(); + } + + @Test + @DisplayName("non-null registry registers Timer with cache tag and description") + void nonNullRegistry_registersTimerWithTagAndDescription() { + Timer timer = meterRegistry.find("resicache.cache.get") + .tag(CACHE_TAG, CACHE_NAME).timer(); + + assertThat(timer).isNotNull(); + assertThat(timer.getId().getName()).isEqualTo("resicache.cache.get"); + assertThat(timer.getId().getTag(CACHE_TAG)).isEqualTo(CACHE_NAME); + assertThat(timer.getId().getDescription()) + .isEqualTo("Time spent getting cache entries"); + } + + @Test + @DisplayName("non-null registry registers Counter with cache tag and description") + void nonNullRegistry_registersCounterWithTagAndDescription() { + Counter counter = meterRegistry.find("resicache.cache.hit") + .tag(CACHE_TAG, CACHE_NAME).counter(); + + assertThat(counter).isNotNull(); + assertThat(counter.getId().getName()).isEqualTo("resicache.cache.hit"); + assertThat(counter.getId().getTag(CACHE_TAG)).isEqualTo(CACHE_NAME); + assertThat(counter.getId().getDescription()).isEqualTo("Cache hit count"); + } + + @Test + @DisplayName("null counter operations are silent no-ops") + void nullCounters_areSilentNoOps() { + RedisProCacheMetricsRegistry emptyRegistry = + new RedisProCacheMetricsRegistry(null, CACHE_NAME); + + emptyRegistry.recordHit(); + emptyRegistry.recordMiss(); + + assertThat(emptyRegistry.metrics().hitCount()).isZero(); + assertThat(emptyRegistry.metrics().missCount()).isZero(); + } + + @Test + @DisplayName("non-null counter operations increment the registered counter") + void nonNullCounter_increments() { + Counter counter = meterRegistry.find("resicache.cache.hit") + .tag(CACHE_TAG, CACHE_NAME).counter(); + + registry.recordHit(); + registry.recordHit(); + + assertThat(counter.count()).isEqualTo(2.0); + } + + @Test + @DisplayName("null timer executes void body without recording") + void nullTimer_executesVoidBodyWithoutRecording() { + RedisProCacheMetricsRegistry emptyRegistry = + new RedisProCacheMetricsRegistry(null, CACHE_NAME); + AtomicInteger invocations = new AtomicInteger(); + + emptyRegistry.recordClear(invocations::incrementAndGet); + + assertThat(invocations.get()).isEqualTo(1); + } + + @Test + @DisplayName("non-null timer records one void timing sample") + void nonNullTimer_recordsVoidTiming() { + Timer timer = meterRegistry.find("resicache.cache.evict") + .tag(CACHE_TAG, CACHE_NAME).timer(); + AtomicInteger invocations = new AtomicInteger(); + + registry.recordClear(invocations::incrementAndGet); + + assertThat(invocations.get()).isEqualTo(1); + assertThat(timer.count()).isEqualTo(1); + assertThat(timer.totalTime(TimeUnit.NANOSECONDS)).isGreaterThanOrEqualTo(0); + } + + @Test + @DisplayName("void body exception propagates and timer still records") + void voidBodyException_propagatesAndStillRecords() { + Timer timer = meterRegistry.find("resicache.cache.evict") + .tag(CACHE_TAG, CACHE_NAME).timer(); + + assertThatThrownBy(() -> registry.recordClear(() -> { + throw new IllegalStateException("boom"); + })).isInstanceOf(IllegalStateException.class) + .hasMessage("boom"); + + assertThat(timer.count()).isEqualTo(1); + } + + @Test + @DisplayName("null timer forwards supplier result without recording") + void nullTimer_forwardsSupplierResult() { + RedisProCacheMetricsRegistry emptyRegistry = + new RedisProCacheMetricsRegistry(null, CACHE_NAME); + + String result = emptyRegistry.recordGet(() -> "hello"); + + assertThat(result).isEqualTo("hello"); + } + + @Test + @DisplayName("non-null timer records and forwards supplier result") + void nonNullTimer_recordsAndForwardsSupplierResult() { + Timer timer = meterRegistry.find("resicache.cache.get") + .tag(CACHE_TAG, CACHE_NAME).timer(); + + String result = registry.recordGet(() -> "computed-value"); + + assertThat(result).isEqualTo("computed-value"); + assertThat(timer.count()).isEqualTo(1); + } + + @Test + @DisplayName("supplier exception propagates and timer still records") + void supplierException_propagatesAndStillRecords() { + Timer timer = meterRegistry.find("resicache.cache.get") + .tag(CACHE_TAG, CACHE_NAME).timer(); + AtomicReference sideEffect = new AtomicReference<>(); + + assertThatThrownBy(() -> registry.recordGet(() -> { + sideEffect.set("body-was-called"); + throw new RuntimeException("from supplier"); + })).isInstanceOf(RuntimeException.class) + .hasMessage("from supplier"); + + assertThat(sideEffect.get()).isEqualTo("body-was-called"); + assertThat(timer.count()).isEqualTo(1); + } + } + // ==================== 集成场景 ==================== @Nested From 878f293c5a726aa109e14f0d44c9251b2f46542f Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Sat, 19 Sep 2026 13:07:14 +0800 Subject: [PATCH 3/5] refactor(metrics): inline timer helpers into registry --- .../cache/RedisProCacheMetricsRegistry.java | 93 ++++++--- .../redis/cache/RedisProCacheTimers.java | 152 -------------- .../redis/cache/metrics/CacheMetrics.java | 3 +- .../redis/cache/RedisProCacheTimersTest.java | 194 ------------------ 4 files changed, 71 insertions(+), 371 deletions(-) delete mode 100644 src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheTimers.java delete mode 100644 src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheTimersTest.java diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java index dc04357d..7d910f84 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java @@ -22,11 +22,8 @@ *

与 {@link CacheMetrics} 的对称性:本 seam 是"写侧"(注册 + 记录),与读侧 * 快照({@link CacheMetrics})配对形成指标领域 完整边界。读侧只读、写侧只写 — 关注点分离。 * - *

与 {@link RedisProCacheTimers} 的关系:{@link RedisProCacheTimers} 是 metric 原语 - * helper({@code registerTimer} / {@code registerCounter} / {@code timed} / {@code timedGet} / - * {@code safeIncrement}),本身是工具类无状态。本类是 metric 集合的容器(6 字段 + 6 - * 业务语义方法),把原语按"缓存实例"的语义组装起来。两层 seam 形成 composition — 删除任意 - * 一层,复杂度上浮。 + *

本类在 metric ownership seam 内部封装注册、自增与计时原语,并按缓存实例语义组装。 + * 所有 record 方法复用这些私有 helper,保持 null-safe 与异常传播行为。 * *

公开方法(业务语义): *

    @@ -89,20 +86,19 @@ final class RedisProCacheMetricsRegistry { /** * 构造期一次性注册 6 个 metric — 在 cache 构造期调用一次,运行期 record 路径直接复用。 * - *

    内部委派 {@link RedisProCacheTimers} 原语(registerTimer / registerCounter)保证 null-safe 语义。 - * + *

    内部注册 helper 保证 {@code meterRegistry == null} 时所有字段保持 null。 * @param meterRegistry Micrometer 注册表(可为 null → 全部 6 字段为 null) * @param cacheName cache 标识,作为 {@code tags("cache", cacheName)} 写入每个 metric */ public RedisProCacheMetricsRegistry(@Nullable MeterRegistry meterRegistry, String cacheName) { this.cacheName = cacheName; - this.getTimer = RedisProCacheTimers.registerTimer(meterRegistry, TIMER_GET, DESC_GET_TIMER, cacheName); - this.putTimer = RedisProCacheTimers.registerTimer(meterRegistry, TIMER_PUT, DESC_PUT_TIMER, cacheName); - this.evictTimer = RedisProCacheTimers.registerTimer(meterRegistry, TIMER_EVICT, DESC_EVICT_TIMER, cacheName); - this.hitCounter = RedisProCacheTimers.registerCounter(meterRegistry, COUNTER_HIT, DESC_HIT, cacheName); - this.missCounter = RedisProCacheTimers.registerCounter(meterRegistry, COUNTER_MISS, DESC_MISS, cacheName); - this.putCounter = RedisProCacheTimers.registerCounter(meterRegistry, COUNTER_PUT, DESC_PUT, cacheName); - this.evictCounter = RedisProCacheTimers.registerCounter(meterRegistry, COUNTER_EVICT, DESC_EVICT, cacheName); + this.getTimer = registerTimer(meterRegistry, TIMER_GET, DESC_GET_TIMER, cacheName); + this.putTimer = registerTimer(meterRegistry, TIMER_PUT, DESC_PUT_TIMER, cacheName); + this.evictTimer = registerTimer(meterRegistry, TIMER_EVICT, DESC_EVICT_TIMER, cacheName); + this.hitCounter = registerCounter(meterRegistry, COUNTER_HIT, DESC_HIT, cacheName); + this.missCounter = registerCounter(meterRegistry, COUNTER_MISS, DESC_MISS, cacheName); + this.putCounter = registerCounter(meterRegistry, COUNTER_PUT, DESC_PUT, cacheName); + this.evictCounter = registerCounter(meterRegistry, COUNTER_EVICT, DESC_EVICT, cacheName); } // ==================== 业务方法(get / put / evict / clear) ==================== @@ -120,21 +116,21 @@ public RedisProCacheMetricsRegistry(@Nullable MeterRegistry meterRegistry, Strin * @return body.get() 的结果 */ public T recordGet(Supplier body) { - return RedisProCacheTimers.timedGet(getTimer, body); + return timedGet(getTimer, body); } /** * 记录 hit 计数(get 返回非 null 时调用)— null-safe。 */ public void recordHit() { - RedisProCacheTimers.safeIncrement(hitCounter); + safeIncrement(hitCounter); } /** * 记录 miss 计数(get 返回 null 时调用)— null-safe。 */ public void recordMiss() { - RedisProCacheTimers.safeIncrement(missCounter); + safeIncrement(missCounter); } /** @@ -145,7 +141,7 @@ public void recordMiss() { public void recordPut(Runnable body) { if (putTimer == null) { body.run(); - increment(putCounter); + safeIncrement(putCounter); return; } long start = System.nanoTime(); @@ -153,7 +149,7 @@ public void recordPut(Runnable body) { body.run(); } finally { putTimer.record(System.nanoTime() - start, TimeUnit.NANOSECONDS); - increment(putCounter); + safeIncrement(putCounter); } } @@ -165,7 +161,7 @@ public void recordPut(Runnable body) { public void recordEvict(Runnable body) { if (evictTimer == null) { body.run(); - increment(evictCounter); + safeIncrement(evictCounter); return; } long start = System.nanoTime(); @@ -173,7 +169,7 @@ public void recordEvict(Runnable body) { body.run(); } finally { evictTimer.record(System.nanoTime() - start, TimeUnit.NANOSECONDS); - increment(evictCounter); + safeIncrement(evictCounter); } } @@ -183,7 +179,7 @@ public void recordEvict(Runnable body) { * @param body 实际的 clear 操作(不可为 null) */ public void recordClear(Runnable body) { - RedisProCacheTimers.timed(evictTimer, body); + timed(evictTimer, body); } /** @@ -212,8 +208,57 @@ String cacheName() { // ==================== 私有 helper ==================== - private static void increment(@Nullable Counter counter) { - RedisProCacheTimers.safeIncrement(counter); + private static Timer registerTimer(@Nullable MeterRegistry registry, String name, + String description, String cacheName) { + if (registry == null) { + return null; + } + return Timer.builder(name) + .tag("cache", cacheName) + .description(description) + .register(registry); + } + + private static Counter registerCounter(@Nullable MeterRegistry registry, String name, + String description, String cacheName) { + if (registry == null) { + return null; + } + return Counter.builder(name) + .tag("cache", cacheName) + .description(description) + .register(registry); + } + + private static void safeIncrement(@Nullable Counter counter) { + if (counter != null) { + counter.increment(); + } + } + + private static void timed(@Nullable Timer timer, Runnable body) { + if (timer == null) { + body.run(); + return; + } + long start = System.nanoTime(); + try { + body.run(); + } finally { + timer.record(System.nanoTime() - start, TimeUnit.NANOSECONDS); + } + } + + private static T timedGet(@Nullable Timer timer, Supplier body) { + if (timer == null) { + return body.get(); + } + long start = System.nanoTime(); + try { + return body.get(); + } finally { + timer.record(System.nanoTime() - start, TimeUnit.NANOSECONDS); + } } private static long countOf(@Nullable Counter counter) { diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheTimers.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheTimers.java deleted file mode 100644 index 14ff7b86..00000000 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheTimers.java +++ /dev/null @@ -1,152 +0,0 @@ -package io.github.davidhlp.spring.cache.redis.cache; - - - - - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Timer; -import java.util.concurrent.TimeUnit; -import java.util.function.Supplier; - -/** - * 缓存层 timing & metric 注册与调用的单一 seam. - * - *

    把 null-safe timer/counter 行为封装在四个静态入口: - *

      - *
    • {@link #registerTimer} / {@link #registerCounter} —— Timer & Counter 创建, - * {@code registry == null} 时返回 {@code null}
    • - *
    • {@link #safeIncrement} —— Counter null-safe increment
    • - *
    • {@link #timed} —— void body timing wrapper(put / evict / clear 路径)
    • - *
    • {@link #timedGet} —— 返回值 body timing wrapper(get 的 3 个重载)
    • - *
    - * - *

    行为保真: - *

      - *
    • {@code timer == null}({@code meterRegistry} 未启用)时静默 no-op: - * {@code timed/timedGet} 直接执行 body,不计算 nanoTime
    • - *
    • {@code timer != null} 时按 {@code start → body → finally record duration} 推进, - * 异常不被吞 —— 仍沿 finally 释放
    • - *
    - * - *

    接口是测试面:本类四个方法是单一测试目标。新增 metric(hit-ratio / 复合 timer 等) - * 只在 seam 内扩展,不污染调用点。 - * - *

    deletion test:删本类 → timing/counter 样板在调用点重现 → 真 seam。 - * - * @see RedisProCache - */ -final class RedisProCacheTimers { - - private RedisProCacheTimers() { - // 工具类,不可实例化 - } - - /** - * 注册 Timer.{@code registry == null} 时返回 {@code null}(由 {@link #timed} / - * {@link #timedGet} 静默 no-op 吸收)。 - * - * @param registry Micrometer 注册中心,生产可为 null - * @param name Timer 名,如 {@code "resicache.cache.get"} - * @param description Timer 描述 - * @param cacheName cache tag 值,用于 {@code tags("cache", cacheName)} - * @return 注册成功的 Timer;{@code registry == null} 时返回 null - */ - static Timer registerTimer(MeterRegistry registry, String name, - String description, String cacheName) { - if (registry == null) { - return null; - } - return Timer.builder(name) - .tag("cache", cacheName) - .description(description) - .register(registry); - } - - /** - * 注册 Counter.{@code registry == null} 时返回 {@code null}(由 - * {@link #safeIncrement} 静默 no-op 吸收)。 - * - * @param registry Micrometer 注册中心,生产可为 null - * @param name Counter 名,如 {@code "resicache.cache.hit"} - * @param description Counter 描述 - * @param cacheName cache tag 值,用于 {@code tags("cache", cacheName)} - * @return 注册成功的 Counter;{@code registry == null} 时返回 null - */ - static Counter registerCounter(MeterRegistry registry, String name, - String description, String cacheName) { - if (registry == null) { - return null; - } - return Counter.builder(name) - .tag("cache", cacheName) - .description(description) - .register(registry); - } - - /** - * Counter null-safe increment.{@code counter == null} 时静默 no-op(等价于 - * {@link RedisProCache#metrics()} 对 null Counter 返回 {@code 0L} 的零返回值路径)。 - * - * @param counter 待自增 Counter,生产可为 null - */ - static void safeIncrement(Counter counter) { - if (counter != null) { - counter.increment(); - } - } - - /** - * void body 用 timing wrapper —— 语义与 try-finally 等价: - *

      - *
    • {@code timer == null}:直接执行 body,不计算 nanoTime
    • - *
    • {@code timer != null}:{@code start → body → finally record duration};异常不被吞, - * 仍沿 finally 释放
    • - *
    - * - * @param timer 待记录 Timer,生产可为 null - * @param body 待执行的操作,可抛出 RuntimeException - */ - static void timed(Timer timer, Runnable body) { - if (timer == null) { - body.run(); - return; - } - long start = System.nanoTime(); - try { - body.run(); - } finally { - timer.record(System.nanoTime() - start, TimeUnit.NANOSECONDS); - } - } - - /** - * 返回值 body 用 timing wrapper —— 语义与 try-finally 等价: - *
      - *
    • {@code timer == null}:直接执行 body 并返回,不计算 nanoTime
    • - *
    • {@code timer != null}:{@code start → body → finally record duration};异常不被吞, - * 仍沿 finally 释放
    • - *
    - * - *

    调用方如需把 body 异常翻译为 {@code Cache.ValueRetrievalException} 或自增 miss 计数, - * 应当在本 {@code timedGet} 之外再套一层 try-catch —— 调用点的 catch 与本类的 - * {@code finally} 互不干扰。 - * - * @param 返回值类型 - * @param timer 待记录 Timer,生产可为 null - * @param body 待执行的操作,可抛出任意 Exception - * @return body.get() 的结果 - */ - static T timedGet(Timer timer, Supplier body) { - if (timer == null) { - return body.get(); - } - long start = System.nanoTime(); - try { - return body.get(); - } finally { - timer.record(System.nanoTime() - start, TimeUnit.NANOSECONDS); - } - } -} diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/metrics/CacheMetrics.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/metrics/CacheMetrics.java index ada766e3..d4dcfc50 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/metrics/CacheMetrics.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/metrics/CacheMetrics.java @@ -11,7 +11,8 @@ *

      *
    • 调用方关心「这个 cache 的指标快照」语义而非多个独立 getter — deep 方法
    • *
    • 派生字段(hitRate)由 record 工厂方法计算,调用方不写算术
    • - *
    • Counter/Timer 字段由 {@code RedisProCacheTimers} 维护 null-safe 语义, + *
    • Counter/Timer 字段由 + * {@link io.github.davidhlp.spring.cache.redis.cache.RedisProCacheMetricsRegistry} 维护 null-safe 语义, * 本 record 工厂方法对 {@code null} Counter 返回 {@code 0L}
    • *
    • 新增指标(hit-ratio / 复合 timer 等)只在本 record 加字段 + 工厂方法加一行, * 不污染 RedisProCache public surface
    • diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheTimersTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheTimersTest.java deleted file mode 100644 index d8bc34aa..00000000 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheTimersTest.java +++ /dev/null @@ -1,194 +0,0 @@ -package io.github.davidhlp.spring.cache.redis.cache; - - - - - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Timer; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * {@link RedisProCacheTimers} helper class 单元测试. - * - *

      本测试承担原 {@link RedisProCache} 6 处私有 {@code try-finally + safeRecord} 样板的测试覆盖: - *

        - *
      • {@link RedisProCacheTimers#registerTimer} / {@link RedisProCacheTimers#registerCounter} - * —— registry=null 时返回 null;否则注册带正确 tag/description
      • - *
      • {@link RedisProCacheTimers#safeIncrement} —— counter=null 时静默 no-op;否则自增
      • - *
      • {@link RedisProCacheTimers#timed} —— timer=null 时直接执行 body;否则记录时长, - * body 抛异常时 finally 仍执行
      • - *
      • {@link RedisProCacheTimers#timedGet} —— 同 {@code timed} 但保留返回值
      • - *
      - */ -@DisplayName("RedisProCacheTimers Tests") -class RedisProCacheTimersTest { - - private MeterRegistry meterRegistry; - - @BeforeEach - void setUp() { - meterRegistry = new SimpleMeterRegistry(); - } - - @Nested - @DisplayName("registerTimer Tests") - class RegisterTimerTests { - - @Test - @DisplayName("null registry returns null") - void nullRegistry_returnsNull() { - assertThat(RedisProCacheTimers.registerTimer( - null, "test.timer", "desc", "cache1")).isNull(); - } - - @Test - @DisplayName("non-null registry registers Timer with cache tag") - void nonNullRegistry_registersTimerWithTag() { - Timer timer = RedisProCacheTimers.registerTimer( - meterRegistry, "resicache.cache.get", "Time spent getting", "userCache"); - - assertThat(timer).isNotNull(); - assertThat(timer.getId().getName()).isEqualTo("resicache.cache.get"); - assertThat(timer.getId().getTag("cache")).isEqualTo("userCache"); - assertThat(timer.getId().getDescription()).isEqualTo("Time spent getting"); - } - } - - @Nested - @DisplayName("registerCounter Tests") - class RegisterCounterTests { - - @Test - @DisplayName("null registry returns null") - void nullRegistry_returnsNull() { - assertThat(RedisProCacheTimers.registerCounter( - null, "test.counter", "desc", "cache1")).isNull(); - } - - @Test - @DisplayName("non-null registry registers Counter with cache tag") - void nonNullRegistry_registersCounterWithTag() { - Counter counter = RedisProCacheTimers.registerCounter( - meterRegistry, "resicache.cache.hit", "Cache hit count", "userCache"); - - assertThat(counter).isNotNull(); - assertThat(counter.getId().getName()).isEqualTo("resicache.cache.hit"); - assertThat(counter.getId().getTag("cache")).isEqualTo("userCache"); - } - } - - @Nested - @DisplayName("safeIncrement Tests") - class SafeIncrementTests { - - @Test - @DisplayName("null counter is silent no-op") - void nullCounter_silentNoOp() { - // 必须不抛 NPE —— 这是 null-safe 行为的核心承诺 - RedisProCacheTimers.safeIncrement(null); - } - - @Test - @DisplayName("non-null counter increments") - void nonNullCounter_increments() { - Counter counter = meterRegistry.counter("test.counter"); - RedisProCacheTimers.safeIncrement(counter); - RedisProCacheTimers.safeIncrement(counter); - - assertThat(counter.count()).isEqualTo(2.0); - } - } - - @Nested - @DisplayName("timed Tests") - class TimedTests { - - @Test - @DisplayName("null timer executes body without recording") - void nullTimer_executesBodyWithoutRecording() { - AtomicInteger invocations = new AtomicInteger(); - - RedisProCacheTimers.timed(null, () -> invocations.incrementAndGet()); - - assertThat(invocations.get()).isEqualTo(1); - } - - @Test - @DisplayName("non-null timer records one sample") - void nonNullTimer_recordsOneSample() { - Timer timer = meterRegistry.timer("test.timer"); - AtomicInteger invocations = new AtomicInteger(); - - RedisProCacheTimers.timed(timer, () -> invocations.incrementAndGet()); - - assertThat(invocations.get()).isEqualTo(1); - assertThat(timer.count()).isEqualTo(1); - assertThat(timer.totalTime(TimeUnit.NANOSECONDS)).isGreaterThanOrEqualTo(0); - } - - @Test - @DisplayName("body exception is propagated and timer still records (finally semantics)") - void bodyException_propagatesAndStillRecords() { - Timer timer = meterRegistry.timer("test.timer"); - - assertThatThrownBy(() -> RedisProCacheTimers.timed(timer, () -> { - throw new IllegalStateException("boom"); - })).isInstanceOf(IllegalStateException.class) - .hasMessage("boom"); - - // 与原 try-finally 字节级等价:异常仍沿 finally 推进 timer - assertThat(timer.count()).isEqualTo(1); - } - } - - @Nested - @DisplayName("timedGet Tests") - class TimedGetTests { - - @Test - @DisplayName("null timer returns supplier result without recording") - void nullTimer_returnsResultWithoutRecording() { - String result = RedisProCacheTimers.timedGet(null, () -> "hello"); - - assertThat(result).isEqualTo("hello"); - } - - @Test - @DisplayName("non-null timer records and returns result") - void nonNullTimer_recordsAndReturnsResult() { - Timer timer = meterRegistry.timer("test.timer"); - - String result = RedisProCacheTimers.timedGet(timer, () -> "computed-value"); - - assertThat(result).isEqualTo("computed-value"); - assertThat(timer.count()).isEqualTo(1); - } - - @Test - @DisplayName("supplier exception propagates; timer still records") - void supplierException_propagatesAndStillRecords() { - Timer timer = meterRegistry.timer("test.timer"); - AtomicReference sideEffect = new AtomicReference<>(); - - assertThatThrownBy(() -> RedisProCacheTimers.timedGet(timer, () -> { - sideEffect.set("body-was-called"); - throw new RuntimeException("from supplier"); - })).isInstanceOf(RuntimeException.class) - .hasMessage("from supplier"); - - assertThat(sideEffect.get()).isEqualTo("body-was-called"); - assertThat(timer.count()).isEqualTo(1); - } - } -} From 3d43955790ad41a4d055c349cafdbd26832c95e4 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Sat, 19 Sep 2026 13:25:24 +0800 Subject: [PATCH 4/5] test(metrics): cover evict counter on body failure --- .../cache/RedisProCacheMetricsRegistryTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java index 43dc5d81..e877bb1c 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java @@ -278,6 +278,22 @@ void recordPut_bodyException_propagatesAndRecords() { assertThat(putTimer.count()).isEqualTo(1); assertThat(putCounter.count()).isEqualTo(1.0); } + @Test + @DisplayName("recordEvict body 抛异常 — 异常透传,timer + counter 仍记录(原 try-finally 字节级等价)") + void recordEvict_bodyException_propagatesAndRecords() { + Timer evictTimer = meterRegistry.find("resicache.cache.evict") + .tag(CACHE_TAG, CACHE_NAME).timer(); + Counter evictCounter = meterRegistry.find("resicache.cache.evict.count") + .tag(CACHE_TAG, CACHE_NAME).counter(); + + assertThatThrownBy(() -> registry.recordEvict(() -> { + throw new RuntimeException("evict boom"); + })).isInstanceOf(RuntimeException.class) + .hasMessage("evict boom"); + + assertThat(evictTimer.count()).isEqualTo(1); + assertThat(evictCounter.count()).isEqualTo(1.0); + } @Test @DisplayName("null registry — recordPut/recordEvict 走 fallback 路径(body 执行 + counter 仍尝试自增,null counter no-op)") From f245ac86bdd74d4609cbfb97e2ae83e90b010fbd Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Sat, 19 Sep 2026 13:41:10 +0800 Subject: [PATCH 5/5] fix(metrics): align seven-meter documentation --- .../cache/redis/cache/RedisProCache.java | 6 +++--- .../cache/RedisProCacheMetricsRegistry.java | 11 +++++----- .../RedisProCacheMetricsRegistryTest.java | 21 ++++++++++++++++--- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCache.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCache.java index 819a726e..15198892 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCache.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCache.java @@ -37,9 +37,9 @@ public class RedisProCache extends RedisCache { /** - * 指标写侧 seam — 6 个 metric 的注册 + null-safe 记录 + 快照读取全部收口在本字段。 + * 指标写侧 seam — 7 个 metric 的注册 + null-safe 记录 + 快照读取全部收口在本字段。 * - *

      {@code MeterRegistry} 缺失时本字段构造为空 registry(全部 6 字段为 null),record 方法全 no-op。 + *

      {@code MeterRegistry} 缺失时本字段构造为空 registry(全部 7 字段为 null),record 方法全 no-op。 */ private final RedisProCacheMetricsRegistry metricsRegistry; @@ -69,7 +69,7 @@ public class RedisProCache extends RedisCache { * *

      构造期委派 3 个 deep seam: *

        - *
      1. {@link RedisProCacheMetricsRegistry} — 6 metric 注册
      2. + *
      3. {@link RedisProCacheMetricsRegistry} — 7 metric 注册
      4. *
      5. {@link CacheOperationResolver} — operation 解析
      6. *
      7. {@link LoaderOrchestrator} — loader 路径编排
      8. *
      diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java index 7d910f84..603f710b 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java @@ -23,7 +23,8 @@ * 快照({@link CacheMetrics})配对形成指标领域 完整边界。读侧只读、写侧只写 — 关注点分离。 * *

      本类在 metric ownership seam 内部封装注册、自增与计时原语,并按缓存实例语义组装。 - * 所有 record 方法复用这些私有 helper,保持 null-safe 与异常传播行为。 + * record 方法复用这些私有 helper;recordPut/recordEvict 保留计时启用时将 operation counter + * 放在 finally 中的局部结构,以维持异常路径的计数语义。 * *

      公开方法(业务语义): *

        @@ -34,7 +35,7 @@ *
      • {@link #metrics()} — 返回当前 cache 实例的不可变指标快照
      • *
      * - *

      null-safe 语义:{@link MeterRegistry} 为 null 时(即未启用指标),全部 6 个内部 + *

      null-safe 语义:{@link MeterRegistry} 为 null 时(即未启用指标),全部 7 个内部 * 字段为 null,所有 record 方法走 no-op 路径。 * *

      线程安全:本类仅在 cache 构造期由单线程初始化;运行期 record 方法调 @@ -66,7 +67,7 @@ final class RedisProCacheMetricsRegistry { private final String cacheName; - // 写侧 6 字段:3 Timer + 4 Counter + // 写侧 7 字段:3 Timer + 4 Counter // 注:clear 路径无 Counter(batch 操作语义不适合计数),仅 Timer @Nullable private final Timer getTimer; @@ -84,10 +85,10 @@ final class RedisProCacheMetricsRegistry { private final Counter evictCounter; /** - * 构造期一次性注册 6 个 metric — 在 cache 构造期调用一次,运行期 record 路径直接复用。 + * 构造期一次性注册 7 个 metric — 在 cache 构造期调用一次,运行期 record 路径直接复用。 * *

      内部注册 helper 保证 {@code meterRegistry == null} 时所有字段保持 null。 - * @param meterRegistry Micrometer 注册表(可为 null → 全部 6 字段为 null) + * @param meterRegistry Micrometer 注册表(可为 null → 全部 7 字段为 null) * @param cacheName cache 标识,作为 {@code tags("cache", cacheName)} 写入每个 metric */ public RedisProCacheMetricsRegistry(@Nullable MeterRegistry meterRegistry, String cacheName) { diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java index e877bb1c..ba49b530 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java @@ -25,8 +25,8 @@ * *

      本测试独立验证 registry seam 的 6 大契约: *

        - *
      1. 构造期注册 — 6 个 metric(3 Timer + 4 Counter)在构造期一次性注册,带 cache tag - * 和正确描述;{@code MeterRegistry} 缺失时全 6 字段为 null(全 no-op 路径)
      2. + *
      3. 构造期注册 — 7 个 metric(3 Timer + 4 Counter)在构造期一次性注册,带 cache tag + * 和正确描述;{@code MeterRegistry} 缺失时全 7 字段为 null(全 no-op 路径)
      4. *
      5. recordGet timing — 计时 + 返回值透传;null timer 时直接执行 body 不计时
      6. *
      7. recordHit / recordMiss — Counter null-safe 自增;null counter 时静默 no-op
      8. *
      9. recordPut / recordEvict — 计时 + 写/淘汰 counter 自增;null timer 时直接执行 body @@ -110,11 +110,26 @@ void nonNullRegistry_registersAllMetricsWithTag() { void metricsHaveDescriptions() { Timer getTimer = meterRegistry.find("resicache.cache.get") .tag(CACHE_TAG, CACHE_NAME).timer(); + Timer putTimer = meterRegistry.find("resicache.cache.put") + .tag(CACHE_TAG, CACHE_NAME).timer(); + Timer evictTimer = meterRegistry.find("resicache.cache.evict") + .tag(CACHE_TAG, CACHE_NAME).timer(); Counter hitCounter = meterRegistry.find("resicache.cache.hit") .tag(CACHE_TAG, CACHE_NAME).counter(); + Counter missCounter = meterRegistry.find("resicache.cache.miss") + .tag(CACHE_TAG, CACHE_NAME).counter(); + Counter putCounter = meterRegistry.find("resicache.cache.put.count") + .tag(CACHE_TAG, CACHE_NAME).counter(); + Counter evictCounter = meterRegistry.find("resicache.cache.evict.count") + .tag(CACHE_TAG, CACHE_NAME).counter(); assertThat(getTimer.getId().getDescription()).isEqualTo("Time spent getting cache entries"); + assertThat(putTimer.getId().getDescription()).isEqualTo("Time spent putting cache entries"); + assertThat(evictTimer.getId().getDescription()).isEqualTo("Time spent evicting cache entries"); assertThat(hitCounter.getId().getDescription()).isEqualTo("Cache hit count"); + assertThat(missCounter.getId().getDescription()).isEqualTo("Cache miss count"); + assertThat(putCounter.getId().getDescription()).isEqualTo("Cache put count"); + assertThat(evictCounter.getId().getDescription()).isEqualTo("Cache evict count"); } @Test @@ -124,7 +139,7 @@ void doubleConstruction_isIdempotent() { RedisProCacheMetricsRegistry registry2 = new RedisProCacheMetricsRegistry(meterRegistry, CACHE_NAME); - // 6 metric 仍各 1 个(无重复) + // 7 metric 仍各 1 个(无重复) assertThat(meterRegistry.getMeters().stream() .filter(m -> m.getId().getTag(CACHE_TAG) != null && CACHE_NAME.equals(m.getId().getTag(CACHE_TAG)))