iT邦幫忙

2

VScode 開發應用系統專案(7 -3 ) - Spring Boot Cache — L1 + L2 快取暫存處理

  • 分享至 

  • xImage
  •  

Spring Boot Cache — Redis 快取暫存處理 (提供開關)

概述

  • 之前發文分享過 Spring Boot Cache — Caffeine & Redis 快取暫存處理,後來遇到系統可能沒有 Redis Server而無法使用 L2 Cache的狀況,所以調整架構,增加一個 redis.l2.cache.enabled 開關,以便關閉Redis L2 Cache,單使用Caffeine L1 Cache。
  • 另外每個Cache Method 應更有不同的 TTL (暫存時間),所以調整架構,增加CachePolicy物件,分別對Caffeine L1 Cache 以及Redis L2 Cache 分別設定各自cacheManager。

準備與檢核

  1. VScode 開發應用系統專案 (1) - 啟動Spring Boot Web專案。
  1. 工具類程式已經準備好可以使用。
  1. Spring Boot資料庫存取密碼保護。
  1. Spring Boot Cache — Caffeine 快取暫存處理
  1. Spring Boot Cache — Redis 快取暫存處理

一、總覽 Spring Boot Redis 設定檔案

1. application.properties

  • 新增redis.l2.cache.enabled 開關變數與Redis相關設定。
    實務上正確做法應該於不同環境例如 application-dev.properties/application-uat.properties 分別增加一個 redis.l2.cache.enabled變數,這裡先設定於通用的application.properties

關閉Redis相關設定

---省略其他設定---

#L2 Cache 關閉 (Redis) 
redis.l2.cache.enabled=false
# 關閉 Spring Data Redis repositories 功能,避免不必要的 Redis 相關功能被啟用
spring.data.redis.repositories.enabled=false
# 指定 Session 儲存庫為 none,表示不使用任何 Session 儲存庫,適用於不需要 Session 管理的應用程式
spring.session.store-type=none

開啟Redis相關設定

---省略其他設定---

# 當 Redis 開啟時 (L1 + L2 模式) ---
# 建議同時設定 Session 刷新模式(由 Redis 索引管理)
redis.l2.cache.enabled=true
# 指定 Session 儲存庫為 Redis
spring.session.store-type=redis
# 使用 Redis 索引管理 Session 刷新,確保 Session 在 Redis 中的有效性和一致性
spring.session.redis.repository-type=indexed
# Redis 連線設定
redis_host=redis.lewishome.tw
redis_port=6379
# 若沒有實作加密機制,則直接使用明文密碼(不建議)
redis_password=${KEYSTORE:redis_password}

**調整後的 application.properties **

#各類資料庫的 url 參考:
# H2 database ==> jdbc:h2:mem:testdb
# SQL-Server  ==> jdbc:sqlserver://[serverName[\instanceName][:portNumber]][;property=value[;property=value]]
#  example    ==> jdbc:sqlserver://sql.lewis-home.tw:1433;databasename=MBS;encrypt=false
# MySQL       ==> jdbc:mysql://[hosts][:portNumber][/database]
#  example    ==> jdbc:mysql://mysql.lewis-home.tw:33060
# AS400(Jt400)==> jdbc:as400://[hosts][;property=value]  
#  example    ==> jdbc:as400://as400system;transaction isolation=none;translate binary=true;date format=iso;prompt=false
# Oracle      ==> jdbc:oracle:thin:@[HOST][:PORT]:SID or jdbc:oracle:thin:@//[HOST][:PORT]/SERVICE
#  example    ==> jdbc:oracle:thin:@oracle.lewis-home.tw:1521:oracle.lewis-home.tw
# postgres    ==> jdbc:postgresql://@[netloc][:port][/dbname][?param1=value1&...]
#  example    ==> jdbc:postgresql://postsql.lewis-home.tw:5432/database

#各類資料庫的 driver class Name
# H2 database ==> org.h2.Driver
# SQL-Server  ==> com.microsoft.sqlserver.jdbc.SQLServerDriver
# MySQL       ==> com.mysql.jdbc.Driver
# AS400(Jt400)==> com.ibm.as400.access.AS400JDBCDriver
# Oracle      ==> oracle.jdbc.driver.OracleDriver
# postgres    ==> org.postgresql.Driver

#Store primary Datasource (這些是自訂的變數名稱,只要與程式內取用的設定一致即可)
primary.datasource.enabled=true
primary.datasource.jdbcurl=jdbc:sqlserver://sql.lewishome.tw:1433;databasename=DbMuser1;encrypt=false;characterEncoding=utf-8
primary.datasource.username=Muser1
# Database connection password 建議不要存在此駔,使用 Keystore(安全線以及後續密碼交出去給資管理部)
# Encrypted in KeyStore with alias: primary_datasource_password
primary.datasource.password=${KEYSTORE:primary_datasource_password}
primary.datasource.driver_class_name=com.microsoft.sqlserver.jdbc.SQLServerDriver
primary.datasource.hibernate.hbm2ddl.auto=update


#Secondary Datasource 是否有需要
secondary.datasource.enabled=true
# secondary.datasource.jdbcurl=jdbc:oracle:thin:@oracle.lewishome.tw:1521/pdb1
secondary.datasource.jdbcurl=jdbc:oracle:thin:@oracle.lewishome.tw:1521/orclpdb.lewishome.tw
secondary.datasource.username=OUSER1
# Database connection password 建議不要存在此駔,使用 Keystore(安全線以及後續密碼交出去給資管理部)
# Encrypted in KeyStore with alias: secondary_datasource_password
secondary.datasource.password=${KEYSTORE:secondary_datasource_password}
secondary.datasource.driver_class_name=oracle.jdbc.OracleDriver
secondary.datasource.hibernate.hbm2ddl.auto=update
# secondary.datasource.hibernate.dialect=org.hibernate.dialect.Oracle12cDialect

# # CSRF protection whitelist - add /doMenu endpoint
# csrf.endpoint.white-list=/jwtAuth/**;/systemApiTest/**;/opt/fonts/**;{base}/callback/**;/home;/doMenu

# mail_resource_jndi=java=jboss/mail/mailSmtp
mail.smtp.host=mail.lewis-home.tw
mail.smtp.port=25
mail.sender=lewis@lewis-home.tw
mail.sender_name=lewis.yang
mail.subject.prefix=[WebAppSystem]


# #L2 Cache 關閉 (Redis) 
# redis.l2.cache.enabled=false
# # 關閉 Spring Data Redis repositories 功能,避免不必要的 Redis 相關功能被啟用
# spring.data.redis.repositories.enabled=false
# # 指定 Session 儲存庫為 none,表示不使用任何 Session 儲存庫,適用於不需要 Session 管理的應用程式
# spring.session.store-type=none


# 當 Redis 開啟時 (L1 + L2 模式) ---
# 建議同時設定 Session 刷新模式(由 Redis 索引管理)
redis.l2.cache.enabled=true
# 指定 Session 儲存庫為 Redis
spring.session.store-type=redis
# 使用 Redis 索引管理 Session 刷新,確保 Session 在 Redis 中的有效性和一致性
spring.session.redis.repository-type=indexed
# Redis 連線設定
redis_host=redis.lewishome.tw
redis_port=6379
# 若沒有實作加密機制,則直接使用明文密碼(不建議)
redis_password=${KEYSTORE:redis_password}

2. WebappApplication.java
位置: tw.lewishome.webapp;

  • 因為pom 有引用 spring-session-data-redis & spring-boot-starter-data-redis套件,單我們可能會關閉 Redis (redis.l2.cache.enabled=false),所以於 Sring boot 啟動時,排除Redis 相關auto configuration,
---省略其他設定---
@SpringBootApplication(exclude = {
		// 當Redis開關關閉時,我們不希望 Spring Session 去碰 Redis
		RedisAutoConfiguration.class
		,RedisRepositoriesAutoConfiguration.class
		// ,SessionAutoConfiguration.class
})

**調整後的 WebappApplication.java **

package tw.lewishome.webapp;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration;

import lombok.extern.slf4j.Slf4j;

/**
 * WebappApplication 是 Spring Boot 應用程式的主要入口點。
 * 此類別負責啟動應用程式並加載 Spring 上下文。
 * 
 * @author Lewis
 * @version 1.0
 */
@SpringBootApplication(exclude = {
		// 當Redis開關關閉時,我們不希望 Spring Session 去碰 Redis
		RedisAutoConfiguration.class
		,RedisRepositoriesAutoConfiguration.class
		// ,SessionAutoConfiguration.class
})
@Slf4j
public class WebappApplication {

	/**
	 * Fix for javadoc warning :
	 * use of default constructor, which does not provide a comment
	 * Constructs a new AsyncServiceWorkerSample instance.
	 * This is the default constructor, implicitly provided by the compiler
	 * if no other constructors are defined.
	 */
	private WebappApplication() {
		// This constructor is intentionally empty. Nothing special is needed here.
	}

	/**
	 * 主方法是應用程式的進入點。
	 * 此方法啟動 Spring 應用程式。
	 *
	 * @param args 命令列參數
	 */
	public static void main(String[] args) {
		log.info("┌────────────────────────────────────────────────────┐");
		log.info("│ SpringApplication WebappApplication started!       │");
		log.info("└────────────────────────────────────────────────────┘");
		SpringApplication springApplication = new SpringApplication(WebappApplication.class);
		// 手動添加 EnvironmentPostProcessor,以確保在應用程式啟動時處理安全屬性(如加密的密碼)
		springApplication.addInitializers(new PropertySecureProcessInitializer(
				new PropertySecureProcessor(),
				new PropertySecureConverter()));
		springApplication.run(args);
	}

}

3. CachePolicy.java

-位置: package tw.lewishome.webapp.base.cache;

package tw.lewishome.webapp.base.cache;

import java.time.Duration;
import java.util.HashMap;
import java.util.Map;

/**
 * 集中定義系統中所有快取空間的名稱與過期時間
 */
public class CachePolicy {

    // 統一管理快取名稱字串,避免 typo
    public static final String USER_MENU_ITEMS = "catchUserMenuItems";
    public static final String USER_AUTH_SEQ = "catchUserAuthSeq";
    public static final String SHORT_LIVED = "shortLivedCache";

    /**
     * L1 (Caffeine) 的特定配置 
     * 回傳 Map<CacheName, TTL_Minutes>
     */
    public static Map<String, Integer> getL1CustomPolicies() {
        Map<String, Integer> policies = new HashMap<>();
        policies.put(USER_MENU_ITEMS, 60); // 60 分鐘
        policies.put(USER_AUTH_SEQ, 3);    // 3 分鐘 (對應原本的預設)
        policies.put(SHORT_LIVED, 1);      // 1 分鐘
        return policies;
    }

    /**
     * L2 (Redis) 的特定配置
     * 回傳 Map<CacheName, Duration>
     */
    public static Map<String, Duration> getL2CustomPolicies() {
        Map<String, Duration> policies = new HashMap<>();
        policies.put(USER_MENU_ITEMS, Duration.ofMinutes(30));
        policies.put(USER_AUTH_SEQ, Duration.ofHours(12));
        return policies;
    }
}

4. CaffeineConfiguration.java

-位置: package tw.lewishome.webapp.base.cache.caffeine;

package tw.lewishome.webapp.base.cache.caffeine;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.interceptor.CacheInterceptor;
import org.springframework.cache.interceptor.CacheOperationSource;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Role;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import tw.lewishome.webapp.base.cache.CachePolicy;
import tw.lewishome.webapp.base.cache.redis.MultiCacheInterceptor;

/**
 * Caffeine 快取設定(CaffeineConfiguration)。
 * 
 * 主要功能:
 * 1. 初始化 L1 本地快取 (Caffeine)。
 * 2. 針對不同快取區塊設定不同 TTL。
 * 3. 註冊自定義 CacheInterceptor 以支援 L1+L2 聯動。
 */
@EnableCaching
@Configuration
@Slf4j
public class CaffeineConfiguration {

     /**
     * 建立 CacheManager 並針對特定 Cache 名稱設定不同的 TTL
     */
    @Bean(name = "caffeineCacheManager")
    @Primary
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    public static CacheManager caffeineCacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        List<CaffeineCache> caches = new ArrayList<>();

        // 從 Policy 類別循環加入自定義 TTL 的快取
        CachePolicy.getL1CustomPolicies().forEach((name, ttlMinutes) -> {
            caches.add(createCaffeineCache(name, ttlMinutes));
        });

        cacheManager.setCaches(caches);

        log.info("┌────────────────────────────────────────────────────┐");
        log.info("│ [L1 Cache] CaffeineCacheManager 初始化完成...       │");
        log.info("└────────────────────────────────────────────────────┘");
        return cacheManager;
    }
    /**
     * 重要:註冊自定義攔截器以取代預設行為。
     * 這會讓標註為 @Cacheable(..., cacheManager="redisCacheManager") 的方法
     * 自動先去查詢 caffeineCacheManager (L1)。
     */
    @Bean
    @Primary
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    public static CacheInterceptor customerCacheInterceptor(
            CacheOperationSource cacheOperationSource,
			//這裡要加 @Qualifier避免使用錯誤的CacheManager
            @Qualifier("caffeineCacheManager") CacheManager caffeineCacheManager,            
            ObjectProvider<CacheManager> allManagersProvider) { 
        
        MultiCacheInterceptor interceptor = new MultiCacheInterceptor();
        interceptor.setCacheOperationSources(cacheOperationSource);
        interceptor.setCaffeineCacheManager(caffeineCacheManager);
        
        // 透過型別探測:找出不是 Caffeine」的那個 Manager
        CacheManager redisManager = allManagersProvider.orderedStream()
                .filter(cacheManager -> cacheManager != caffeineCacheManager)
                .findFirst()
                .orElse(null);
        
       
        if (redisManager == null) {
            log.info("┌────────────────────────────────────────────────────┐");
            log.info("│ [Cache] L2 Redis 已關閉,僅使用 L1 (Caffeine)       │");
            log.info("└────────────────────────────────────────────────────┘");
        } else {
            log.info("┌────────────────────────────────────────────────────┐");
            log.info("│ [Cache] L1+L2 (Caffeine + Redis) 聯動模式已啟動     │");
            log.info("└────────────────────────────────────────────────────┘");
        }
        
        return interceptor;
    }
    /**
     * 輔助方法:根據分鐘數建立獨立的 Cache 實例
     */
    private static CaffeineCache createCaffeineCache(String name, int ttlMinutes) {
        return new CaffeineCache(name, Caffeine.newBuilder()
                .expireAfterWrite(ttlMinutes, TimeUnit.MINUTES)
                .maximumSize(1000)
                .recordStats()
                .build());
    }
}

4. RedisConfiguration.java

-位置: package tw.lewishome.webapp.base.cache.redis;

package tw.lewishome.webapp.base.cache.redis;

import java.time.Duration;
import java.util.HashMap;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Role;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisPassword;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
// import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisIndexedHttpSession;

import lombok.extern.slf4j.Slf4j;
import tw.lewishome.webapp.base.cache.CachePolicy;
import tw.lewishome.webapp.base.utility.common.SystemEnvReader;
import tw.lewishome.webapp.base.utility.common.TypeConvert;

/**
 * Redis RedisConfiguration
 * 負責設定 Redis 連線與相關快取管理 Bean。
 * 
 * 主要功能:
 *  1. 初始化 L2 Redis快取
 *  2. 支援多重 TTL 配置,根據 CachePolicy 定義不同快取區塊的 TTL。
 *  3. 提供 RedisTemplate 以支援非註解式的 Redis 操作。
 *  4. 從環境變數讀取 Redis 連線配置,確保靈活性與安全性。
 *  5. 使用 @ConditionalOnProperty 控制 Redis 快取的啟用    
 *
 * @author Lewis
 * @since 2024
 */
@Configuration
@Slf4j
@Role(BeanDefinition.ROLE_INFRASTRUCTURE) // 加入這一行,保護配置類本身 不被掃描為一般 Bean
@ConditionalOnProperty(name = "redis.l2.cache.enabled", havingValue = "true")
public class RedisConfiguration {

    @Autowired
    SystemEnvReader systemEnvReader;

    public RedisConfiguration() {
        // Default constructor
    }

    /**
     * 建立 Redis 快取管理器 (L2 Cache)。
     * 
     * @param redisConnectionFactory Redis 連線工廠
     * @return CacheManager 返回配置完成的 RedisCacheManager
     */
    @Bean(name = "redisCacheManager")
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
        log.info("┌────────────────────────────────────────────────────┐");
        log.info("│ redisCacheManager with Multi-TTL initializing...   │");
        log.info("└────────────────────────────────────────────────────┘");

        // 1. 取得預設 TTL (預設 1 小時)
        String redisTimeToLife = systemEnvReader != null ? systemEnvReader.getProperty("REDIS_LIFE_HOUR","1") : "1";
        int defaultHours = TypeConvert.toInteger(redisTimeToLife);
        
        // 2. 基本配置:使用 JSON 序列化並設定預設 TTL
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofHours(defaultHours))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        // 3. 建立並回傳 RedisCacheManager,包含自定義的多重 TTL 配置
        return RedisCacheManager.builder(redisConnectionFactory)
                    .cacheDefaults(defaultConfig)
                    .withInitialCacheConfigurations(getL2Configurations(defaultConfig))
                    .build();
    }

    // 根據 CachePolicy 定義的多重 TTL 建立對應的 RedisCacheConfiguration
    private Map<String, RedisCacheConfiguration> getL2Configurations(RedisCacheConfiguration defaultConfig) {
        Map<String, RedisCacheConfiguration> configurations = new HashMap<>();
        
        // 從 Policy 類別循環加入配置
        CachePolicy.getL2CustomPolicies().forEach((name, ttl) -> {
            configurations.put(name, defaultConfig.entryTtl(ttl));
        });
        
        return configurations;
    }

    /**
     * 建立 LettuceConnectionFactory 連線工廠。
     */
    @Bean
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    public LettuceConnectionFactory redisConnectionFactory() {
        try {
            RedisStandaloneConfiguration redisStandaloneConfiguration = getRedisStandaloneConfiguration();
            return new LettuceConnectionFactory(redisStandaloneConfiguration);
        } catch (Exception ex) {
            log.error("CRITICAL: Failed to create RedisConnectionFactory: {}", ex.getMessage(), ex);
            return null;
        }
    }

    /**
     * 建立標準 RedisTemplate 以進行非註解式的 Redis 操作。
     */
    @Bean
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }

    /**
     * 從 SystemEnvReader 讀取環境變數並初始化 Redis 獨立連線配置。
     */
    private RedisStandaloneConfiguration getRedisStandaloneConfiguration() {
        String redisHostName = systemEnvReader != null ? systemEnvReader.getProperty("REDIS_HOST","redis.lewishocme.tw") : "redis.lewishome.tw";
        String redisPort = systemEnvReader != null ? systemEnvReader.getProperty("REDIS_PORT","637s9") : "6379";
        String redisHostPassword = systemEnvReader != null ? systemEnvReader.getProperty("REDIS_PASSWORD","XXXXXXX") : "XXXXXXXX";

        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHostName, TypeConvert.toInteger(redisPort));
        config.setPassword(RedisPassword.of(redisHostPassword));
        return config;
    }

    
}

**5. 調整SystemEnvReader.java

-位置:package tw.lewishome.webapp.base.utility.common;

-因為系統開機時有以下警告訊息:

trationDelegate$BeanPostProcessorChecker : Bean 'systemEnvReader' of type [tw.lewishome.webapp.base.utility.common.SystemEnvReader] is not eligible for getting processed by all BeanPostProcessors 
(for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [meterRegistryPostProcessor]? 
Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE.

所以依建議,將SystemEnvReader.java宣告為ROLE_INFRASTRUCTURE

@Component
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class SystemEnvReader {
	--省略程式邏輯 --
}

6. 使用Catchable方式

  • 於method前宣告 @Cacheable,並指定cacheNames以及 key,系統自動依設定 使用 L1 或 L1 + L2 Cache)
  @Cacheable(cacheNames = CachePolicy.USER_AUTH_SEQ, key = "#userId")
    public List<String> getSysUserLastAuth(String userId) {
	 -- 省略程式邏輯 --
	}

圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言