KotlinでSpring Boot Cache Caffeineを作る

概要

Kotlin言語を使って、簡単なSpring Boot Cacheを作成してみる。

Spring Bootで公式に紹介されているCacheの方法はいくつかある。その中でもCaffeineは導入コストが低い。

  • Caffeineはローカルキャッシュである。
  • ローカルキャッシュなので、別途サーバーは必要ない。
  • TTL(Timeout)を設定できる。

DBから毎回同じ結果を取得するクエリを実行する必要がよくある場合は、キャッシュを利用すると負荷を減らせる。

プロジェクト作成

次のようにcurlコマンドを使って、Spring Bootの初期プロジェクトを作成する。

curl https://start.spring.io/starter.tgz \
-d bootVersion=2.5.6 \
-d dependencies=cache \
-d baseDir=spring-cache-caffeine \
-d groupId=com.devkuma \
-d artifactId=spring-cache-caffeine \
-d packageName=com.devkuma.cache.caffeine \
-d applicationName=CaffeineApplication \
-d packaging=jar \
-d language=kotlin \
-d javaVersion=11 \
-d type=gradle-project | tar -xzvf -

生成されたプロジェクトの確認

プロジェクトファイル構造

正しく生成されていれば、プロジェクトのファイル構造は次のようになる。

.
├── HELP.md
├── build.gradle.kts
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
    ├── main
    │   ├── kotlin
    │   │   └── com
    │   │       └── devkuma
    │   │           └── cache
    │   │               └── caffeine
    │   │                   └── CaffeineApplication.kt
    │   └── resources
    │       └── application.properties
    └── test
        └── kotlin
            └── com
                └── devkuma
                    └── cache
                        └── caffeine
                            └── CaffeineApplicationTests.kt

ビルドスクリプト

/build.gradle.kts

// ... 省略 ...

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-cache")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

// ... 省略 ...

依存ライブラリにSpring Cacheライブラリ(spring-boot-starter-cache)が含まれていることを確認できる。

Caffeine cache設定

Caffeineライブラリ追加

まず、GradleにCaffeineライブラリ(com.github.ben-manes.caffeine:caffeine)を追加する。

/build.gradle.kts

// ... 省略 ...

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-cache")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    implementation("com.github.ben-manes.caffeine:caffeine")
    implementation("io.github.microutils:kotlin-logging:2.0.11")
    
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

// ... 省略 ...

追加で結果を確認するため、ログライブラリ(io.github.microutils:kotlin-logging)も追加した。

Caffeine設定クラス追加

新規にCacheConfig設定クラスを作成し、次のように記述する。

/src/main/kotlin/com/devkuma/cache/caffeine/config/CacheConfig.kt

package com.devkuma.cache.caffeine.config

import org.springframework.cache.annotation.EnableCaching
import org.springframework.context.annotation.Configuration

@Configuration
@EnableCaching
class CacheConfig

キャッシュを使用するために、クラスに@EnableCachingを追加する。

アプリケーション設定追加

アプリケーション設定ファイル(application.yml)にキャッシュ関連設定を追加する。
初期プロジェクトを作成すると、application.propertiesファイルがあるはずだ。このファイルの拡張子だけを.ymlに変更すればよい。

/src/main/resources/application.yml

spring:
  cache:
    cache-names: category
    caffeine:
      spec: maximumSize=100, expireAfterAccess=60s
  • cache-namesは管理されるキャッシュ名の一覧である。expireAfterWrite
  • maximumSizeはキャッシュの最大サイズである。キャッシュサイズが最大値に近づくと、キャッシュは再利用される可能性が低い項目を削除する。サイズが0の場合、キャッシュへロードされた直後に削除される。
  • expireAfterWriteは、最後の書き込みが発生してから一定期間が過ぎると項目が期限切れになる。
    • その他。

Cache適用関連クラスの作成

Cacheを適用するRepositoryクラス追加

/src/main/kotlin/com/devkuma/cache/caffeine/repository/CategoryRepository.kt

package com.devkuma.cache.caffeine.repository

import com.devkuma.cache.caffeine.dto.Category
import mu.KotlinLogging
import org.springframework.cache.annotation.CacheConfig
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Repository

private val log = KotlinLogging.logger {}

@Repository
@CacheConfig
class CategoryRepository {

    @Cacheable("category")
    fun findById(id: Long): Category {
        log.info("category[${id}] cache is not used.")
        return Category(id,"Book")
    }
}

Cacheを適用したRepositoryを呼び出すServiceクラス追加

/src/main/kotlin/com/devkuma/cache/caffeine/service/CategoryService.kt

package com.devkuma.cache.caffeine.service

import com.devkuma.cache.caffeine.dto.Category
import com.devkuma.cache.caffeine.repository.CategoryRepository
import mu.KotlinLogging
import org.springframework.stereotype.Service

private val log = KotlinLogging.logger {}

@Service
class CategoryService(
    private val categoryRepository: CategoryRepository
) {

    fun getById(id: Long): Category {
        log.info("Call function categoryRepository.findAll(${id})")
        return categoryRepository.findById(id)
    }
}

アプリケーション実行

アプリケーション実行クラス作成

/src/main/kotlin/com/devkuma/cache/caffeine/service/CategoryService.kt

package com.devkuma.cache.caffeine

import com.devkuma.cache.caffeine.service.CategoryService
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.CommandLineRunner
import org.springframework.stereotype.Component

@Component
class CaffeineRunner: CommandLineRunner {

    @Autowired
    private lateinit var categoryService: CategoryService

    @Throws(Exception::class)
    override fun run(vararg args: String?) {
        categoryService.getById(1) // No hit , since this is the first request.
        categoryService.getById(2) // No hit , since this is the first request.
        categoryService.getById(1) // hit , since it is already in the cache.
        categoryService.getById(1) // hit , since it is already in the cache.
    }
}

アプリケーション実行確認

それでは実行してみる。
ツールを使用している場合は、CaffeineApplicationを実行すればよい。


  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.5.6)

2021-11-12 12:46:49.034  INFO 12757 --- [           main] c.d.c.caffeine.CaffeineApplicationKt     : Starting CaffeineApplicationKt using Java 15.0.4 on 2021090004.local with PID 12757 (/Users/we/develop/tutorial/spring-tutorial/spring-cache-caffeine/build/classes/kotlin/main started by we in /Users/we/develop/tutorial/spring-tutorial/spring-cache-caffeine)
2021-11-12 12:46:49.036  INFO 12757 --- [           main] c.d.c.caffeine.CaffeineApplicationKt     : No active profile set, falling back to default profiles: default
2021-11-12 12:46:50.037  INFO 12757 --- [           main] c.d.c.caffeine.CaffeineApplicationKt     : Started CaffeineApplicationKt in 6.481 seconds (JVM running for 6.898)
2021-11-12 12:46:50.047  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 12:46:50.075  INFO 12757 --- [           main] c.d.c.c.repository.CategoryRepository    : category[1] cache is not used.
2021-11-12 12:46:50.077  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(2)
2021-11-12 12:46:50.077  INFO 12757 --- [           main] c.d.c.c.repository.CategoryRepository    : category[2] cache is not used.
2021-11-12 12:46:50.078  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 12:46:50.080  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)

Process finished with exit code 0

ServiceクラスでcatagoryIdごとにfindById(id)メソッドが実行されるたびに、Repositoryクラスでcategory[${id}] cache is not used.が実行され、出力されることを確認できる。

2021-11-12 12:46:50.047  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 12:46:50.075  INFO 12757 --- [           main] c.d.c.c.repository.CategoryRepository    : category[1] cache is not used.
2021-11-12 12:46:50.077  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(2)
2021-11-12 12:46:50.077  INFO 12757 --- [           main] c.d.c.c.repository.CategoryRepository    : category[2] cache is not used.

2回目にServiceクラスのfindById(id)メソッドが実行されるときは、Repositoryクラスのログが実行されないことを確認できる。

2021-11-12 12:46:50.078  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 12:46:50.080  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)

テストコードで確認

テストライブラリ追加

テストのためにmockkライブラリ(io.mockk:mockkcom.ninja-squad:springmockk)を追加する。

/build.gradle.kts

// ... 省略 ...

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-cache")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    implementation("com.github.ben-manes.caffeine:caffeine")
    implementation("io.github.microutils:kotlin-logging:2.0.11")
    
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("io.mockk:mockk:1.12.0")
    testImplementation("com.ninja-squad:springmockk:3.0.1")
    testImplementation("org.awaitility:awaitility-kotlin:4.1.1")
}

// ... 省略 ...

テストコード作成

/src/test/kotlin/com/devkuma/cache/caffeine/service/CategoryServiceTests.kt

package com.devkuma.cache.caffeine.service

import com.devkuma.cache.caffeine.config.CacheConfig
import com.devkuma.cache.caffeine.repository.CategoryRepository
import com.ninjasquad.springmockk.SpykBean
import io.mockk.verify
import mu.KotlinLogging
import org.junit.jupiter.api.Test

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest

private val log = KotlinLogging.logger {}

@SpringBootTest(classes = [CategoryService::class, CategoryRepository::class, CacheConfig::class])
class CategoryServiceTests {

    @Autowired
    private lateinit var categoryService: CategoryService

    @SpykBean
    private lateinit var categoryRepository: CategoryRepository

    @Test
    fun `카테고리 ID로 조회시 캐시 사용 여부 테스트`() {

        // When
        val category11 = categoryService.getById(1)
        val category21 = categoryService.getById(2)
        val category12 = categoryService.getById(1)
        val category22 = categoryService.getById(2)

        log.info { "category1=$category11" }
        log.info { "category2=$category21" }
        log.info { "category3=$category12" }
        log.info { "category4=$category22" }

        // Then
        verify(exactly = 2) { categoryRepository.findById(any()) }
    }
}

実行してログを確認しよう。

... 省略 ...

2021-11-12 14:11:25.528  INFO 14035 --- [    Test worker] c.d.c.c.service.CategoryServiceTests     : Started CategoryServiceTests in 7.305 seconds (JVM running for 8.881)
2021-11-12 14:11:26.010  INFO 14035 --- [    Test worker] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 14:11:26.143  INFO 14035 --- [    Test worker] c.d.c.c.repository.CategoryRepository    : category[1] cache is not used.
2021-11-12 14:11:26.148  INFO 14035 --- [    Test worker] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(2)
2021-11-12 14:11:26.149  INFO 14035 --- [    Test worker] c.d.c.c.repository.CategoryRepository    : category[2] cache is not used.
2021-11-12 14:11:26.149  INFO 14035 --- [    Test worker] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 14:11:26.157  INFO 14035 --- [    Test worker] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(2)
2021-11-12 14:11:26.165  INFO 14035 --- [    Test worker] c.d.c.c.service.CategoryServiceTests     : category1=Category(id=1, name=Book)
2021-11-12 14:11:26.167  INFO 14035 --- [    Test worker] c.d.c.c.service.CategoryServiceTests     : category2=Category(id=2, name=Book)
2021-11-12 14:11:26.170  INFO 14035 --- [    Test worker] c.d.c.c.service.CategoryServiceTests     : category3=Category(id=1, name=Book)
2021-11-12 14:11:26.172  INFO 14035 --- [    Test worker] c.d.c.c.service.CategoryServiceTests     : category4=Category(id=2, name=Book)
BUILD SUCCESSFUL in 10s
4 actionable tasks: 2 executed, 2 up-to-date
2:11:26 오후: Task execution finished ':test --tests "com.devkuma.cache.caffeine.service.CategoryServiceTests.카테고리 ID로 조회시 캐시 사용 여부 테스트"'.

アプリケーション実行時と同じように、キャッシュが使用されたことを示すログ(category[$id] cache is not used.)が2回出力され、その後は出力されないことを確認できる。

テストコードでも実行有無をチェックする箇所で2回実行されると予測しており、実際に2回実行されたためテストは成功した。

verify(exactly = 2) { categoryRepository.findById(any()) }

その他

詳細設定

設定に関する詳しい説明は次で確認してほしい。
http://static.javadoc.io/com.github.ben-manes.caffeine/caffeine/2.2.0/com/github/benmanes/caffeine/cache/Caffeine.html

キャッシュ有効期限戦略

キャッシュの有効期限戦略は3つある。

  • アクセス後に期限切れ(expireAfterAccess): 最後の読み取りまたは書き込みが発生してから一定期間が過ぎると項目が期限切れになる。
  • 書き込み後に期限切れ(expireAfterWrite): 最後の書き込みが発生してから一定期間が過ぎると項目が期限切れになる。
  • ユーザー定義ポリシー(expireAfter): 有効期限は、期限切れ実装によって各項目ごとに個別に計算される。

参考