mn create-app example.micronaut.micronautguide \
--features=microstream,serialization-jackson,micronaut-validation \
--build=maven
--lang=kotlin
Using MicroStream persistence with Micronaut
Learn how to use MicroStream as a high-performance persistence layer.
Authors: Tim Yates
Micronaut Version: 3.9.2
1. Getting Started
In this guide, we will create a Micronaut application written in Kotlin.
You will use MicroStream for persistence.
2. What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE
-
JDK 1.8 or greater installed with
JAVA_HOME
configured appropriately
3. Solution
We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.
-
Download and unzip the source
4. Writing the Application
Create an application using the Micronaut Command Line Interface or with Micronaut Launch.
If you don’t specify the --build argument, Gradle is used as the build tool. If you don’t specify the --lang argument, Java is used as the language.
|
The previous command creates a Micronaut application with the default package example.micronaut
in a directory named micronautguide
.
If you use Micronaut Launch, select Micronaut Application as application type and add microstream
, serialization-jackson
, and micronaut-validation
features.
If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features and apply those changes to your application. |
4.1. Dependencies
The microstream
features adds the following dependencies:
<!-- Add the following to your annotationProcessorPaths element -->
<annotationProcessorPath>
<groupId>io.micronaut.microstream</groupId>
<artifactId>micronaut-microstream-annotations</artifactId>
</annotationProcessorPath>
<dependency>
<groupId>io.micronaut.microstream</groupId>
<artifactId>micronaut-microstream-annotations</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.micronaut.microstream</groupId>
<artifactId>micronaut-microstream</artifactId>
<scope>compile</scope>
</dependency>
4.2. Domain object
Create a Fruit
class which will be used as the domain object.
package example.micronaut
import io.micronaut.serde.annotation.Serdeable
import javax.validation.constraints.NotBlank
@Serdeable (1)
data class Fruit(
@field:NotBlank val name: String, (2)
var description: String? (3)
)
1 | Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized. |
2 | Use javax.validation.constraints Constraints to ensure the data matches your expectations. |
3 | The description is allowed to be null. |
4.3. Root Object
Create a FruitContainer
POJO which we will be used as the root of our object graph.
package example.micronaut
import java.util.concurrent.ConcurrentHashMap
class FruitContainer {
val fruits: MutableMap<String, Fruit> = ConcurrentHashMap()
}
4.4. Configuration
Add the following snippet to application.yml
to configure MicroStream.
microstream:
storage:
main:
root-class: 'example.micronaut.FruitContainer'
storage-directory: 'build/fruit-storage'
4.5. Command object
And a FruitCommand
class which will be used as the command object over HTTP.
package example.micronaut
import io.micronaut.serde.annotation.Serdeable
import javax.validation.constraints.NotBlank
@Serdeable (1)
data class FruitCommand(
@field:NotBlank val name: String, (2)
val description: String? = null (3)
)
1 | Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized. |
2 | Use javax.validation.constraints Constraints to ensure the data matches your expectations. |
3 | The description is allowed to be null. |
4.6. Repository
Create a repository interface to encapsulate the CRUD actions for Fruit
.
package example.micronaut
import javax.validation.Valid
interface FruitRepository {
fun list(): Collection<Fruit>
fun create(@Valid fruit: FruitCommand): Fruit (1)
fun update(@Valid fruit: FruitCommand): Fruit? (1)
fun find(name: String): Fruit?
fun delete(@Valid fruit: FruitCommand) (1)
}
1 | Add @Valid to any method parameter which requires validation. |
4.7. Error handling
In the event an attempt is made to create a duplicate fruit, we will catch the exception with a custom class.
package example.micronaut
class FruitDuplicateException(name: String) : RuntimeException("Fruit '$name' already exists.")
This exception will be handled by a custom ExceptionHandler to return a 400 error with a sensible message.
package example.micronaut
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.annotation.Produces
import io.micronaut.http.server.exceptions.ExceptionHandler
import io.micronaut.http.server.exceptions.response.ErrorContext
import io.micronaut.http.server.exceptions.response.ErrorResponseProcessor
import jakarta.inject.Singleton
@Produces (1)
@Singleton (2)
class FruitDuplicateExceptionHandler(private val errorResponseProcessor: ErrorResponseProcessor<*>) :
ExceptionHandler<FruitDuplicateException, HttpResponse<*>> {
override fun handle(request: HttpRequest<*>, exception: FruitDuplicateException): HttpResponse<*> {
val errorContext = ErrorContext.builder(request)
.cause(exception)
.errorMessage(exception.message ?: "No message")
.build()
return errorResponseProcessor.processResponse(errorContext, HttpResponse.unprocessableEntity<Any>())
}
}
1 | Ensure the response content-type is set to application/json with the @Produces annotation. |
2 | Use jakarta.inject.Singleton to designate a class as a singleton. |
4.8. Repository implementation
Implement the FruitRepository
interface.
When an object in your graph changes, you need to persist the object that contains the change.
This can be achieved through the StoreParams
and StoreReturn
annotations
package example.micronaut
import io.micronaut.microstream.RootProvider
import io.micronaut.microstream.annotations.StoreParams
import io.micronaut.microstream.annotations.StoreReturn
import jakarta.inject.Inject
import jakarta.inject.Singleton
import javax.validation.Valid
@Singleton (1)
open class FruitRepositoryImpl: FruitRepository {
@Inject
private lateinit var rootProvider: RootProvider<FruitContainer> (2)
override fun list() = rootProvider.root().fruits.values (3)
override fun create(@Valid fruit: FruitCommand): Fruit {
val fruits: MutableMap<String, Fruit> = rootProvider.root().fruits
if (fruits.containsKey(fruit.name)) {
throw FruitDuplicateException(fruit.name)
}
return performCreate(fruits, fruit)
}
@StoreParams("fruits") (4)
protected open fun performCreate(fruits: MutableMap<String, Fruit>,
fruit: FruitCommand): Fruit {
val newFruit = Fruit(fruit.name, fruit.description)
fruits[fruit.name] = newFruit
return newFruit
}
override fun update(@Valid fruit: FruitCommand): Fruit? {
val fruits: Map<String, Fruit> = rootProvider.root().fruits
val foundFruit = fruits[fruit.name]
return foundFruit?.let { performUpdate(it, fruit) }
}
@StoreReturn (5)
protected open fun performUpdate(foundFruit: Fruit,
fruit: FruitCommand): Fruit {
foundFruit.description = fruit.description
return foundFruit
}
override fun find(name: String) = rootProvider.root().fruits[name]
override fun delete(@Valid fruit: FruitCommand) {
performDelete(fruit)
}
@StoreReturn (5)
protected open fun performDelete(fruit: FruitCommand): Map<String, Fruit>? {
return if (rootProvider.root().fruits.remove(fruit.name) != null) {
rootProvider.root().fruits
} else null
}
}
1 | Use jakarta.inject.Singleton to designate a class as a singleton. |
2 | Use constructor injection to inject a bean of type RootProvider . |
3 | Return all the values in the FruitContainer . |
4 | With @StoreParams , on successful completion of this method, the Map argument fruits will be persisted in MicroStream. |
5 | With @StoreReturn , on successful completion of this method, the return value will be persisted in MicroStream. |
4.9. Controller
Create FruitController
:
package example.micronaut
import io.micronaut.http.HttpStatus
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Delete
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.PathVariable
import io.micronaut.http.annotation.Post
import io.micronaut.http.annotation.Put
import io.micronaut.http.annotation.Status
import io.micronaut.scheduling.TaskExecutors.IO
import io.micronaut.scheduling.annotation.ExecuteOn
import jakarta.inject.Inject
import javax.validation.Valid
@Controller("/fruits") (1)
open class FruitController {
@Inject
private lateinit var fruitRepository: FruitRepository (2)
@Get (3)
fun list() = fruitRepository.list()
@ExecuteOn(IO)
@Post (4)
@Status(HttpStatus.CREATED) (5)
open fun create(@Valid fruit: FruitCommand) (6)
= fruitRepository.create(fruit)
@Put
open fun update(@Valid fruit: FruitCommand) = fruitRepository.update(fruit)
@Get("/{name}") (7)
fun find(@PathVariable name: String): Fruit? = fruitRepository.find(name)
@ExecuteOn(IO)
@Delete
@Status(HttpStatus.NO_CONTENT)
open fun delete(@Valid fruit: FruitCommand) = fruitRepository.delete(fruit)
}
1 | The class is defined as a controller with the @Controller annotation mapped to the path /fruits . |
2 | Use constructor injection to inject a bean of type FruitRepository . |
3 | The @Get annotation maps the list method to an HTTP GET request on /fruits . |
4 | The @Post annotation maps the save method to an HTTP POST request on /fruits . |
5 | You can specify the HTTP status code via the @Status annotation. |
6 | Add @Valid to any method parameter which requires validation. |
7 | The @Get annotation maps the find method to an HTTP GET request on /fruits/{name} . |
4.10. Test
Create a test that verifies the validation of the FruitCommand
POJO when we invoke the FruitRepository
interface:
package example.micronaut
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import jakarta.inject.Inject
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import javax.validation.ConstraintViolationException
@MicronautTest(startApplication = false) (1)
class FruitRepositoryTest {
@Inject
lateinit var fruitRepository: FruitRepository
@Test
fun methodsValidateParameters() {
Assertions.assertThrows(ConstraintViolationException::class.java) {
fruitRepository.create(FruitCommand(""))
}
}
}
1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. By default, each @Test method will be wrapped in a transaction that will be rolled back when the test finishes. This behaviour is is changed by setting transaction to false . |
Create a test that verifies the validation of the FruitCommand
POJO when we create a new entity via POST
:
package example.micronaut
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import jakarta.inject.Inject
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
@MicronautTest (1)
class FruitValidationControllerTest(
@Inject @Client("/") val httpClient: HttpClient (2)
) : BaseTest() {
@Test
fun fruitIsValidated() {
val exception = assertThrows(HttpClientResponseException::class.java) {
httpClient.toBlocking().exchange<FruitCommand, Any>(
HttpRequest.POST("/fruits", FruitCommand("", ""))
)
}
Assertions.assertEquals(HttpStatus.BAD_REQUEST, exception.status)
}
}
1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info. |
2 | Inject the HttpClient bean and point it to the embedded server. |
We will use temporary directories to persist our data under test.
To facilitate this, create a base test class that handles the creation of a temporary folder, and configuring the application.
package example.micronaut
import io.micronaut.test.support.TestPropertyProvider
import org.junit.jupiter.api.io.TempDir
import java.io.File
abstract class BaseTest: TestPropertyProvider {
companion object {
@TempDir
@JvmField
var tempDir: File? = null
}
override fun getProperties(): MutableMap<String, String> {
return mutableMapOf(
"microstream.storage.main.storage-directory" to tempDir!!.absolutePath
)
}
}
Create a test which validate FruitDuplicateExceptionHandler
.
package example.micronaut
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import jakarta.inject.Inject
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@MicronautTest (1)
@TestInstance(TestInstance.Lifecycle.PER_CLASS) (2)
class FruitDuplicationExceptionHandlerTest : BaseTest() {
@Inject
@field:Client("/")
lateinit var httpClient: HttpClient (3)
@Test
fun duplicatedFruitsReturns400() {
val banana = FruitCommand("Banana")
val request = HttpRequest.POST("/fruits", banana)
val response = httpClient.toBlocking().exchange<FruitCommand, Any>(request)
assertEquals(HttpStatus.CREATED, response.status())
val exception = assertThrows(HttpClientResponseException::class.java) {
httpClient.toBlocking().exchange<FruitCommand, Any>(request)
}
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, exception.status)
val deleteRequest = HttpRequest.DELETE("/fruits", banana)
val deleteResponse = httpClient.toBlocking().exchange<FruitCommand, Any>(deleteRequest)
assertEquals(HttpStatus.NO_CONTENT, deleteResponse.status())
}
}
1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info. |
2 | Classes that implement TestPropertyProvider must use this annotation to create a single class instance for all tests (not necessary in Spock tests). |
3 | Inject the HttpClient bean and point it to the embedded server. |
Add a Micronaut declarative HTTP Client to src/test
to ease the testing of the application’s API.
package example.micronaut
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Delete
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.PathVariable
import io.micronaut.http.annotation.Post
import io.micronaut.http.annotation.Put
import io.micronaut.http.client.annotation.Client
import java.util.Optional
import javax.validation.Valid
@Client("/fruits")
interface FruitClient {
@Get
fun list(): Iterable<Fruit>
@Get("/{name}")
fun find(@PathVariable name: String?): Optional<Fruit>
@Post
fun create(@Body @Valid fruit: FruitCommand): HttpResponse<Fruit>
@Put
fun update(@Body @Valid fruit: FruitCommand): Fruit?
@Delete
fun delete(@Body @Valid fruit: FruitCommand): HttpStatus
}
And finally, create a test that checks our controller works against MicroStream correctly:
package example.micronaut
import io.micronaut.context.ApplicationContext
import io.micronaut.http.HttpStatus
import io.micronaut.runtime.server.EmbeddedServer
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.stream.Collectors
import java.util.stream.Stream
import java.util.stream.StreamSupport
import kotlin.streams.toList
class FruitControllerTest : BaseTest() {
@Test
fun testInteractionWithTheController() {
val apple = FruitCommand("apple", "Keeps the doctor away")
val bananaName = "banana"
val bananaDescription = "Yellow and curved"
val properties: Map<String, Any> = super.getProperties()
ApplicationContext.run(EmbeddedServer::class.java, properties).use { embeddedServer -> (1)
val fruitClient = embeddedServer.applicationContext.getBean(FruitClient::class.java)
var response = fruitClient.create(FruitCommand(bananaName))
assertEquals(HttpStatus.CREATED, response.status)
assertTrue(response.body.isPresent)
val banana = response.body.get()
val fruitList = fruitsList(fruitClient)
assertEquals(1, fruitList.size)
assertEquals(banana.name, fruitList[0].name)
assertNull(fruitList[0].description)
var bananaOptional: Fruit? = fruitClient.update(apple)
assertNull(bananaOptional)
response = fruitClient.create(apple)
assertEquals(HttpStatus.CREATED, response.status)
assertTrue(
fruitsStream(fruitClient)
.anyMatch { (_, description): Fruit -> "Keeps the doctor away" == description }
)
bananaOptional = fruitClient.update(FruitCommand(bananaName, bananaDescription))
Assertions.assertNotNull(bananaOptional)
assertEquals(
Stream.of("Keeps the doctor away", "Yellow and curved")
.collect(Collectors.toSet()),
fruitsStream(fruitClient).map { it.description }.toList().toSet()
)
}
ApplicationContext.run(EmbeddedServer::class.java, properties).use { embeddedServer -> (1)
val fruitClient =
embeddedServer.applicationContext.getBean(FruitClient::class.java)
assertEquals(2, numberOfFruits(fruitClient))
fruitClient.delete(apple)
fruitClient.delete(FruitCommand(bananaName, bananaDescription))
}
ApplicationContext.run(EmbeddedServer::class.java, properties).use { embeddedServer -> (1)
val fruitClient =
embeddedServer.applicationContext.getBean(FruitClient::class.java)
assertEquals(0, numberOfFruits(fruitClient))
}
}
private fun numberOfFruits(fruitClient: FruitClient): Int {
return fruitsList(fruitClient).size
}
private fun fruitsList(fruitClient: FruitClient): List<Fruit> {
return fruitsStream(fruitClient)
.collect(Collectors.toList())
}
private fun fruitsStream(fruitClient: FruitClient): Stream<Fruit> {
val fruits: Iterable<Fruit> = fruitClient.list()
return StreamSupport.stream(fruits.spliterator(), false)
}
}
1 | Start and stop application to verify the data is persisted to disk by MicroStream and can be retrieved after application restart. |
5. Testing the Application
To run the tests:
./mvnw test
6. Running the Application
To run the application, use the ./mvnw mn:run
command, which starts the application on port 8080.
curl -i -d '{"name":"Pear"}' \
-H "Content-Type: application/json" \
-X POST http://localhost:8080/fruits
HTTP/1.1 201 Created
date: Thu, 12 May 2022 13:45:56 GMT
Content-Type: application/json
content-length: 16
connection: keep-alive
{"name":"Pear"}
curl -i localhost:8080/fruits
HTTP/1.1 200 OK
date: Thu, 12 May 2022 13:46:54 GMT
Content-Type: application/json
content-length: 70
connection: keep-alive
[{"name":"Pear"}]
7. MicroStream REST and GUI
Often, during development is useful to see the data being saved by MicroStream. Micronaut MicroStream integration helps to do that.
Add the following dependency:
<dependency>
<groupId>io.micronaut.microstream</groupId>
<artifactId>micronaut-microstream-rest</artifactId>
<scope>developmentOnly</scope>
</dependency>
The above dependency provides several JSON endpoints which expose the contents of the MicroStream storage.
7.1. MicroStream Client GUI
Run the client and connect to the MicroStream REST API exposed by the Micronaut application:
You can visualize the data you saved via cURL.
8. Next steps
Explore more features with Micronaut Guides.
Read more about the Micronaut MicroStream integration. Read more about MicroStream for Java.
Read more about Micronaut Serialization.
9. Sponsors
MicroStream sponsored the creation of this Guide.