Works by

Ren's blog

@rennnosuke_rk 技術ブログです

【Spring】Spring Cache でキャッシュ機能を利用する

f:id:rennnosukesann:20181220183020p:plain

Spring Cacheとは

Spring Cacheは、Springでキャッシュ機能を使用するためのライブラリです。

Spring Cacheでは抽象的なキャッシュ機能の枠組みを提供します。そのため、内部の具体的な実装を気にせずに簡単にキャッシュ機能を使うことができます。
実装はデフォルト設定のほか、既存のキャッシュライブラリを使用することができます。例えば、CaffeineやJCacheなどをSpring Cache実装として選択することができます。

Spring Cacheのアーキテクチャ

具体的なキャッシュライブラリのことは考えず、まずはSpring Cacheのアーキテクチャを眺めてみます。
このアーキテクチャの概要は、Macchinetta Framework documentationで掲載されている図がとてもわかり易いので、構成だけ引用させていただきました。


f:id:rennnosukesann:20190810090751p:plain

青いアイコンが、Spring Cacheで提供される部分になります。

データ読み込み時の流れ

  1. データを参照するモジュールは、@Cacheable アノテーションがついたデータ供給用メソッドを呼び出す。
  2. Cache AOPが提供する @Cacheable にキー値が渡される。キー値はデフォルトで引数値の組み合わせとなる。Cache AOPはキー値をもとにCacheManagerにキャッシュ値の有無を問い合わせる。
  3. CacheManagerはキー値を元にハッシュテーブルに問い合わせる。キャッシュが存在すればキャッシュ値をCache AOPに返す。
  4. Cache AOPはキャッシュ値を受け取り、呼び出し元に返す。キャッシュがヒットしなかった場合、通常通りデータ供給用メソッドを実行し、その結果を返す。このとき、供給用メソッドの引数をキー値として(デフォルト設定であれば)、供給用メソッドの結果がハッシュテーブルに保存される。

Spring Cacheを構成するコンポーネント

Cache AOP

@Cacheable などCacheを有効化するためのアノテーションです。Cache機能の入り口となるインタフェースになります。この層が、キャッシュデータの使用元であるモジュールから、データがキャッシュされたものなのか、最新のデータなのかといった詳細を隠蔽します。 キャッシュ(コンピューティング) - Wikipedia)でも書かれていますが、

キャッシュは通常、隣接する層からは見えないように設計されている抽象化層です。

のように、通常キャッシュは隣接するデータ参照モジュールからはキャッシュの有無は隠蔽されます。 AOPという名がついているのはSpring AOPを使用した実装であるためで、Spring AOPはアプリケーション横断的に処理を挿入することを容易にします。この処理挿入は通常アノテーションで行われ、Spring Cacheもこのアノテーションによる処理挿入を利用しています。

CacheManager

Spring Cacheのキャッシュ処理をコントロールします。上図のキャッシュデータ引き当て処理の場合、CacheManagerはハッシュテーブルからキャッシュキーに対応するキャッシュ値を読み出し返します。CacheManagerはあくまでインタフェースであり、実装はキャッシュ実装によって変わります。

ハッシュテーブル

キャッシュ値をキーに紐付けて保存します。保存されたキャッシュがどこに格納されているのか、どのように管理されるのかはキャッシュ実装によって変わりますが、主にHashMap実装になります。

実装

では、実際にSpring Cacheを使用したキャッシュ処理を実装してみましょう。

Sprint Initializr上でSpring Web StartarとSpring Cacheを導入したプロジェクトを作成しました。言語選定はKotlinとしています。

https://start.spring.io/

プロジェクト内の構造は以下の通り。

 └── springcachedemo
     ├── ServletInitializer.kt
     ├── SpringCacheDemoApplication.kt
     ├── application
     │   └── controller
     │       └── ProductController.kt
     ├── config
     │   └── cache
     │       ├── CacheConfig.kt
     │       └── MyKeyGenerator.kt
     └── domain
         ├── entity
         │   └── Product.kt
         ├── repository
         │   └── ProductRepository.kt
         └── service
             └── ProductService.kt

依存関係は下記の通り。

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-cache")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    runtime ("mysql:mysql-connector-java")
    providedRuntime("org.springframework.boot:spring-boot-starter-tomcat")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

キャッシュの検証用アプリとして、商品情報を取得するAPIを実装します。 また検証のためRDB(MySQL)上にテーブル PRODUCTS を用意し、その中に100000件のレコードを用意しました。

CREATE TABLE `PRODUCTS` (
  `ID` int(11) NOT NULL AUTO_INCREMENT,
  `NAME` varchar(45) COLLATE utf8mb4_general_ci NOT NULL,
  `PRICE` varchar(45) COLLATE utf8mb4_general_ci NOT NULL,
  PRIMARY KEY (`ID`)
) ENGINE=InnoDB AUTO_INCREMENT=100001 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
mysql> SELECT count(ID) FROM `cache-example`.PRODUCTS;
+-----------+
| count(ID) |
+-----------+
|    100000 |
+-----------+
1 row in set (0.01 sec)

次にコード実装に移ります。

まず、レコードをマッピングするオブジェクトのEntityクラスとして Product を定義します。

IDはAutoIncrementとしたので @GeneratedValue で明示的に値の自動生成を記述します。

@Entity
@Table(name = "PRODUCTS")
data class Product(

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ID")
    val id: Int?,

    @Column(name = "NAME")
    val name: String,

    @Column(name = "PRICE")
    val price: Int

)

Product テーブルを抽象化するリポジトリ ProductRepository を用意。

@Repository
interface ProductRepository : JpaRepository<Product, Int>

ProductRepository を参照して商品情報を取得するサービスクラス ProductService では、商品情報取得メソッド getProducts()@Cacheable アノテーションを付加し、取得結果がキャッシュされるようにしています。 @Cacheable の引数としてキャッシュを識別するラベルを付与します。

@Service
class ProductService(
    @Autowired private val productRepository: ProductRepository
) {

    @Cacheable("getProducts")
    fun getProducts(): List<Product> = productRepository.findAll()

}

商品情報取得APIを定義した ProductController クラスを用意します。
呼び出し先の ProductService::getProducts() はキャッシュ設定が効いているので、二回目以降の呼び出しでは処理速度が変化するはずです。

@RestController
@RequestMapping("/products")
class ProductController(
    @Autowired private val productService: ProductService
) {

    @GetMapping("/")
    fun getProducts() = productService.getProducts()

}

最後に、 @SpringBootApplication を付加したクラスに @EnableCaching アノテーションを付加して、アプリケーションに対するSpring Cache機能を有効化します。

@SpringBootApplication
@EnableCaching
class SpringCacheDemoApplication

fun main(args: Array<String>) {
    runApplication<SpringCacheDemoApplication>(*args)
}

この状態でアプリケーションを起動し、以下のAPIをSpring Cache設定有り・無しの状態で3回ほど実行してそのレスポンスタイム(リクエストを送信してから、応答が返るまで、データ整形を除く)を見ます。なお、キャッシュなしの場合でも別の部分でキャッシュが効いている可能性があるため、複数回試行します。

$ curl http://localhost:8080/api/v1/products/ | jq '.[]'
{
  "id": 1,
  "name": "prod0",
  "price": 0
}
{
  "id": 2,
  "name": "prod1",
  "price": 100
}

jq

キャッシュ無効

1st 1307 ms
2nd 618 ms
3nd 451 ms

キャッシュ有効

1st 1277 ms
2nd 61 ms
3nd 53 ms

キャッシュ前と比較し、キャッシュ後のレスポンスタイムは約10分の1まで短くなりました。
他の実装でも線形にこの速度短縮が適用されるわけではありませんが、十分キャッシュの効果は現れているといえるでしょう。

Spring Cacheの主な機能

@Cacheable

Cacheable (Spring Framework 5.1.9.RELEASE API)

メソッド(またはクラス内のすべてのメソッド)を呼び出した結果をキャッシュできることを示す注釈。推奨メソッドが呼び出されるたびに、キャッシュ動作が適用され、指定された引数に対してメソッドが既に呼び出されているかどうかがチェックされます。賢明なデフォルトでは、単にメソッドのパラメーターを使用してキーを計算しますが、SpEL式はkey()属性を介して提供するか、カスタム KeyGenerator実装でデフォルトの実装を置き換えることができます(keyGenerator()を参照)。

(Cacheable (Spring Framework 5.1.9.RELEASE API) より)

@Cacheableをメソッドに付加することで、その返戻値の結果をキャッシュすることができます。キャッシュストレージや格納データ構造は後述するキャッシュ実装に任せます。

    @Cacheable("getProducts")
    fun getProducts(): List<Product> = productRepository.findAll()

キャッシュキー

Spring Cacheでは、キャッシュの結果に対し一意のキーを設定します。
このキーの値はデフォルトではキャッシュの引数を元に生成されます。
ただし、引数 keySpEL式と呼ばれる形式で独自のキーを設定することもできます。

@Cacheable(value = ["getProduct"], key = "'products/' + #id")
fun getProduct(id: Int): Product = productRepository.findById(id).orElseThrow()

上記の例では、引数 key に 'products/' という文字列と、引数である id を結合した文字列を設定した例となっています。
keyに渡す文字列内では、静的文字列はシングルクォートで囲む必要があります(これをしないと、変数として扱われてしまいます)。

KeyGenerator

@Cacheableデフォルトキーの生成規則をカスタマイズできます。
KeyGenerator インタフェースを実装したクラスを定義し、メソッド generate 内でどのようなキーを生成するかを定義します。

class MyKeyGenerator : KeyGenerator {
    override fun generate(target: Any, method: Method, vararg params: Any?): Any {
        return target.javaClass.simpleName + "/" + method.name + "/" + params.joinToString()
    }
}

生成したKeyGeneratorはBeanとして登録し、 @Cacheable の引数 keyGenerator に設定したBean名を渡せば自前のKeyGeneratorが適用されます。

@Configurationclass CacheConfig : CachingConfigurerSupport() {
    @Bean("MyKeyGenerator")    override fun keyGenerator(): KeyGenerator? = MyKeyGenerator()
}
@Service
class ProductService(@Autowired private val productRepository: ProductRepository) {
    @Cacheable("getProducts", keyGenerator = "MyKeyGenerator")
    fun getProductsWithMyKeyCache(): List<Product> = productRepository.findAll()
}

CacheManager

Springのキャッシュ機能を管理するクラスです。キャッシュの引当処理と、キャッシュに紐づくキーの取得機能を提供します。CacheManager実装では、キャッシュの有効期限などより細かい機能を提供します。
CacheManagerを用意せずとも @Cacheable アノテーションでキャッシュ機能を使うことができますが、@Cacheable の引数にCacheManagerを渡すことで、引数に渡したCacheManagerの管理の元、キャッシュ機能を使用することができます。 以下の例では、 SimpleCacheManager クラスをインスタンス化し、それを明示的に @Cacheable に引き渡しています。

@Configurationclass CacheConfig : CachingConfigurerSupport() {
    @Bean("MyCacheManager")
    override fun cacheManager(): CacheManager? = SimpleCacheManager()
}

先程の KeyGenerator 同様、定義した CacheManager Beanの名称を @Cacheable アノテーション引数に渡します。

@Service
class ProductService(@Autowired private val productRepository: ProductRepository) {
    @Cacheable("getProducts", cacheManager = "MyCacheManager")
    fun getProducts(): List<Product> = productRepository.findAll()
}

上記の SimpleCacheManager はデフォルトで使用されるCacheManager実装ですが、このキャッシュマネージャを独自にカスタマイズしたり、キャッシュライブラリの実装に基づいたCacheManagerも使用することができます。

@CachePut

キャッシュの値を更新します。 @Cacheable で指定したラベル値を指定し、それを更新するメソッドに付与します。

CachePut(Spring Framework 5.1.9.RELEASE API)

メソッド(またはクラスのすべてのメソッド)がcache put操作をトリガーすることを示す注釈 。 @Cacheable注釈とは対照的に、この注釈は推奨されるメソッドをスキップさせません。むしろ、常にメソッドが呼び出され、その結果が関連付けられたキャッシュに保存されます。Java8のOptional戻り値の型は自動的に処理され、そのコンテンツはキャッシュに保存されます(存在する場合)。

キャッシュは @CachePut を付与したメソッドの戻り値によって更新され、引き続き更新後の値がキャッシュされます。

    @CachePut("getProduct", key = "'products/' + #product.id")
    fun putProduct(product: Product) = productRepository.save(product)

@CacheEvict

付加したメソッドを呼び出すと、指定したキーに紐づくキャッシュを削除します。

主に不要なキャッシュを能動的に削除したい場合に使用します。 例えば、外部からキャッシュを削除するAPIを定義し、 @CacheEvict を付加したServiceメソッドを呼び出していつでも外からキャッシュ値を削除する・・・といったことができます。

    @CacheEvict("getProduct", key = "'products/' + #id")
    fun deleteProducts(id: Int) = productRepository.deleteAll()

参考文献