This commit is contained in:
2024-05-30 21:41:02 +02:00
parent 03ba14d918
commit ae5c229e4b
32 changed files with 537 additions and 344 deletions

View File

@@ -1,5 +1,6 @@
package eventDemo
import eventDemo.plugins.configureCommandHandler
import eventDemo.plugins.configureHTTP
import eventDemo.plugins.configureKoin
import eventDemo.plugins.configureRouting
@@ -22,4 +23,5 @@ fun Application.module() {
configureHTTP()
configureRouting()
configureKoin()
configureCommandHandler()
}

View File

@@ -1,39 +0,0 @@
package eventDemo.app
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Game(
val id: GameId,
) {
companion object {
fun new(): Game = Game(GameId())
}
}
@Serializable
sealed interface Card {
@Serializable
enum class Color {
Blue,
Red,
Yellow,
Green,
}
@Serializable
@SerialName("Simple")
data class Simple(
val number: Int,
val color: Color,
) : Card
sealed interface Special : Card
@Serializable
@SerialName("Reverse")
data class ReverseCard(
val color: Color,
) : Special
}

View File

@@ -1,24 +0,0 @@
package eventDemo.app
import io.github.oshai.kotlinlogging.KotlinLogging
class CommandStream {
private val logger = KotlinLogging.logger {}
private val commandBus: MutableList<Command> = mutableListOf()
fun sendRequest(command: Command) {
commandBus.add(command)
logger.atInfo {
message = "Command published: $command"
payload = mapOf("command" to command)
}
}
fun sendRequest(vararg commands: Command) {
commands.forEach { sendRequest(it) }
}
fun readNext(): Command? = commandBus.firstOrNull()
fun <U : Command> readNext(commandClass: Class<U>): U? = commandBus.filterIsInstance(commandClass).firstOrNull()
}

View File

@@ -1,27 +0,0 @@
package eventDemo.app
import io.github.oshai.kotlinlogging.KotlinLogging
class EventStream<ID : AggregateId> {
private val logger = KotlinLogging.logger {}
private val eventBus: MutableMap<ID, MutableList<Event<ID>>> = mutableMapOf()
fun publish(event: Event<ID>) {
eventBus.getOrPut(event.id) { mutableListOf() }.add(event)
logger.atInfo {
message = "Event published: $event"
payload = mapOf("event" to event)
}
}
fun publish(vararg events: Event<ID>) {
events.forEach { publish(it) }
}
fun <U : Event<ID>> read(
aggregateId: ID,
eventClass: Class<U>,
): U? = eventBus.get(aggregateId)?.filterIsInstance(eventClass)?.firstOrNull()
}
inline fun <reified U : Event<ID>, ID : AggregateId> EventStream<ID>.read(aggregateId: ID): U? = this.read(aggregateId, U::class.java)

View File

@@ -1,10 +0,0 @@
package eventDemo.app
sealed interface Event<ID : AggregateId> {
val id: ID
}
data class PlayCardEvent(
override val id: GameId,
val card: Card,
) : Event<GameId>

View File

@@ -1,61 +0,0 @@
package eventDemo.app.actions
import eventDemo.app.Card
import eventDemo.app.EventStream
import eventDemo.app.GameId
import eventDemo.app.PlayCardEvent
import eventDemo.app.read
import eventDemo.plugins.GameIdSerializer
import io.ktor.http.HttpStatusCode
import io.ktor.resources.Resource
import io.ktor.server.application.call
import io.ktor.server.request.receive
import io.ktor.server.resources.get
import io.ktor.server.resources.post
import io.ktor.server.response.respond
import io.ktor.server.response.respondNullable
import io.ktor.server.routing.Routing
import kotlinx.serialization.Serializable
import org.koin.ktor.ext.inject
@Serializable
@Resource("/game/{id}")
class Game(
@Serializable(with = GameIdSerializer::class)
val id: GameId,
) {
@Serializable
@Resource("card")
class Card(
val game: Game,
) {
@Serializable
@Resource("")
class PutCard(
val card: Card,
)
@Serializable
@Resource("last")
class LastCard(
val card: Card,
)
}
}
fun Routing.card() {
val eventStream by inject<EventStream<GameId>>()
post<Game.Card.PutCard> {
val card = call.receive<Card>()
eventStream.publish(PlayCardEvent(it.card.game.id, card))
call.respondNullable<Any?>(HttpStatusCode.OK, null)
}
get<Game.Card.LastCard> {
eventStream
.read<PlayCardEvent, GameId>(it.card.game.id)
?.let { it1 -> call.respond<Card>(it1.card) }
?: call.response.status(HttpStatusCode.BadRequest)
}
}

View File

@@ -1,56 +0,0 @@
package eventDemo.app.actions
import eventDemo.app.Command
import eventDemo.app.CommandId
import eventDemo.app.CommandStream
import eventDemo.app.PlayCardCommand
import io.ktor.http.HttpStatusCode
import io.ktor.resources.Resource
import io.ktor.server.application.call
import io.ktor.server.request.receive
import io.ktor.server.resources.get
import io.ktor.server.resources.post
import io.ktor.server.response.respond
import io.ktor.server.routing.Routing
import kotlinx.serialization.Serializable
import org.koin.ktor.ext.inject
@Serializable
@Resource("/command")
class CommandRoute {
@Resource("send")
class Send(
val commandRoute: CommandRoute,
) {
@Serializable
data class Response(
val id: CommandId,
) {
constructor(command: Command) : this(command.id)
}
}
@Resource("next")
class Next(
val commandRoute: CommandRoute,
)
}
fun Routing.command() {
val commandStream by inject<CommandStream>()
post<CommandRoute.Send> {
val command = call.receive<PlayCardCommand>()
commandStream.sendRequest(command)
call.respond(HttpStatusCode.OK, CommandRoute.Send.Response(command))
}
get<CommandRoute.Next> {
val command = commandStream.readNext()
if (command == null) {
call.response.status(HttpStatusCode.NoContent)
} else {
call.respond(HttpStatusCode.OK, command)
}
}
}

View File

@@ -0,0 +1,50 @@
package eventDemo.app.actions.playNewCard
import eventDemo.libs.command.send
import eventDemo.plugins.GameIdSerializer
import eventDemo.shared.GameId
import eventDemo.shared.command.GameCommandStream
import eventDemo.shared.entity.Card
import eventDemo.shared.entity.Game
import io.ktor.http.HttpStatusCode
import io.ktor.resources.Resource
import io.ktor.server.application.call
import io.ktor.server.request.receive
import io.ktor.server.resources.post
import io.ktor.server.response.respondNullable
import io.ktor.server.routing.Routing
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import org.koin.ktor.ext.inject
@Serializable
@Resource("/game/{id}")
class GameRoute(
@Serializable(with = GameIdSerializer::class)
val id: GameId,
) {
@Serializable
@Resource("card")
class Card(
val game: GameRoute,
)
}
fun Routing.playNewCard() {
val commandStream by inject<GameCommandStream>()
/*
* A player request to play a new card.
*
* It always returns [HttpStatusCode.OK], but it is not mean that card is already played!
*/
post<GameRoute.Card> {
val card = call.receive<Card>()
launch(Dispatchers.Default) {
commandStream.send(PlayCardCommand(Game(it.game.id), card))
}
call.respondNullable<Any?>(HttpStatusCode.OK, null)
}
}

View File

@@ -1,25 +1,11 @@
package eventDemo.app
package eventDemo.app.actions.playNewCard
import eventDemo.plugins.CommandIdSerializer
import eventDemo.libs.command.Command
import eventDemo.libs.command.CommandId
import eventDemo.shared.entity.Card
import eventDemo.shared.entity.Game
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.UUID
@JvmInline
@Serializable(with = CommandIdSerializer::class)
value class CommandId(
private val id: UUID = UUID.randomUUID(),
) {
constructor(id: String) : this(UUID.fromString(id))
override fun toString(): String = id.toString()
}
@Serializable
sealed interface Command {
val id: CommandId
val name: String
}
@Serializable
@SerialName("PlayCard")

View File

@@ -0,0 +1,26 @@
package eventDemo.app.actions.playNewCard
import eventDemo.shared.command.GameCommandStream
import eventDemo.shared.event.CardIsPlayedEvent
import eventDemo.shared.event.GameEventStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Listen [PlayCardCommand] on [GameCommandStream], check the validity and execute the action.
* This action produces a new [CardIsPlayedEvent]
*/
class PlayCardCommandHandler(
private val commandStream: GameCommandStream,
private val eventStream: GameEventStream,
) {
operator fun invoke() {
CoroutineScope(Dispatchers.IO).launch {
commandStream.process {
// TODO check the command can be executed
eventStream.publish(CardIsPlayedEvent(it.payload.game.id, it.payload.card))
}
}
}
}

View File

@@ -0,0 +1,42 @@
package eventDemo.app.actions.readLastPlayedCard
import eventDemo.libs.event.readLastOf
import eventDemo.plugins.GameIdSerializer
import eventDemo.shared.GameId
import eventDemo.shared.event.CardIsPlayedEvent
import eventDemo.shared.event.GameEventStream
import io.ktor.http.HttpStatusCode
import io.ktor.resources.Resource
import io.ktor.server.application.call
import io.ktor.server.resources.get
import io.ktor.server.response.respond
import io.ktor.server.routing.Routing
import kotlinx.serialization.Serializable
import org.koin.ktor.ext.inject
@Serializable
@Resource("/game/{id}")
class Game(
@Serializable(with = GameIdSerializer::class)
val id: GameId,
) {
@Serializable
@Resource("card/last")
class Card(
val game: Game,
)
}
fun Routing.readLastPlayedCard() {
val eventStream by inject<GameEventStream>()
/*
* Read the last played card on the game.
*/
get<Game.Card> { card ->
eventStream
.readLastOf<CardIsPlayedEvent, _, _>(card.game.id)
?.let { call.respond(it.card) }
?: call.response.status(HttpStatusCode.BadRequest)
}
}

View File

@@ -0,0 +1,20 @@
package eventDemo.libs.command
import eventDemo.plugins.CommandIdSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@JvmInline
@Serializable(with = CommandIdSerializer::class)
value class CommandId(
private val id: UUID = UUID.randomUUID(),
) {
constructor(id: String) : this(UUID.fromString(id))
override fun toString(): String = id.toString()
}
interface Command {
val id: CommandId
val name: String
}

View File

@@ -0,0 +1,36 @@
package eventDemo.libs.command
import kotlin.reflect.KClass
interface CommandStream<C : Command> {
/**
* Send a new [Command] to the queue.
*/
suspend fun send(
type: KClass<C>,
command: C,
)
/**
* Send multiple [Command] to the queue.
*/
suspend fun send(
type: KClass<C>,
vararg commands: C,
) {
commands.forEach { send(type, it) }
}
/**
* A class to implement succes/faild action.
*/
interface ComputeStatus {
fun ack()
fun nack()
}
suspend fun process(block: CommandBlock<C>)
}
suspend inline fun <reified C : Command> CommandStream<C>.send(vararg command: C) = send(C::class, *command)

View File

@@ -0,0 +1,82 @@
package eventDemo.libs.command
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlin.reflect.KClass
typealias CommandBlock<C> = CommandStream.ComputeStatus.(C) -> Unit
/**
* Manage [Command]'s
*
* It stores the new [Command] in memory.
*/
abstract class CommandStreamInMemory<C : Command> : CommandStream<C> {
private val logger = KotlinLogging.logger {}
private val failedCommand = mutableListOf<Command>()
private val queue: Channel<C> = Channel(onUndeliveredElement = { logger.atWarn { "${it.name} elem not send" } })
/**
* Send a new [Command] to the queue.
*/
override suspend fun send(
type: KClass<C>,
command: C,
) {
logger.atInfo {
message = "Command published: $command"
payload = mapOf("command" to command)
}
queue.send(command)
}
override suspend fun process(block: CommandBlock<C>) {
queue.consumeEach { command ->
compute(command, block)
}
for (command in queue) {
compute(command, block)
}
}
private fun compute(
command: C,
block: CommandBlock<C>,
) {
val status = object : CommandStream.ComputeStatus {
var isSet: Boolean = false
override fun ack() {
if (!isSet) markAsSuccess(command) else error("Already NACK")
isSet = true
}
override fun nack() {
if (!isSet) markAsFailed(command) else error("Already ACK")
isSet = true
}
}
if (runCatching { status.block(command) }.isFailure) {
markAsFailed(command)
} else if (!status.isSet) {
status.ack()
}
}
private fun <C : Command> markAsSuccess(command: C) {
logger.atInfo {
message = "Compute command SUCCESS and it removed of the stack : $command"
payload = mapOf("command" to command)
}
}
private fun <C : Command> markAsFailed(command: C) {
failedCommand.add(command)
logger.atWarn {
message = "Compute command FAILD and it put on the top of the stack : $command"
payload = mapOf("command" to command)
}
}
}

View File

@@ -0,0 +1,11 @@
package eventDemo.libs.event
import java.util.UUID
interface AggregateId {
val id: UUID
}
interface Event<ID : AggregateId> {
val id: ID
}

View File

@@ -0,0 +1,19 @@
package eventDemo.libs.event
import kotlinx.coroutines.flow.Flow
import kotlin.reflect.KClass
interface EventStream<E : Event<ID>, ID : AggregateId> {
fun publish(event: E)
fun publish(vararg events: E)
fun readLast(aggregateId: ID): E?
fun <R : E> readLastOf(
aggregateId: ID,
eventType: KClass<out R>,
): E?
fun readAll(aggregateId: ID): Flow<E>
}

View File

@@ -0,0 +1,43 @@
package eventDemo.libs.event
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.reflect.KClass
abstract class EventStreamInMemory<E : Event<ID>, ID : AggregateId>(
private val eventType: Class<E>,
) : EventStream<E, ID> {
private val logger = KotlinLogging.logger {}
private val eventBus: MutableList<E> = mutableListOf()
override fun publish(event: E) {
eventBus.add(event)
logger.atInfo {
message = "Event published: $event"
payload = mapOf("event" to event)
}
}
override fun publish(vararg events: E) {
events.forEach { publish(it) }
}
override fun readLast(aggregateId: ID): E? = eventBus.lastOrNull()
override fun <R : E> readLastOf(
aggregateId: ID,
eventType: KClass<out R>,
): R? =
eventBus
.filterIsInstance(eventType.java)
.lastOrNull { it.id == aggregateId }
override fun readAll(aggregateId: ID): Flow<E> =
flow {
eventBus.forEach { emit(it) }
}
}
inline fun <reified R : E, E : Event<ID>, ID : AggregateId> EventStreamInMemory<E, ID>.readLastOf(aggregateId: ID): R? =
readLastOf(aggregateId, R::class)

View File

@@ -0,0 +1,9 @@
package eventDemo.plugins
import eventDemo.app.actions.playNewCard.PlayCardCommandHandler
import io.ktor.server.application.Application
import org.koin.java.KoinJavaComponent.getKoin
fun Application.configureCommandHandler() {
getKoin().get<PlayCardCommandHandler>()()
}

View File

@@ -1,8 +1,8 @@
package eventDemo.plugins
import eventDemo.app.CommandStream
import eventDemo.app.EventStream
import eventDemo.app.GameId
import eventDemo.app.actions.playNewCard.PlayCardCommandHandler
import eventDemo.shared.command.GameCommandStream
import eventDemo.shared.event.GameEventStream
import io.ktor.server.application.Application
import io.ktor.server.application.install
import org.koin.core.module.dsl.singleOf
@@ -19,6 +19,7 @@ fun Application.configureKoin() {
val appModule =
module {
singleOf<EventStream<GameId>>(::EventStream)
singleOf<CommandStream>(::CommandStream)
singleOf<GameEventStream>(::GameEventStream)
singleOf<GameCommandStream>(::GameCommandStream)
singleOf(::PlayCardCommandHandler)
}

View File

@@ -1,7 +1,7 @@
package eventDemo.plugins
import eventDemo.app.actions.card
import eventDemo.app.actions.command
import eventDemo.app.actions.playNewCard.playNewCard
import eventDemo.app.actions.readLastPlayedCard.readLastPlayedCard
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.install
@@ -24,7 +24,7 @@ fun Application.configureRouting() {
}
routing {
card()
command()
playNewCard()
readLastPlayedCard()
}
}

View File

@@ -1,7 +1,7 @@
package eventDemo.plugins
import eventDemo.app.CommandId
import eventDemo.app.GameId
import eventDemo.libs.command.CommandId
import eventDemo.shared.GameId
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install

View File

@@ -1,13 +1,10 @@
package eventDemo.app
package eventDemo.shared
import eventDemo.libs.event.AggregateId
import eventDemo.plugins.GameIdSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
sealed interface AggregateId {
val id: UUID
}
@JvmInline
@Serializable(with = GameIdSerializer::class)
value class GameId(

View File

@@ -0,0 +1,6 @@
package eventDemo.shared.command
import eventDemo.app.actions.playNewCard.PlayCardCommand
import eventDemo.libs.command.CommandStreamInMemory
class GameCommandStream : CommandStreamInMemory<PlayCardCommand>()

View File

@@ -0,0 +1,54 @@
package eventDemo.shared.entity
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
sealed interface Card {
@Serializable
enum class Color {
Blue,
Red,
Yellow,
Green,
}
@Serializable
@SerialName("Simple")
data class NumericCard(
val number: Int,
val color: Color,
) : Card
sealed interface Special : Card
@Serializable
@SerialName("Reverse")
data class ReverseCard(
val color: Color,
) : Special
@Serializable
@SerialName("Pass")
data class PassCard(
val color: Color,
) : Special
@Serializable
@SerialName("Plus2")
data class Plus2Card(
val color: Color,
) : Special
@Serializable
@SerialName("Plus4")
data class Plus4Card(
val nextColor: Color,
) : Special
@Serializable
@SerialName("ChangeColor")
data class ChangeColorCard(
val nextColor: Color,
) : Special
}

View File

@@ -0,0 +1,13 @@
package eventDemo.shared.entity
import eventDemo.shared.GameId
import kotlinx.serialization.Serializable
@Serializable
data class Game(
val id: GameId,
) {
companion object {
fun new(): Game = Game(GameId())
}
}

View File

@@ -0,0 +1,14 @@
package eventDemo.shared.event
import eventDemo.libs.event.Event
import eventDemo.shared.GameId
import eventDemo.shared.entity.Card
sealed interface GameEvent : Event<GameId> {
override val id: GameId
}
data class CardIsPlayedEvent(
override val id: GameId,
val card: Card,
) : GameEvent

View File

@@ -0,0 +1,6 @@
package eventDemo.shared.event
import eventDemo.libs.event.EventStreamInMemory
import eventDemo.shared.GameId
class GameEventStream : EventStreamInMemory<GameEvent, GameId>(GameEvent::class.java)