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は管理されるキャッシュ名の一覧である。expireAfterWritemaximumSizeはキャッシュの最大サイズである。キャッシュサイズが最大値に近づくと、キャッシュは再利用される可能性が低い項目を削除する。サイズが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:mockk、com.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): 有効期限は、期限切れ実装によって各項目ごとに個別に計算される。