快速开始
在 Wow 中,你用普通的 Kotlin 编写领域:命令、事件、一个状态,以及一个做出决定的聚合。编译器把它们变成模型和契约,Spring Boot 宿主经 HTTP 提供它们,不需要写控制器或仓储。本页在一个 Gradle 模块中构建一个小的银行服务:一个 account 聚合、它的测试,以及一个把数据放在内存中的服务器。
预发布版本
Wow 10.0.0-alpha.3 是预发布版本。在 10.0.0 之前,它的 API、配置和存储格式仍可能改变。
你需要什么
- JDK 21。
- 带 Kotlin JVM 插件的 Gradle 9。Wow Gradle 插件会应用 KSP 和 Wow 编译器。
准备构建
settings.gradle.kts:
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
rootProject.name = "bank"build.gradle.kts:
plugins {
kotlin("jvm") version "2.4.21"
// Applies KSP with wow-compiler of the same version, and assembles the service's Manifest.
id("me.ahoo.wow") version "10.0.0-alpha.3"
application
}
kotlin {
jvmToolchain(21)
}
application {
mainClass.set("me.ahoo.wow.example.quickstart.BankServerKt")
}
dependencies {
// The V10 BOM: one version for every Wow module.
implementation(platform("me.ahoo.wow:wow-bom:10.0.0-alpha.3"))
// The kernel the generated models build on.
implementation("me.ahoo.wow:wow-kernel")
// The Spring Boot host: auto-configuration, the HTTP routes, the memory backend.
implementation("me.ahoo.wow:wow-spring-boot")
// Jakarta Bean Validation: the host checks the commands' constraints (@field:Positive) at admission.
implementation("org.springframework.boot:spring-boot-starter-validation")
// The aggregate test DSL, package me.ahoo.wow.test.
testImplementation("me.ahoo.wow:wow-test")
testImplementation(platform("org.junit:junit-bom:6.1.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("me.ahoo.test:fluent-assert-core:1.2.1")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform()
}插件连同它的 marker 一起发布到 Maven Central,所以 pluginManagement 需要 mavenCentral()。预发布版本在那里只有确切的版本号:要写 10.0.0-alpha.3,不能写范围。真实的服务会拆成 api、domain 和 server 三个模块(在已有服务中引入 Wow);试用时一个模块就够了。
编写命令和事件
用 @BoundedContext 命名一次上下文。命令和事件都是数据类。命令不带聚合 ID:ID 来自路由。存款和取款的金额为正:这是命令的形状,一个由宿主在账户决定之前检查的 Jakarta 约束。
package me.ahoo.wow.example.quickstart
import jakarta.validation.constraints.Positive
import me.ahoo.wow.api.annotation.BoundedContext
import me.ahoo.wow.api.annotation.CreateAggregate
import me.ahoo.wow.api.annotation.Summary
@BoundedContext(name = "bank", description = "Accounts and their balances")
object BankBoundedContext
@CreateAggregate
@Summary("Open an account")
data class OpenAccount(val owner: String)
@Summary("Deposit money")
data class Deposit(@field:Positive val amount: Long)
@Summary("Withdraw money")
data class Withdraw(@field:Positive val amount: Long)
@Summary("An account was opened")
data class AccountOpened(val owner: String)
@Summary("Money was deposited")
data class Deposited(val amount: Long)
@Summary("Money was withdrawn")
data class Withdrawn(val amount: Long)编写状态和聚合
状态在 onSourcing 函数中应用每个事件。聚合在 onCommand 函数中决定每条命令:它返回事件,或者返回一个以错误码拒绝命令的 Decision,这时什么都不写。它只拒绝需要它状态的命令:余额不足以支付的取款。
package me.ahoo.wow.example.quickstart
import me.ahoo.wow.api.annotation.AggregateRoot
import me.ahoo.wow.api.annotation.ErrorCode
import me.ahoo.wow.api.annotation.OnCommand
import me.ahoo.wow.api.decide.Decision
class AccountState {
var owner: String = ""
private set
var balance: Long = 0
private set
fun onSourcing(event: AccountOpened) {
owner = event.owner
}
fun onSourcing(event: Deposited) {
balance += event.amount
}
fun onSourcing(event: Withdrawn) {
balance -= event.amount
}
}
@AggregateRoot
class Account(private val state: AccountState) {
fun onCommand(command: OpenAccount): AccountOpened = AccountOpened(command.owner)
fun onCommand(command: Deposit): Deposited = Deposited(command.amount)
@OnCommand(produces = [Withdrawn::class])
fun onCommand(command: Withdraw): Decision =
if (command.amount > state.balance) {
Decision.reject(Rejections.INSUFFICIENT_BALANCE, "The balance is ${state.balance}.")
} else {
Decision.events(Withdrawn(command.amount))
}
/** The codes it rejects with, by local name: the Manifest and its answers say `bank.account.insufficient-balance`. */
object Rejections {
@ErrorCode(description = "The balance does not cover the withdrawal.", status = 409)
const val INSUFFICIENT_BALANCE = "insufficient-balance"
}
}@OnCommand(produces = ...) 告诉契约,返回 Decision 的函数能产生哪些事件。所有规则见编写聚合。
测试
编译器在 Account 旁边生成了 accountModel。aggregateSpec 在真实的内核和内存日志上运行它:给定这些事件,执行这条命令,期望得到这些事件和这个状态。
package me.ahoo.wow.example.quickstart
import me.ahoo.test.asserts.assert
import me.ahoo.wow.test.aggregateSpec
import org.junit.jupiter.api.Test
class AccountSpec {
private val model = accountModel("bank")
@Test
fun `opening an account names its owner`() = aggregateSpec(model, id = "alice") {
whenCommand(OpenAccount("Alice")) {
expectEvents(AccountOpened("Alice"))
expectState { owner.assert().isEqualTo("Alice") }
}
}
@Test
fun `a deposit adds to the balance`() = aggregateSpec(model, id = "alice") {
given(AccountOpened("Alice"))
whenCommand(Deposit(100)) {
expectEvents(Deposited(100))
expectState { balance.assert().isEqualTo(100) }
}
}
@Test
fun `a withdrawal the balance does not cover is rejected`() = aggregateSpec(model, id = "alice") {
given(AccountOpened("Alice"), Deposited(100))
whenCommand(Withdraw(150)) { expectRejected(Account.Rejections.INSUFFICIENT_BALANCE) }
whenCommand(Withdraw(30)) {
expectEvents(Withdrawn(30))
expectState { balance.assert().isEqualTo(70) }
}
}
}./gradlew test全部断言见测试领域代码。
运行服务
服务器是一个 Spring Boot 应用。宿主在类路径上找到生成的模型并提供它们:
package me.ahoo.wow.example.quickstart
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication(proxyBeanMethods = false)
class BankServer
fun main(args: Array<String>) {
runApplication<BankServer>(*args)
}src/main/resources/application.yaml 运行一个把数据放在内存中的节点,并且也从内存提供快照查询:
spring:
application:
name: bank-service
server:
port: 8080
wow:
# One node, its data in memory: for trying the service out, not for production.
backend: memory
# Serve the snapshot queries from memory too.
query:
memory:
enabled: true
internal:
port: 9090./gradlew run节点在 8080 端口上提供路由,在 9090 端口上提供内部端口。它会警告数据在内存中,并且在应用认证调用方之前,每个请求都是匿名调用方(提供和调用 HTTP API)。
发送命令
每条命令都有一个路由 POST /<context>/<aggregate>/<id>/<command>,命令名采用 kebab case:
curl -X POST localhost:8080/bank/account/alice/open-account \
-H 'Content-Type: application/json' -d '{"owner":"Alice"}'
curl -X POST localhost:8080/bank/account/alice/deposit \
-H 'Content-Type: application/json' -d '{"amount":100}'
curl -X POST localhost:8080/bank/account/alice/withdraw \
-H 'Content-Type: application/json' -d '{"amount":150}'命令在快照包含它的提交后返回结果:"status":"SUCCEEDED",以及账户新的 version,所以紧接着的读取能看到这次改变。取款被拒绝:回答是一个 RFC 9457 problem,状态 409,带 "code":"bank.account.insufficient-balance"。
金额为零的存款根本到不了账户:
curl -X POST localhost:8080/bank/account/alice/deposit \
-H 'Content-Type: application/json' -d '{"amount":0}'它回答状态 400,带 "code":"wow.command.invalid" 及其 violations:[{"path":"amount","code":"Positive","message":"must be greater than 0"}]。
读取与查询
读取一个账户的状态,再按余额查询所有账户:
curl localhost:8080/bank/account/alice/state
curl -X POST localhost:8080/bank/account/snapshot/query \
-H 'Content-Type: application/json' \
-d '{"kind":"list","filter":{"op":"GTE","field":"state.balance","value":100}}'第一个请求返回版本和状态 {"balance":100,"owner":"Alice"};第二个返回快照列表,其中有 alice 的。过滤、排序、分页和其他种类的查询见查询快照。在内存后端上,只有设置了 wow.query.memory.enabled 才提供查询;PostgreSQL 和 MongoDB 自己就能提供。
从代码发送命令
应用代码通过宿主的 CommandClient 发送命令对象,经过与 HTTP 路由相同的准入:
package me.ahoo.wow.example.quickstart
import me.ahoo.wow.api.aggregate.AggregateType
import me.ahoo.wow.api.caller.Caller
import me.ahoo.wow.api.client.CommandClient
import me.ahoo.wow.api.result.CommandResult
import org.springframework.stereotype.Service
/** Deposits from application code, through the host's [CommandClient]: the same admission as the HTTP routes. */
@Service
class Teller(private val commands: CommandClient) {
suspend fun deposit(accountId: String, amount: Long): CommandResult =
commands.wait(Deposit(amount), ACCOUNT.ref(Caller.DEFAULT_TENANT, accountId))
private companion object {
val ACCOUNT = AggregateType("bank", "account")
}
}wait 在命令的等待结束时回答,默认是快照;send 在命令被接受后就回答。其余用法见调用其他服务。
接下来读什么
- 概念。 架构总览说明宿主为你做了什么。
- 真实的服务。 在已有服务中引入 Wow 把服务按模块组织起来。
- 指南。 开发指南说明每项任务:Saga、投影、查询、测试。
- 完整的示例。 eShop 是基于 PostgreSQL、Kafka 和 ClickHouse 的五个服务,带有上下文之间的 Saga、定时命令、数据集和一个 Web 界面。
- 这个示例就是
example/quickstart,每次改动都会构建和测试。