Spring Cache 帶你飛(二)

接著上一篇講了 Spring Cache 如何被 Spring Aop 代理載入對應的程式碼,以及何如注入相關介面邏輯。

Spring Cache 帶你飛(一)

本篇我們圍繞兩個要點展開:

  • 一個數據是如何被Spring Cache 放入快取的。

  • Spring Cache 如何擴展存儲源,即支援不同的快取技術。

Spring Cache 的數據存儲之路

Spring Cache 相關的註解有 5 個:

  • @Cacheable 在調用方法的同時能夠根據方法的請求參數對結果進行快取。
  • @CachePut 調用發放的同時進行 Cache 存儲,作用於方法上。
  • @CacheEvict 刪除,作用於方法上。
  • @Caching 用於處理複雜的快取情況,一次性設置多個快取,作用於方法上。
  • @CacheConfig 可以在類級別上標註一些公用的快取屬性,所有方法共享。
@Cacheable

@Cacheable 是我們最常使用的註解:


@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Cacheable {
    @AliasFor("cacheNames")
    String[] value() default {};

    @AliasFor("value")
    String[] cacheNames() default {};

    String key() default "";

    String keyGenerator() default "";

    String cacheManager() default "";

    String cacheResolver() default "";

    String condition() default "";

    String unless() default "";

    boolean sync() default false;
}

cacheNamesvalue 這兩個屬性任意使用一個都可以,它們的作用可以理解為 key 的前綴。

@Cacheable(value = "user:cache")
public User findById(String id) {
    User user = this.getById(id);
    if (user != null){
        System.out.println("user.name = " + user.getName());
    }
    return user;
}

key 和 keyGenerator 是互斥的一對。當指定了 key 的時候就會使用你指定的 key + 參數 作為快取 key。否則則使用默認 keyGenerator(SimpleKeyGenerator)或者你自定義的 Generator 來生成 key。

默認的 SimpleKeyGenerator 通過源碼我們能看到它的生成規則:

public static Object generateKey(Object... params) {
		if (params.length == 0) {
			return SimpleKey.EMPTY;
		}
		if (params.length == 1) {
			Object param = params[0];
			if (param != null && !param.getClass().isArray()) {
				return param;
			}
		}
		return new SimpleKey(params);
	}
  • 如果方法沒有入參則拋異常,即必須要有入參才能構建 key;
  • 如果只有一個入參,則使用該入參作為 key=入參值。
  • 如果有多個入參則返回包含所有入參的構造函數 new SimpleKey(params)

Spring 官方推薦使用顯式指定 key 的方式來生成 key。當然你也可以通過自定義 KeyGenerator 來實現自己制定規則的 key 生成方式,只需要實現 KeyGenerator 介面即可。

注意 key 屬性為 spEL 表達式,如果要寫字元串需要將該字元串用單引號括起來。比如我們有如下配置:

@Cacheable(cacheNames = "userInfo", key = "'p_'+ #name")
public String getName(String name) {
    return "hello:" + name;
}

假設 name = xiaoming,那麼快取的 key = userInfo::p_xiaoming

condition 參數的作用是限定存儲條件:

@Cacheable(cacheNames = "userInfo", key = "'p_'+ #name",condition = "#sex == 1")
public String getName(String name, int sex) {
    return "hello:" + name;
}

上例限制條件為 sex == 1 的時候才寫入快取,否則不走快取。

unless 參數跟 condition 參數相反,作用是當不滿足某個條件的時候才寫入快取。

sync 欄位上一篇說過,多執行緒情況下並發更新的情況是否只需要一個執行緒更新即可。

還有個屬性 cacheManager 比較大頭放在後面單獨說,從命名上能看出它是 cache 的管理者,即指定當前 Cache 使用何種 Cache 配置,比如是 Redis 還是 local Cache 等等。這也是我們這一篇要討論的重點。

@CacheConfig

CacheConfig 註解包含以下配置:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CacheConfig {
    String[] cacheNames() default {};

    String keyGenerator() default "";

    String cacheManager() default "";

    String cacheResolver() default "";
}

如果你在一個類中使用多個 Cache 註解,並且這些 Cache 註解有公共的基礎操作,比如:使用相同的 Cache key 生成規則,使用相同的 Cache Name 前綴等等,那麼你就可以定義一個 CacheConfig 來統一單獨管理這些 Cache 操作。

@CacheConfig(cacheNames = "user")
public class UserService {

    @Cacheable(key = "#userInfoDTO.uid")
    public GirgirUser.UserInfo getUser(UserInfoDTO userInfoDTO) {
        return xxx;
    }


    @Cacheable(key = "'base_' + #userInfoDTO.uid")
    public GirgirUser.UserInfo getBaseUser(UserInfoDTO userInfoDTO) {
        return xxx;
    }
}

上面示例中的 兩個 Cache Key 都會有一個公共前綴 」user「。需要注意的是:CacheConfig 註解的優先順序高於同類當中別的註解,如果你在 CacheConfig 中配置了 cacheNames,方法中也配置了,那麼 CacheConfig 中的 cacheNames 會覆蓋掉方法上的配置。

@Caching

@Caching 註解適用於複雜快取操作的場景,當你有多個快取操作的需求,比如下例:你需要先刪除就快取,再插入新數據到快取:

@Caching(evict = @CacheEvict(key = "'base' + #userInfoDTO.uid"),
         put = @CachePut(key = "'base' + #userInfoDTO.uid"))
public GirgirUser.UserInfo getBaseUser(UserInfoDTO userInfoDTO) {
  return xxx;
}

那麼你可以使用 @Caching 註解來操作多個快取。

註解的使用就說到這裡,其餘幾個註解的配置基本同 @Cacheable 差不多,剩下的大家可以自己學習。接下來我們要說的重點來了:待快取的數據到底是如何被存儲起來的。Spring Cache 如何知道當前要使用的數據源。

Spring EL 對 Cache 的支援

Name Location Description Example
methodName Root object 被調用的方法的名稱 #root.methodName
method Root object 被調用的方法 #root.method.name
target Root object 當前調用方法的對象 #root.target
targetClass Root object 當前調用方法的類 #root.targetClass
args Root object 當前方法的參數 #root.args[0]
caches Root object 當前方法的快取集合 #root.caches[0].name
Argument name Evaluation context 當前方法的參數名稱 #iban or #a0 (you can also use #p0 or #p<#arg> notation as an alias).
result Evaluation context 方法返回的結果(要快取的值)。只有在 unless 、@CachePut(用於計算鍵)或@CacheEvict(beforeInvocation=false)中才可用.對於支援的包裝器(例如Optional),#result引用的是實際對象,而不是包裝器 #result

Spring Cache 數據源配置

Spring 在 application.yml 中提供配置文件支援,通過配置 spring.cache.type 標籤來指定當前要使用的存儲方案,目前支援的有:

public enum CacheType {
    GENERIC,
    JCACHE,
    EHCACHE,
    HAZELCAST,
    INFINISPAN,
    COUCHBASE,
    REDIS,
    CAFFEINE,
    SIMPLE,
    NONE;

    private CacheType() {
    }
}

使用的時候需要引入相關存儲對應的 jar 包以及相關的配置。

Java Caching 定義了 5 個核心介面,分別是 CachingProvider, CacheManager, Cache, Entry和 Expiry

  • CachingProvider 用於配置和管理 CacheManager,目前它只有一個唯一的實現類 EhcacheCachingProvider,ehcache 也是 Spring 默認提供的實現之一。其餘的第三方快取組件都沒有用到。
  • CacheManager 定義了創建、配置、獲取、管理和控制多個唯一命名的 Cache,這些 Cache 存在於 CacheManager 的上下文中。一個 CacheManager 僅被一個 CachingProvider 所擁有。
  • Cache 是一個類似 Map 的數據結構並臨時存儲以 Key 為索引的值。一個 Cache 僅被一個 CacheManager 所擁有。
  • Entry是一個存儲在 Cache 中的 key-value 對。
  • Expiry 每一個存儲在 Cache 中的條目有一個定義的有效期。一旦超過這個時間,條目為過期的狀態。一旦過期,條目將不可訪問、更新和刪除。快取有效期可以通過 ExpiryPolicy 設置。

Spring 定義了org.springframework.cache.CacheManagerorg.springframework.cache.Cache介面來統一不同的快取技術。其中,CacheManager 是 Spring 提供的各種快取技術抽象介面,Cache 介面包含了快取的各種操作。

針對不同的快取方案需要提供不同的 CacheManager,Spring提供的實現類包括:

  • SimpleCacheManager:使用檢點的 Collection 來存儲快取,主要用來測試
  • ConcurrentMapCacheManager:使用 ConcurrentMap 來存儲快取
  • NoOpCacheManager:僅測試用途,不會實際存儲快取
  • EhCacheManager:使用 EhCache 作為快取技術
  • GuavaCacheManager:使用 Google Guava 的 GuavaCache 作為快取技術
  • HazelcastCacheManager:使用 Hazelcast 作為快取技術
  • JCacheManager:支援 JCache(JSR—107)標準的實現作為快取技術
  • RedisCacheManager:使用 Redis 作為快取技術

CacheManager 的載入來自於 spring.factories 文件中的配置:org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration,即在 Spring啟動的時候載入:

@Configuration
@ConditionalOnClass(CacheManager.class)
@ConditionalOnBean(CacheAspectSupport.class)
@ConditionalOnMissingBean(value = CacheManager.class, name = "cacheResolver")
@EnableConfigurationProperties(CacheProperties.class)
@AutoConfigureBefore(HibernateJpaAutoConfiguration.class)
@AutoConfigureAfter({ CouchbaseAutoConfiguration.class, HazelcastAutoConfiguration.class,
		RedisAutoConfiguration.class })
@Import(CacheConfigurationImportSelector.class)
public class CacheAutoConfiguration {
    
 	......   
}

那不同的存儲實現是如何載入各自的 CacheManger 的呢?我們就拿 Redis 來說,在配置類:

@Configuration
@AutoConfigureAfter({RedisAutoConfiguration.class})
@ConditionalOnBean({RedisConnectionFactory.class})
@ConditionalOnMissingBean({CacheManager.class})
@Conditional({CacheCondition.class})
class RedisCacheConfiguration {
 ...... 
   
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory, ResourceLoader resourceLoader) {
        RedisCacheManagerBuilder builder = RedisCacheManager.builder(redisConnectionFactory).cacheDefaults(this.determineConfiguration(resourceLoader.getClassLoader()));
        List<String> cacheNames = this.cacheProperties.getCacheNames();
        if (!cacheNames.isEmpty()) {
            builder.initialCacheNames(new LinkedHashSet(cacheNames));
        }

        return (RedisCacheManager)this.customizerInvoker.customize(builder.build());
    }
  ......
}

Redis 的配置類啟動的時候先檢查 CacheManager 是否有載入成功,有的話則去執行各種配置相關操作。上面程式碼截出來了初始化 RedisCacheManager 的步驟。RedisCacheManager 實現了 CacheManager 介面。

當使用 RedisCacheManager 進行存儲的時候,通過被包裝的 Cache 對象來使用相關的存儲操作,我們看一下 RedisCache 對應的操作:

public class RedisCache extends AbstractValueAdaptingCache {
  	......
    public synchronized <T> T get(Object key, Callable<T> valueLoader) {
        ValueWrapper result = this.get(key);
        if (result != null) {
            return result.get();
        } else {
            T value = valueFromLoader(key, valueLoader);
            this.put(key, value);
            return value;
        }
    }

    public void put(Object key, @Nullable Object value) {
        Object cacheValue = this.preProcessCacheValue(value);
        if (!this.isAllowNullValues() && cacheValue == null) {
            throw new IllegalArgumentException(String.format("Cache '%s' does not allow 'null' values. Avoid storing null via '@Cacheable(unless=\"#result == null\")' or configure RedisCache to allow 'null' via RedisCacheConfiguration.", this.name));
        } else {
            this.cacheWriter.put(this.name, this.createAndConvertCacheKey(key), this.serializeCacheValue(cacheValue), this.cacheConfig.getTtl());
        }
    }
    ......

}

可以看到 Redis 的存儲使用的是普通的 KV 結構,value 的序列化方式是 yml 文件中的配置。另外很重要的一點是 ttl 的配置,這裡能看到也是獲取配置文件的屬性。所以當你想給每個 key 單獨設置過期時間的話就不能使用默認的 Redis 配置。而是需要自己實現 CacheManager。