Build Spring Boot Cache Caffeine with Kotlin
Overview
This article creates a simple Spring Boot Cache with Kotlin.
Spring Boot officially introduces several caching approaches. Among them, Caffeine has a low adoption cost.
- Caffeine is a local cache.
- Because it is a local cache, no separate server is required.
- TTL (timeout) can be configured.
If you frequently need to run queries that fetch the same result from the database every time, using a cache can reduce the load.
Create the Project
Create the initial Spring Boot project with the following curl command.
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 -
Check the Generated Project
Project File Structure
If it was generated correctly, the project file structure is as follows.
.
├── 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 Script
/build.gradle.kts
// ... omitted ...
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")
}
// ... omitted ...
You can see that the Spring Cache library (spring-boot-starter-cache) is included in the dependencies.
Caffeine Cache Configuration
Add the Caffeine Library
First, add the Caffeine library (com.github.ben-manes.caffeine:caffeine) to Gradle.
/build.gradle.kts
// ... omitted ...
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")
}
// ... omitted ...
The logging library (io.github.microutils:kotlin-logging) was also added to check the results.
Add the Caffeine Configuration Class
Create a new CacheConfig configuration class and write it as follows.
/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
Add @EnableCaching to the class to use caching.
Add Application Configuration
Add cache-related settings to the application configuration file (application.yml).
When the initial project is generated, there should be an application.properties file. You can simply change its extension to .yml.
/src/main/resources/application.yml
spring:
cache:
cache-names: category
caffeine:
spec: maximumSize=100, expireAfterAccess=60s
cache-namesis the list of managed cache names.expireAfterWritemaximumSizeis the maximum size of the cache. When the cache size approaches the maximum value, the cache removes entries that are less likely to be used again. If the size is 0, entries are removed immediately after being loaded into the cache.expireAfterWriteexpires entries after a period has passed since the last write.- And more.
Create Classes Related to Applying Cache
Add the Repository Class to Apply Cache To
/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")
}
}
Add the Service Class That Calls the Cached Repository
/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)
}
}
Run the Application
Create the Application Runner Class
/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.
}
}
Check Application Execution
Now run it.
If you are using a tool, run 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
You can confirm that every time the findById(id) method is executed by catagoryId in the Service class, category[${id}] cache is not used. is executed and printed from the Repository class.
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.
When the findById(id) method in the Service class is executed for the second time, you can confirm that the log in the Repository class is not executed.
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)
Check with Test Code
Add Test Libraries
Add the mockk libraries (io.mockk:mockk, com.ninja-squad:springmockk) for testing.
/build.gradle.kts
// ... omitted ...
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")
}
// ... omitted ...
Write the Test Code
/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()) }
}
}
Run it and check the logs.
... omitted ...
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로 조회시 캐시 사용 여부 테스트"'.
As with the application execution, you can confirm that the log indicating the cache was used (category[$id] cache is not used.) is printed twice and is not printed afterward.
The test code also predicted that the execution check would run twice, and the test succeeded because it actually ran twice.
verify(exactly = 2) { categoryRepository.findById(any()) }
Other
Detailed Configuration
For a detailed explanation of the configuration, see the following.
http://static.javadoc.io/com.github.ben-manes.caffeine/caffeine/2.2.0/com/github/benmanes/caffeine/cache/Caffeine.html
Cache Expiration Strategies
There are three cache expiration strategies.
- Expire after access (
expireAfterAccess): Entries expire after a period has passed since the last read or write. - Expire after write (
expireAfterWrite): Entries expire after a period has passed since the last write. - Custom policy (
expireAfter): The expiration time is calculated individually for each entry by the expiration implementation.