Spring BootでElasticache for Memcachedのキャッシュ操作をしてみた


この記事は [アイスタイル Advent Calendar 2022](https://qiita.com/advent-calendar/2022/istyle) 7日目の記事です。

こんにちは。アイスタイルにて会員基盤の運用保守や機能改修を担当しているyukawacです。

[Spring Boot](https://spring.io/projects/spring-boot)で[Elasticache for Memcached](https://aws.amazon.com/jp/elasticache/memcached/)のキャッシュ操作をする機会があったので、  
キャッシュ操作するまでに実施したことやコード例を書きます。

⚠️注意⚠️
※本記事ではElasticache for Memcachedへ疎通するためのネットワーク周りの設定については記載しません。  
※ローカル環境にて、JavaとDockerの実行環境は構築済みとします。  
※ElastiCache for Memcachedクラスターは作成済みとします。  

## 一連の流れ
1. ローカル環境でmemcachedを立ち上げる（dockerコンテナ）
2. Spring Bootでローカル環境のmemcachedのキャッシュ操作をするコードを作成する
3. Elasticache for Memcachedへ接続できるように設定を書き換える

#### 筆者のローカル環境
- OS ： Windows10
- IDE ： IntelliJ IDEA 2021.3.1
- Docker環境 ： Rancher Desktop 1.6.2
- OpenJDK ： Amazon Corretto 17

それでは、進めます。

### 1. ローカル環境でmemcachedを立ち上げる（dockerコンテナ）
いきなりElastiCache for Memcachedへ接続はせず、
ローカル環境構築も兼ねて、まずはローカル環境でmemcachedを立ち上げ、接続を試みました。
[memcached公式イメージ](https://hub.docker.com/_/memcached)を利用します。  
ローカル環境でdockerコマンドを実行し、memcachedを立ち上げます。
```bash
# 初回起動
docker run --name demo-memcached --env TZ=Asia/Tokyo -d -p 11211:11211 memcached:latest
```

telnetコマンドにて接続確認できます。
```bash
telnet localhost 11211
```
※ちなみに、停止と2回目以降の起動コマンドは下記です。
```bash
# 停止
docker stop demo-memcached
# 2回目以降の起動
docker start demo-memcached
```
### 2. Spring Bootでローカル環境のmemcachedのキャッシュ操作をするコードを作成する

#### 2-1. Spring Bootプロジェクトを作成する
[Spring Initializr](https://start.spring.io/)でSpring Bootプロジェクトを生成します。
![Spring Initializr](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984476/b9e0833b-6833-7efc-64c2-5e28b3e151ca.png)

主要なものは
- Spring Boot：2.7.6 (作成時の最新2系）
- Java：17
- Spring Web (動作確認デモ用に追加)
- Lombok (動作確認デモ用に追加)

を選択しました。

「GENERATE」をクリックしてdemo.zipをダウンロードし、展開します。  
展開したプロジェクトはIntelliJ IDEAで開きます。

#### 2-2. Memcachedクライアントライブラリの依存関係をbuild.gradleに記述しダウンロードする

JavaのMemcachedクライアントは[Amazon ElastiCache Cluster Client](https://github.com/awslabs/aws-elasticache-cluster-client-memcached-for-java)を利用します。  
その他のクライアントライブラリでもキャッシュ操作することはできますが、
AWSのElastiCache for Memcachedの機能
- [オートディスカバリ](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/mem-ug/AutoDiscovery.html)
- [転送時の暗号化 (TLS)](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/mem-ug/in-transit-encryption-mc.html)

等を利用するためにはAWSのライブラリの利用が必要となります。  

build.gradleファイルのdependenciesに下記を追加し、ダウンロードします。
```
implementation 'com.amazonaws:elasticache-java-cluster-client:1.2.0'
```
#### 2-3. Cacheインターフェースを実装する
キャッシュ操作のための[Cache](https://spring.pleiades.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/Cache.html)インターフェースを実装します。  
今回は最低限、[@Cacheable](https://spring.pleiades.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/Cacheable.html)を利用したキャッシュの登録、取得が操作できるような実装例を記載します。

###### Cacheインターフェースの実装例
```java:Memcached.java
package com.example.demo;

import java.util.concurrent.Callable;
import lombok.extern.slf4j.Slf4j;
import net.spy.memcached.MemcachedClient;
import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;

@Slf4j
public class Memcached implements Cache {

  private final String name;
  private final MemcachedClient memcachedClient;

  public Memcached(String name, MemcachedClient memcachedClient) {
    this.name = name;
    this.memcachedClient = memcachedClient;
  }

  @Override
  public String getName() {
    return this.name;
  }

  @Override
  public Object getNativeCache() {
    return this.memcachedClient;
  }

  @Override
  public ValueWrapper get(Object key) {
    Object value = this.memcachedClient.get(key.toString());
    if (value == null) {
      log.debug("cache miss for key: " + key); // 動作確認用DEBUGログ
      return null;
    }
    log.debug("cache hit for key: " + key); // 動作確認用DEBUGログ
    return new SimpleValueWrapper(value);
  }

  @Override
  public <T> T get(Object key, Class<T> type) { return null; } // 実装省略

  @Override
  public <T> T get(Object key, Callable<T> valueLoader) { return null; } // 実装省略

  @Override
  public void put(Object key, Object value) {
    if (value == null) {
      return;
    }
    this.memcachedClient.set(key.toString(), 24 * 60 * 60 /* expiration 1 day */, value);
    log.debug("cache put for key: " + key); // 動作確認用DEBUGログ
  }

  @Override
  public void evict(Object key) { } // 実装省略

  @Override
  public void clear() { } // 実装省略

}
```

#### 2-4. Memcached接続設定クラスを作成する
[CachingConfigurerSupport](https://spring.pleiades.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/CachingConfigurerSupport.html)を継承したMemcached接続設定クラスを作成します。

###### CachingConfigurerSupportを継承したMemcached接続設定クラス実装例
```java:CachingConfiguration.java
package com.example.demo;

import java.io.*;
import java.util.*;
import lombok.extern.slf4j.*;
import net.spy.memcached.*;
import org.springframework.beans.factory.annotation.*;
import org.springframework.cache.*;
import org.springframework.cache.annotation.*;
import org.springframework.cache.interceptor.*;
import org.springframework.cache.support.*;
import org.springframework.context.annotation.*;

@EnableCaching
@Configuration
@Slf4j
public class CachingConfiguration extends CachingConfigurerSupport {

  @Value("${cache.hostname}")
  private String hostname;

  @Value("${cache.port}")
  private String port;

  @Bean
  MemcachedClient memcachedClient() {
    String memcachedAddress = hostname + ":" + port;
    /* ※1 */
    ClientMode clientMode = hostname.contains("cache.amazonaws.com") ? ClientMode.Dynamic : ClientMode.Static;
    /* ※2 */
    ConnectionFactory connectionFactory = new ConnectionFactoryBuilder(new KetamaConnectionFactory())
        .setClientMode(clientMode)
        .build();

    try {
      return new MemcachedClient(connectionFactory, AddrUtil.getAddresses(memcachedAddress));
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }

  @Override
  @Bean
  public CacheManager cacheManager() {
    SimpleCacheManager cacheManager = new SimpleCacheManager();
    // 複数のCacheインスタンスをセットできますが、今回は1つのみセットします。
    cacheManager.setCaches(Collections.singletonList(new Memcached("cache", memcachedClient())));
    cacheManager.afterPropertiesSet();
    return cacheManager;
  }

  @Override
  public CacheErrorHandler errorHandler() {
    return new SimpleCacheErrorHandler();
  }

}
```
###### ローカル用接続のapplication.yml設定例
```yml:application.yml
cache:
  hostname: localhost
  port: 11211

logging:
  level:
    com.example.demo: DEBUG # ログ出力用
```
##### ※補足
1. クライアントモードを決定しています。  
```java
ClientMode clientMode = hostname.contains("cache.amazonaws.com") ? ClientMode.Dynamic : ClientMode.Static;
```
オートディスカバリ機能を利用するためには`ClientMode.Dynamic`を利用する必要があります。  
Elasticache for Memcachedクラスターへ接続するときのみ`ClientMode.Dynamic`を利用し、　
オートディスカバリ機能がないmemcachedへ接続する場合は`ClientMode.Static`を利用するように分岐させています。
2. [効率的な負荷分散のための ElastiCache クライアントの設定](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/mem-ug/BestPractices.LoadBalancing.html)をするため、KetamaConnectionFactoryインスタンスを指定しています。
```java
new ConnectionFactoryBuilder(new KetamaConnectionFactory())
```

#### 2-5. キャッシュ操作のための、動作確認デモ用のエンドポイントを用意する
動作確認デモ用にエンドポイントを用意します。

###### コントローラクラス
```java:DemoController.java
package com.example.demo;

import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class DemoController {

  final DemoService demoService;

  @GetMapping("/demo")
  public ResponseEntity<String> demo() {
    String value = demoService.getValue("keyName");
    return new ResponseEntity<>(value, HttpStatus.OK);
  }

}
```
###### サービスクラス
```java:DemoService.java
package com.example.demo;

import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class DemoService  {

  /**
   * @Cacheableでキャッシュ機能の有効化をしています。
   * キャッシュがなければ、return文の"Value!!"値が返却され、keyNameをキーとしてキャッシュ登録されます。
   * キャッシュがあれば、キャッシュに登録されている"Value!!"値が返却されます。
   */
  @Cacheable(cacheNames = "cache", key = "#keyName")
  public String getValue(final String keyName) {
    return "Value!!";
  }

}
```

以上のクラスや設定ファイルを用意したら、Spring Bootアプリケーションを起動します。
```
http://localhost:8080/demo
```
へGETリクエストすると、ローカル環境にてキャッシュ操作の動作確認ができます。
数回リクエストすると動作確認用DEBUGログにより、キャッシュ登録・取得操作の様子がうかがえます。
```
2022-12-05 14:17:19.783 DEBUG 25796 --- [nio-8080-exec-7] com.example.demo.Memcached               : cache miss for key: keyName
2022-12-05 14:17:19.788 DEBUG 25796 --- [nio-8080-exec-7] com.example.demo.Memcached               : cache put for key: keyName
2022-12-05 14:17:25.890 DEBUG 25796 --- [nio-8080-exec-8] com.example.demo.Memcached               : cache hit for key: keyName
2022-12-05 14:17:26.624 DEBUG 25796 --- [nio-8080-exec-9] com.example.demo.Memcached               : cache hit for key: keyName
```



### 3. Elasticache for Memcachedへ接続できるように設定を書き換える
ElastiCache for Memcachedクラスターの接続設定をapplication.ymlに記載します。  
AWSマネジメントコンソールから作成済のクラスターの詳細画面を開き、「設定エンドポイント」に記載されているホスト名とポートを設定します。

###### ElastiCache for Memcachedクラスターの設定エンドポイントを記載したapplication.yml例
```yml:application.yml
# 設定エンドポイントに「demo-memcached.xxxxx.cfg.apne1.cache.amazonaws.com:11211」と記載されている場合
cache:
  hostname: demo-memcached.xxxxx.cfg.apne1.cache.amazonaws.com
  port: 11211
```
※ホスト名は必ず「.cfg」が入っているものを指定すること（[重要](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/mem-ug/AutoDiscovery.Using.html#AutoDiscovery.Using.ModifyApp)）

設定変更後、Spring BootアプリケーションをElastiCache for Memcachedクラスターに接続できるネットワーク上（AWS上であればEC2やFargate等）に置いて起動すると、キャッシュ操作ができます。  

以上、Spring BootでElasticache for Memcachedのキャッシュ操作をするまでの一連の流れでした。
memcachedサーバーに接続できているアプリケーションは、接続をElasticache for Memcachedクラスターに向けるよう設定変更すれば接続可能ということがわかりました。

### （おまけ）オートディスカバリ機能の動作確認
ElastiCache for Memcachedクラスターへ`ClientMode.Dynamic`モードで接続すると[オートディスカバリ](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/mem-ug/AutoDiscovery.html)機能が利用できます。  
クラスターへ定期的に問い合わせをし、ノード数の増減が有るか無いかを確認し、
クラスターの状態を把握し接続設定を更新するようになります。  
ElastiCache for Memcachedクラスターのノード増減によってconfigurationが変更される様子はログで確認できます。（WARNレベルで出力されます。）
###### クラスターのノード数を1から4に増やしたとき
```:ノード増加時のログ例
Level:WARN	Logger:n.s.m.ConfigurationPoller:90	 Message:Change in configuration - Existing configuration: Version:1
  CacheNode List:
    demo-memcached.xxxxxx.0001.apne1.cache.amazonaws.com:11211
New configuration:Version:2
  CacheNode List:
    demo-memcached.xxxxxx.0001.apne1.cache.amazonaws.com:11211
    demo-memcached.xxxxxx.0002.apne1.cache.amazonaws.com:11211
    demo-memcached.xxxxxx.0003.apne1.cache.amazonaws.com:11211
    demo-memcached.xxxxxx.0004.apne1.cache.amazonaws.com:11211
```
configurationのversionが1から2に上がり、0001～0004ノードが認識されるようになりました。

###### クラスターのノード数を4から2に減らしたとき
```:ノード減少時のログ例
Level:WARN	Logger:n.s.m.ConfigurationPoller:90	Message:Change in configuration - Existing configuration: Version:2
  CacheNode List:
    demo-memcached.xxxxxx.0001.apne1.cache.amazonaws.com:11211
    demo-memcached.xxxxxx.0002.apne1.cache.amazonaws.com:11211
    demo-memcached.xxxxxx.0003.apne1.cache.amazonaws.com:11211
    demo-memcached.xxxxxx.0004.apne1.cache.amazonaws.com:11211
New configuration:Version:3
  CacheNode List:
    demo-memcached.xxxxxx.0001.apne1.cache.amazonaws.com:11211
    demo-memcached.xxxxxx.0002.apne1.cache.amazonaws.com:11211
```
configurationのversionが2から3に上がり、0003と0004のノードは認識されなくなりました。

自動検出されるため、ノードを追加してもアプリケーション側の修正は必要ありません。便利です。

### 参考文献
- [Spring Boot](https://spring.io/projects/spring-boot)
- [Elasticache for Memcached](https://aws.amazon.com/jp/elasticache/memcached/)
- [memcached公式イメージ](https://hub.docker.com/_/memcached)
- [Amazon ElastiCache Cluster Client (Memcached for Java)](https://github.com/awslabs/aws-elasticache-cluster-client-memcached-for-java) 
- [MVN REPOSITORY ElastiCacheJavaClusterClient](https://mvnrepository.com/artifact/com.amazonaws/elasticache-java-cluster-client)
- [オートディスカバリ](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/mem-ug/AutoDiscovery.html)
- [転送時の暗号化 (TLS)](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/mem-ug/in-transit-encryption-mc.html)

最後までお読みいただきありがとうございました。