La implementación de mecanismos de caché en aplicaciones Spring Boot es fundamental para mejorar el rendimiento, especialmente cuando se interactúa con fuentes de datos lentas. Spring Framework ofrece una abstracción de caché podreosa, permitiendo integrar fácilmente diferentes proveedores como Redis. Este artículo detalla cómo utilizar la anotación @Cacheable y, más importante, cómo extender la funcionalidad predeterminada para definir tiempos de expiración personalizados directamente en el nombre de la caché.
1. Habilitación de la Caché en Spring Boot
Para activar el soporte de caché en su aplicación Spring Boot, debe añadir la anotación @EnableCaching a su clase principal de aplicación o a cualquier clase de configuración.
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching // Habilita el soporte de caché
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
2. Uso Básico de @Cacheable
Una vez habilitada la caché, puede utilizar la anotación @Cacheable en los métodos cuyos resultados desea almacenar en caché. Los atributos clave son cacheNames (o value) para identificar la caché y key para definir la clave específica del elemento en caché. La clave se puede definir utilizando Spring Expression Language (SpEL).
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode; // Ejemplo para JSONObject/JsonNode
@Service
public class ProductService {
// Ejemplo con JsonNode
@Cacheable(cacheNames = "productDetails", key = "#requestBody.get('productCode').asText()")
public String getProductInfo(JsonNode requestBody) {
// Lógica para obtener la información del producto
System.out.println("Fetching product info for: " + requestBody.get("productCode").asText());
return "Product data for " + requestBody.get("productCode").asText();
}
// Ejemplo con un objeto de dominio
@Cacheable(cacheNames = "userSettings", key = "#settings.userId")
public UserSettings fetchUserSettings(UserSettings settings) {
// Lógica para obtener la configuración del usuario
System.out.println("Fetching user settings for: " + settings.getUserId());
return settings;
}
// Ejemplo con un String como ID
@Cacheable(cacheNames = "itemById", key = "#itemId")
public String getItemDescription(String itemId) {
// Lógica para obtener la descripción del ítem
System.out.println("Fetching item description for: " + itemId);
return "Description of item " + itemId;
}
}
// Clase de ejemplo para UserSettings
class UserSettings {
private String userId;
// Getters y Setters
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
}
3. Configuración de Tiempos de Expiración Dinámicos
Por defecto, @Cacheable no ofrece una forma directa de especificar el tiempo de vida (TTL) para cada entrada de caché individualmente en la anotación misma, especialmente cuando se usa con Redis. Para lograr esto, podemos extender RedisCacheManager y parsear el TTL del nombre de la caché.
Nuestro objetivo es permitir el formato cacheName#TTL_EN_SEGUNDOS, por ejemplo, "productDetails#60" para una caché que expira en 60 segundos.
3.1. Implementación de un Gestor de Caché Personalizado
Crearemos una clase que extienda RedisCacheManager para interceptar la creación de caché y aplicar un TTL si se especifica en el nombre de la caché.
import org.springframework.data.redis.cache.CacheKeyPrefix;
import org.springframework.data.redis.cache.RedisCache;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.util.StringUtils;
import java.time.Duration;
public class DynamicTtlRedisCacheManager extends RedisCacheManager {
// Serializador predeterminado para valores, utilizando Jackson para JSON
private static final RedisSerializationContext.SerializationPair<Object> DEFAULT_VALUE_SERIALIZATION_PAIR =
RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer());
// Prefijo personalizado para las claves de caché
private static final CacheKeyPrefix CUSTOM_KEY_PREFIX_FUNCTION = cacheName -> cacheName + ":";
public DynamicTtlRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) {
super(cacheWriter, defaultCacheConfiguration);
}
@Override
protected RedisCache createRedisCache(String cacheNameWithTtl, RedisCacheConfiguration currentCacheConfig) {
// Buscar el delimitador '#' en el nombre de la caché
final int hashIndex = StringUtils.lastIndexOf(cacheNameWithTtl, '#');
if (hashIndex > -1) {
// Extraer la cadena TTL (después de '#')
final String ttlString = cacheNameWithTtl.substring(hashIndex + 1);
try {
final long expirationSeconds = Long.parseLong(ttlString);
final Duration expirationDuration = Duration.ofSeconds(expirationSeconds);
// Modificar la configuración de la caché para incluir el TTL
currentCacheConfig = currentCacheConfig.entryTtl(expirationDuration);
// Aplicar serialización de valores y prefijo de clave personalizados
currentCacheConfig = currentCacheConfig
.computePrefixWith(CUSTOM_KEY_PREFIX_FUNCTION)
.serializeValuesWith(DEFAULT_VALUE_SERIALIZATION_PAIR);
// Obtener el nombre base de la caché (antes de '#')
final String baseCacheName = cacheNameWithTtl.substring(0, hashIndex);
return super.createRedisCache(baseCacheName, currentCacheConfig);
} catch (NumberFormatException e) {
// Si el TTL no es un número válido, tratar como nombre de caché regular
System.err.println("Invalid TTL format for cache: " + cacheNameWithTtl + ". Using default configuration.");
return applyDefaultConfiguration(cacheNameWithTtl, currentCacheConfig);
}
} else {
// Si no hay '#', usar la configuración predeterminada
return applyDefaultConfiguration(cacheNameWithTtl, currentCacheConfig);
}
}
private RedisCache applyDefaultConfiguration(String cacheName, RedisCacheConfiguration config) {
// Aplicar serialización de valores y prefijo de clave personalizados por defecto
config = config
.computePrefixWith(CUSTOM_KEY_PREFIX_FUNCTION)
.serializeValuesWith(DEFAULT_VALUE_SERIALIZATION_PAIR);
return super.createRedisCache(cacheName, config);
}
}
3.2. Configuración del Gestor de Caché en Spring
Necesitamos una clase de configuración para crear y registrar nuestro DynamicTtlRedisCacheManager como un bean en el contexto de Spring. Aquí también se configura la serialiazción para las claves y valores de Redis.
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
public class RedisCacheSetup {
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
// Serializador para las claves (siempre String)
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// Serializador para los valores (objetos Java a JSON)
Jackson2JsonRedisSerializer<Object> jsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
// Configurar ObjectMapper para resolver problemas de serialización/deserialización
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jsonRedisSerializer.setObjectMapper(objectMapper);
// Configuración predeterminada de RedisCache
RedisCacheConfiguration defaultCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
// Duración por defecto: -1 (sin expiración), si no se especifica un TTL en el nombre
.entryTtl(Duration.ofMillis(-1))
// Serializadores para claves y valores
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(stringRedisSerializer))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonRedisSerializer))
// No almacenar valores nulos
.disableCachingNullValues();
// Crear un escritor de caché para Redis (sin bloqueo)
RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory);
// Retornar nuestro gestor de caché personalizado
return new DynamicTtlRedisCacheManager(redisCacheWriter, defaultCacheConfiguration);
}
}
Con esta configuración, ahora puede usar @Cacheable especificando el tiempo de vida en segundos directamente en el atributo cacheNames:
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class ReportService {
// La caché "dailyReports" expirará después de 30 segundos
@Cacheable(cacheNames = "dailyReports#30", key = "#reportId")
public String generateDailyReport(String reportId) {
System.out.println("Generating daily report for: " + reportId);
return "Report data for " + reportId + " - " + System.currentTimeMillis();
}
// La caché "monthlySummaries" no tendrá un tiempo de expiración específico (usará el valor por defecto: -1)
@Cacheable(cacheNames = "monthlySummaries", key = "#yearMonth")
public String getMonthlySummary(String yearMonth) {
System.out.println("Fetching monthly summary for: " + yearMonth);
return "Summary data for " + yearMonth + " - " + System.currentTimeMillis();
}
}
De esta manera, si se utiliza el formato nombreDeCache#TTL, la caché tendrá la duración especificada. Si se omite el #TTL, se aplicará la duración por defecto configurada en defaultCacheConfig (en este caso, sin expiración).