视图存储
视图存储是视图引擎 ViewStore 端口在 Wow 上的服务端:读者保存的视图与偏好是两个 Wow 聚合,view(视图)与 view_preferences(视图偏好),限界上下文 view-store。前端用 @ahoo-wang/wow-view-store 的 WowViewStore 接它(视图存在哪里)。
它不做认证。 租户、所有者与应用按请求写的取:路径里的 {tenantId}、{ownerId} 与请求头 CoSec-App-Id。谁能用哪条路径,全由前面的 CoSec 网关决定。所以每个部署——嵌入的也好,独立的也好——都只能放在 CoSec 网关后面(安全模型)。
模块
| 模块 | 内容 |
|---|---|
wow-view-store-api | 限界上下文 view-store、命令、事件、ViewKind/ViewAudience、SystemView、错误码 |
wow-view-store-domain | 聚合 View(view)与 ViewPreferences(view_preferences)及其规则 |
wow-view-store-starter | Spring Boot 自动配置:把视图存储嵌进一个 Wow 服务 |
wow-view-store-server | 基于 starter 的独立服务,事件与快照存在 MongoDB;以 Docker 镜像发布 |
前三个模块从 9.2.0 起发布到 Maven Central,由 wow-bom 约束版本,与视图引擎的 npm 包同一个版本号。
嵌入 starter,还是运行独立服务端
嵌入 wow-view-store-starter | 独立的 wow-view-store-server | |
|---|---|---|
| 适合 | 已经有一个 Wow WebFlux 服务,视图跟着这个业务走 | 没有自己的 Wow 服务,或想把各个前端的视图集中存放 |
| 存储 | 宿主用的存储:MongoDB 或 Elasticsearch | 只有 MongoDB(9.2.0) |
| 消息总线 | 宿主的;视图存储的 Kafka 主题可以单独加前缀 | 自己的 Kafka 主题前缀 |
| 例子 | 示例服务端(example/example-server)、补偿服务(补偿控制台的视图存在那里) | Docker 镜像 wow-view-store-server |
嵌入 starter
在一个 Wow WebFlux 服务里加上依赖:
implementation("me.ahoo.wow:wow-view-store-starter")两个聚合随 domain 模块进到 classpath,Wow 像路由宿主自己的聚合一样路由它们,前缀是 /view-store/tenant/{tenantId}/owner/{ownerId}/…。starter 加上它们需要的其余部分,每个 Bean 都以视图存储命名、只碰视图存储的聚合与路径,宿主自己的聚合、路由与查询不受影响。
- 开放的路由:
POST /view(创建)、PUT /view/{id}/save与/rename、DELETE /view/{id};PUT /view/{id}/share(在视图的个人路径上,移到owner/(shared))与PUT /view/{id}/claim(在调用者自己的个人路径上,把共享视图收为己有);/view/snapshot/…与/view_preferences/snapshot/…的快照查询;GET /system-views、GET/PUT /definitions/{definitionId}/preferences与GET /view/requests/{requestId}(重放)。 - 其余路由一律 404:Wow 为两个聚合生成的状态、追踪、事件流、快照维护、补偿、恢复与资源标签路由,以及它们命令的命令门面,都关掉了。少一条路由,网关就少一条要管的路径。
- 命令不带 id(Wow 从
{id}取);没有字段的命令(share、delete)以{}为请求体。 - 设
wow.view-store.enabled=false关掉 starter。
聚合名会撞。 两个聚合叫 view 与 view_preferences,Wow 的 MongoDB 集合(view_event_stream、view_snapshot……)不带上下文名。宿主自己若有叫 view 或 view_preferences 的聚合,会共用这些集合:不要在它里面嵌入 starter,或者给宿主的聚合改名。
路径大小写。 starter 的规则都不区分大小写地匹配路径,所以设了 PathMatchConfigurer.setUseCaseSensitiveMatch(false) 的宿主也受保护。只有一处例外:开放路由只在它自己的大小写下胜过关闭的路由,所以在不区分大小写的宿主上,…/view/REQUESTS/state 这样的路径答 404。用别的解析选项替换了 Spring 的 RouterFunctionMapping 的宿主,不要嵌入 starter。
独立服务端
wow-view-store-server 在与补偿服务相同的中间件上运行 starter,可以多实例部署:
| 中间件 | 用途 | 配置 |
|---|---|---|
| MongoDB | 事件流与快照(Wow 的默认存储) | spring.mongodb.uri |
| Kafka | 命令、事件与状态事件总线(Wow 的默认总线) | wow.kafka.bootstrap-servers、wow.kafka.topic-prefix |
| Redis | 多个实例共享的 CosId 机器号 | spring.data.redis.url、cosid.machine.distributor.type: redis |
9.2.0 只支持 MongoDB。 独立服务端构建时不带 Wow 的 Elasticsearch 支持。要把视图存在 Elasticsearch 上,把 starter 嵌进一个跑在 Elasticsearch 上的宿主:索引定义随 starter 发布,不随服务端。
从源码运行(仓库根目录下):
service_dir=view-store/wow-view-store-server
mkdir -p "$service_dir/logs" "$service_dir/data" "$service_dir/config"
test -e "$service_dir/config/application.yaml" || cp "$service_dir/src/dist/config/application.yaml" "$service_dir/config/application.yaml"
./gradlew :wow-view-store-server:runsrc/dist/config/application.yaml 是 config/application.yaml 的模板,所有后端都指向 localhost。
Docker 镜像
镜像从 installDist 构建,有 linux/amd64 与 linux/arm64 两种架构,推到三处:
ahoowang/wow-view-store-serverghcr.io/ahoo-wang/wow-view-store-serverregistry.cn-shanghai.aliyuncs.com/ahoo/wow-view-store-server
v<version> tag 发布 <version> 与 <major>.<minor>;推到 main(以及每天一次的构建)发布 main。预发布 tag(v9.2.0-rc.0)不发布镜像。老版本线上的补丁(v9.3.0 之后的 v9.2.3)不移动 latest,它停在最高的稳定版本上。
镜像以非 root 用户在 8080 端口运行 /opt/wow-view-store-server/bin/wow-view-store-server,从 /opt/wow-view-store-server/config/ 读配置:把自己的 application.yaml 挂到那里,或者用环境变量覆盖。三个后端的地址都要设:漏了一个,容器照样启动,指向 localhost,第一次用到那个后端时才失败。
| 环境变量 | 配置项 | 模板里的值 |
|---|---|---|
SPRING_MONGODB_URI | spring.mongodb.uri | mongodb://root:root@localhost:27017/wow_view_store_db?authSource=admin&maxIdleTimeMS=60000 |
WOW_KAFKA_BOOTSTRAPSERVERS | wow.kafka.bootstrap-servers | PLAINTEXT://localhost:9092 |
WOW_KAFKA_TOPICPREFIX | wow.kafka.topic-prefix | wow.view-store-server. |
SPRING_DATA_REDIS_URL | spring.data.redis.url(CosId 机器号) | redis://localhost:6379 |
docker run -d --network internal \
-e SPRING_MONGODB_URI='mongodb://root:root@mongo:27017/wow_view_store_db?authSource=admin' \
-e WOW_KAFKA_BOOTSTRAPSERVERS='PLAINTEXT://kafka:9092' \
-e SPRING_DATA_REDIS_URL='redis://redis:6379' \
ahoowang/wow-view-store-server:9.2.0- 这里没有
-p 8080:8080:镜像不做认证,它的端口只给 CoSec 网关访问,绝不直接暴露给用户。internal是网关与它共用的网络。 HEALTHCHECK读/actuator/health/liveness(进程活着)。后端宕了显示在/actuator/health,模板只答状态(show-details: when-authorized),端口不泄露后端的名字与地址。- JVM 参数(
-Xms512M -Xmx512M、ZGC、logs/下的 GC 日志、data/下的堆转储)来自installDist的启动脚本;要追加就设JAVA_OPTS。
配置项
starter 的配置在 wow.view-store 下(ViewStoreProperties):
| 配置项 | 缺省 | 作用 |
|---|---|---|
wow.view-store.enabled | true | 是否把视图存储加进宿主 |
wow.view-store.system-views | 空 | 配置的系统视图,见系统视图 |
wow.view-store.kafka.topic-prefix | 未设 | 只给视图存储两个聚合的 Kafka 主题前缀,见下文 |
相关的 Wow 配置:
| 配置项 | 与视图存储的关系 |
|---|---|
wow.kafka.topic-prefix | 没设 wow.view-store.kafka.topic-prefix 时,视图存储的主题跟着它;独立服务端用它(wow.view-store-server.) |
wow.elasticsearch.index-prefix | 视图存储的索引没有自己的前缀,跟着它移动(多个部署共用一个集群) |
一个 Kafka 主题命名空间只放一个部署
Wow 按上下文与聚合给 Kafka 主题命名(wow.view-store.view.command……),所以同一个 Kafka 集群上的两个视图存储部署(独立服务端与一个嵌入 starter 的宿主,或两个宿主)会消费对方的命令与事件。给每个部署一个自己的前缀:
- 嵌入 starter 的宿主设
wow.view-store.kafka.topic-prefix,它只作用于视图存储的两个聚合(<prefix>view-store.view.command……),宿主自己的聚合仍用wow.kafka.topic-prefix给的主题。补偿服务设的是wow.compensation-service.。不设或为空白时,视图存储的主题跟着wow.kafka.topic-prefix。设了它以后,宿主的每个主题转换器 Bean 都必须只是一种(命令、事件流或状态事件),和 Wow 自己的一样:同时是几种的 Bean 说不清被要的是哪一种,宿主拒绝启动。 - 独立服务端自己设
wow.kafka.topic-prefix(wow.view-store-server.):它没有别的聚合。
wow:
view-store:
kafka:
topic-prefix: wow.compensation-service.- 改一个运行中部署的前缀,视图存储就搬到新的空主题上:先把旧主题消费完。
- 视图存储的主题是 Wow 缺省主题(两个前缀都没设,总线在 Kafka 上)的宿主,启动时打一条警告,点名
wow.view-store.kafka.topic-prefix:它无从知道集群上是否还有别的部署。 - Kafka 集群不自动建主题(
auto.create.topics.enable=false)时,在宿主启动前建好视图存储的六个主题:<prefix>view-store.view.{command,event,state}与<prefix>view-store.view_preferences.{command,event,state}。补偿服务就是wow.compensation-service.view-store.view.command和它旁边的五个。 - 视图存储有自己前缀的宿主,在它生成的 BI 脚本(
wow.bi.script)里不包含视图存储:脚本按 BI 唯一的topic-prefix读每个聚合的主题,而那个前缀不是视图存储的。
存储
MongoDB
- starter 不建自己的 MongoDB 索引:Wow 没有给模块加快照索引的钩子。视图多时自己在
view_snapshot上加:state.config.kind,以及多键的state.config.panels.instanceId、state.config.panels.opens与state.config.panels.click.instanceId。 - 在 MongoDB 上,两个视图存储部署只靠数据库分开:两个宿主连同一个数据库会共用集合(查询把租户、所有者与应用分开,存储不分)。给每个部署一个自己的数据库,补偿服务与独立服务端都是这样。
Elasticsearch
starter 带着两个快照索引的定义(META-INF/wow/elasticsearch/wow.view-store.view.snapshot.json 与 wow.view-store.view_preferences.snapshot.json)和视图事件流的定义(wow.view-store.view.es.json),索引还不存在时,Wow 在启动时按它们建,与它自己的模板并列:
- 被查询的路径(
state.definitionId、state.appId、state.config.kind与面板引用)是不带ignore_above的keyword,所以无论 Wow 的模板推断出什么,查询模式都接受视图存储发的过滤。 state.config是dynamic: false:它装着引擎配置里的一切,包括同一个键下不同类型的值;只有kind与panels(nested数组,供共享看板检查用,含instanceId、opens、click.instanceId)是字段。服务端因此拒绝panels不是至多 1000 个对象的数组、引用不是至多 256 字的字符串的配置,以及超过 256 字的definitionId:存储会拒绝它索引不了的文档。- 偏好的
state.lastTabs不索引(enabled: false):它的键是宿主的。 - 视图事件里,
body.body(事件的载荷)是dynamic: false,只有audience与toOwnerId是 keyword:重放路由靠它们找到一次收为个人。
已经存在的索引不会被改。 宿主在用上带这些定义的 starter 之前就写过视图时,Wow 已经只按模板建了索引,它保留原来的映射,宿主启动时打一条警告,点名映射不同的路径:这样的快照索引上视图列表被拒绝,这样的事件流索引上重放答 400。趁它还空时删掉它,或者在宿主启动前把它重建索引到按定义建的索引里(通用做法见重建已有索引)。事件流的步骤(宿主停着):
PUT wow.view-store.view.es-new,请求体{"mappings":{"enabled":false}},再POST _reindex从wow.view-store.view.es到它。这个临时索引不匹配任何 Wow 模板(wow.*.es),映射只来自请求体;只映射一部分文档的请求体会让 Elasticsearch 动态映射其余部分,遇到载荷形状不同的事件时重建失败。关掉映射,它只保留_source,回程只需要这个。- 删掉
wow.view-store.view.es,用wow.view-store.view.es.json的内容重新PUT它(其余由 Wow 的模板补上),再POST _reindex从wow.view-store.view.es-new回来。 - 删掉
wow.view-store.view.es-new,启动宿主:警告消失。
索引前缀。 视图存储的索引没有自己的前缀:两个嵌入 starter 的宿主连同一个 Elasticsearch 集群会共用 wow.view-store.view.snapshot(查询把应用分开,存储不分),除非各自设 wow.elasticsearch.index-prefix。它移动宿主所有的索引与模板,视图存储的也在内(<prefix>wow.view-store.view.snapshot……);上面那些定义的文件名不变。
系统视图
系统视图是一个应用的每个用户都能读、但谁也不拥有的视图。它有三个来源:
| 来源 | 在哪里 | 谁写 |
|---|---|---|
| 代码 | 宿主的视图定义,id 以 system: 开头 | 没有人:只读,从不发给服务端 |
| 配置 | wow.view-store.system-views(或宿主的 SystemViewProvider Bean),按租户与应用(空白即全部) | 没有人:只读,改了要重启 |
| 存储 | 保留租户 (platform)、所有者 (system) 下的视图聚合,按应用与定义 | 管理员,经常规的视图路由,不必重启 |
GET …/tenant/{tenantId}/owner/(shared)/system-views[?definitionId=] 对任何请求租户,先答与它的租户和应用匹配的配置视图,再答它这个应用的存储视图;GET …/system-views/{id} 先找存储的,再找配置的。每个 SystemView 带 source(configured 或 stored),存储的还带 version;两者的 revision 都是内容的散列。存储视图与配置视图同 id 时,读写都以存储的为准,服务端打一次警告。
配置的系统视图
wow:
view-store:
system-views:
- tenant-id: '' # 空白:所有租户
app-id: my-app # 空白:所有应用
definition-id: orders
id: orders-open
title: Open orders
config: |
{"kind": "record", "columns": []}config 是引擎 ViewConfig 的 JSON 文本。启动时逐条检查:config 必须是 JSON 对象、kind 是 record、analysis 或 dashboard 之一,id 不能为空也不能以 system: 开头(那是代码声明的),同一租户、应用下的 id 不能重复;不合格就启动失败。
要从别处读(数据库、配置中心),换掉缺省的 SystemViewProvider Bean,用 SystemViews.of 构建每个视图,它做同样的检查并算出内容散列的 revision:
import me.ahoo.wow.serialization.toObject
import me.ahoo.wow.viewstore.starter.system.SystemViewProvider
import me.ahoo.wow.viewstore.starter.system.SystemViews
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import reactor.core.publisher.Flux
import tools.jackson.databind.node.ObjectNode
@Configuration
class SystemViewConfiguration {
@Bean
fun systemViewProvider(): SystemViewProvider = SystemViewProvider { tenantId, appId ->
// 按请求的租户与应用给出;这里给每个租户、每个应用同一个。
Flux.just(
SystemViews.of(
id = "orders-open",
definitionId = "orders",
title = "Open orders",
config = """{"kind": "record", "columns": []}""".toObject<ObjectNode>(),
),
)
}
}存储的系统视图
存储的系统视图是全局的:只放在租户 (platform) 下(ViewStoreService.SYSTEM_TENANT_ID,即 CoSec 平台租户的值;不是 Wow 的缺省租户 (0),那是不分租户的部署用的),每个租户都读它们。在别的租户下以所有者 (system) 创建会被拒绝(ViewInvalid)。它们走每个视图都有的路由,只在一条路径上:
POST /view-store/tenant/(platform)/owner/(system)/view 创建(「发布」:复制一个视图的标题与配置)
PUT /view-store/tenant/(platform)/owner/(system)/view/{id}/save
PUT /view-store/tenant/(platform)/owner/(system)/view/{id}/rename
DELETE /view-store/tenant/(platform)/owner/(system)/view/{id} 删除(「撤下」)
GET /view-store/tenant/(platform)/owner/(system)/view/requests/{requestId} 重试写入的重放(宿主自己的上下文就是 view-store 时没有 /view-store 前缀。)
- 系统视图从不改受众:
share与claim被拒绝(SystemViewReadOnly)。删掉一个共享看板正在显示的系统视图是允许的,和共享视图一样,那块面板坏掉。 - 服务端生成每个视图的 id,所以经 HTTP 不会意外与配置视图撞 id;要原地替换一个配置视图的宿主,在进程内以那个 id 向聚合
(platform)/(system)/<id>发CreateView。 - 存储视图经宿主的快照查询后端(MongoDB 或 Elasticsearch)从视图快照里读,每个应用至多 1000 个(超出时警告点名那个应用)。没有快照查询后端的宿主(内存快照)只提供配置视图,启动时打一条警告:存储的系统视图在那里是关的。宿主自己的
StoredSystemViewSourceBean 可以替换两者。 - 前端由宿主给
editSystem的人发布与编辑它们(权限)。
安全模型
网关拥有权限
服务端不认证,身份就是路径:路径的 {ownerId} 就是用户(或 (shared)),租户与应用是路径的 {tenantId} 与 CoSec-App-Id 请求头。服务端没有任何「谁能写」的设置,所有的放行都是 CoSec 网关的路径规则(CoSec)。服务端这一侧只保证路径就是它读到的那个:
- 应用只从
CoSec-App-Id取;调用者发到视图存储路径的每个Command-Header-*都被丢掉(Wow 会拒绝它保留的键,command_operator与app_id都在其中,其余的键会原样抄进命令头);Command-Tenant-Id与Command-Owner-Id也被丢掉。 - 解码后租户或所有者为空,或含有显示为空白或什么都不显示的字符(空白、控制与格式字符如 U+200B、代理项、私用区与未分配码位,以及别的不可见字符),在到达 Wow 之前答 400
ViewScopeRequired:Wow 自身会拒绝空白的已声明路径值(400IllegalArgument),但不拒绝不可见字符,而不可见字符会造出一个看起来像别人的所有者。被收为个人时的新所有者守同样的规则。
网关规则
规则把租户、所有者与应用都绑到令牌上(路径都在服务的基础路径下):
| 路径 | 方法 | 放行条件 |
|---|---|---|
/view-store/tenant/{tenantId}/owner/{ownerId}/**,…/view/{id}/claim 与 …/view/{id}/share 除外 | 全部 | {tenantId} 是令牌的租户,且 {ownerId} 是令牌的 sub |
/view-store/tenant/{tenantId}/owner/(shared)/** | GET,以及 POST …/snapshot/**(读) | {tenantId} 是令牌的租户 |
/view-store/tenant/{tenantId}/owner/(shared)/** | POST /view、PUT、DELETE(写) | {tenantId} 是令牌的租户,且调用者具备写共享视图的角色 |
/view-store/tenant/{tenantId}/owner/{ownerId}/view/{id}/claim、…/view/{id}/share | PUT | {tenantId} 是令牌的租户,{ownerId} 是令牌的 sub,且调用者具备写共享视图的角色 |
/view-store/tenant/{tenantId}/owner/(shared)/definitions/{definitionId}/preferences | PUT | 同共享写入(不登录的宿主把偏好存在这里) |
/view-store/tenant/(platform)/owner/(system)/** | 全部 | 调用者是系统视图的管理员(任何租户:这些视图是全局的) |
- 收为个人与设为共享两样都要。 收为个人把共享视图移给调用者,等于把它从所有人的列表里拿掉;设为共享把个人视图移到
(shared),等于把它发布到所有人的列表里。两者都发到个人路径(sub == {ownerId}),还要写共享视图的角色。个人规则不能单独放行它们:把…/view/{id}/claim与…/view/{id}/share从个人规则里排除,只由这一条决定;否则谁都能先建个人视图、再设为共享,发布一个共享视图。 - 应用也来自令牌。 CoSec 认证
CoSec-App-Id;请求头缺失,或写的应用不是令牌所属的应用,就拒绝。服务端按这个请求头把应用分开。 (shared)与(system)永远不是用户。 不要签发sub为(shared)或(system)、或含括号的令牌:服务端拒绝这样的新所有者,而网关的个人规则会把它放进共享或系统路径。- 读系统视图不需要单独的规则:客户端经自己租户的
…/owner/(shared)/system-views读它们。
系统视图的写入只给平台管理员
…/tenant/(platform)/owner/(system)/** 上的规则决定谁能发布、修改、撤下每个租户都在读的系统视图;个人规则不能放行这条路径(租户 (platform) 用户的 sub 永远不是 (system))。下面这条 CoSec 策略不区分大小写地匹配这条路径,拒绝平台租户里角色为 admin 的用户以外的所有人(这些管理员由部署自己的允许规则放行,这条策略只负责拒绝其余的人)。它没有策略级的 condition,因为 CoSec 5.2 拒绝空的 condition:
{
"id": "view-store-system-views",
"name": "View store system views",
"category": "view-store",
"description": "Only platform administrators write the global system views.",
"type": "global",
"tenantId": "(platform)",
"statements": [
{
"name": "SystemViewsPlatformAdminOnly",
"effect": "deny",
"action": {
"path": {
"pattern": "/view-store/tenant/(platform)/owner/(system)/**",
"options": { "caseSensitive": false }
}
},
"condition": {
"bool": {
"or": [
{ "inTenant": { "value": "platform", "negate": true } },
{ "inRole": { "value": "admin", "negate": true } }
]
}
}
}
]
}服务端拒绝这条路径的其他写法。 解码后是 (platform)/(system)、却写成别的样子的路径(百分号编码、带 ; 参数、换了大小写)以 ViewScopeRequired 拒绝,所以网关在字面路径上的规则看得到每一次系统视图的写入。其余写入视图的途径都关着:Wow 的命令门面与视图存储聚合的批量和维护路由都关闭,发到别的所有者或租户路径的命令过不了 Wow 的所有者检查,或者找不到聚合。
任何路径规则都有两个边界:网关看到的路径必须与服务端路由的一致,所以在网关之前合并重复的斜杠(/view-store//tenant/… 若到网关之后才合并,就绕过了字面规则);直接往命令总线(Kafka)写消息的生产者绕过网关,这和其他视图的信任边界相同。
WARNING
没有 …/tenant/(platform)/owner/(system)/** 的网关规则,任何能连到视图存储的人都能创建、修改、删除每个租户的系统视图。
前端的按钮跟着同一个角色
WowViewStore 的 permissions(createShared、instance(id) 的 save/rename/delete/changeAudience,以及引擎的 editSystem)由宿主按网关检查的同一个角色给出:createShared 与 changeAudience 要写共享视图的角色,editSystem 要系统视图管理员的角色。服务端不告诉客户端它能做什么。
不登录的宿主(例如补偿控制台)没有令牌:它自己填租户 (0)、所有者 (shared) 与它的应用,它的网关按网络或服务令牌放行它,和放行那个控制台的其余请求一样(不登录的宿主)。
写入的几条事实
- 创建不幂等。 重试的
POST /view会以服务端新生成的 id 再建一个视图。保存、改名、改受众、删除与偏好按Command-Request-Id幂等,重放路由答一个请求 id 的写入留下了什么。 - 共享看板检查读的是快照。 被共享仪表盘引用的视图不能收为个人;检查查询的是仪表盘的快照,所以刚保存的看板可能还看不到(最终一致)。
源码
view-store/ · ViewStoreProperties.kt · ViewStoreAutoConfiguration.kt · 服务端配置模板 · Dockerfile