前言
在 spring boot 應用程式開發的時候,在對 service 層加入緩存支持的過程中,遇到了處理分頁緩存的難題,在摸索了多個解決方式后,找到了比較適合,特此記錄
問題描述
在程序中存在 User與 Note 實體。假設用戶此時需要從服務器獲得 Note 數據,在大部分情況下,用戶不需要一次性獲取所有的 Note 數據,我們會使用 Page 來減少帶寬壓力的,同時使用緩存來減少對數據庫的訪問。
在緩存流程中,在對數據進行查找時候,需要先查找緩存中是否存在相應 key 的數據,如果沒有,則觸發一次數據庫訪問並把查詢到的結果存入緩存中,這在 Spring Boot 中以注解的方式很容易實現,我們可以這樣子使用:
1 @Transactional(readOnly = true) 2 @Cacheable( 3 key = "'user_'+#user.id+':'+#pageable.pageSize+'_'+#pageable.pageNumber", 4 condition = "#user!=null && #pageable!=null", 5 unless = "#result==null", 6 cacheNames = "note" 7 ) 8 @Nonnull 9 @Override 10 public Page<Note> findAllByUserAndPageable(@Nonnull User user, @Nonnull Pageable pageable) throws IllegalArgumentException {
但是當用戶對其中一個 Note 進行更改時,緩存的更新就成了一大難題,試想有一個方法如下:
1 @Nonnull 2 @Override 3 public Note addOne(@Nonnull Note toAdd) throws IllegalArgumentException {...
我們新增了一個 Note,現在程序需要清除與之有關聯的緩存數據,比如 分頁,Spring Cache 提供了 @CacheEvict ,現在我們使用它來清除緩存,很自然的,對應產生緩存的方式,我們在 @CacheEvict 的數據中加入 key = "'user_'+#user.id+':'#pageable.pageSize+'_'+#pageable.pageNumber"。很不幸,這是個錯誤的使用方式,在這里,我們獲取不到 pageable 對象。
@CacheEvict 里邊有個 allEntries 選項,把它設置為 true 清空 當前 cacheNames 下的所有緩存如何?同樣的,這不是一個好的處理方式。如果這么做了,當前緩存中的所有 緩存,包括那些沒有被修改的數據,比如單個的 Note 緩存同樣被刪的一干二凈。但是,其中的涉及小細節給了我們一些 hint。
解決思路
在上面的問題中,我們發現使用 allEntries 並不適用,它會導致緩存 cacheNames 下的所有數據被清空,請注意是指定的 cacheNames 下的所有數據,那么我們是否可以將分頁數據單獨放入對應 User 的 cacheName 下,當發生緩存更改時,直接刪除只包含分頁緩存的 cacheName 下的緩存? 比如 cacheNames="#principle.username" ?
如果通讀過 Spring-doc 的 Cache 章節,會發現,cacheNames 屬性並不支持 SpEL 表達式,所以以上的設置並不行得通。似乎山窮水盡,其實不然。仔細觀察 @Cache... 的通用屬性,會發現 cacheResolver 的注釋是這樣的。
The bean name of the custom {@link org.springframework.cache.interceptor.CacheResolver} to use.
可以使用 bean ,至少有一絲曙光,點進去 CacheResolver,他只規定了一個方法 resolveCaches,查看它的 Hierarchy 視圖,所幸類試圖並不復雜,

讓我們查看 CacheResolverAdapter 的源碼與注釋。
Spring's {@link CacheResolver} implementation that delegates to a standard
JSR-107 {@link javax.cache.annotation.CacheResolver}.
Used internally to invoke user-based JSR-107 cache resolvers.
使用JCacheCache 的 JCache (JSR-107)實現, pass
查看 AbstractCacheResolver 的源碼與注釋。
A base {@link CacheResolver} implementation that requires the concrete
implementation to provide the collection of cache name(s) based on the
invocation context.
很幸運,我們很快發現了候選。但是我們需要參考 spring 的默認實現 SimpleCacheResolver。
A simple {@link CacheResolver} that resolves the {@link Cache} instance(s)
based on a configurable {@link CacheManager} and the name of the
cache(s) as provided by {@link BasicOperation#getCacheNames() getCacheNames()}.
解決進行
讓我們針對 cache names 細節做一些具體實現:繼承這個類,更改 getCacheNames 方法,然后我們就得到了一個具體的實現
1 /** 2 * dynamic generates cache name: findAllByUserAndPageable_${userId} 3 * <br> 4 * use for page<Note> with annotation{@link org.springframework.cache.annotation.CacheEvict}, {@link org.springframework.cache.annotation.Cacheable} 5 */ 6 public static class DynamicNotePageCacheNames extends SimpleCacheResolver implements CacheResolver { 7 private final AuthenticationHelper authenticationHelper; 8 public static final String FIND_ALL_BY_USER_AND_PAGEABLE = "findAllByUserAndPageable"; 9 10 public DynamicNotePageCacheNames(CacheManager cacheManager, AuthenticationHelper authenticationHelper) { 11 super(cacheManager); 12 log.debug(String.format("using customize CacheResolver: %s , cacheManager: %s", 13 this.getClass().getName(), cacheManager.getClass().getName())); 14 this.authenticationHelper = authenticationHelper; 15 } 16 17 @Override 18 protected Collection<String> getCacheNames(CacheOperationInvocationContext<?> context) { 19 20 Long uId = authenticationHelper.checkAndExtractUserFromAuthentication().getId(); 21 String cacheName = String.format("%s_%d", FIND_ALL_BY_USER_AND_PAGEABLE, uId); 22 log.debug(String.format("generate cache name %s for target %s", cacheName, context.getTarget())); 23 return Collections.singleton((String.format("%s_%d", FIND_ALL_BY_USER_AND_PAGEABLE, uId))); 24 } 25 }
這里我從 Security 中獲取到當前的登錄用戶的 id,將 id 與 方法字符串拼接起來作為 cachename 返回。需要注意的是 在方法體中我並沒有對 uId 進行空檢查是因為在調用 checkAndExtractUserFromAuthentication 方法 嘗試獲取 user 的時候,如果當前 Authentication 中只存在匿名用戶的時候,方法會拋出 AccessDeniedException 錯誤,直接導致了 403 返回,具體可參考章節附。
讓我們使用這個bean,實例化並且改寫 service 層
@Bean("dynamicNotePageCacheNames")
public DynamicNotePageCacheNames cacheResolver() {
return new DynamicNotePageCacheNames(cacheManager, authenticationHelper);
}
@Cacheable的使用:
1 @Transactional(readOnly = true) 2 @Cacheable( 3 key = "#pageable.pageSize+'_'+#pageable.pageNumber", 4 condition = "#user!=null && #pageable!=null", 5 unless = "#result==null", 6 cacheResolver = "dynamicNotePageCacheNames" 7 ) 8 @Nonnull 9 @Override 10 public Page<Note> findAllByUserAndPageable(@Nonnull User user, @Nonnull Pageable pageable) throws IllegalArgumentException {
@CacheEvict的使用:
@Transactional(rollbackFor = EntityExistsException.class) @Caching( evict = { @CacheEvict( condition = "#toAdd!=null ", cacheResolver = "dynamicNotePageCacheNames", allEntries = true), }, put = @CachePut(key = "'id_'+#result.id", condition = "#toAdd!=null", unless = "#result==null", cacheNames = CustomCacheConfig.NOTE) ) @Nonnull @Override public Note addOne(@Nonnull Note toAdd) throws IllegalArgumentException {
需要注意的是,類上的注解 @CacheConfig 會覆蓋方法中的設置,參考

附
AuthenticationHelper:
1 /** 2 * 3 * 4 * @author pancc 5 * @version 1.0 6 */ 7 @Component 8 public class AuthenticationHelper { 9 private final UserService userService; 10 11 public AuthenticationHelper(UserService userService) { 12 this.userService = userService; 13 } 14 15 /** 16 * extract User From Authentication at current request, at this case it refers to a {@link UsernamePasswordAuthenticationToken}<br> 17 * <br> 18 * <code>anonymousUser</code> filter out anonymous user set by spring security web 19 * 20 * @param authentication if null, will get from current request 21 * @return optional with current user or empty 22 * @see JWTAuthenticationFilter 23 * @see TokenAuthenticationProcessor#getAuthentication(String) 24 * @see AnonymousAuthenticationFilter#AnonymousAuthenticationFilter(java.lang.String) 25 */ 26 private Optional<User> extractUserOptionalFromAuthentication(Authentication authentication) { 27 final String anonymous = "anonymousUser"; 28 if (authentication == null) { 29 authentication = SecurityContextHolder.getContext().getAuthentication(); 30 } 31 if (authentication == null || !authentication.isAuthenticated()) { 32 return Optional.empty(); 33 } 34 if ((authentication.getPrincipal().getClass().equals(String.class))) { 35 String username = (String) authentication.getPrincipal(); 36 if (username == null || username.contentEquals(anonymous)) { 37 return Optional.empty(); 38 } 39 return userService.findByUsername(username); 40 } 41 return Optional.empty(); 42 } 43 44 /** 45 * extract User From Authentication at current request, at this case it refers to a {@link UsernamePasswordAuthenticationToken}<br> 46 * 47 * @return optional with current user or empty 48 * @see JWTAuthenticationFilter 49 * @see TokenAuthenticationProcessor#getAuthentication(String) 50 */ 51 public Optional<User> extractUserOptionalFromAuthentication() { 52 return this.extractUserOptionalFromAuthentication(null); 53 } 54 55 /** 56 * extract User From Authentication, at this case it points to a {@link UsernamePasswordAuthenticationToken}<br> 57 * this method may throw {@link AccessDeniedException} if not caught, resulting in server response 403 code 58 * <br> 59 * <b>required login</b> 60 * 61 * @param authentication {@link UsernamePasswordAuthenticationToken} 62 * @return current user or throw AccessDeniedException that means 403 Http-code 63 * @throws AccessDeniedException if current Authentication contains bad Credentials 64 * @see ExceptionResolver.AccessDeniedAdvice 65 * @see JWTAuthenticationFilter 66 * @see TokenAuthenticationProcessor#getAuthentication(String) 67 */ 68 public User checkAndExtractUserFromAuthentication(Authentication authentication) throws AccessDeniedException { 69 return this.extractUserOptionalFromAuthentication(authentication).orElseThrow(() -> new AccessDeniedException("Access Denied")); 70 } 71 72 /** 73 * extract User From Authentication at current request, at this case it refers to a {@link UsernamePasswordAuthenticationToken}<br> 74 * this method may throw {@link AccessDeniedException} if not caught, resulting in server response 403 code 75 * <br> 76 * <b>required login</b> 77 * 78 * @return current user or throw AccessDeniedException that means 403 Http-code 79 * @throws AccessDeniedException if current Authentication contains bad Credentials 80 * @see ExceptionResolver.AccessDeniedAdvice 81 * @see JWTAuthenticationFilter 82 * @see TokenAuthenticationProcessor#getAuthentication(String) 83 */ 84 public User checkAndExtractUserFromAuthentication() throws AccessDeniedException { 85 return this.extractUserOptionalFromAuthentication(null).orElseThrow(() -> new AccessDeniedException("Access Denied")); 86 } 87 88 }
