定义视图
定义说明一份数据集能怎样观察。本页为一个销售订单的快照模型逐项写出一份定义,并说明测试怎样证明引擎会准入它。背后的概念见视图引擎的核心概念。
1. 从描述开始,并把它提交
每个 Wow 查询模型都描述自己:GET /{context}/{aggregate}/{model}/query 返回它的描述,ETag 是描述的版本。生成的查询客户端用 describe() 读取它。把描述存成 JSON 放在定义旁边,两者一起提交:
// src/views/salesOrderDescriptor.ts
import type { QueryModelDescriptor } from '@ahoo-wang/wow-client/dsl';
// GET /sales/sales-order/snapshot/query, version sha256:a30adffc…
import snapshot from './salesOrderDescriptor.json';
export const salesOrderDescriptor = snapshot as unknown as QueryModelDescriptor;提交一份副本,而不是在运行时去请求,原因是:
- 定义在模块加载时同步构建,测试读的也是同一个文件;
- 漂移在评审里看得见:服务变化后,重新取一次覆盖旧副本,diff 和准入测试会列出它影响的每个字段和视图;
- 运行时仍会读取实时的描述:在第一次查询之前,引擎通过数据源把能力收窄到这次部署允许的范围。
2. 事实、能力与选择
defineView(descriptor, spec) 把三类东西分开:
| 类别 | 是什么 | 来自 | 定义能做什么 |
|---|---|---|---|
| 事实 | 路径、值类型、枚举值、时间怎样保存、敏感级别、弃用、数组的元素 | 提交的描述 | 只能选择和命名;写了描述里没有的路径或值,就是准入错误 |
| 能力 | 操作符、排序、聚合、检索、上限 | 实时的描述;随存储变化 | 不写就随部署;写了只能收窄 |
| 选择 | 列哪些字段、什么顺序、什么措辞、单元格与语气、收窄、行的身份、系统视图 | 定义 | 全部由你决定 |
字段的种类从事实推出:枚举值或你写的 options 得到 enum;时间得到 datetime 或 date;布尔、数字和字符串各归其类;带元素的数组是 elementMatch。推不出来的,例如对象,需要你写 kind。
3. 一份定义,逐项选择
仓库团队每天清理待发货的订单;客服按城市看销量。为他们写的定义:
// src/views/salesOrders.ts
import { AggregationDateUnit } from '@ahoo-wang/wow-client/dsl';
import { defineView, text } from '@ahoo-wang/wow-view-engine';
import { SALES_ORDER_VIEWS } from './salesOrderViews';
export const SALES_ORDERS = 'sales-orders';
export const salesOrders = defineView(salesOrderDescriptor, {
id: SALES_ORDERS,
// The key the host registers the source under.
source: 'sales-order',
title: text('orders.title'),
// What an analysis counts in: "12 orders", not "12 records".
recordNoun: text('orders.noun'),
// A board's date filter means when the order was placed.
timeField: 'firstEventTime',
// Only what is listed appears, in this order: the field picker and the
// default columns follow it.
fields: {
// Looked up and copied, never grouped by.
aggregateId: {
label: text('orders.id'),
cell: 'copyable',
analysis: false,
},
'state.status': {
label: text('orders.status'),
cell: 'status',
// Every value a reader meets, worded; a tone only for a value that is
// good, bad or waiting.
options: {
CREATED: { label: text('orders.created'), tone: 'neutral' },
PAID: { label: text('orders.paid'), tone: 'warning' },
SHIPPED: { label: text('orders.shipped'), tone: 'success' },
RECEIVED: { label: text('orders.received'), tone: 'success' },
},
},
'state.address.province': text('orders.province'),
'state.address.city': text('orders.city'),
// A total of the amounts means something; an average per page does not.
'state.totalAmount': {
label: text('orders.total'),
summary: ['SUM'],
more: { numberFormat: { style: 'currency', currency: 'CNY' } },
},
'state.paidAmount': {
label: text('orders.paidAmount'),
summary: ['SUM'],
more: { numberFormat: { style: 'currency', currency: 'CNY' } },
},
// Readers follow trends by day and by month: wow-client's enum, not strings.
firstEventTime: {
label: text('orders.placedAt'),
analysis: {
dateUnits: [AggregationDateUnit.DAY, AggregationDateUnit.MONTH],
},
},
// An order's lines, each named by its product.
'state.items': {
label: text('orders.items'),
elementTitle: 'productId',
elements: {
productId: text('orders.product'),
quantity: text('orders.quantity'),
price: text('orders.price'),
},
},
// Free text read in the detail only: never filtered, sorted or grouped.
'state.address.detail': {
label: text('orders.address'),
cell: 'text',
operators: [],
sortable: false,
analysis: false,
},
},
record: {
layouts: ['table', 'card'],
// The host's "Ship" reads each row's status, whether or not the view
// shows that column (section 5).
rowFields: ['state.status'],
},
// The system views that ship with the definition (section 6).
views: SALES_ORDER_VIEWS,
});没写的也是选择:租户、所有者、区县、应付余额。定义是一份取舍,不是描述的副本:列得越长,字段选择器越难用。
| 问题 | 选择 |
|---|---|
| 读者会读它、按它过滤或分组吗? | 会就列,不会就不列。 |
| 它是人们称呼一条记录的方式吗? | cell: 'copyable'、analysis: false;它唯一且可排序时,作为 record.rowKey。 |
| 是只计数、从不分组的 ID 吗? | analysis: { groups: [] }:它仍能用于去重计数。 |
| 是自由文本吗? | cell: 'text'、sortable: false、analysis: false;没人按它过滤时写 operators: []。 |
| 是类别吗? | 为读者会遇到的每个值写措辞(options);只有好、坏或等待中的值才带 tone。 |
| 是金额或数量吗? | 只给读者会读的合计(summary);币种和单位写在 more.numberFormat。 |
| 是时刻吗? | 把 analysis.dateUnits 收窄到读者习惯的粒度。 |
| 是数组吗? | 它的元素写在 elements 里;elementTitle 指出哪个元素字段说明“这是哪一项”。 |
字段很多的定义用 fieldGroups 给字段分组,每组一个业务上的名字。
4. 措辞是键;为受众收窄
每个标题、标签、选项和视图名都写成 text(key),措辞放在每种语言一张的表里,由宿主交给 ViewHost。定义、配置和存储里只有键,所以切换语言只是重画:
// src/views/words.ts
/** The words of every key, in each language the host serves. `admit` reports a key without words (section 7). */
export const SALES_ORDER_WORDS = {
'zh-CN': {
'orders.title': '销售订单',
'orders.noun': '订单',
'orders.id': '订单号',
'orders.status': '状态',
'orders.created': '待付款',
'orders.paid': '已付款',
'orders.shipped': '已发货',
'orders.received': '已签收',
'orders.province': '省份',
'orders.city': '城市',
'orders.total': '金额',
'orders.paidAmount': '实付',
'orders.placedAt': '下单时间',
'orders.items': '明细',
'orders.product': '商品',
'orders.quantity': '件数',
'orders.price': '单价',
'orders.address': '详细地址',
// The system views' and the analysis' words.
'orders.toShip': '待发货',
'orders.all': '全部订单',
'orders.cityTotals': '各城市销售额',
'orders.count': '订单数',
'orders.perOrder': '客单价',
'orders.day': '日期',
// The board.
'overview.title': '订单概览',
'overview.daily': '每日',
'overview.today': '今天',
'overview.dailyTotal': '每日销售额',
},
en: {
'orders.title': 'Sales orders',
'orders.noun': 'order',
'orders.id': 'Order no.',
'orders.status': 'Status',
'orders.created': 'Awaiting payment',
'orders.paid': 'Paid',
'orders.shipped': 'Shipped',
'orders.received': 'Delivered',
'orders.province': 'Province',
'orders.city': 'City',
'orders.total': 'Amount',
'orders.paidAmount': 'Amount paid',
'orders.placedAt': 'Placed at',
'orders.items': 'Items',
'orders.product': 'Product',
'orders.quantity': 'Quantity',
'orders.price': 'Unit price',
'orders.address': 'Street address',
'orders.toShip': 'To ship',
'orders.all': 'All orders',
'orders.cityTotals': 'Sales by city',
'orders.count': 'Order count',
'orders.perOrder': 'Average order value',
'orders.day': 'Day',
'overview.title': 'Order overview',
'overview.daily': 'Daily',
'overview.today': 'Today',
'overview.dailyTotal': 'Sales per day',
},
} as const;措辞是读者的话:绝不是路径、常量、类名或存储的词。没写措辞的字段先退回描述,再退回它的路径,准入会记下这一点。
为受众收窄,绝不为复述存储而收窄。 不写的能力随部署:同一份定义在 Elasticsearch 上提供短语检索,在 MongoDB 上没有。收窄会误导读者的东西,例如订单号的平均值、按 ID 分组。不要重复描述已经不提供的东西,也不要重复服务端的上限。运行时,引擎会再把你写的与实时描述取交集。
敏感字段由引擎替你守住:敏感字段不参与分析,机密字段不能比较也不能排序。你要决定的只是列不列它。
5. 行必须带的字段
行的身份 record.rowKey 默认是描述里的身份,快照是 aggregateId。它必须是列出的、可排序的字段:每次记录查询都以它作为排序的最后一项,所以分页既不重复也不遗漏。
引擎只查询视图显示的东西:可见的列、卡片的字段、排序和汇总读取的字段,以及身份。隐藏的列不会取回。所以当宿主代码从行上读一个字段时,例如检查状态的操作,这个字段在不显示它的视图里可能不存在。把宿主代码读取的每个字段都列进 record.rowFields:
import type { RecordCapability } from '@ahoo-wang/wow-view-engine';
export const record: Partial<RecordCapability> = {
layouts: ['table', 'card'],
// "Ship" reads the status; the row's identity is always fetched.
rowFields: ['state.status'],
};看板布局
记录要依次经过几个阶段时,提供第三种布局 'kanban':卡片按一个枚举字段的选项排成一栏一栏,每栏有自己的条数和自己的“加载更多”:
import type { RecordCapability } from '@ahoo-wang/wow-view-engine';
export const pipeline: Partial<RecordCapability> = {
// A lane's next cards are its next page: a cursor reads them best.
paging: 'cursor',
layouts: ['kanban', 'table', 'card'],
};每一栏都是一条独立的查询,所以用游标读取下一批卡片最合适。把卡片移到另一栏,是一个设置分栏字段的声明式操作(操作)。
6. 系统视图与仪表盘
系统视图随定义发布:所有人可见、只读,读者修改之前先另存为自己的。只写读者每天打开的那几张,标题说明它装着什么(“待发货”),按决定下一条处理谁的字段排序:
// src/views/salesOrderViews.ts
import {
text,
type AnalysisViewConfig,
type FilterNode,
type RecordViewConfig,
type SystemView,
} from '@ahoo-wang/wow-view-engine';
const COLUMNS = [
'aggregateId',
'state.status',
'state.address.city',
'state.totalAmount',
'firstEventTime',
];
function queue(
filter: FilterNode[],
overrides: Partial<RecordViewConfig> = {},
): RecordViewConfig {
return {
kind: 'record',
filter: { op: 'and', children: filter },
filterMode: 'simple',
refresh: { interval: null },
sort: [{ field: 'firstEventTime', direction: 'DESC' }],
pageSize: 20,
summaries: [{ field: 'state.totalAmount', fn: 'SUM' }],
layout: 'table',
table: { columns: COLUMNS.map(field => ({ field })) },
card: {
title: 'aggregateId',
fields: ['state.status', 'state.totalAmount'],
},
...overrides,
};
}
/** Sales by city: the last 30 days, resolved against the calendar on every run. */
const cityTotals: AnalysisViewConfig = {
kind: 'analysis',
filter: {
op: 'and',
children: [
{
field: 'firstEventTime',
operator: 'BETWEEN',
value: { type: 'relative', amount: 30, unit: 'day' },
},
],
},
filterMode: 'simple',
refresh: { interval: null },
groups: [
{
type: 'TERMS',
field: 'state.address.city',
alias: 'city',
label: text('orders.city'),
},
],
metrics: [
{ type: 'COUNT', alias: 'orders', label: text('orders.count') },
{
type: 'NUMERIC',
alias: 'total',
function: 'SUM',
expression: { type: 'FIELD', field: 'state.totalAmount' },
label: text('orders.total'),
},
{
// A ratio is a metric over metrics, not a field.
type: 'DERIVED',
alias: 'perOrder',
label: text('orders.perOrder'),
expression: {
type: 'BINARY',
operator: 'DIVIDE',
left: { type: 'METRIC_REF', metric: 'total' },
right: { type: 'METRIC_REF', metric: 'orders' },
},
format: { style: 'currency', currency: 'CNY' },
},
],
sort: [{ alias: 'total', direction: 'DESC' }],
limit: 20,
layout: 'chart',
table: { columns: [] },
chart: {
type: 'bar',
cartesian: { x: 'city', series: [{ metric: 'total' }] },
},
};
export const SALES_ORDER_VIEWS: SystemView[] = [
{
id: 'to-ship',
title: text('orders.toShip'),
// On the system view, not in its config: a queue is read whole, and a
// board's date filter never reaches it.
timeField: null,
// The earliest paid ships first.
config: queue(
[{ field: 'state.status', operator: 'IN', value: ['PAID'] }],
{ sort: [{ field: 'firstEventTime', direction: 'ASC' }] },
),
},
{ id: 'all', title: text('orders.all'), config: queue([]) },
{ id: 'city-totals', title: text('orders.cityTotals'), config: cityTotals },
];- id 在定义内唯一,不含
:。它的实例 ID 是system:<definition>:<view>,仪表盘和路由用systemInstanceId拼出它。 - 时间是意图。 一段时间随日历移动(
{ type: 'relative', amount: 7, unit: 'day' });绝不把今天的日期写进配置。 - 系统视图上的
timeField: null让它不受仪表盘日期筛选的影响:队列要整体读。 - 分析按它回答的问题命名,并选择能回答这个问题的图。
仪表盘是一份独立的定义,kind: 'dashboard',没有数据源;它的系统视图就是系统仪表盘。问一问读者先看什么、按什么顺序、哪个数字点进去打开哪个队列:
// src/views/overview.ts
import {
emptyDashboardConfig,
inRows,
systemInstanceId,
text,
type AnalysisViewConfig,
type DashboardDefinition,
} from '@ahoo-wang/wow-view-engine';
import { SALES_ORDERS } from './salesOrders';
/** One number: no dimension, one row over everything in range, so a limit of 1. */
function card(
metric: AnalysisViewConfig['metrics'][number],
filter: AnalysisViewConfig['filter']['children'] = [],
): AnalysisViewConfig {
return {
kind: 'analysis',
filter: { op: 'and', children: filter },
filterMode: 'simple',
refresh: { interval: null },
groups: [],
metrics: [metric],
sort: [],
limit: 1,
layout: 'chart',
table: { columns: [] },
chart: { type: 'metric', metric: { metric: metric.alias } },
};
}
const total = {
type: 'NUMERIC',
alias: 'total',
function: 'SUM',
expression: { type: 'FIELD', field: 'state.totalAmount' },
label: text('orders.total'),
} as const;
const dailyTotal: AnalysisViewConfig = {
...card(total),
groups: [
{
type: 'DATE_HISTOGRAM',
field: 'firstEventTime',
alias: 'day',
unit: 'DAY',
label: text('orders.day'),
},
],
sort: [{ alias: 'day', direction: 'ASC' }],
// A row a day: room for the longest window a reader picks.
limit: 92,
chart: { type: 'line', cartesian: { x: 'day', series: [{ metric: 'total' }] } },
};
export const overview: DashboardDefinition = {
id: 'overview',
title: text('overview.title'),
kind: 'dashboard',
views: [
{
id: 'daily',
title: text('overview.daily'),
config: {
...emptyDashboardConfig(),
// The board's one date filter: it reaches each panel through that
// definition's timeField.
fields: [
{
name: 'window',
label: text('orders.placedAt'),
kind: 'datetime',
default: { type: 'preset', preset: 'today' },
required: true,
},
],
panels: inRows(
[
{
id: 'today',
kind: 'heading',
content: text('overview.today'),
layout: { w: 24, h: 1 },
},
],
[
{
id: 'total',
kind: 'view',
title: text('orders.total'),
bindings: [],
layout: { w: 6, h: 2 },
owned: { definitionId: SALES_ORDERS, config: card(total) },
},
{
id: 'to-ship-count',
kind: 'view',
title: text('orders.toShip'),
bindings: [],
// What waits now, whenever it was paid.
ignoresTime: true,
layout: { w: 6, h: 2 },
owned: {
definitionId: SALES_ORDERS,
config: card(
{ type: 'COUNT', alias: 'orders', label: text('orders.count') },
[{ field: 'state.status', operator: 'IN', value: ['PAID'] }],
),
},
// "Open in workbench" opens the queue itself, not a count.
opens: systemInstanceId(SALES_ORDERS, 'to-ship'),
},
],
[
{
id: 'daily-total',
kind: 'view',
title: text('overview.dailyTotal'),
bindings: [],
layout: { w: 24, h: 6 },
owned: { definitionId: SALES_ORDERS, config: dailyTotal },
},
],
[
{
id: 'to-ship',
kind: 'view',
title: text('orders.toShip'),
bindings: [],
// Read whole: its system view says timeField: null.
layout: { w: 24, h: 6 },
instanceId: systemInstanceId(SALES_ORDERS, 'to-ship'),
},
],
),
},
},
],
};inRows根据面板的尺寸逐行排布面板,所以增加面板不需要重新编号。- 仪表盘的日期与面板自己的时段合并:仪表盘的时间窗口通过
timeField作用到每个面板,并与视图自己的条件取交集。ignoresTime让某个面板不跟随。 opens让“在工作台中打开”打开另一个视图,例如一个计数所统计的那个队列。- 系统仪表盘只展示系统视图,它们每个租户都能读到。仪表盘自有的分析不受这条约束。
7. 用 admit 检查
路径只是一个字符串;只有描述知道它对不对。/testing 中的 admit 按引擎注册它们时的规则准入你的声明:每个键都有措辞,每个系统视图和仪表盘都有效,每份数据定义都收窄到它提交的描述。全部成立时返回 []。一个测试准入宿主注册的全部东西,覆盖它服务的每种语言:
// src/views/admit.test.ts
import { admit } from '@ahoo-wang/wow-view-engine/testing';
import { overview } from './overview';
import { salesOrders } from './salesOrders';
import { SALES_ORDER_WORDS } from './words';
describe('the order views', () => {
it.each(['zh-CN', 'en'] as const)('are admitted in %s', locale => {
const words: Readonly<Record<string, string>> = SALES_ORDER_WORDS[locale];
expect(
admit(
// A board is admitted with the data definitions it reads, so its
// references are checked.
[salesOrders, overview],
// By each data definition's source: the committed snapshots.
{ 'sales-order': salesOrderDescriptor },
{ text: key => words[key] },
),
).toEqual([]);
});
});它不需要浏览器:在 Node 环境里运行。每条发现带一个 code、一个指向定义中那一处的 path,以及它说的是哪份定义。在它点名的那项选择上修正,而不是让它闭嘴:
| 发现 | 回头看 |
|---|---|
definition.text.unknown | 这个键在这种语言里没有措辞 |
definition.field.undescribed、definition.option.undescribed | 描述里没有的路径或值:去掉它,不要猜另一个 |
definition.field.unlabelled | 给字段一个读者的词 |
definition.field.deprecated | 换成替代的路径,或保留并写明理由 |
capability.field.protected | 列了敏感字段:只在读者必须看到掩码值时保留,并断言这条发现 |
definition.record.row-key-*、definition.record.row-field-* | 身份或某个 rowFields 项不是列出的、可排序的、有值的字段 |
definition.view.*、filter.*、analysis.*、dashboard.* | 某个系统视图或仪表盘的配置 |
有意保留的发现要原样断言,并写上理由,这样新出现的发现仍会让测试失败。admit 判断不了的是读者:字段、措辞、收窄和系统视图对他们是不是对的。这要靠评审。
可运行的示例
- ops 控制台的定义、措辞和操作,以及
parked.test.ts中的准入测试:platform/ops/console/src/views/。 - Storybook 的接入导览,一份订单定义从声明到页面。