Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,20 @@
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;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
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;
Expand Down Expand Up @@ -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<CacheMetricsListener> 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)
Expand All @@ -74,9 +85,11 @@ public CacheManager gatewayCacheManager(LocalResponseCacheProperties cacheProper

@Bean
public LocalResponseCacheGatewayFilterFactory localResponseCacheGatewayFilterFactory(
ResponseCacheManagerFactory responseCacheManagerFactory, LocalResponseCacheProperties properties) {
ResponseCacheManagerFactory responseCacheManagerFactory, LocalResponseCacheProperties properties,
ObjectProvider<CacheMetricsListener> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -109,6 +122,24 @@ public List<String> 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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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<Object, Object> caffeine = LocalResponseCacheUtils.createCaffeine(properties);
com.github.benmanes.caffeine.cache.Cache<Object, Object> 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<Object, Object> caffeine = LocalResponseCacheUtils.createCaffeine(properties);
com.github.benmanes.caffeine.cache.Cache<Object, Object> 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();
}

}

}