查询快照
每个聚合都保存它最新状态的快照,而快照是一个不用写投影就能查询的读模型:按状态的任意字段过滤、排序、分页、计数和聚合。本页用 Kotlin 和经 HTTP 编写查询,并声明它们需要的索引和脱敏。完整的语法见查询语言;按 ID 读取一个聚合,见读取状态。
用 Kotlin 编写查询
me.ahoo.wow.api.query.dsl(位于 wow-query-api)中的 DSL 构建查询。领域模块的类路径上有 wow-query-api 时,编译器会在每个状态旁边生成 <State>Properties:查询能指明的每条路径一个 QueryField 常量,所以字段改名会成为编译错误,而不是一个悄悄匹配不到任何东西的查询。catalog 的店面列出某个分类中已上架的商品,从第五个开始,价格从低到高:
val query = listQuery {
filter {
ProductStateProperties.STATE__CATEGORY_ID eq category
ProductStateProperties.STATE__STATUS eq ProductStatus.PUBLISHED
ProductStateProperties.STATE__PRICE gte BigDecimal("5.00")
}
sort { ProductStateProperties.STATE__PRICE.asc() }
}
// In process, typed: the node's own queries (NodeQueryApi), as trusted code.
val products = catalog.context.getBean(NodeQueryApi::class.java).trusted().snapshots<ProductState>()
val rows = runBlocking { products.list(query).toList() }
rows.map { it.ref.id }.assert().containsExactly(five, seven)
rows.map { it.state.price }.assert().containsExactly(BigDecimal("5.00"), BigDecimal("7.00"))filter { }块中的条件以and组合;and { }、or { }、nor { }和not { }可以嵌套。值按它写成的 JSON 比较,所以枚举按它的名字匹配。ProductStateProperties.STATE__PRICE就是state.price;系统字段(AGGREGATE_ID、OWNER_ID、VERSION、LAST_EVENT_TIME、ACCESS等)来自SnapshotProperties,SnapshotProperties.access(name)就是访问属性access.<name>。snapshots<ProductState>()把每一行解码为Snapshot<ProductState>:它的ref、version、scope、access和类型化的state。投影了部分字段的查询无法填充类型化的行:改用dynamic<Map<String, Any?>>()执行它。of(caller)按调用方所看到的模型执行查询;trusted()以受信任的代码执行(读取状态)。
查询的种类
| 构建器 | 返回 |
|---|---|
singleQuery { } | 第一个匹配项,或 null |
listQuery { } | 至多 limit 个匹配项 |
pagedQuery { } | 一页(pagination { index(1); size(20) }),按 total(...) 的要求带上总数 |
cursorQuery { } | 游标之后的一个键集分页,带下一个游标;需要 sort |
countQuery { } | 有多少个匹配项,以及计数是否精确 |
aggregation { } | 分组和指标:terms、直方图、日期;计数、求和、平均值等。实验性:用 @OptIn(ExperimentalWowApi::class) 选择使用 |
Kotlin 用 DSL 构建查询;Java 没有 DSL,用同样类型的工厂方法构建,例如 ListQuery.of(filter) 和 Sort.of(field, Sort.Direction.ASC)。查询类型是普通的值:可以比较,但要改动时重新构建一个,而不是复制。
整体读取的列表受它的 limit 限制。要读取一个很大的结果的每一行,就流式读取:stream(query) 按键集分块读取,每块都是一次独立的查询,并给每一行一个位置,同一个查询可以从这个位置继续,在本节点或另一个节点上都可以。
经 HTTP
每个聚合类型在 POST /<context>/<aggregate>/snapshot/query 上提供快照查询。请求体是 JSON 形式的查询,操作写在 kind 中:
curl -X POST localhost:8081/eshop-catalog/product/snapshot/query \
-H 'Content-Type: application/json' \
-d '{"kind":"paged","filter":{"op":"eq","field":"state.status","value":"PUBLISHED"},
"sort":[{"field":"state.price","direction":"asc"}],"pagination":{"index":1,"size":20}}'对同一路径发 GET,返回模型的能力描述:它的字段及其类型、每个字段接受的操作符,以及上限;它的版本就是它的 ETag。客户端和视图引擎读取它,只提供模型能回答的东西。
带 Accept: text/event-stream 的 list 以服务端推送事件流式返回它的行,每条记录一个 row,然后是一个 end:
val events = catalog.stream(PRODUCTS, """{"kind":"list","filter":${published(category)}}""", BUYER)
// One row per record, then the end of a resumable stream.
events.map { it.first }.assert().containsExactly("row", "row", "row", "end")
WowJson.mapper.readTree(events.last().second)["reason"].asString().assert().isEqualTo("complete")
val streamed = events.dropLast(1).map { (_, data) -> WowJson.mapper.readTree(data)["aggregateId"].asString() }
streamed.assert().containsExactlyInAnyOrderElementsOf(filed)每个 row 事件的 id 就是它的位置。流以 {"reason":"complete"} 结束;或者在 wow.query.stream.max-hold(30 秒)之后以 {"reason":"hold-expired","resumable":true} 结束,客户端把最后一个 id 作为 Last-Event-ID 发送来续读。
谁能看到什么
查询以它的调用方的身份准入:
- 调用方的租户会加进每个查询,它有所有者和空间时,这两项也会加进去:所有者只对
Ownership.OWNER的聚合加上,空间对每个聚合都加上。没有所有者的租户级调用方,能看到它租户里、它空间之内的每个聚合;属于租户的聚合(Ownership.TENANT),租户里的每个调用方在其空间之内都能看到,所有者级的调用方也一样(谁能读取它)。 - 有访问策略(服务自己的
AccessPolicybean)的聚合,按它的read授予调用方的读取,取代所有者与空间:谓词是存储执行的过滤条件(策略在哪里生效)。 - 在这一切之前,服务的读取授权器(
ReadAuthorizerbean)可以拒绝调用方:按它是谁拒绝,从不按它问的是什么拒绝。服务正是这样把属于租户的聚合限定给某些角色(授权读取)。 - 已删除的聚合不包含在内,除非查询指明了它们的删除状态:
deletion(DeletionState.ALL)或deleted。 - 读取侧拒绝的查询返回
400 wow.query.invalid,它的 violations 指明违反的每条规则;读取授权器拒绝的调用方,或者写了它无权写的Wow-Scope的调用方,返回403 wow.auth.forbidden;什么都没匹配到的single返回404 wow.query.not-found。
上限
每个查询都在一个预算下运行:HTTP 用 wow.query.budgets.http.*,Kotlin 中用 wow.query.budgets.in-process.*。列表至多返回 max-list-size(1,000)行,不指明 limit 时为 100;一页至多 max-page-size(100)行;计数在 count-limit 处停止,并说明是否精确;查询至多运行 timeout(30 秒)。超出上限的查询会被拒绝,而不是不声不响地截断。9.x 的 limit(Int.MAX_VALUE) 同样会被拒绝:必须读全部匹配的代码(任务或迁移步骤,而不是代表调用方的读取)用 NodeQueryApi.trusted().unbounded(),它不受进程内预算限制、改用自己的超时(wow.query.unbounded-timeout),并读取所有租户;或者以流读取。超时的查询返回 504 wow.query.timeout:收窄它,而不是再运行一次。存储上运行的查询已达到允许的数量时,返回 429 wow.query.busy 并带上 Retry-After。
声明索引
按没有索引的字段过滤或排序的查询,会扫描存储。状态字段上的 @QueryIndex 声明后端在启动时建立的索引,按所在存储的 ddl 管理(默认 wow.ddl;validate 下缺失索引,或现有索引不是后端规划的那个时,会拒绝启动:例如早先版本建立的另一种定义的索引,auto 会重建它一次);with 添加跟在它后面的字段,用于常见的过滤与排序组合。店面按分类、品牌和状态过滤商品,并按价格排序:
@field:QueryIndex
var price: BigDecimal = BigDecimal.ZERO
private set
@field:QueryIndex(with = ["state.status", "state.price"])
var brandId: String? = null
private set
@field:QueryIndex(with = ["state.status", "state.price"])
var categoryId: String? = null
private set
@field:QueryIndex(with = ["state.price"])
var status: ProductStatus = ProductStatus.DRAFT
private set注解写在字段上(@field:),因为编译器从编译后的状态读取它们。
描述类型说明不了的东西
me.ahoo.wow.api.query.annotation 中还有几个注解,告诉查询模型字段的类型本身说明不了的东西:
| 注解 | 声明 |
|---|---|
@QueryTemporal | 一个以某种单位保存纪元时间的整数,或一个按某种格式化模式写成的字符串 |
@QueryDecimal、@QueryMoney | 定点小数;某种货币的金额 |
@QueryDuration | 以某种单位计量的一段时长 |
@QueryReference | 另一个聚合的 ID |
@QueryAlias | 查询可以用于这个字段的其他路径,例如它改名之前的名字;只用于输入,响应把字段放在它自己的路径下 |
@QueryText | 供人按词检索的文字,例如名称或描述:search 可以指定它,不指定字段的 search 也会读它(Elasticsearch 上) |
@Sensitive | 在每个结果中脱敏的字符串:DISPLAY 仍可以过滤和排序,CONFIDENTIAL 永远不可以 |
脱敏的字段对每个调用方都脱敏:在查询、状态读取和轨迹中都一样,在数仓的目录中也一样。
在哪些存储上
快照在 PostgreSQL 和 MongoDB 上原地查询;某个类型的快照被索引到 Elasticsearch 时,也在那里查询。在内存后端上,快照从内存中查询,用于本地运行和测试;wow.query.memory.enabled: false 关闭它,此时快照路由返回 404。