快速开始
在 Wow 中,你用普通的 Kotlin 编写领域:命令、事件、一个状态,以及一个做出决定的聚合。编译器把它们变成模型和契约,Spring Boot 宿主经 HTTP 提供它们,不需要写控制器或仓储。本页在一个 Gradle 模块中构建一个小的银行服务:一个 account 聚合、它的测试,以及一个把数据放在内存中的服务器。
预发布版本
Wow 10.0.0-beta.1 是预发布版本。在 10.0.0 之前,它的 API、配置和存储格式仍可能改变。
你需要什么
- JDK 21 或更高版本;生产环境在 JDK 25 上运行(JDK)。
- 带 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"
// The service in one module: KSP with wow-compiler, the BOM and the kernel of the same version, and the
// service's Manifest.
id("me.ahoo.wow.service") version "10.0.0-beta.1"
application
}
kotlin {
jvmToolchain(21)
}
application {
mainClass.set("me.ahoo.wow.example.quickstart.BankServerKt")
}
dependencies {
// 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.3.0")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform()
}插件连同它的 marker 一起发布到 Maven Central,所以 pluginManagement 需要 mavenCentral()。预发布版本在那里只有确切的版本号:要写 10.0.0-beta.1,不能写范围。真实的服务会拆成 api、domain 和 server 三个模块(在已有服务中引入 Wow);试用时一个模块就够了。
插件还会注册测试套件 integrationTest,供在容器里的真实存储上运行的测试使用:源码放在 src/integrationTest/kotlin,用 ./gradlew integrationTest 运行,不属于 check(在真实存储上运行)。这个银行用不到它:它的测试都在内存中运行。
./gradlew build 还会把服务的 Manifest(客户端看到的契约)与提交在构建文件旁边的基线 manifest/baseline.json 比较,没有基线时会失败。写好下面的代码后,记录一次基线,并与代码一起提交:
./gradlew updateWowManifestBaseline此后 check 会让基线没有记录的改变失败,这样每次改变的评审都能看到客户端会看到什么(Manifest)。
编写命令和事件
用 @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.decide.reject
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)
fun onCommand(command: Withdraw): Withdrawn {
if (command.amount > state.balance) {
reject(Rejections.INSUFFICIENT_BALANCE, "The balance is ${state.balance}.")
}
return 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"
}
}每个函数返回它产生的事件,Withdraw 通过抛出 RejectedException 拒绝;契约从返回类型读出事件。所有规则见编写聚合。
测试
编译器在 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()
@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. The `mongo` profile keeps it on
# MongoDB, the `postgres` profile on PostgreSQL (application-mongo.yaml, application-postgres.yaml).
internal:
port: 9090
---
# On this machine alone, where Spring Boot detects no cloud platform: the internal port on the loopback interface, and
# the node serves the anonymous caller. A deployment binds the address its nodes reach.
spring:
config:
activate:
on-cloud-platform: none
wow:
internal:
host: 127.0.0.1./gradlew run类路径上有 wow-mongo,或者有 wow-postgres 和连接池(com.zaxxer:HikariCP,宿主用它创建 DataSource)时,--args='--spring.profiles.active=mongo'(或 postgres)会改为把数据放在 MongoDB 或 PostgreSQL 中,配置见模块的 application-mongo.yaml 和 application-postgres.yaml(MongoDB、PostgreSQL)。
节点在 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: false 关闭它;PostgreSQL 和 MongoDB 自己就能提供。
从代码发送命令
应用代码通过宿主的 CommandClient 发送命令对象,经过与 HTTP 路由相同的准入:
package me.ahoo.wow.example.quickstart
import me.ahoo.wow.api.client.CommandClient
import me.ahoo.wow.api.client.wait
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), accountId)
}wait 在命令的等待结束时回答,默认是快照;send 在命令被接受后就回答。其余用法见调用其他服务。
本地运行多个服务
由多个服务组成的系统可以在一个 JVM 里运行,用于开发、前端联调或演示。wow-test-spring-boot 的 runWowLocal 启动每个服务,各自只服务自己的上下文,全部使用内存后端,并共用一份内存中的主题。于是 Saga 发往另一个服务的命令、各服务发布的流,都像在 Kafka 上一样在它们之间流转。把它的 main 写在类路径上有全部服务的源码集里,例如 integrationTest 套件:
fun main() = runWowLocal {
service<InventoryServer>("eshop-inventory-service", listOf("eshop-inventory"), 8084, 9094)
service<OrderingServer>("eshop-ordering-service", listOf("eshop-ordering"), 8083, 9093)
service<BasketServer>("eshop-basket-service", listOf("eshop-basket"), 8082, 9092)
}- 每个服务在
devprofile 下运行,所以请求用Wow-*头指名调用者。服务以spring.application.name命名,只服务自己的上下文(wow.contexts.include);properties加上它自己的设置。 - 服务按列出的顺序启动,每个服务调用列在它前面的服务:它的
wow.peers.<context>.url指向那些服务的内部端口。把被调用的服务列在调用方之前,这里是 ordering 在 basket 之前,basket 的结账会调用它。 - 端口是每个服务自己的;
-Dwow-local.ports=random让系统选择每一个端口,这时服务可以不指定端口。 - 所有服务都开始监听后,它打印一行公开端口清单:
wow-local ready {"eshop-inventory-service":8084,…}。加上-Dwow-local.exit-on-stdin-eof=true,JVM 会在标准输入关闭时退出,所以启动它的进程(例如前端的端到端测试)不会把它留在运行中。 wowLocal { … }在测试里启动同样的服务,并把它们交还(servers、publicPorts)以便关闭。
由构建的一个任务运行它:
tasks.register<JavaExec>("runLocal") {
classpath = sourceSets["integrationTest"].runtimeClasspath
mainClass.set("com.example.shop.LocalKt")
}接下来读什么
- 概念。 架构总览说明宿主为你做了什么。
- 真实的服务。 在已有服务中引入 Wow 把服务按模块组织起来。
- 指南。 开发指南说明每项任务:Saga、投影、查询、测试。
- 完整的示例。 eShop 是基于 PostgreSQL、Kafka 和 ClickHouse 的五个服务,带有上下文之间的 Saga、定时命令、数据集和一个 Web 界面。
- 部署它。Kubernetes 用 Wow 的 Helm chart、构建生成的 values 和你自己的 values 安装一个服务。
- 这个示例就是
examples/quickstart,每次改动都会构建和测试。