diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java index 752295d5cd..bdebc87d89 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java @@ -21,6 +21,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.AllNestedConditions; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -28,10 +29,12 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCache; import org.springframework.cache.caffeine.CaffeineCacheManager; import org.springframework.cloud.gateway.config.conditional.ConditionalOnEnabledFilter; import org.springframework.cloud.gateway.filter.factory.cache.GlobalLocalResponseCacheGatewayFilter; import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory.CacheMetricsListener; import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheProperties; import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheUtils; import org.springframework.cloud.gateway.filter.factory.cache.ResponseCacheManagerFactory; @@ -60,10 +63,18 @@ public class LocalResponseCacheAutoConfiguration { @Conditional(LocalResponseCacheAutoConfiguration.OnGlobalLocalResponseCacheCondition.class) public GlobalLocalResponseCacheGatewayFilter globalLocalResponseCacheGatewayFilter( ResponseCacheManagerFactory responseCacheManagerFactory, - @Qualifier(RESPONSE_CACHE_MANAGER_NAME) CacheManager cacheManager, - LocalResponseCacheProperties properties) { - return new GlobalLocalResponseCacheGatewayFilter(responseCacheManagerFactory, responseCache(cacheManager), - properties.getTimeToLive(), properties.getRequest()); + @Qualifier(RESPONSE_CACHE_MANAGER_NAME) CacheManager cacheManager, LocalResponseCacheProperties properties, + ObjectProvider metricsListenerProvider) { + Cache cache = responseCache(cacheManager); + CacheMetricsListener listener = metricsListenerProvider.getIfAvailable(() -> CacheMetricsListener.NOOP); + if (cache instanceof CaffeineCache caffeineCache) { + listener.onCacheCreated(caffeineCache.getNativeCache(), RESPONSE_CACHE_NAME); + } + else if (listener != CacheMetricsListener.NOOP) { + LOGGER.warn("Global response cache is not a CaffeineCache instance; cache metrics will not be registered"); + } + return new GlobalLocalResponseCacheGatewayFilter(responseCacheManagerFactory, cache, properties.getTimeToLive(), + properties.getRequest()); } @Bean(name = RESPONSE_CACHE_MANAGER_NAME) @@ -74,9 +85,11 @@ public CacheManager gatewayCacheManager(LocalResponseCacheProperties cacheProper @Bean public LocalResponseCacheGatewayFilterFactory localResponseCacheGatewayFilterFactory( - ResponseCacheManagerFactory responseCacheManagerFactory, LocalResponseCacheProperties properties) { + ResponseCacheManagerFactory responseCacheManagerFactory, LocalResponseCacheProperties properties, + ObjectProvider metricsListenerProvider) { + CacheMetricsListener listener = metricsListenerProvider.getIfAvailable(() -> CacheMetricsListener.NOOP); return new LocalResponseCacheGatewayFilterFactory(responseCacheManagerFactory, properties.getTimeToLive(), - properties.getSize(), properties.getRequest()); + properties.getSize(), properties.getRequest(), new CaffeineCacheManager(), listener); } @Bean diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java new file mode 100644 index 0000000000..b447e1ea00 --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java @@ -0,0 +1,60 @@ +/* + * Copyright 2013-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.config; + +import java.util.Collections; + +import com.github.benmanes.caffeine.cache.Caffeine; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics; + +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.micrometer.metrics.autoconfigure.CompositeMeterRegistryAutoConfiguration; +import org.springframework.boot.micrometer.metrics.autoconfigure.MetricsAutoConfiguration; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory.CacheMetricsListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.DispatcherHandler; + +/** + * Auto-configuration for LocalResponseCache metrics. Registers Caffeine cache metrics + * with the {@link MeterRegistry} when both the cache infrastructure and Micrometer are + * available. + * + * @author LivingLikeKrillin + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty(name = GatewayProperties.PREFIX + ".enabled", matchIfMissing = true) +@AutoConfigureAfter({ LocalResponseCacheAutoConfiguration.class, MetricsAutoConfiguration.class, + CompositeMeterRegistryAutoConfiguration.class }) +@ConditionalOnClass({ DispatcherHandler.class, Caffeine.class, CaffeineCacheManager.class, MeterRegistry.class, + MetricsAutoConfiguration.class }) +public class LocalResponseCacheMetricsAutoConfiguration { + + @Bean + @ConditionalOnBean(MeterRegistry.class) + @ConditionalOnProperty(name = GatewayProperties.PREFIX + ".metrics.enabled", matchIfMissing = true) + public CacheMetricsListener localResponseCacheMetricsListener(MeterRegistry meterRegistry) { + return (cache, cacheName) -> CaffeineCacheMetrics.monitor(meterRegistry, cache, cacheName, + Collections.emptyList()); + } + +} diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java index 95fa4e8d7e..13f398f8b3 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java @@ -63,20 +63,31 @@ public class LocalResponseCacheGatewayFilterFactory private final CaffeineCacheManager caffeineCacheManager; + private final CacheMetricsListener cacheMetricsListener; + public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory, Duration defaultTimeToLive, DataSize defaultSize, RequestOptions requestOptions) { - this(cacheManagerFactory, defaultTimeToLive, defaultSize, requestOptions, new CaffeineCacheManager()); + this(cacheManagerFactory, defaultTimeToLive, defaultSize, requestOptions, new CaffeineCacheManager(), + CacheMetricsListener.NOOP); } public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory, Duration defaultTimeToLive, DataSize defaultSize, RequestOptions requestOptions, CaffeineCacheManager caffeineCacheManager) { + this(cacheManagerFactory, defaultTimeToLive, defaultSize, requestOptions, caffeineCacheManager, + CacheMetricsListener.NOOP); + } + + public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory, + Duration defaultTimeToLive, DataSize defaultSize, RequestOptions requestOptions, + CaffeineCacheManager caffeineCacheManager, CacheMetricsListener cacheMetricsListener) { super(RouteCacheConfiguration.class); this.cacheManagerFactory = cacheManagerFactory; this.defaultTimeToLive = defaultTimeToLive; this.defaultSize = defaultSize; this.requestOptions = requestOptions; this.caffeineCacheManager = caffeineCacheManager; + this.cacheMetricsListener = cacheMetricsListener; } @Override @@ -86,7 +97,9 @@ public GatewayFilter apply(RouteCacheConfiguration config) { Caffeine caffeine = LocalResponseCacheUtils.createCaffeine(cacheProperties); String cacheName = config.getRouteId() + "-cache"; - caffeineCacheManager.registerCustomCache(cacheName, caffeine.build()); + com.github.benmanes.caffeine.cache.Cache nativeCache = caffeine.build(); + caffeineCacheManager.registerCustomCache(cacheName, nativeCache); + cacheMetricsListener.onCacheCreated(nativeCache, cacheName); Cache routeCache = caffeineCacheManager.getCache(cacheName); Objects.requireNonNull(routeCache, "Cache " + cacheName + " not found"); return new ResponseCacheGatewayFilter( @@ -109,6 +122,24 @@ public List shortcutFieldOrder() { return List.of("timeToLive", "size"); } + /** + * Listener notified when a new Caffeine cache is created, allowing external + * components (e.g., metrics) to observe cache instances. + */ + @FunctionalInterface + public interface CacheMetricsListener { + + // Uses FQN to avoid ambiguity with org.springframework.cache.Cache + void onCacheCreated(com.github.benmanes.caffeine.cache.Cache cache, String cacheName); + + /** + * No-op implementation used when metrics infrastructure is not available. + */ + CacheMetricsListener NOOP = (cache, cacheName) -> { + }; + + } + @Validated public static class RouteCacheConfiguration implements HasRouteId { diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtils.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtils.java index fc7224adb2..4fccf660a4 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtils.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtils.java @@ -48,7 +48,9 @@ public static CaffeineCacheManager createGatewayCacheManager(LocalResponseCacheP @SuppressWarnings({ "unchecked", "rawtypes" }) public static Caffeine createCaffeine(LocalResponseCacheProperties cacheProperties) { - Caffeine caffeine = Caffeine.newBuilder(); + // Always record stats so metrics are available when Micrometer is present. + // The overhead of LongAdder-based stat counters is minimal. + Caffeine caffeine = Caffeine.newBuilder().recordStats(); LOGGER.info("Initializing Caffeine"); Duration ttlSeconds = cacheProperties.getTimeToLive(); caffeine.expireAfterWrite(ttlSeconds); diff --git a/spring-cloud-gateway-server-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-cloud-gateway-server-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 639d71a0bc..df9533beeb 100644 --- a/spring-cloud-gateway-server-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-cloud-gateway-server-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -10,4 +10,5 @@ org.springframework.cloud.gateway.discovery.GatewayDiscoveryClientAutoConfigurat org.springframework.cloud.gateway.config.SimpleUrlHandlerMappingGlobalCorsAutoConfiguration org.springframework.cloud.gateway.config.GatewayReactiveLoadBalancerClientAutoConfiguration org.springframework.cloud.gateway.config.LocalResponseCacheAutoConfiguration +org.springframework.cloud.gateway.config.LocalResponseCacheMetricsAutoConfiguration org.springframework.cloud.gateway.config.GatewayTracingAutoConfiguration diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java new file mode 100644 index 0000000000..6db7dd96a8 --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java @@ -0,0 +1,180 @@ +/* + * Copyright 2013-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.config; + +import java.time.Duration; + +import com.github.benmanes.caffeine.cache.Caffeine; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory.CacheMetricsListener; +import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheProperties; +import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheUtils; +import org.springframework.cloud.gateway.filter.factory.cache.ResponseCacheManagerFactory; +import org.springframework.cloud.gateway.filter.factory.cache.keygenerator.CacheKeyGenerator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link LocalResponseCacheMetricsAutoConfiguration}. + * + * @author LivingLikeKrillin + */ +public class LocalResponseCacheMetricsAutoConfigurationTests { + + @Test + void metricsListenerCreatedWhenMeterRegistryPresent() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LocalResponseCacheAutoConfiguration.class, + LocalResponseCacheMetricsAutoConfiguration.class)) + .withUserConfiguration(MeterRegistryConfig.class) + .withPropertyValues(GatewayProperties.PREFIX + ".filter.local-response-cache.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(CacheMetricsListener.class); + }); + } + + @Test + void metricsListenerNotCreatedWhenMeterRegistryAbsent() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LocalResponseCacheAutoConfiguration.class, + LocalResponseCacheMetricsAutoConfiguration.class)) + .withPropertyValues(GatewayProperties.PREFIX + ".filter.local-response-cache.enabled=true") + .run(context -> { + assertThat(context).doesNotHaveBean(CacheMetricsListener.class); + }); + } + + @Test + void metricsListenerNotCreatedWhenGatewayDisabled() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LocalResponseCacheAutoConfiguration.class, + LocalResponseCacheMetricsAutoConfiguration.class)) + .withUserConfiguration(MeterRegistryConfig.class) + .withPropertyValues(GatewayProperties.PREFIX + ".filter.local-response-cache.enabled=true", + GatewayProperties.PREFIX + ".enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean(CacheMetricsListener.class); + }); + } + + @Test + void metricsListenerNotCreatedWhenMetricsDisabled() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LocalResponseCacheAutoConfiguration.class, + LocalResponseCacheMetricsAutoConfiguration.class)) + .withUserConfiguration(MeterRegistryConfig.class) + .withPropertyValues(GatewayProperties.PREFIX + ".filter.local-response-cache.enabled=true", + GatewayProperties.PREFIX + ".metrics.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean(CacheMetricsListener.class); + }); + } + + @Test + void caffeineRecordStatsEnabled() { + LocalResponseCacheProperties properties = new LocalResponseCacheProperties(); + properties.setTimeToLive(Duration.ofMinutes(5)); + Caffeine caffeine = LocalResponseCacheUtils.createCaffeine(properties); + com.github.benmanes.caffeine.cache.Cache cache = caffeine.build(); + + cache.put("key", "value"); + cache.getIfPresent("key"); + cache.getIfPresent("missing"); + + assertThat(cache.stats().hitCount()).isEqualTo(1); + assertThat(cache.stats().missCount()).isEqualTo(1); + } + + @Test + void cacheMetricsListenerBindsToMeterRegistry() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + LocalResponseCacheProperties properties = new LocalResponseCacheProperties(); + properties.setTimeToLive(Duration.ofMinutes(5)); + Caffeine caffeine = LocalResponseCacheUtils.createCaffeine(properties); + com.github.benmanes.caffeine.cache.Cache cache = caffeine.build(); + + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LocalResponseCacheMetricsAutoConfiguration.class)) + .withBean(MeterRegistry.class, () -> registry) + .run(context -> { + CacheMetricsListener listener = context.getBean(CacheMetricsListener.class); + listener.onCacheCreated(cache, "test-cache"); + + cache.put("key", "value"); + cache.getIfPresent("key"); + + assertThat(registry.find("cache.gets").tag("result", "hit").functionCounter()).isNotNull(); + assertThat(registry.find("cache.size").tag("cache", "test-cache").gauge()).isNotNull(); + }); + } + + @Test + void globalCacheMetricsRegisteredViaCaffeineCache() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LocalResponseCacheAutoConfiguration.class, + LocalResponseCacheMetricsAutoConfiguration.class)) + .withBean(MeterRegistry.class, () -> registry) + .withPropertyValues(GatewayProperties.PREFIX + ".filter.local-response-cache.enabled=true", + GatewayProperties.PREFIX + ".enabled=true", + GatewayProperties.PREFIX + ".global-filter.local-response-cache.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(CacheMetricsListener.class); + assertThat(registry.find("cache.size").tag("cache", "response-cache").gauge()).isNotNull(); + }); + } + + @Test + void perRouteCacheMetricsRegisteredViaFilterFactory() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + CacheMetricsListener listener = (cache, + cacheName) -> io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics.monitor(registry, cache, + cacheName, java.util.Collections.emptyList()); + + Duration ttl = Duration.ofMinutes(5); + ResponseCacheManagerFactory cacheManagerFactory = new ResponseCacheManagerFactory(new CacheKeyGenerator()); + LocalResponseCacheGatewayFilterFactory factory = new LocalResponseCacheGatewayFilterFactory(cacheManagerFactory, + ttl, null, new LocalResponseCacheProperties.RequestOptions(), new CaffeineCacheManager(), listener); + + LocalResponseCacheGatewayFilterFactory.RouteCacheConfiguration routeConfig = new LocalResponseCacheGatewayFilterFactory.RouteCacheConfiguration(); + routeConfig.setRouteId("my-route"); + routeConfig.setTimeToLive(ttl); + factory.apply(routeConfig); + + assertThat(registry.find("cache.size").tag("cache", "my-route-cache").gauge()).isNotNull(); + } + + @Configuration(proxyBeanMethods = false) + static class MeterRegistryConfig { + + @Bean + MeterRegistry meterRegistry() { + return new SimpleMeterRegistry(); + } + + } + +}