mn create-app --features=kafka,graalvm,reactor,testcontainers example.micronaut.chess-game --build=gradle --lang=kotlin
Table of Contents
- 1. Getting Started
- 2. What You Will Need
- 3. Solution
- 4. Writing the Application
- 5. Kafka
- 6. Running the application
- 7. Moving to Oracle Cloud
- 8. Local Testing with Cloud Resources
- 9. Writing Tests
- 10. Deploy to OCI
- 11. Generate Micronaut Application Native Executables with GraalVM
- 12. Next Steps
- 13. License
Oracle Cloud Streaming and the Micronaut Framework - Event-Driven Applications at Scale
Use Oracle Cloud Streaming to communicate between your Micronaut applications.
Authors: Burt Beckwith
Micronaut Version: 4.6.3
1. Getting Started
In this guide, we will create two Micronaut microservices written in Kotlin that will use Oracle Cloud Streaming to communicate with each other in an asynchronous and decoupled way.
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 11 or greater installed with
JAVA_HOME
configured appropriately -
An Oracle Cloud account (create a free trial account at signup.oraclecloud.com)
-
Oracle Cloud CLI installed with local access to Oracle Cloud configured by running
oci setup config
-
Docker and Docker Compose installed if you will be running Kafka in Docker and for running tests.
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
The microservices will use Oracle Cloud Streaming and the Kafka API to send and receive messages. The messages will represent chess gameplay events, including game start and end and each move.
The microservices are:
-
chess-game
- Has a simple JavaScript and Ajax UI that renders a variable number of chess games that will be auto-played to generate many events -
chess-listener
- Receives the chess event messages and persists them to a database
4.1. chess-game Microservice
Create the chess-game
microservice using the Micronaut Command Line Interface or with Micronaut Launch.
If you don’t specify the --build argument, Gradle with the Kotlin DSL is used as the build tool. If you don’t specify the --lang argument, Java is used as the language.If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.
|
If you use Micronaut Launch, select Micronaut Application as application type and add the kafka
, graalvm
, reactor
, and testcontainers
features.
The previous command creates a directory named chess-game
and a Micronaut application inside it with default package example.micronaut
.
In addition to the dependencies added by the testcontainers
feature, we also need a test dependency for Kafka in Testcontainers, along with one for the Awaitility library:
testImplementation("org.testcontainers:kafka")
testImplementation("org.awaitility:awaitility:4.2.2")
4.1.1. DTOs
Create a GameDTO
data-transfer object to represent game data:
package example.micronaut.chess.dto
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.annotation.JsonTypeInfo.Id.NAME
import io.micronaut.core.annotation.Creator
import io.micronaut.core.annotation.Introspected
import jakarta.validation.constraints.Size
@Introspected (1)
@JsonTypeInfo(use = NAME, property = "_className") (2)
class GameDTO
@Creator (3)
constructor(@field:Size(max = 36) val id: String,
@field:Size(max = 255) val blackName: String?,
@field:Size(max = 255) val whiteName: String?,
val draw: Boolean,
@field:Size(max = 1) val winner: String?) {
constructor(id: String, blackName: String, whiteName: String) :
this(id, blackName, whiteName, false, null)
constructor(id: String, draw: Boolean, winner: String?) :
this(id, null, null, draw, winner)
}
1 | Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized. |
2 | Annotate with @JsonTypeInfo to include the class name without package in the "_className" property |
3 | Annotate with @Creator to indicate the constructor to use when deserializing from JSON |
Create a GameStateDTO
data-transfer object to represent game move data:
package example.micronaut.chess.dto
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.annotation.JsonTypeInfo.Id.NAME
import io.micronaut.core.annotation.Introspected
import jakarta.validation.constraints.Size
@Introspected (1)
@JsonTypeInfo(use = NAME, property = "_className") (2)
data class GameStateDTO(@field:Size(max = 36) val id: String,
@field:Size(max = 36) val gameId: String,
@field:Size(max = 1) val player: String,
@field:Size(max = 100) val move: String,
val fen: String,
@field:Size(max = 10) val pgn: String)
1 | Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized. |
2 | Annotate with @JsonTypeInfo to include the class name without package in the "_className" property |
4.1.2. GameReporter
Create a GameReporter
Kafka client to send chess-related messages:
package example.micronaut.chess
import example.micronaut.chess.dto.GameDTO
import example.micronaut.chess.dto.GameStateDTO
import io.micronaut.configuration.kafka.annotation.KafkaClient
import io.micronaut.configuration.kafka.annotation.KafkaKey
import io.micronaut.configuration.kafka.annotation.Topic
import io.micronaut.core.annotation.NonNull
import reactor.core.publisher.Mono
@KafkaClient (1)
interface GameReporter {
@Topic("chessGame") (2)
@NonNull
fun game(@NonNull @KafkaKey gameId: String, (4)
@NonNull game: GameDTO): Mono<GameDTO> (3)
@Topic("chessGameState") (2)
@NonNull
fun gameState(@NonNull @KafkaKey gameId: String, (4)
@NonNull gameState: GameStateDTO): Mono<GameStateDTO> (3)
}
1 | Annotate the class with @KafkaClient to declare it as a Kafka message producer; the Micronaut framework will generate the sending code at compile time |
2 | Annotate the method with @Topic and specify the topic name |
3 | By returning a reactive type, the Micronaut framework will use non-blocking code to send the message |
4 | Use the Game primary key as the Kafka partition key |
4.1.3. GameController
Create a GameController
class to contain Ajax endpoints for the front end:
package example.micronaut.chess
import example.micronaut.chess.dto.GameDTO
import example.micronaut.chess.dto.GameStateDTO
import io.micronaut.http.HttpStatus.CREATED
import io.micronaut.http.HttpStatus.NO_CONTENT
import io.micronaut.http.MediaType.APPLICATION_FORM_URLENCODED
import io.micronaut.http.MediaType.TEXT_PLAIN
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.PathVariable
import io.micronaut.http.annotation.Post
import io.micronaut.http.annotation.Status
import io.micronaut.scheduling.TaskExecutors
import io.micronaut.scheduling.annotation.ExecuteOn
import reactor.core.publisher.Mono
import java.util.UUID
@Controller("/game") (1)
@ExecuteOn(TaskExecutors.BLOCKING) (2)
class GameController(private val gameReporter: GameReporter) {
@Post(value = "/start", (3)
consumes = [APPLICATION_FORM_URLENCODED], (4)
produces = [TEXT_PLAIN]) (5)
@Status(CREATED) (6)
fun start(b: String,
w: String): Mono<String> { (7)
val game = GameDTO(UUID.randomUUID().toString(), b, w) (8)
return gameReporter.game(game.id, game).map { game.id } (9)
}
@Post(value = "/move/{gameId}", (10)
consumes = [APPLICATION_FORM_URLENCODED]) (11)
@Status(CREATED) (12)
fun move(@PathVariable gameId: String,
player: String,
move: String,
fen: String,
pgn: String) {
val gameState = GameStateDTO(UUID.randomUUID().toString(),
gameId, player, move, fen, pgn)
gameReporter.gameState(gameId, gameState).subscribe() (13)
}
@Post("/checkmate/{gameId}/{player}") (14)
@Status(NO_CONTENT) (15)
fun checkmate(@PathVariable gameId: String,
@PathVariable player: String) {
val game = GameDTO(gameId, false, player)
gameReporter.game(gameId, game).subscribe() (16)
}
@Post("/draw/{gameId}") (17)
@Status(NO_CONTENT) (18)
fun draw(@PathVariable gameId: String) {
val game = GameDTO(gameId, true, null)
gameReporter.game(gameId, game).subscribe() (19)
}
}
1 | The class is defined as a controller with the @Controller annotation mapped to the path /game . |
2 | It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop. |
3 | The method accepts HTTP POST requests to indicate a new game has started |
4 | The method accepts encoded form data (the two players' names) |
5 | The response will be plain text and contain the ID of the persisted Game |
6 | Send a "Created" (201) status to indicate a Game was persisted |
7 | Return a reactive type to configure a reactive response |
8 | Assign the game’s primary key as a UUID |
9 | Use the GameReporter to send a Kafka message with new game data |
10 | The method accepts HTTP POST requests to indicate a move has occurred |
11 | The method accepts encoded form data (the move information) |
12 | Send a "Created" (201) status to indicate a GameState was persisted |
13 | Use the GameReporter to send a Kafka message with game move data |
14 | The method accepts HTTP POST requests to indicate the game ended with a checkmate |
15 | Send a "No Content" (204) status to indicate success and no response body |
16 | Use the GameReporter to send a Kafka message that the game ended in a checkmate |
17 | The method accepts HTTP POST requests to indicate the game ended with a draw |
18 | Send a "No Content" (204) status to indicate success and no response body |
19 | Use the GameReporter to send a Kafka message that the game ended in a draw |
4.1.4. Development environment
Modify the Application
class to use dev
as a default environment:
The Micronaut framework supports the concept of one or many default environments. A default environment is one that is only applied if no other environments are explicitly specified or deduced.
package example.micronaut
import io.micronaut.context.env.Environment.DEVELOPMENT
import io.micronaut.runtime.Micronaut.build
fun main(args: Array<String>) {
build()
.args(*args)
.packages("example.micronaut")
.defaultEnvironments(DEVELOPMENT)
.start()
}
Delete the kafka.bootstrap.servers
config option from application.yml
and move it to application-dev.yml
.
Create src/main/resources/application-dev.yml
. The Micronaut framework applies this configuration file only for the dev
environment.
kafka:
bootstrap:
servers: localhost:9092 (1)
1 | Use local Kafka in the development environment |
4.1.5. Static Resources
Update application.yml
to add static resource configuration:
micronaut:
router:
static-resources:
default:
paths: classpath:public (1)
1 | Configure the Framework to look for static resources in src/main/resources/public . |
4.1.6. UI Resources
Create index.html
with the simple chess game UI:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Micronaut Chess Multi</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" type="image/png" href="favicon-32x32.png">
<link rel="stylesheet"
href="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.css"
integrity="sha384-q94+BZtLrkL1/ohfjR8c6L+A6qzNH9R2hBLwyoAfu3i/WCvQjzL2RQJ3uNHDISdU"
crossorigin="anonymous">
<style>
.gamesRow {
width: 100%;
margin: 0 auto;
}
.gameContainer {
display: inline-block;
}
</style>
</head>
<body>
<div id="counts">
<div>
<label for="rowCount">Rows: </label><input type="number" value="1" min="1" max="10" id="rowCount">
</div>
<div>
<label for="gamesPerRow">Games per row: </label><input type="number" value="1" min="1" max="10" id="gamesPerRow">
</div>
<div>
<label for="playDelay">Play delay milliseconds: </label><input type="number" value="5" min="1" id="playDelay">
</div>
<div>
<button id="startButton">START</button>
</div>
</div>
<div id="gamesContainer"></div>
<script src="https://code.jquery.com/jquery-3.5.1.min.js"
integrity="sha384-ZvpUoO/+PpLXR1lu4jmpXWu80pZlYUAfxl5NsBMWOEPSjUn/6Z/hRTt8+pR6L4N2"
crossorigin="anonymous"></script>
<script src="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.js"
integrity="sha384-8Vi8VHwn3vjQ9eUHUxex3JSN/NFqUg3QbPyX8kWyb93+8AC/pPWTzj+nHtbC5bxD"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/chess.js/0.12.0/chess.min.js"
integrity="sha512-ujGsB4vTyNcSuViwM2DJ0+G2BIViQJl2rVBZBekStznA9Hq96+Wd9+jwu9zlttp0U2/9CYhgR7pOt2j+E6yewg=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="micronaut-chess.js"></script>
</body>
</html>
The HTML page includes the chessboard.js JavaScript library to create a chess board and the chess.js JavaScript library for chess game logic.
Create micronaut-chess.js
used by index.html
with the JavaScript code:
function updateStatus(n, started) {
if (started) {
onMove(n);
}
const game = GAMES[n];
if (game.in_checkmate()) {
onCheckmate(n);
}
else if (game.in_draw()) {
onDraw(n);
}
}
function onGameStart(n) {
$.post('/game/start', { b: BLACK_NAMES[n], w: WHITE_NAMES[n]}, function (data) {
const gameId = data;
GAME_IDS[n] = gameId;
$('#gameId' + n).text('Game ID: ' + gameId);
window.setTimeout(function () {
makeRandomMove(n);
}, 2000); // delay a bit so the Game is persisted
});
}
function onMove(n) {
const game = GAMES[n];
const history = game.history();
const move = history[history.length - 1];
$.post('/game/move/' + GAME_IDS[n], {
player: other(n),
fen: game.fen(),
pgn: game.pgn(),
move: move
});
}
function onCheckmate(n) {
const winner = other(n);
$.post('/game/checkmate/' + GAME_IDS[n] + '/' + winner);
}
function onDraw(n) {
$.post('/game/draw/' + GAME_IDS[n]);
}
function other(n) {
return GAMES[n].turn() === 'b' ? 'w' : 'b';
}
function makeRandomMove(n) {
const game = GAMES[n];
if (game.game_over()) {
restart(n);
return;
}
const possibleMoves = game.moves();
const moveIndex = Math.floor(Math.random() * possibleMoves.length);
game.move(possibleMoves[moveIndex]);
BOARDS[n].position(game.fen());
updateStatus(n, true);
window.setTimeout(function () {
makeRandomMove(n);
}, playDelay);
}
function restart(n) {
BOARDS[n].position(FEN_INITIAL);
GAMES[n].load(FEN_INITIAL);
updateStatus(n, false);
onGameStart(n);
}
function startGames() {
$('#counts').toggle();
playDelay = parseInt($('#playDelay').val(), 10);
const rowCount = parseInt($('#rowCount').val(), 10);
const gamesPerRow = parseInt($('#gamesPerRow').val(), 10);
const hWidth = (window.innerWidth - 50) / gamesPerRow;
const vWidth = window.innerHeight / rowCount - 50;
const gameWidth = Math.min(400, hWidth, vWidth);
for (let r = 0; r < rowCount; r++) {
const gamesContainer = $('#gamesContainer');
gamesContainer.append(
'<div id="gamesRow' + r + '" class="gamesRow"></div>'
);
for (let c = 0; c < gamesPerRow; c++) {
const n = r * gamesPerRow + c;
const gamesRow = $('#gamesRow' + r);
gamesRow.append(
'<div class="gameContainer" style="width: ' + gameWidth + 'px">' +
'<div id="chessboard' + n + '"></div>' +
'<div><span id="gameId' + n + '"></span></div>' +
'</div>'
);
GAMES[n] = new Chess();
BLACK_NAMES[n] = 'b' + n;
WHITE_NAMES[n] = 'w' + n;
BOARDS[n] = Chessboard('chessboard' + n, {
position: 'start',
appearSpeed: 0,
moveSpeed: 0
});
updateStatus(n, false);
onGameStart(n);
}
}
}
const FEN_INITIAL = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
const GAME_IDS = [];
const BOARDS = [];
const BLACK_NAMES = [];
const WHITE_NAMES = [];
const GAMES = [];
let playDelay = 5;
$('#startButton').on('click', startGames);
Copy these chess piece images to src/main/resources/public/img/chesspieces/wikipedia
(the path must be correct because it is hard-coded in chessboard.js
):
Right-click each image and save to your local file system, or extract the completed example zip file linked above and get them from there.
4.2. chess-listener Microservice
Create the chess-listener
microservice using the Micronaut Command Line Interface or with Micronaut Launch.
mn create-app --features=kafka,graalvm,data-jdbc,flyway,reactor,testcontainers example.micronaut.chess-listener --build=gradle --lang=kotlin
If you don’t specify the --build argument, Gradle with the Kotlin DSL is used as the build tool. If you don’t specify the --lang argument, Java is used as the language.If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.
|
If you use Micronaut Launch, select Micronaut Application as application type and add the kafka
, graalvm
, data-jdbc
, flyway
, reactor
, and testcontainers
features.
The previous command creates a directory named chess-listener
and a Micronaut application inside it with default package example.micronaut
.
In addition to the dependencies added by the testcontainers
feature, we also need a test dependency for Kafka and Oracle in Testcontainers, along with one for the Awaitility library:
testImplementation("org.testcontainers:kafka")
testImplementation("org.testcontainers:oracle-xe")
testImplementation("org.awaitility:awaitility:4.2.2")
4.2.1. Flyway
Enable Flyway database migrations for all environments by adding this configuration to application.yml
:
flyway:
datasources:
default:
enabled: true
4.2.2. DTOs
The same data transfer objects (GameDTO
and GameStateDTO
…) as above in the chess-game
microservice. In a real application, these would be in a shared library, but to keep things simple, we’ll just duplicate them.
4.2.3. Entity Classes
Create a Game
entity to represent persistent game data:
package example.micronaut.chess.entity
import example.micronaut.chess.dto.GameDTO
import io.micronaut.core.annotation.Nullable
import io.micronaut.data.annotation.DateCreated
import io.micronaut.data.annotation.DateUpdated
import io.micronaut.data.annotation.Id
import io.micronaut.data.annotation.MappedEntity
import java.time.LocalDateTime
import java.util.UUID
import jakarta.validation.constraints.Size
@MappedEntity("GAME")
class Game(
@field:Id
val id: UUID,
@field:Size(max = 255)
val blackName: String,
@field:Size(max = 255)
val whiteName: String) {
@DateCreated
var dateCreated: LocalDateTime? = null
@DateUpdated
var dateUpdated: LocalDateTime? = null
var draw = false
@Nullable
@field:Size(max = 1) var winner: String? = null
fun toDto() = GameDTO(id.toString(), blackName, whiteName, draw, winner)
}
Create a GameState
entity to represent persistent game move data:
package example.micronaut.chess.entity
import example.micronaut.chess.dto.GameStateDTO
import io.micronaut.data.annotation.DateCreated
import io.micronaut.data.annotation.Id
import io.micronaut.data.annotation.MappedEntity
import io.micronaut.data.annotation.Relation
import io.micronaut.data.annotation.Relation.Kind.MANY_TO_ONE
import java.time.LocalDateTime
import java.util.UUID
import jakarta.validation.constraints.Size
@MappedEntity("GAME_STATE")
class GameState(
@field:Id
val id: UUID,
@field:Relation(MANY_TO_ONE)
val game: Game,
@field:Size(max = 1)
val player: String,
@field:Size(max = 10)
val move: String,
@field:Size(max = 100)
val fen: String,
val pgn: String) {
@DateCreated
var dateCreated: LocalDateTime? = null
fun toDto() = GameStateDTO(id.toString(), game.id.toString(), player, move, fen, pgn)
}
4.2.4. Repositories
Create a "base" GameRepository
interface to have access to methods for Game
entity persistence:
package example.micronaut.chess.repository
import example.micronaut.chess.entity.Game
import io.micronaut.data.repository.CrudRepository
import java.util.UUID
interface GameRepository : CrudRepository<Game, UUID>
and a H2GameRepository
interface that extends GameRepository
and specifies the H2
dialect to use an in-memory H2 database in the development environment (we’ll also be creating an Oracle repository):
package example.micronaut.chess.repository
import io.micronaut.context.annotation.Primary
import io.micronaut.context.annotation.Requires
import io.micronaut.context.env.Environment.DEVELOPMENT
import io.micronaut.data.jdbc.annotation.JdbcRepository
import io.micronaut.data.model.query.builder.sql.Dialect.H2
@Primary
@JdbcRepository(dialect = H2) (1)
@Requires(env = [DEVELOPMENT]) (2)
interface H2GameRepository : GameRepository
1 | Annotate with @JdbcRepository to make this a Micronaut Data JDBC repository, and specify the H2 dialect. The Micronaut framework will generate persistence logic at compile time and use H2-specific SQL. |
2 | Restrict the bean to be available only in the development environment |
Create a "base" GameStateRepository
interface to have access to methods for GameState
entity persistence:
package example.micronaut.chess.repository
import example.micronaut.chess.entity.GameState
import io.micronaut.core.annotation.NonNull
import io.micronaut.data.annotation.Join
import io.micronaut.data.annotation.Join.Type.FETCH
import io.micronaut.data.repository.CrudRepository
import java.util.Optional
import java.util.UUID
import jakarta.validation.constraints.NotNull
interface GameStateRepository : CrudRepository<GameState, UUID> {
@NonNull
@Join(value = "game", type = FETCH) (1)
override fun findById(@NotNull @NonNull id: UUID): Optional<GameState?>
}
1 | Override the findById method from CrudRepository to add a @Join annotation. This will configure the SQL query to load Game data when retrieving a GameState to resolve the game property. |
Also create a H2GameStateRepository
interface that extends GameStateRepository
:
package example.micronaut.chess.repository
import io.micronaut.context.annotation.Primary
import io.micronaut.context.annotation.Requires
import io.micronaut.context.env.Environment.DEVELOPMENT
import io.micronaut.data.jdbc.annotation.JdbcRepository
import io.micronaut.data.model.query.builder.sql.Dialect.H2
@Primary
@JdbcRepository(dialect = H2)
@Requires(env = [DEVELOPMENT])
interface H2GameStateRepository : GameStateRepository
4.2.5. GameService
Create GameService
to coordinate transactional persistence using GameRepository
and GameStateRepository
:
package example.micronaut.chess
import example.micronaut.chess.dto.GameDTO
import example.micronaut.chess.dto.GameStateDTO
import example.micronaut.chess.entity.Game
import example.micronaut.chess.entity.GameState
import example.micronaut.chess.repository.GameRepository
import example.micronaut.chess.repository.GameStateRepository
import org.slf4j.LoggerFactory
import java.util.UUID
import jakarta.inject.Singleton
import jakarta.transaction.Transactional
@Singleton
@Transactional
open class GameService(private val gameRepository: GameRepository,
private val gameStateRepository: GameStateRepository) {
private val log = LoggerFactory.getLogger(GameService::class.java.name)
open fun newGame(gameDTO: GameDTO): Game {
log.debug("New game {}, black: {}, white: {}",
gameDTO.id, gameDTO.blackName, gameDTO.whiteName)
val game = Game(UUID.fromString(gameDTO.id), gameDTO.blackName!!, gameDTO.whiteName!!)
return gameRepository.save(game)
}
open fun newGameState(gameStateDTO: GameStateDTO) {
val game = findGame(gameStateDTO.gameId)
val gameState = GameState(
UUID.fromString(gameStateDTO.id), game,
gameStateDTO.player, gameStateDTO.move,
gameStateDTO.fen, gameStateDTO.pgn)
gameStateRepository.save(gameState)
}
open fun checkmate(gameDTO: GameDTO) {
log.debug("Game {} ended with winner: {}", gameDTO.id, gameDTO.winner)
val game = findGame(gameDTO.id)
game.winner = gameDTO.winner
gameRepository.update(game)
}
open fun draw(gameDTO: GameDTO) {
log.debug("Game {} ended in a draw", gameDTO.id)
val game = findGame(gameDTO.id)
game.draw = true
gameRepository.update(game)
}
private fun findGame(gameId: String) =
gameRepository.findById(UUID.fromString(gameId))
.orElseThrow { IllegalArgumentException("Game with id '$gameId' not found") }
}
4.2.6. ChessListener
Create ChessListener
Kafka listener to receive messages sent from the chess-game
microservice:
package example.micronaut.chess
import example.micronaut.chess.dto.GameDTO
import example.micronaut.chess.dto.GameStateDTO
import io.micronaut.configuration.kafka.annotation.KafkaListener
import io.micronaut.configuration.kafka.annotation.OffsetReset.EARLIEST
import io.micronaut.configuration.kafka.annotation.Topic
@KafkaListener(offsetReset = EARLIEST) (1)
class ChessListener(private val gameService: GameService) { (2)
@Topic("chessGame") (3)
fun onGame(gameDTO: GameDTO) {
if (gameDTO.draw) {
gameService.draw(gameDTO) (4)
} else if (gameDTO.winner != null) {
gameService.checkmate(gameDTO) (5)
} else {
gameService.newGame(gameDTO) (6)
}
}
@Topic("chessGameState") (3)
fun onGameState(gameState: GameStateDTO) = gameService.newGameState(gameState) (7)
}
1 | Annotate the class with @KafkaListener to declare it as a Kafka message listener; the Micronaut framework will generate the receiving code at compile time |
2 | Dependency injection for GameService |
3 | Annotate the method with @Topic and specify the topic name |
4 | Use GameService to record that the game ended in a draw |
5 | Use GameService to record that the game ended in checkmate |
6 | Use GameService to record that a new game has started |
7 | Use GameService to record that a new game move occurred |
4.2.7. Development environment
Modify the Application
class to use dev
as a default environment:
The Micronaut framework supports the concept of one or many default environments. A default environment is one that is only applied if no other environments are explicitly specified or deduced.
package example.micronaut
import io.micronaut.context.env.Environment.DEVELOPMENT
import io.micronaut.runtime.Micronaut.build
fun main(args: Array<String>) {
build()
.args(*args)
.packages("example.micronaut")
.defaultEnvironments(DEVELOPMENT)
.start()
}
Create src/main/resources/application-dev.yml
. The Micronaut framework applies this configuration file only for the dev
environment.
micronaut:
server:
port: 8081 (1)
datasources:
default:
url: jdbc:h2:mem:devDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE (2)
driverClassName: org.h2.Driver
username: sa
password: ''
schema-generate: none
dialect: H2
kafka:
bootstrap:
servers: localhost:9092 (3)
flyway:
datasources:
default:
locations:
- classpath:db/migration/h2 (4)
1 | Run this microservice on port 8081; chess-game will run on the default port 8080 |
2 | Use an in-memory H2 database. Delete the datasources block so it’s only in application-dev.yml |
3 | Use local Kafka. Delete the kafka.bootstrap.servers property so it’s only in application-dev.yml |
4 | Configure Flyway to look for migration scripts in src/main/resources/db/migration/h2 |
4.2.8. H2 Flyway Migration Script
Create a database migration script to create the database tables:
CREATE TABLE game (
id CHAR(36) PRIMARY KEY,
black_name VARCHAR(255) NOT NULL,
white_name VARCHAR(255) NOT NULL,
date_created TIMESTAMP NOT NULL,
date_updated TIMESTAMP NOT NULL,
draw BOOLEAN NOT NULL,
winner CHAR(1)
);
CREATE TABLE game_state (
id CHAR(36) PRIMARY KEY,
game_id CHAR(36) NOT NULL,
date_created TIMESTAMP NOT NULL,
player CHAR(1) NOT NULL,
fen VARCHAR(100) NOT NULL,
pgn CLOB NOT NULL,
move VARCHAR(10) NOT NULL,
CONSTRAINT fk_game_state_game FOREIGN KEY (game_id) REFERENCES game(id)
);
5. Kafka
We’ll use Oracle Cloud Streaming in the "real" application, but for local development, we can use a local Kafka instance.
5.1. Install Kafka
A fast way to start using Kafka is via Docker. Create this docker-compose.yml
file:
version: '2'
services:
zookeeper:
image: confluentinc/cp-zookeeper
ports:
- 2181:2181 (1)
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
kafka:
image: confluentinc/cp-kafka
depends_on:
- zookeeper
ports:
- 9092:9092 (2)
environment:
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
1 | Zookeeper uses port 2181 by default, but change the value if needed |
2 | Kafka uses port 9092 by default, but change the value if needed |
Start Zookeeper and Kafka (use CTRL-C to stop both):
docker-compose up
Alternatively you can install and run a local Kafka instance.
6. Running the application
Start the chess-game
microservice:
./gradlew run
16:35:55.614 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 576ms. Server Running: http://localhost:8080
Start the chess-listener
microservice:
./gradlew run
16:35:55.614 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 623ms. Server Running: http://localhost:8081
Test the app functionality by opening http://localhost:8080/ in a browser. The UI lets you choose one or more chess games that will auto-play with the specified delay between plays. Events (game start and end, player moves) are sent to the server via Ajax and then sent to the chess-listener
microservice for processing, analysis, etc.
You can, for example, start a single game with a moderately large delay between plays:
A single board is displayed:
Or you can start multiple games with a short delay (or any combination you want):
Multiple simultaneous boards are displayed:
7. Moving to Oracle Cloud
7.1. Oracle Autonomous Database (ATP)
Update the chess-listener
microservice to support Oracle in addition to the in-memory H2 database.
Use the Oracle Autonomous Database guide to provision an Oracle database at OCI.
7.1.1. Dependencies
Add the micronaut-oraclecloud-atp
dependency to the chess-listener
microservice to support using ATP:
implementation("io.micronaut.oraclecloud:micronaut-oraclecloud-atp")
7.1.2. Configuration
Create src/main/resources/application-oraclecloud.yml
. The Micronaut framework applies this configuration file only for the oraclecloud
environment.
datasources:
default:
ocid: (1)
walletPassword: (2)
username: micronautdemo
password: (3)
oci:
config:
profile: DEFAULT (4)
flyway:
datasources:
default:
locations:
- classpath:db/migration/oracle (5)
1 | Set the value of the ocid property with the database OCID unique identifier you saved when creating the database |
2 | Set the walletPassword property with a password to encrypt the wallet keys (must be at least 8 characters and include at least 1 letter and either 1 numeric or special character) |
3 | Set the password property with the micronautdemo schema user password you created |
4 | Change the profile name if you’re not using the default, and optionally add a value for the path to the config file if necessary as described in the Authentication section of the Micronaut Oracle Cloud docs |
5 | Configure Flyway to look for migration scripts in src/main/resources/db/migration/oracle |
7.1.3. Repositories
Create the OracleGameRepository
interface that extends GameRepository
and specifies the ORACLE
dialect in the oraclecloud
environment:
package example.micronaut.chess.repository
import io.micronaut.context.annotation.Primary
import io.micronaut.context.annotation.Requires
import io.micronaut.context.env.Environment.ORACLE_CLOUD
import io.micronaut.context.env.Environment.TEST
import io.micronaut.data.jdbc.annotation.JdbcRepository
import io.micronaut.data.model.query.builder.sql.Dialect.ORACLE
@Primary
@JdbcRepository(dialect = ORACLE) (1)
@Requires(env = [ORACLE_CLOUD, TEST]) (2)
interface OracleGameRepository : GameRepository
1 | Annotate with @JdbcRepository to make this a Micronaut Data JDBC repository, and specify the ORACLE dialect. The Micronaut framework will generate persistence logic at compile time and use Oracle-specific SQL. |
2 | Restrict the bean to be available only in the oraclecloud environment |
Create the OracleGameStateRepository
interface that extends GameStateRepository
:
package example.micronaut.chess.repository
import io.micronaut.context.annotation.Primary
import io.micronaut.context.annotation.Requires
import io.micronaut.context.env.Environment.ORACLE_CLOUD
import io.micronaut.context.env.Environment.TEST
import io.micronaut.data.jdbc.annotation.JdbcRepository
import io.micronaut.data.model.query.builder.sql.Dialect.ORACLE
@Primary
@JdbcRepository(dialect = ORACLE)
@Requires(env = [ORACLE_CLOUD, TEST])
interface OracleGameStateRepository : GameStateRepository
7.1.4. Flyway
Create a database migration script to create the Oracle database tables:
CREATE TABLE game (
id CHAR(36) PRIMARY KEY,
black_name VARCHAR2(255) NOT NULL,
white_name VARCHAR2(255) NOT NULL,
date_created TIMESTAMP NOT NULL,
date_updated TIMESTAMP NOT NULL,
draw NUMBER(3) NOT NULL,
winner CHAR(1)
);
CREATE TABLE game_state (
id CHAR(36) PRIMARY KEY,
game_id CHAR(36) NOT NULL,
date_created TIMESTAMP NOT NULL,
player CHAR(1) NOT NULL,
fen VARCHAR2(100) NOT NULL,
pgn CLOB NOT NULL,
move VARCHAR2(10) NOT NULL,
CONSTRAINT fk_game_state_game FOREIGN KEY (game_id) REFERENCES game(id)
);
7.2. Oracle Cloud Streaming
Up to now, we’ve been using a local Kafka, but let’s configure the equivalent infrastructure in OCI. This will involve minimal application changes thanks to the ability to send and receive Cloud Streaming messages using Kafka APIs, and Micronaut support for Kafka.
7.2.1. Stream Pool and Streams
Log in to your Oracle Cloud tenancy and from the Oracle Cloud Menu, select "Analytics & AI" and then "Streaming":
Choose the compartment to create the streams in, then click "Create Stream Pool":
Enter a name for the pool, e.g., "mn-guide-pool", and click "Create":
Click the "Copy" link in the OCID
row and save the value for later. Also save the "FQDN" URL. Click "Create Stream":
Create two streams within the pool you created with the Topic names used in the microservices. First create "chessGame":
and then create "chessGameState":
7.2.2. User and Group
Create a group for the streams by clicking the Oracle Cloud menu and selecting "Identity & Security" and then click "Groups":
Click "Create Group":
Choose a name and a description, e.g., "mn-guide-streaming-group", and click "Create":
Create a user by clicking the Oracle Cloud menu and selecting "Identity & Security" and then click "Users":
Click "Create User":
Choose a name and a description, e.g., "mn-guide-streaming-user", and click "Create":
Scroll down and click "Add User to Group":
Select the group you created and click "Add":
You’ll need an auth token to use as the password in the Micronaut Kafka configuration. Click "Auth Tokens" and then "Generate Token":
Enter a name for the token, e.g., "mn-guide-streaming", and click "Generate Token":
Copy the token to the clipboard and save it for later:
See the Groups and Users docs for more information.
7.2.3. Policy
Create a policy to grant various Streams access to the user and group you created.
Open the Oracle Cloud Menu and click "Identity & Security" and then "Policies":
Select the compartment where you created the streams from the dropdown and click "Create Policy":
Choose a name and description, e.g., "mn-guide-streaming-policy", and click "Show Manual Editor". Copy the following and paste it into the "Policy Builder" field, replacing "micronaut-guides" with the name of the compartment you’re using, and click "Create":
7.2.4. Application configuration
Create src/main/resources/application-oraclecloud.yml
in the chess-game
microservice. Add the following there, and also add it to the application-oraclecloud.yml
you already created in the chess-listener
microservice:
kafka:
bootstrap:
servers: (1)
security:
protocol: SASL_SSL
sasl:
mechanism: PLAIN
jaas:
config: (2)
retries: 3
max:
request:
size: 1048576 (3)
partition:
fetch:
bytes: 1048576 (4)
1 | Use the "FQDN" URL value you saved earlier here, along with the Kafka port (9092), e.g., cell-1.streaming.us-ashburn-1.oci.oraclecloud.com:9092 |
2 | Use the value config: org.apache.kafka.common.security.plain.PlainLoginModule required username="<tenancy-name>/<username>/<stream-pool-ocid>" password="<auth-token>"; , replacing <tenancy-name> with the name of your tenancy, <username> with the username created above, <stream-pool-ocid> with the OCID of the stream pool you saved earlier, and <auth-token> with the auth token value you saved earlier. |
3 | Limit request size to 1MB |
4 | Limit request size to 1MB per partition |
8. Local Testing with Cloud Resources
You can now start both microservices in the oraclecloud
environment to use Cloud Streaming and the ATP database you created:
To run each application use:
MICRONAUT_ENVIRONMENTS=oraclecloud ./gradlew run
or if you use Windows:
cmd /C "set MICRONAUT_ENVIRONMENTS=oraclecloud && gradlew run"
9. Writing Tests
We’ll run Kafka inside a Docker container using Testcontainers for both application tests and also run Oracle database inside a Docker container for testing persistence in the chess-listener
tests.
9.1. chess-game tests
Create a test in the chess-game
microservice to verify that Kafka message processing works:
package example.micronaut
import example.micronaut.chess.dto.GameDTO
import example.micronaut.chess.dto.GameStateDTO
import io.micronaut.configuration.kafka.annotation.KafkaListener
import io.micronaut.configuration.kafka.annotation.OffsetReset.EARLIEST
import io.micronaut.configuration.kafka.annotation.Topic
import io.micronaut.core.type.Argument
import io.micronaut.http.HttpRequest
import io.micronaut.http.MediaType.APPLICATION_FORM_URLENCODED_TYPE
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import io.micronaut.test.support.TestPropertyProvider
import org.awaitility.Awaitility.await
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
import org.testcontainers.containers.KafkaContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName
import java.util.Optional
import java.util.concurrent.ConcurrentLinkedDeque
import java.util.concurrent.TimeUnit.SECONDS
import jakarta.inject.Inject
@Testcontainers (1)
@MicronautTest
@TestInstance(PER_CLASS) (2)
class GameReporterTest : TestPropertyProvider { (3)
companion object {
val receivedGames: MutableCollection<GameDTO> = ConcurrentLinkedDeque()
val receivedMoves: MutableCollection<GameStateDTO> = ConcurrentLinkedDeque()
@Container
val kafka = KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:latest")) (4)
}
@Inject
lateinit var chessListener: ChessListener (5)
@Inject (6)
@field:Client("/") (7)
lateinit var client: HttpClient (8)
@Test
fun testGameEndingInCheckmate() {
val blackName = "b_name"
val whiteName = "w_name"
// start game
val result = startGame(blackName, whiteName)
val gameId = result.orElseThrow { RuntimeException("Expected GameDTO id") }
await().atMost(5, SECONDS).until { !receivedGames.isEmpty() } (9)
assertEquals(1, receivedGames.size)
assertEquals(0, receivedMoves.size)
var game = receivedGames.iterator().next()
assertEquals(gameId, game.id)
assertEquals(blackName, game.blackName)
assertEquals(whiteName, game.whiteName)
assertFalse(game.draw)
assertNull(game.winner)
// make moves
receivedGames.clear()
makeMove(gameId, "w", "f3", "rnbqkbnr/pppppppp/8/8/8/5P2/PPPPP1PP/RNBQKBNR b KQkq - 0 1", "1. f3")
makeMove(gameId, "b", "e6", "rnbqkbnr/pppp1ppp/4p3/8/8/5P2/PPPPP1PP/RNBQKBNR w KQkq - 0 2", "1. f3 e6")
makeMove(gameId, "w", "g4", "rnbqkbnr/pppp1ppp/4p3/8/6P1/5P2/PPPPP2P/RNBQKBNR b KQkq g3 0 2", "1. f3 e6 2. g4")
makeMove(gameId, "b", "Qh4#", "rnb1kbnr/pppp1ppp/4p3/8/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3", "1. f3 e6 2. g4 Qh4#")
await().atMost(5, SECONDS).until { receivedMoves.size > 3 }
assertEquals(0, receivedGames.size)
assertEquals(4, receivedMoves.size)
val moves: List<GameStateDTO> = ArrayList(receivedMoves)
assertEquals("w", moves[0].player)
assertEquals("f3", moves[0].move)
assertEquals("b", moves[1].player)
assertEquals("e6", moves[1].move)
assertEquals("w", moves[2].player)
assertEquals("g4", moves[2].move)
assertEquals("b", moves[3].player)
assertEquals("Qh4#", moves[3].move)
// end game
receivedMoves.clear()
endGame(gameId, "b")
await().atMost(5, SECONDS).until { !receivedGames.isEmpty() }
assertEquals(1, receivedGames.size)
assertEquals(0, receivedMoves.size)
game = receivedGames.iterator().next()
assertEquals(gameId, game.id)
assertNull(game.blackName)
assertNull(game.whiteName)
assertFalse(game.draw)
assertEquals("b", game.winner)
}
@Test
fun testGameEndingInDraw() {
val blackName = "b_name"
val whiteName = "w_name"
// start game
val result = startGame(blackName, whiteName)
val gameId = result.orElseThrow { RuntimeException("Expected GameDTO id") }
await().atMost(5, SECONDS).until { !receivedGames.isEmpty() }
assertEquals(1, receivedGames.size)
assertEquals(0, receivedMoves.size)
var game = receivedGames.iterator().next()
assertEquals(gameId, game.id)
assertEquals(blackName, game.blackName)
assertEquals(whiteName, game.whiteName)
assertFalse(game.draw)
assertNull(game.winner)
// make moves
receivedGames.clear()
makeMove(gameId, "w", "f3", "rnbqkbnr/pppppppp/8/8/8/5P2/PPPPP1PP/RNBQKBNR b KQkq - 0 1", "1. f3")
makeMove(gameId, "b", "e6", "rnbqkbnr/pppp1ppp/4p3/8/8/5P2/PPPPP1PP/RNBQKBNR w KQkq - 0 2", "1. f3 e6")
await().atMost(5, SECONDS).until { receivedMoves.size > 1 }
assertEquals(0, receivedGames.size)
assertEquals(2, receivedMoves.size)
// end game
receivedMoves.clear()
endGame(gameId, null)
await().atMost(5, SECONDS).until { !receivedGames.isEmpty() }
assertEquals(1, receivedGames.size)
assertEquals(0, receivedMoves.size)
game = receivedGames.iterator().next()
assertEquals(gameId, game.id)
assertNull(game.blackName)
assertNull(game.whiteName)
assertTrue(game.draw)
assertNull(game.winner)
}
override fun getProperties(): Map<String, String> {
kafka.start()
return mapOf("kafka.bootstrap.servers" to kafka.bootstrapServers) (10)
}
@AfterEach
fun cleanup() {
receivedGames.clear()
receivedMoves.clear()
}
@KafkaListener(offsetReset = EARLIEST)
class ChessListener {
@Topic("chessGame")
fun onGame(game: GameDTO) {
receivedGames.add(game)
}
@Topic("chessGameState")
fun onGameState(gameState: GameStateDTO) {
receivedMoves.add(gameState)
}
}
private fun startGame(blackName: String, whiteName: String): Optional<String> {
val body = mapOf("b" to blackName, "w" to whiteName) (11)
val request: HttpRequest<*> = HttpRequest.POST("/game/start", body)
.contentType(APPLICATION_FORM_URLENCODED_TYPE)
return client.toBlocking().retrieve(request,
Argument.of(Optional::class.java, String::class.java)) as Optional<String> (12)
}
private fun makeMove(gameId: String, player: String, move: String,
fen: String, pgn: String) {
val body = mapOf("player" to player, "move" to move, "fen" to fen, "pgn" to pgn)
val request = HttpRequest.POST("/game/move/$gameId", body)
.contentType(APPLICATION_FORM_URLENCODED_TYPE)
client.toBlocking().exchange<Map<String, String>, Any>(request) (13)
}
private fun endGame(gameId: String, winner: String?) {
val uri = if (winner == null) "/game/draw/$gameId" else "/game/checkmate/$gameId/$winner"
val request: HttpRequest<Any?> = HttpRequest.POST(uri, null)
client.toBlocking().exchange<Any?, Any>(request) (14)
}
}
1 | Use the @Testcontainers annotation to configure automatic container management (not necessary in Spock tests) |
2 | Classes that implement TestPropertyProvider must use this annotation to create a single class instance for all tests (not necessary in Spock tests). |
3 | Implementing TestPropertyProvider allows the test class to provide application configuration properties, in this case the dynamically allocated Kafka broker port |
4 | The Testcontainer instance for Kafka |
5 | Dependency injection for the ChessListener class declared below, a Kafka listener class that replicates the functionality of the class of the same name in the chess-listener microservice |
6 | Dependency injection for an HTTP client that the Micronaut framework will implement at compile to make calls to GameController |
7 | When annotating a property or a primary constructor parameter, multiple Java elements are generated from the corresponding Kotlin element, and therefore multiple locations for the annotation in the generated Java bytecode. @field: specifies that it should annotate a Java field. |
8 | By using lateinit , you tell Kotlin that you have not forgotten to initialize the variable and plan to get to that momentarily. Without lateinit , the Kotlin compiler will give an error for the unassigned variable. |
9 | Wait a few seconds for the message to arrive; it should happen very quickly, but the message will be sent on a separate thread |
10 | Configure the Kafka broker port (it will be different unused port each time) so Micronaut Kafka clients and listeners connect to the test broker |
11 | Use a Map to hold form parameter names and values |
12 | Use the HttpClient to send a POST request that indicates a game has started, which will trigger sending a message with Kafka |
13 | Use the HttpClient to send a POST request that indicates a game move has occurred, which will trigger sending a message with Kafka |
14 | Use the HttpClient to send a POST request that indicates a game has ended (in either a draw or checkmate), which will trigger sending a message with Kafka |
9.2. chess-listener tests
Create a test in the chess-listener
microservice to verify that Kafka message processing and database persistence works:
package example.micronaut
import example.micronaut.chess.dto.GameDTO
import example.micronaut.chess.dto.GameStateDTO
import example.micronaut.chess.entity.GameState
import example.micronaut.chess.repository.GameRepository
import example.micronaut.chess.repository.GameStateRepository
import io.micronaut.configuration.kafka.annotation.KafkaClient
import io.micronaut.configuration.kafka.annotation.KafkaKey
import io.micronaut.configuration.kafka.annotation.Topic
import io.micronaut.core.annotation.NonNull
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import io.micronaut.test.support.TestPropertyProvider
import org.awaitility.Awaitility.await
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
import org.testcontainers.containers.KafkaContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName
import reactor.core.publisher.Mono
import java.util.UUID
import java.util.concurrent.TimeUnit.SECONDS
import jakarta.inject.Inject
@Testcontainers (1)
@MicronautTest
@TestInstance(PER_CLASS) (2)
class GameServiceTest : TestPropertyProvider { (3)
companion object {
@Container
var kafka = KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:latest")) (4)
}
@Inject
lateinit var gameReporter: GameReporter (5)
@Inject
lateinit var gameRepository: GameRepository
@Inject
lateinit var gameStateRepository: GameStateRepository
@Test
fun testGameEndingInCheckmate() {
val blackName = "b_name"
val whiteName = "w_name"
// start game
val gameId = UUID.randomUUID()
val gameIdString = gameId.toString()
var gameDto = GameDTO(gameIdString, blackName, whiteName)
gameReporter.game(gameIdString, gameDto).subscribe()
await().atMost(5, SECONDS).until { gameRepository.count() > 0 } (6)
assertEquals(1, gameRepository.count())
assertEquals(0, gameStateRepository.count())
var game = gameRepository.findById(gameId).orElseThrow { IllegalStateException("Unable to find expected Game") }
assertEquals(gameId, game.id)
assertEquals(blackName, game.blackName)
assertEquals(whiteName, game.whiteName)
assertFalse(game.draw)
assertNull(game.winner)
// make moves
val gameStateIds = mutableListOf<UUID>()
var gameStateId = makeMove(gameIdString, "w", "f3", "rnbqkbnr/pppppppp/8/8/8/5P2/PPPPP1PP/RNBQKBNR b KQkq - 0 1", "1. f3")
gameStateIds.add(gameStateId)
gameStateId = makeMove(gameIdString, "b", "e6", "rnbqkbnr/pppp1ppp/4p3/8/8/5P2/PPPPP1PP/RNBQKBNR w KQkq - 0 2", "1. f3 e6")
gameStateIds.add(gameStateId)
gameStateId = makeMove(gameIdString, "w", "g4", "rnbqkbnr/pppp1ppp/4p3/8/6P1/5P2/PPPPP2P/RNBQKBNR b KQkq g3 0 2", "1. f3 e6 2. g4")
gameStateIds.add(gameStateId)
gameStateId = makeMove(gameIdString, "b", "Qh4#", "rnb1kbnr/pppp1ppp/4p3/8/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3", "1. f3 e6 2. g4 Qh4#")
gameStateIds.add(gameStateId)
await().atMost(5, SECONDS).until { gameStateRepository.count() > 3 }
assertEquals(1, gameRepository.count())
assertEquals(4, gameStateRepository.count())
val moves = mutableListOf<GameState>()
for (id in gameStateIds) {
moves.add(gameStateRepository.findById(id).orElseThrow { IllegalStateException("Unable to find expected GameState") }!!)
}
assertEquals("w", moves[0].player)
assertEquals("f3", moves[0].move)
assertEquals("b", moves[1].player)
assertEquals("e6", moves[1].move)
assertEquals("w", moves[2].player)
assertEquals("g4", moves[2].move)
assertEquals("b", moves[3].player)
assertEquals("Qh4#", moves[3].move)
// end game
gameDto = GameDTO(gameIdString, false, "b")
gameReporter.game(gameIdString, gameDto).subscribe()
await().atMost(5, SECONDS).until {
val g = gameRepository.findById(gameId).orElse(null) ?: return@until false
g.winner != null
}
assertEquals(1, gameRepository.count())
assertEquals(4, gameStateRepository.count())
game = gameRepository.findById(gameId).orElseThrow { IllegalStateException("Unable to find expected Game") }
assertEquals(gameId, game.id)
assertEquals(blackName, game.blackName)
assertEquals(whiteName, game.whiteName)
assertFalse(game.draw)
assertEquals("b", game.winner)
}
@Test
fun testGameEndingInDraw() {
val blackName = "b_name"
val whiteName = "w_name"
// start game
val gameId = UUID.randomUUID()
val gameIdString = gameId.toString()
var gameDto = GameDTO(gameIdString, blackName, whiteName)
gameReporter.game(gameIdString, gameDto).subscribe()
await().atMost(5, SECONDS).until { gameRepository.count() > 0 } (6)
assertEquals(1, gameRepository.count())
assertEquals(0, gameStateRepository.count())
var game = gameRepository.findById(gameId).orElseThrow {
IllegalStateException("Unable to find expected Game") }
assertEquals(gameId, game.id)
assertEquals(blackName, game.blackName)
assertEquals(whiteName, game.whiteName)
assertFalse(game.draw)
assertNull(game.winner)
// make moves
val gameStateIds = mutableListOf<UUID>()
var gameStateId = makeMove(gameIdString, "w", "f3", "rnbqkbnr/pppppppp/8/8/8/5P2/PPPPP1PP/RNBQKBNR b KQkq - 0 1", "1. f3")
gameStateIds.add(gameStateId)
gameStateId = makeMove(gameIdString, "b", "e6", "rnbqkbnr/pppp1ppp/4p3/8/8/5P2/PPPPP1PP/RNBQKBNR w KQkq - 0 2", "1. f3 e6")
gameStateIds.add(gameStateId)
await().atMost(5, SECONDS).until { gameStateRepository.count() > 1 }
assertEquals(1, gameRepository.count())
assertEquals(2, gameStateRepository.count())
val moves = mutableListOf<GameState>()
for (id in gameStateIds) {
moves.add(gameStateRepository.findById(id).orElseThrow { IllegalStateException("Unable to find expected GameState") }!!)
}
assertEquals("w", moves[0].player)
assertEquals("f3", moves[0].move)
assertEquals("b", moves[1].player)
assertEquals("e6", moves[1].move)
// end game
gameDto = GameDTO(gameIdString, true, null)
gameReporter.game(gameIdString, gameDto).subscribe()
await().atMost(5, SECONDS).until {
val g = gameRepository.findById(gameId).orElse(null) ?: return@until false
g.draw
}
assertEquals(1, gameRepository.count())
assertEquals(2, gameStateRepository.count())
game = gameRepository.findById(gameId).orElseThrow { IllegalStateException("Unable to find expected Game") }
assertEquals(gameId, game.id)
assertEquals(blackName, game.blackName)
assertEquals(whiteName, game.whiteName)
assertTrue(game.draw)
assertNull(game.winner)
}
@NonNull
override fun getProperties(): Map<String, String> =
mapOf("kafka.bootstrap.servers" to kafka.bootstrapServers) (7)
@AfterEach
fun cleanup() {
gameStateRepository.deleteAll()
gameRepository.deleteAll()
}
@KafkaClient
interface GameReporter {
@Topic("chessGame")
fun game(@KafkaKey gameId: String, game: GameDTO): Mono<GameDTO>
@Topic("chessGameState")
fun gameState(@KafkaKey gameId: String, gameState: GameStateDTO): Mono<GameStateDTO>
}
private fun makeMove(gameId: String, player: String, move: String,
fen: String, pgn: String): UUID {
val gameStateId = UUID.randomUUID()
gameReporter.gameState(gameId, GameStateDTO(gameStateId.toString(),
gameId, player, move, fen, pgn)).subscribe()
return gameStateId
}
}
1 | Use the @Testcontainers annotation to configure automatic container management (not necessary in Spock tests) |
2 | Classes that implement TestPropertyProvider must use this annotation to create a single class instance for all tests (not necessary in Spock tests). |
3 | Implementing TestPropertyProvider allows the test class to provide application configuration properties, in this case the dynamically allocated Kafka broker port |
4 | The Testcontainer instance for Kafka |
5 | Dependency injection for the GameReporter interface declared below, a Kafka producer interface that replicates the functionality of the class of the same name in the chess-game microservice |
6 | Wait a few seconds for the message to arrive; it should happen very quickly, but the message will be sent on a separate thread |
7 | Configure the Kafka broker port (it will be different unused port each time) so Micronaut Kafka clients and listeners connect to the test broker |
Create application-test.yml
file in src/test/resources
with this content:
datasources:
default:
url: jdbc:tc:oracle:thin:@/xe
driverClassName: org.testcontainers.jdbc.ContainerDatabaseDriver
username: system
password: oracle
flyway:
datasources:
default:
locations: classpath:db/migration/oracle
baseline-version: 0
baseline-on-migrate: true
9.3. Running the tests
To run the tests:
./gradlew test
Then open build/reports/tests/test/index.html
in a browser to see the results.
10. Deploy to OCI
Once you’ve verified that the microservices work with the configured cloud resources, you can deploy the microservices to Compute instances and run everything in Oracle Cloud.
Follow the steps in this guide for each service.
10.1. Instance Principal authentication
The current configuration in application-oraclecloud.yml
works when running locally using OCI resources (ATP database and Cloud Streams) but won’t work when deploying the application because it doesn’t make sense to install the Oracle Cloud CLI in Compute instances. Instead, we’ll use Instance Principal authentication.
To use this, we need to update the config, create a dynamic group, and add policy statements granting permissions.
10.1.1. Dynamic Group
Create a Dynamic Group by clicking the Oracle Cloud menu and selecting "Identity & Security" and then click "Dynamic Groups":
Click "Create Dynamic Group":
Then enter a name and description for the group, e.g., "mn-streaming-guide-dg", and a matching rule, i.e., the logic that will be used to determine group membership. We’ll make the rule fairly broad - enter ALL {instance.compartment.id = 'ocid1.compartment.oc1..aaaaaxxxxx'}
replacing ocid1.compartment.oc1..aaaaaxxxxx
with the compartment OCID where you’re creating your Compute instances and click "Create":
See the Dynamic Group docs for more information.
10.1.2. Dynamic Group Policy Statements
Edit the policy you created earlier and add three new policies: one to grant access to Autonomous Database, one to allow sending stream messages, and one to allow receiving stream messages:
10.1.3. Configuration
Edit application-oraclecloud.yml
in the chess-listener
microservice and replace
oci:
config:
profile: DEFAULT
with
oci:
config:
instance-principal:
enabled: true
11. Generate Micronaut Application Native Executables with GraalVM
We will use GraalVM, an advanced JDK with ahead-of-time Native Image compilation, to generate a native executable of this Micronaut application.
Compiling Micronaut applications ahead of time with GraalVM significantly improves startup time and reduces the memory footprint of JVM-based applications.
Only Java and Kotlin projects support using GraalVM’s native-image tool. Groovy relies heavily on reflection, which is only partially supported by GraalVM.
|
11.1. Native Executable Generation
sdk install java 21.0.5-graal
For installation on Windows, or for a manual installation on Linux or Mac, see the GraalVM Getting Started documentation.
The previous command installs Oracle GraalVM, which is free to use in production and free to redistribute, at no cost, under the GraalVM Free Terms and Conditions.
Alternatively, you can use the GraalVM Community Edition:
sdk install java 21.0.2-graalce
To generate native executables for each application using Gradle, run:
./gradlew nativeCompile
The native executables are created in build/native/nativeCompile
directory and can be run with build/native/nativeCompile/micronautguide
.
It is possible to customize the name of a native executable or pass additional parameters to GraalVM:
graalvmNative {
binaries {
main {
imageName.set('mn-graalvm-application') (1)
buildArgs.add('-Ob') (2)
}
}
}
1 | The native executable name will now be mn-graalvm-application |
2 | It is possible to pass extra build arguments to native-image . For example, -Ob enables the quick build mode. |
Native executable building will fail if the H2 driver is in the classpath, so comment out that dependency in your build script before building. No other changes are needed since there are no compile dependencies on the library, so you can keep the H2 versions of the repository interfaces for use in dev mode. |
11.2. Deployable Native Executables
The native executables you built probably won’t be deployable to OCI even if you build on the same Linux distro your Compute instances use. To create deployable native executables, change the build process a bit.
To generate deployable native executables for each application using Gradle, run:
./gradlew dockerBuildNative
Then you just need to extract the native executable applications from the Docker images you built.
You’ll need the Docker image IDs, so run:
docker image ls
The output should look like this:
REPOSITORY TAG IMAGE ID CREATED SIZE
chess-listener latest 0e262e1754a7 32 seconds ago 246MB
chess-game latest 43f567f2fed6 39 minutes ago 86.1MB
confluentinc/cp-kafka latest ca0dbcd0244c 2 weeks ago 771MB
confluentinc/cp-zookeeper latest 04999d93068f 2 weeks ago 771MB
ghcr.io/graalvm/graalvm-ce java11-21.1.0 9762c6e631f0 2 months ago 1.29GB
ghcr.io/graalvm/graalvm-ce java8-21.1.0 aef3649e379d 2 months ago 1.12GB
frolvlad/alpine-glibc alpine-3.12 39c4d33bd807 2 months ago 17.9MB
portainer/portainer latest cd645f5a4769 13 months ago 79.1MB
The IDs should be at the top since they’re the most recent.
Then run this for each image, replacing image_id
with the Docker image ID, e.g., 0e262e1754a7
and 43f567f2fed6
:
docker create --name container_temp <image_id>
docker cp container_temp:/app/application .
docker rm container_temp
Now you can scp each native executable to a Compute instance with no Java installed and see the startup time and resource usage reduction you expect when running applications as native executables.
12. Next Steps
Read more about Kafka support in the Micronaut framework.
13. License
All guides are released with an Apache license 2.0 license for the code and a Creative Commons Attribution 4.0 license for the writing and media (images…). |