编写 V10 服务
本页按真实服务的方式把一个新的 Wow V10 服务分成三个模块,从一个空的 Gradle 构建开始,直到节点通过 HTTP 接收第一条命令。想先在一个模块里试用 V10,请从快速开始读起。V10 指南的其余页面逐步展开每一步:
| 页面 | 内容 |
|---|---|
| 编程模型 | 聚合、命令、事件、拒绝;Saga、投影、反应器;定时命令;其他上下文 |
| 编译器生成什么 | 模型工厂、ModelProvider、Manifest 片段、路由 |
| 测试 | aggregateSpec 及其所在位置、Saga 测试、服务端测试 |
| HTTP API | 命令路由、Wow-Wait、状态读取、查询路由、数据集 |
| 配置项 | 全部 wow.* 配置项及默认值 |
| 部署 | PostgreSQL 或 MongoDB、内部端口、Kafka、ClickHouse、个人信息、租户 |
V10 处于预发布
V10 在 main 分支 上开发;9.x 在 v9 上维护。第一个预发布 10.0.0-alpha.3 只用于试用与评估:它不做兼容性承诺,9.x 数据的迁移也尚未完成(见快速开始)。它的模块以 me.ahoo.wow:wow-* 发布在 Maven Central,BOM 为 me.ahoo.wow:wow-bom,Gradle 插件为 me.ahoo.wow。本 V10 栏目之外的指南描述的是 9.x。
前提
- JDK 21:所有 V10 模块都在它上面构建和运行。
- Kotlin 与 KSP:Gradle 插件会替你应用 KSP。Java 源文件同样可以:KSP 能读取它们,但构建仍需要 Kotlin Gradle 插件。
- 服务端使用 Spring Boot 4;领域代码里涉及 JSON 的部分使用 Jackson 3(
tools.jackson)。
划分模块
一个服务由三个 Gradle 模块组成。下面以 catalog 上下文为例;仓库内 eShop 示例 example/eshop 的每个上下文(catalog、basket、ordering、inventory、payment)都是同样的结构。
| 模块 | 内容 | 依赖 |
|---|---|---|
catalog-api | 命令、事件与值类型:上下文的契约 | wow-api |
catalog-domain | 聚合根、状态、拒绝码、Saga、投影与反应器 | catalog-api、wow-kernel;插件 me.ahoo.wow |
catalog-server | Spring Boot 应用:宿主、一个后端、领域服务的 Bean | catalog-domain、wow-spring-boot、一个后端模块 |
其他服务只依赖 catalog-api,从不依赖领域模块。领域模块只依赖内核,从不依赖运行时或宿主。
配置 Gradle
settings.gradle.kts:
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
plugins {
kotlin("jvm") version "2.4.21"
id("me.ahoo.wow") version "10.0.0-alpha.3"
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
rootProject.name = "catalog"
include("catalog-api", "catalog-domain", "catalog-server")gradle.properties 只写一次 Wow 版本:
wowVersion=10.0.0-alpha.3catalog-api/build.gradle.kts:
plugins {
kotlin("jvm")
}
val wowVersion: String by project
kotlin {
jvmToolchain(21)
}
dependencies {
api("me.ahoo.wow:wow-api:$wowVersion")
}catalog-domain/build.gradle.kts 应用 Gradle 插件:
plugins {
kotlin("jvm")
id("me.ahoo.wow")
}
val wowVersion: String by project
kotlin {
jvmToolchain(21)
}
dependencies {
api(project(":catalog-api"))
// 生成的模型与绑定索引只依赖内核。
api("me.ahoo.wow:wow-kernel:$wowVersion")
// 聚合测试 DSL,包名 me.ahoo.wow.test。
testImplementation("me.ahoo.wow:wow-test:$wowVersion")
testImplementation(platform("org.junit:junit-bom:6.1.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform()
}应用 Kotlin JVM 插件之后,me.ahoo.wow:
- 以
generate模式应用 KSP 与同版本的me.ahoo.wow:wow-compiler,并以项目名命名模块的 Manifest 片段; - 注册
generateWowManifestFragment,用模块中各类型的 JSON Schema 补全片段; - 注册
mergeWowManifest,把运行时类路径上的全部片段合并为build/wow/manifest.json,以wow-manifest变体发布; - 注册
collectWowPeers与wowPeers配置,把其他服务的 Manifest 放到类路径上(见其他上下文)。
wow { } 块用来配置它:
| 属性 | 默认值 | 含义 |
|---|---|---|
context | 模块中唯一的 @BoundedContext | 模块的聚合与处理器所属的上下文(KSP 选项 wow.context) |
addCompilerDependency | true | 插件是否把 wow-compiler 加入 ksp |
schemaOverrides | 空 | 按 JVM 类名给出 JSON Schema,用于由自定义序列化器写出的类型 |
serviceName | 项目名 | 合并后的 manifest.json 描述的服务 |
serviceVersion | 项目版本 | 其中的服务版本 |
catalog-server/build.gradle.kts:
plugins {
kotlin("jvm")
application
}
val wowVersion: String by project
kotlin {
jvmToolchain(21)
}
application {
mainClass.set("com.example.catalog.server.CatalogServerKt")
}
dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:4.1.1"))
implementation(project(":catalog-domain"))
// 宿主:Spring Boot 自动配置、公开路由与内部端口。
implementation("me.ahoo.wow:wow-spring-boot:$wowVersion")
// 一个后端。没有后端时宿主在内存中运行,只能单节点。
implementation("me.ahoo.wow:wow-postgres:$wowVersion")
implementation("com.zaxxer:HikariCP")
// Bean Validation 实现:宿主据此校验命令上的 Jakarta 约束。
implementation("org.springframework.boot:spring-boot-starter-validation")
}宿主自带 spring-boot-starter 与 WebFlux。用 MongoDB 时把 wow-postgres 换成 wow-mongo;要连接其他上下文加 wow-kafka;要用 ClickHouse Sink 与数据集加 wow-clickhouse。把其他服务的 Manifest 作为对端(peer)的服务端也要应用 me.ahoo.wow,以便使用它的 wowPeers 配置。
不用插件
插件无法应用在产出它的那个构建里,所以 Wow 仓库中的示例手动应用 KSP,并自己传入插件的选项:
plugins {
alias(libs.plugins.ksp)
}
ksp {
arg("wow.model", "generate")
arg("wow.manifest.module", project.name)
}
// 相当于插件的 generateWowManifestFragment:用模块各类型的 Schema 补全片段。
apply(from = rootProject.file("example/wow-manifest-fragment.gradle.kts"))
dependencies {
api(project(":catalog-api"))
api(project(":kernel"))
ksp(project(":compiler"))
testImplementation(project(":test-dsl"))
}example/wow-manifest-fragment.gradle.kts 执行的正是插件 generateWowManifestFragment 执行的内容,并应用 example/wow-contract.gradle.kts,像插件一样把领域的契约打包进它的 api 模块。eShop 的服务端在设置 extra["wowService"] 之后应用 example/eshop/wow-manifest-compatibility.gradle.kts,代替 mergeWowManifest 与 Manifest 兼容性检查。KSP 选项如下:
| 选项 | 含义 |
|---|---|
wow.model | generate 编译模型、绑定索引与片段;report 只写 META-INF/wow/model-report.json;不设置时处理器什么也不做 |
wow.manifest.module | 片段的文件名 META-INF/wow/manifest/<module>.json;不设置时取上下文名 |
wow.context | 模块的上下文;不设置时取模块中唯一的 @BoundedContext |
命名上下文
领域模块在一个单独的对象上声明一次它的限界上下文:
package com.example.catalog.domain
import me.ahoo.wow.api.annotation.BoundedContext
@BoundedContext(name = "catalog", description = "Products and their prices")
object CatalogBoundedContext这个名字出现在每个类型名(catalog.product.ChangePrice)、每条路由(/catalog/product/{id}/change-price)、每个主题(wow.catalog.product.state)与每张表里。一次定好:
上下文名须匹配
[a-z][a-z0-9-]*:小写字母、数字与连字符,以字母开头(D169)。不允许.:它是catalog.product.ChangePrice这类类型名的分隔符,eshop.catalog会产生歧义,请用eshop-catalog。违反规则的@BoundedContext(或wow.context选项)会让编译失败,读取 Manifest(模块的片段或对端的 Manifest)时同样会被拒绝:text@BoundedContext: context name "eshop.catalog" is not valid: a context name is lower-case letters, digits and hyphens, starting with a letter ([a-z][a-z0-9-]*), such as eshop-catalog; a dot separates the context from the aggregate in type names such as context.aggregate.Command (decision D169)聚合名取聚合根的
@Name,否则取类的简单名的snake_case(Product为product,PriceList为price_list)。命令与事件名为
<context>.<aggregate>.<Name>,取类的简单名,除非@Name另行指定。处理器与订阅名为
<processor>.<function>,如PriceSaga.onPriceChanged,处理器取类的@Name或简单名。
编译器要知道上下文才能写出片段:模块中有一个 @BoundedContext,或插件配置了 wow { context = "catalog" }。生成的工厂函数仍然把上下文作为参数(见编译器生成什么)。
编写契约
catalog-api 放命令、事件与它们携带的值。命令不带身份字段:聚合 ID 来自路由(或发送它的 Saga),所有者与空间来自调用方。
package com.example.catalog.api
import me.ahoo.wow.api.annotation.CreateAggregate
import me.ahoo.wow.api.annotation.Summary
import java.math.BigDecimal
@CreateAggregate
@Summary("List a product")
data class ListProduct(val name: String, val price: BigDecimal)
@Summary("Change the product's price")
data class ChangePrice(val price: BigDecimal)
@Summary("A product was listed")
data class ProductListed(val name: String, val price: BigDecimal)
@Summary("The product's price changed")
data class PriceChanged(val price: BigDecimal)编写聚合
catalog-domain 放状态、聚合根与它拒绝时使用的错误码:
package com.example.catalog.domain.product
import com.example.catalog.api.ChangePrice
import com.example.catalog.api.ListProduct
import com.example.catalog.api.PriceChanged
import com.example.catalog.api.ProductListed
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
import java.math.BigDecimal
/** 第一个事件之前每个属性都要有值:内核按原样序列化状态。 */
class ProductState(val id: String) {
var name: String = ""
private set
var price: BigDecimal = BigDecimal.ZERO
private set
fun onSourcing(event: ProductListed) {
name = event.name
price = event.price
}
fun onSourcing(event: PriceChanged) {
price = event.price
}
}
@AggregateRoot
class Product(private val state: ProductState) {
fun onCommand(command: ListProduct): ProductListed = ProductListed(command.name, command.price)
@OnCommand(produces = [PriceChanged::class])
fun onCommand(command: ChangePrice): Decision = when {
command.price.signum() < 0 ->
Decision.reject(Rejections.NEGATIVE_PRICE, "${command.price} is negative.")
command.price.compareTo(state.price) == 0 -> Decision.NoOp
else -> Decision.events(PriceChanged(command.price))
}
object Rejections {
@ErrorCode(description = "A price is never negative.")
const val NEGATIVE_PRICE = "negative-price" // catalog.product.negative-price
}
}名为 onCommand 与 onSourcing 的函数不需要注解。@OnCommand(produces) 告诉 Manifest 返回 Decision 的函数可能产生哪些事件。拒绝是一个值,不是异常:不写入任何东西,调用方收到错误码。完整规则见编程模型。
测试
aggregateSpec 在真实内核与内存日志上运行生成的模型:
package com.example.catalog.domain.product
import com.example.catalog.api.ChangePrice
import com.example.catalog.api.ListProduct
import com.example.catalog.api.PriceChanged
import com.example.catalog.api.ProductListed
import me.ahoo.wow.test.aggregateSpec
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class ProductSpec {
private val model = productModel("catalog")
@Test
fun `a listed product takes a new price`() = aggregateSpec(model, id = "apple") {
given(ProductListed("Apple", BigDecimal("2.50")))
whenCommand(ChangePrice(BigDecimal("3.00"))) {
expectEvents(PriceChanged(BigDecimal("3.00")))
expectState { check(price == BigDecimal("3.00")) }
}
}
@Test
fun `a negative price is rejected`() = aggregateSpec(model, id = "apple") {
given(ProductListed("Apple", BigDecimal("2.50")))
whenCommand(ChangePrice(BigDecimal("-1"))) { expectRejected(Product.Rejections.NEGATIVE_PRICE) }
}
@Test
fun `listing a product creates it`() = aggregateSpec(model, id = "apple") {
whenCommand(ListProduct("Apple", BigDecimal("2.50"))) { expectEvents(ProductListed("Apple", BigDecimal("2.50"))) }
}
}productModel 是编译器在 Product 旁边生成的工厂函数。测试函数要像上面这样返回 Unit:JUnit 会跳过返回值的测试方法。全部断言见测试。
编写服务端
package com.example.catalog.server
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication(proxyBeanMethods = false)
class CatalogServer
fun main(args: Array<String>) {
runApplication<CatalogServer>(*args)
}src/main/resources/application.yaml:
spring:
application:
name: catalog-service
server:
port: 8080
wow:
# 单节点,数据在内存中,用于试用。
backend: memory
internal:
port: 9090宿主在类路径上找到领域模块的绑定索引,用应用的 Bean 构建模型,合并 Manifest 并提供它的路由。命令函数或处理器以参数形式需要的领域服务,是应用中的 Bean,按类型查找(带 @Name 时按名字)。使用 PostgreSQL 时,应用声明一个 DataSource Bean 并设置 wow.backend: postgres(见部署)。
运行并发送命令
./gradlew :catalog-server:run节点在 8080 端口提供公开路由,在 9090 提供内部端口。它会警告自己运行在内存后端上,并且在应用声明 CallerResolver Bean 之前,每个请求都按匿名调用方处理(见 HTTP API)。
curl -X POST localhost:8080/catalog/product/apple/list-product \
-H 'Content-Type: application/json' -d '{"name":"Apple","price":2.50}'
curl -X POST localhost:8080/catalog/product/apple/change-price \
-H 'Content-Type: application/json' -d '{"price":3.00}'
curl localhost:8080/catalog/product/apple/state
curl localhost:8080/wow/manifest命令在快照包含这次提交之后返回它的 CommandResult,所以紧接着读取状态就能看到新价格。被拒绝的命令返回 RFC 9457 问题详情,类型为 urn:wow:error:catalog.product.negative-price,状态码取自它的 @ErrorCode(默认 422)。