➜ Old React website
Chung Cheuk Hang MichaelJava Web Developer
Spring 項目暴露 GraphQL APIJPA/Hibernate 使用方式(一)

Spring 項目使用 Redis 做 caching

Table of contents

1 Redis 簡介

一般既 web applications 都會將最重要既資料(例如客戶資料、交易數據)放喺一個或者多個(如果係 microservice architecture)RDBMS 既 databases 裡面,而呢啲 databases 既資料都可以被視作為 single source of truth。不過,資料可以分好多種,除左最重要、改既話一定要直接改 database 既紀錄果啲,仲有一啲比如係用戶網頁登入既 session states、多國語言既翻譯文本,或者係一啲基本上都唔會點改變既業務配置或者程序配置。為左減少 database 既存取、加快查詢速度,我地可以用唔同既 caching library,例如 Redis、Memcached、Ehcache、Caffeine。
關於 Redis:
  • Key-value pair 既 cache data。
  • 佢係一個獨立既 infrastructure,可以用作 distributed 既 cache provider,畀好多個 Redis client 既 application instances 去存取。
  • 佢既運作方式係 in-memory cache。
    • 不過佢既 data 都可以 save 落 disk(例如 Redis server shutdown 果陣,或者用 Redis command)。
  • 支援 transaction,隔離狀態咁一次過執行多個 commands,但係就唔 ACID(大概只可以做到 isolation,因為唔支援 rollback)。
  • 我地可以建立 Redis cluster、多個 replicas 以及 sentinels 去達到 high availability。

2 安裝

2.1 Docker image

docker container run -d --rm -p 6379:6379 -v "C:/docker-data/redis:/data" --name redis redis:latest redis-server --save 10 1 --requirepass redispw
Redis Docker image 裡面已經包含 Redis CLI,所以我地只需要接連入去個 container 既 CLI 就可以用到。
連接 Redis CLI:
  • 方法一
    1. 安裝 Another Redis Desktop Manager
  • 方法二
    1. 去 Docker Desktop 打開 redis container。
    2. 撳「Exec」分頁。
    3. 執行:
      redis-cli -a redispw
  • 方法三
    1. 喺 Windows Command Prompt 或者 macOS Terminal 執行:
      docker container exec -it <redis container name/ID> redis-cli -a redispw

2.2 Windows(免安裝)

  1. tporadowski/redis GitHub 下載 ZIP 版既 Redis server。
  2. 解壓縮個 ZIP 檔。
  3. 執行 command:redis-server.exe redis.windows.conf

2.3 macOS(Homebrew)

  1. 執行 command:brew install redis
  2. 執行 command:redis-server

3 動手寫

Project structure:
  • src/main/java
    • /code
      • /config
        • RedisConfig.java
      • /controller
        • MyReadController.java
        • MyWriteController.java
      • /dto
        • MyDto.java
        • MyEnum.java
      • /service
        • MyReadService.java
        • MyWriteService.java
      • MainApplication.java

3.1 Maven dependencies

  • 新、舊版本既 Spring Data Redis(由 Spring Boot Starter Data Redis 引入)對於透過 annotations 黎做 Redis caching 既差異:
    • 新版 2.xRedisCacheManager 裡面用到 RedisCacheConfiguration,所以係用 RedisCacheConfiguration 黎配置個別 cache,如果直接配置 RestTemplate bean 係唔會起到作用。
    • 舊版 1.x 係直接配置 RestTemplate bean。
  • 新、舊版本既 Spring Boot Starter Data Redis 引入既 Redis client 既差異:
    • Spring Boot Starter Data Redis 1.x 默認用 Jedis,冇引入 Lettuce 既 dependencies。
    • Spring Boot Starter Data Redis 2.x3.x 默認用 Lettuce,冇引入 Jedis 既 dependencies。
  • 新、舊版本既 Spring Data Redis 配置既差異:
    • Spring Boot 1.x2.x 既配置係以 spring.redis 開頭。
    • Spring Boot 3.x 既配置係以 spring.data.redis 開頭。
1<parent> 2 <groupId>org.springframework.boot</groupId> 3 <artifactId>spring-boot-starter-parent</artifactId> 4 <version>3.4.0</version> 5</parent> 6 7<dependencies> 8 <dependency> 9 <groupId>org.springframework.boot</groupId> 10 <artifactId>spring-boot-starter-web</artifactId> 11 </dependency> 12 13 <dependency> 14 <groupId>org.springframework.boot</groupId> 15 <artifactId>spring-boot-starter-data-redis</artifactId> 16 </dependency> 17 18 <dependency> 19 <groupId>org.apache.commons</groupId> 20 <artifactId>commons-lang3</artifactId> 21 </dependency> 22 23 <dependency> 24 <groupId>org.projectlombok</groupId> 25 <artifactId>lombok</artifactId> 26 <scope>provided</scope> 27 </dependency> 28</dependencies>

3.2 Application 配置

1spring: 2 cache: 3 type: redis 4 data: 5 redis: 6 host: localhost 7 port: 6379 8 database: 0 9 password: redispw 10 11logging.level: 12 org.springframework.cache: TRACE 13

3.3 寫 Java code

3.3.1 Services (caching layer)

MyReadService.java
1@Slf4j 2@CacheConfig(cacheManager = "defaultCacheManager", cacheNames = { "default-cache-name" }) 3@Service 4public class MyReadService { 5 6 // 因為用左 RedisCacheConfiguration.disableCachingNullValues(),如果唔加 unless 就會有 exception。 7 @Cacheable(key = "'null'", unless = "#result == null") 8 public Object getNull() { 9 return null; 10 } 11 12 @Cacheable(key = "'string'") 13 public String getString() { 14 return RandomStringUtils.secure().nextAlphanumeric(10000); 15 } 16 17 @Cacheable(key = "'int'") 18 public int getInt() { 19 return Integer.MAX_VALUE; 20 } 21 22 @Cacheable(key = "'long'") 23 public long getLong() { 24 return Long.MAX_VALUE; 25 } 26 27 @Cacheable(key = "'float'") 28 public float getFloat() { 29 return Float.MIN_VALUE; 30 } 31 32 @Cacheable(key = "'double'") 33 public double getDouble() { 34 return Double.parseDouble("0." + RandomStringUtils.secure().nextNumeric(325)); 35 } 36 37 @Cacheable(key = "'boolean'") 38 public boolean getBoolean() { 39 return true; 40 } 41 42 @Cacheable(key = "'biginteger'") 43 public BigInteger getBigInteger() { 44 return new BigInteger(Integer.MAX_VALUE + ""); 45 } 46 47 @Cacheable(key = "'bigdecimal'") 48 public BigDecimal getBigDecimal() { 49 final BigDecimal value = new BigDecimal("0." + RandomStringUtils.secure().nextNumeric(325)); 50 log.info("Caching BigDecimal value: {}", value); 51 return value; 52 } 53 54 @Cacheable(key = "'date'") 55 public Date getDate() { 56 return new Date(); 57 } 58 59 @Cacheable(key = "'timestamp'") 60 public Timestamp getTimestamp() { 61 return new Timestamp(System.currentTimeMillis()); 62 } 63 64 @Cacheable(key = "'instant'") 65 public Instant getInstant() { 66 return Instant.now(); 67 } 68 69 @Cacheable(key = "'localdatetime'") 70 public LocalDateTime getLocalDateTime() { 71 return LocalDateTime.now(); 72 } 73 74 @Cacheable(key = "'optional-string'") 75 public Optional<String> getOptionalString() { 76 return Optional.of(UUID.randomUUID().toString()); 77 } 78 79 @Cacheable(key = "'string-list'") 80 public List<String> getStringList() { 81 return List.of(UUID.randomUUID().toString()); 82 } 83 84 @Cacheable(key = "'string-map'") 85 public Map<String, String> getStringMap() { 86 return Map.of("my-key", UUID.randomUUID().toString()); 87 } 88 89 @Cacheable(key = "'dto'") 90 public MyDto getDto() { 91 return constructDto(); 92 } 93 94 // XXX Doesn't work 95 @Cacheable(key = "'dto-with-optional-field'") 96 public MyDto getDtoWithOptionalField() { 97 return constructDtoWithOptional(); 98 } 99 100 @Cacheable(key = "'optional-dto'") 101 public Optional<MyDto> getOptionalDto() { 102 return Optional.of(constructDto()); 103 } 104 105 @Cacheable(key = "'dto-list'") 106 public List<MyDto> getDtoList() { 107 return List.of(constructDto()); 108 } 109 110 @Cacheable(key = "'dto-map'") 111 public Map<MyDto, MyDto> getDtoMap() { 112 return Map.of(constructDto(), constructDto()); 113 } 114 115 116 117 private MyDto constructDto() { 118 final MyDto myDto = new MyDto(); 119 myDto.setMyNull(null); 120 myDto.setMyString(UUID.randomUUID().toString()); 121 myDto.setMyInt(Integer.MAX_VALUE); 122 myDto.setMyLong(Long.MAX_VALUE); 123 myDto.setMyFloat(Float.MIN_VALUE); 124 myDto.setMyDouble(Double.parseDouble("0." + RandomStringUtils.secure().nextNumeric(325))); 125 myDto.setMyBoolean(true); 126 myDto.setMyBigInteger(new BigInteger(Integer.MAX_VALUE + "")); 127 myDto.setMyBigDecimal(new BigDecimal("0." + RandomStringUtils.secure().nextNumeric(325))); 128 myDto.setMyDate(new Date()); 129 myDto.setMyTimestamp(new Timestamp(System.currentTimeMillis())); 130 myDto.setMyInstant(Instant.now()); 131 myDto.setMyLocalDateTime(LocalDateTime.now()); 132 myDto.setMyEnum(MyEnum.TEST_VALUE); 133 myDto.setMyStringList(List.of(UUID.randomUUID().toString())); 134 myDto.setMyStringMap(Map.of("my-key", UUID.randomUUID().toString())); 135 136 return myDto; 137 } 138 139 private MyDto constructDtoWithOptional() { 140 final MyDto myDto = constructDto(); 141 myDto.setMyOptionalString(Optional.of(UUID.randomUUID().toString())); 142 143 return myDto; 144 } 145}
MyWriteService.java
1@Slf4j 2@CacheConfig(cacheManager = "defaultCacheManager", cacheNames = { "default-cache-name" }) 3@Service 4public class MyWriteService { 5 6 @CachePut(key = "'string'") 7 public String updateString() { 8 return RandomStringUtils.secure().nextAlphanumeric(10000); 9 } 10 11 @CacheEvict(key = "#myKey") 12 public void delete(String myKey) { 13 // Do nothing 14 } 15 16 @CacheEvict(allEntries = true) 17 public void deleteAll() { 18 // Do nothing 19 } 20}
解釋:
  • @Cacheable
    • 如果存在 cache 紀錄(cache hit),就返回 cache 紀錄;否則(cache miss),就執行 method 裡面既 code,再將 return value 放入 cache。
  • @CachePut
    • 用 return value 新增或者覆蓋現有 cache 紀錄。
  • @CacheEvict
    • 如果存在 cache 紀錄,就刪除 cache 紀錄。
    • 可以用 allEntries = true 黎刪曬所有 value 下既所有 cache 紀錄(不限 key)。
  • key attribute
    • 可以用 method 名、method parameters、return value 以及自定義文字去組成 cache 紀錄既 key 名。
    • 最後得出既 cache key 名會係 <cache name>::<cache key> 既 pattern,例如 default-cache-name::dto-list
  • unless attribute(@Cacheable@CachePut
    • 用黎喺攞到真既 return value 之後,根據自定義既邏輯去選擇性咁去做定唔做相關 caching 操作。

3.3.2 Controllers

MyReadController.java
1@RestController 2@RequestMapping("/test/cache") 3public class MyReadController { 4 5 @Autowired MyReadService myReadService; 6 7 @GetMapping("/null") 8 public Object getNull() { 9 return myReadService.getNull(); 10 } 11 12 @GetMapping("/string") 13 public String getString() { 14 return myReadService.getString(); 15 } 16 17 @GetMapping("/int") 18 public int getInt() { 19 return myReadService.getInt(); 20 } 21 22 @GetMapping("/long") 23 public long getLong() { 24 return myReadService.getLong(); 25 } 26 27 @GetMapping("/float") 28 public String getFloat() { 29 return new BigDecimal(myReadService.getFloat() + "").toPlainString(); 30 } 31 32 @GetMapping("/double") 33 public String getDouble() { 34 return new BigDecimal(myReadService.getDouble() + "").toPlainString(); 35 } 36 37 @GetMapping("/boolean") 38 public boolean getBoolean() { 39 return myReadService.getBoolean(); 40 } 41 42 @GetMapping("/biginteger") 43 public BigInteger getBigInteger() { 44 return myReadService.getBigInteger(); 45 } 46 47 @GetMapping("/bigdecimal") 48 public BigDecimal getBigDecimal() { 49 return myReadService.getBigDecimal(); 50 } 51 52 @GetMapping("/date") 53 public Date getDate() { 54 return myReadService.getDate(); 55 } 56 57 @GetMapping("/timestamp") 58 public Timestamp getTimestamp() { 59 return myReadService.getTimestamp(); 60 } 61 62 @GetMapping("/instant") 63 public Instant getInstant() { 64 return myReadService.getInstant(); 65 } 66 67 @GetMapping("/localdatetime") 68 public LocalDateTime getLocalDateTime() { 69 return myReadService.getLocalDateTime(); 70 } 71 72 @GetMapping("/optional-string") 73 public Optional<String> getOptionalString() { 74 return myReadService.getOptionalString(); 75 } 76 77 @GetMapping("/string-list") 78 public List<String> getStringList() { 79 return myReadService.getStringList(); 80 } 81 82 @GetMapping("/string-map") 83 public Map<String, String> getStringMap() { 84 return myReadService.getStringMap(); 85 } 86 87 @GetMapping("/dto") 88 public MyDto getDto() { 89 return myReadService.getDto(); 90 } 91 92 @GetMapping("/dto-with-optional-field") 93 public MyDto getDtoWithOptionalField() { 94 return myReadService.getDtoWithOptionalField(); 95 } 96 97 @GetMapping("/optional-dto") 98 public Optional<MyDto> getOptionalDto() { 99 return myReadService.getOptionalDto(); 100 } 101 102 @GetMapping("/dto-list") 103 public List<MyDto> getDtoList() { 104 return myReadService.getDtoList(); 105 } 106 107 @GetMapping("/dto-map") 108 public Map<MyDto, MyDto> getDtoMap() { 109 return myReadService.getDtoMap(); 110 } 111}
MyWriteController.java
1@RestController 2@RequestMapping("/test/cache") 3public class MyWriteController { 4 5 @Autowired MyWriteService myWriteService; 6 7 @PutMapping("/string") 8 public String getString() { 9 return myWriteService.updateString(); 10 } 11 12 @DeleteMapping("/one/{key}") 13 public void delete(@PathVariable(name = "key", required = true) String key) { 14 myWriteService.delete(key); 15 } 16 17 @DeleteMapping("/all") 18 public void deleteAll() { 19 myWriteService.deleteAll(); 20 } 21}

3.3.3 自定義 RedisCacheManager

RedisConfig.java
1@EnableCaching 2@Configuration 3public class RedisConfig { 4 private static final Duration TTL = Duration.ofMinutes(10); 5 6 @Bean 7 public RedisCacheManager defaultCacheManager(RedisConnectionFactory connectionFactory, 8 RedisCacheConfiguration redisCacheConfig) { 9 return RedisCacheManager.builder(connectionFactory) 10 .withInitialCacheConfigurations(Map.of("default-cache-name", redisCacheConfig)) 11 .build(); 12 } 13 14 @Bean 15 public RedisCacheConfiguration defaultRedisCacheConfig() { 16 17 final ObjectMapper objectMapper = new ObjectMapper(); 18 objectMapper.registerModule(new JavaTimeModule()); 19 objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); 20 objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), 21 ObjectMapper.DefaultTyping.EVERYTHING, 22 JsonTypeInfo.As.WRAPPER_OBJECT); 23 24 final SerializationPair<String> keySerializer = SerializationPair.fromSerializer( 25 RedisSerializer.string()); 26 final SerializationPair<?> valueSerializer = SerializationPair.fromSerializer( 27 new GenericJackson2JsonRedisSerializer(objectMapper)); 28 29 return RedisCacheConfiguration 30 .defaultCacheConfig() 31 .disableCachingNullValues() 32 .entryTtl(TTL) 33 .serializeKeysWith(keySerializer) 34 .serializeValuesWith(valueSerializer); 35 } 36}
解釋:
  • @EnableCaching
    • 一定要加上呢個 annotation 先會開啟 caching 功能,否則所有 caching 相關既 annotations 都唔會生效。
  • 配置 RedisCacheConfiguration
    • 根據 RedisCacheConfiguration#defaultCacheConfig,Redis caching 默認既 value serializer 係 JdkSerializationRedisSerializer
      • 呢個 value serializer 會用 Java 自帶既 serialization 功能。
      • JdkSerializationRedisSerializer 裡面用到既 DefaultSerializer 喺 serialize object 之前會 check 佢個 Java type 係咪 implement Serializable
      • 所以如果用默認既 RedisCacheConfiguration,我地任何自定義既 Java types 都一定要 implement Serializable
      • 另一個唔好處係,由 Java serialize 出黎既 cache value 都會係亂碼,用一般既 Redis GUI 工具唔會睇得出 data 係咩,咁就冇可能做到 troubleshooting。
    • 一般為左 backward compatibility,都會轉用 JSON 格式黎儲存 cache value。
      • 呢個其實係一個難題,因為 runtime 既時候,Spring Data Redis 唔知道要將 JSON data 以及每一個 nested object 既每一個 entry deserialize 成邊一個 Java type,所以就變左需要將 Java types 既資訊放入 cache data 裡面。
      • 我地需要將 value serializer 改成用 Jackson library 既 GenericJackson2JsonRedisSerializer
      • 我地唔可以用 no-arg constructor,因為默認既情況下會用左 new ObjectMapper(),唔支援 Java 8 既 Date/Time API,例如 LocalDateTime
      • 所以我地會 pass 我地自己配置好既 ObjectMapper object 入去 new GenericJackson2JsonRedisSerializer(ObjectMapper)
      • ObjectMapper 可以畀我地 register JavaTimeModule,咁就可以支援 Java 8 既 Date/Time API,例如 LocalDateTime
      • ObjectMapperactivateDefaultTyping() 可以畀我地揀 cache value 裡面要唔要、點樣儲存 Java type 資訊,有機會影響到最終既 JSON 數據。
        • JsonTypeInfo.As.WRAPPER_OBJECT
          • 會將實際既 Java type 放落一個 JSON object 既 key,而呢個 JSON object 既 value 就係個實際既 cache value。
          • 唯一壞處係,個 cache value 既 JSON data structure 會唔同左,有別於冇 Java type 資訊、純 data 既 JSON data structure。
    • disableCachingNullValues
      • Return value 係 null 既話會出 exception,除非有用 unless attribute。
    • entryTtl
      • Cache 紀錄既存活時間(time-to-live)。

4 測試

4.1 測試 @Cacheable

  1. 運行 Redis。
  2. 運行個 app。
  3. 執行以下既 cURL commands。
1curl -X GET localhost:8080/test/cache/null 2curl -X GET localhost:8080/test/cache/string 3curl -X GET localhost:8080/test/cache/int 4curl -X GET localhost:8080/test/cache/long 5curl -X GET localhost:8080/test/cache/float 6curl -X GET localhost:8080/test/cache/double 7curl -X GET localhost:8080/test/cache/boolean 8curl -X GET localhost:8080/test/cache/biginteger 9curl -X GET localhost:8080/test/cache/bigdecimal 10curl -X GET localhost:8080/test/cache/date 11curl -X GET localhost:8080/test/cache/timestamp 12curl -X GET localhost:8080/test/cache/instant 13curl -X GET localhost:8080/test/cache/localdatetime 14curl -X GET localhost:8080/test/cache/optional-string 15curl -X GET localhost:8080/test/cache/string-list 16curl -X GET localhost:8080/test/cache/string-map 17curl -X GET localhost:8080/test/cache/dto 18curl -X GET localhost:8080/test/cache/optional-dto 19curl -X GET localhost:8080/test/cache/dto-list 20curl -X GET localhost:8080/test/cache/dto-map
預期結果:
  • 第一次執行既結果:
    • Caching 既 TRACE logs 會有 No cache entry for key 以及 Creating cache entry for key
  • 第一次執行既結果:
    • Response body 同第一次既 response body 完全一樣。
    • Caching 既 TRACE logs 會有 Cache entry for key 'xxx' found
    • 如果喺 @Cacheable 既 method body 裡面 set breakpoint,就會見到佢冇入到去 method body。

4.2 測試 @Cacheable - 有 Optional field 既 DTO

  1. 運行 Redis。
  2. 運行個 app。
  3. 執行以下既 cURL command。
curl -X GET localhost:8080/test/cache/dto-with-optional-field
預期結果:
  • ❌ 會 throw exception。
解釋:
  • 雖然我地可以額外對 value serializer 既 ObjectMapper register Jdk8Module
    • 咁做支援到 serialize MyDto class 裡面既 Optional field。
    • 但其實 serialize 出黎既 JSON object entry 係冇辦法被 deserialize 返做 Optional field。
    • 咁係因為我地用左 JsonTypeInfo.As.WRAPPER_OBJECT,而 Jackson library 對 Optional 既處理有啲問題,導致到佢冇將任何 Java types 既資訊放入去 JSON object entry 裡面(佢係需要放 2 個 Java types 既資訊——Optional 以及實際既 Java type),所以當 deserialize 既時候就會 throw exception,話 Unexpected token (VALUE_STRING), expected START_OBJECT: need JSON Object to contain As.WRAPPER_OBJECT type information for class
    • 唯一解決方法係改用 JsonTypeInfo.As.PROPERTY,但呢個方法既壞處係佢會令 Map type 既 data 多左個 @class entry。
  • 其實 Java 自帶既 serialization 功能都唔支援 serialize Optional,咁係因為 Optional 本身冇 implement Serializable

4.3 測試 @CachePut

  1. 運行 Redis。
  2. 運行個 app。
  3. 執行以下既 cURL commands。
curl -X PUT localhost:8080/test/cache/string
預期結果:
  • 可以成功更新相關既 cache 紀錄,每次執行之後既 cache value 都會唔同。

4.4 測試 @CacheEvict

  1. 運行 Redis。
  2. 運行個 app。
  3. 執行以下既 cURL commands。
curl -X DELETE localhost:8080/test/cache/one/string curl -X DELETE localhost:8080/test/cache/one/dto
預期結果:
  • 可以成功逐一刪除單個 cache 紀錄。
curl -X DELETE localhost:8080/test/cache/all
預期結果:
  • 可以成功刪除所有 cache 紀錄。

5 參考資料