发送命令
生成的命令客户端为它的聚合的每条命令提供一个方法。一次调用发送命令,按请求的要求等待到相应的位置,然后以等待结束时的 CommandResult resolve,或者以 CommandRejectedError 或 WowProblemError reject(命令失败时)。本页使用 eShop 生成的客户端(生成客户端)。
只创建一次客户端
每个客户端都通过一个 Fetcher 发送:服务的基础 URL,以及为每个请求签名的拦截器(认证)。每个服务创建一次 Fetcher,再基于它创建客户端:
import { Fetcher } from '@ahoo-wang/fetcher';
import type { WowClientOptions } from '@ahoo-wang/wow-client';
import {
OrderCommandClient,
OrderStateClient,
} from './generated/ordering/index.js';
const ordering: WowClientOptions = {
fetcher: new Fetcher({ baseURL: 'https://ordering.example.com' }),
};
export const orders = new OrderCommandClient(ordering);
export const orderStates = new OrderStateClient(ordering);每个路由都以它的上下文开头,例如 /eshop-ordering/order/…,所以放在一个按这个前缀路由的网关上的 Fetcher 能服务所有服务;basePath 在每个路由前加上网关自己的前缀。
生成的客户端按它所在上下文的 manifest.ts 里的路由发送,即生成器根据服务的 Manifest 写出的 <Context>Manifest,所以运行时从不去拉取 Manifest。自己构建的 CommandClient,new CommandClient(options, { manifest }),同样接收这样的 Manifest;不给时它为所有等待它的命令只拉取一次 GET /wow/manifest,只在拉取失败后才再拉取。每条命令的等待各自独立:它的 signal 结束它自己的等待,拉取仍为其他命令继续;拉取失败时,每条命令各自以自己的错误拒绝(同一个 problem,或一个指明这次拉取并保留其 cause 的 Error),携带该命令自己的请求 ID。客户端的名字是 CommandClient,与 Kotlin 一致:Wow 所说的命令网关,指的是服务端的准入。
发送命令
const result = await orders.submitOrder({
items: [{ productId: 'p-1', quantity: 2, unitPrice: '19.90' }],
});
console.log(result.ref.id, result.version, result.status);SubmitOrder 会创建它的聚合,所以它的方法不接收 ID:服务生成一个,result.ref.id 给出它。在选项中传入 { id } 可以自己选定。作用于已有聚合的命令先接收它的 ID:orders.payOrder(id, body)。ID 取其所有者的聚合,例如购物者的购物车,还有一个不带 ID 的 …Own 方法,baskets.checkoutBasketOwn(body),作用于调用方自己的聚合;按 ID 的方法给没有所有者的调用方用,例如代购物者操作的后台。unitPrice 在服务端是 BigDecimal,所以它的类型是 string,以精确数字写入命令体。
结果说明命令到达了哪里:
| 成员 | 包含 |
|---|---|
status | succeeded;没有成功的调用改为 reject(命令失败时) |
ref | 聚合:它的类型、租户和 ID |
version | 提交之后聚合的版本 |
hop | 这个结果所回答的等待的那一跳 |
results | 命令处理函数放进结果里的内容 |
duplicate | 请求 ID 已经提交过、这次回答的是第一次提交时为 true |
requestId、commandId、correlationId | 请求、命令及其开始的链的 ID |
命令体指明了聚合
命令体指明了聚合的命令(命令类上的 @AggregateId,Manifest 中的 idProperty)没有 ID 参数,也没有 id 选项。它的方法把命令体发往不带 {id} 的路由,服务作用于命令体指明的聚合。这个属性由其他字段算出时,方法接收的命令体不含它,即 Omit<Body, 'stockId'>,由服务算出 ID。
手写的 CommandClient 请求同样按这条路由发送这类命令。eShop 没有这类命令,所以这里发送的是在命令体中指明聚合里的 RestockProduct:
await commands.wait({
command: 'eshop-inventory.stock.RestockProduct',
body: { productId: 'p-1', quantity: 2 },
});这类请求给出的 id 必须就是命令体指明的那个:命令体没有指明时,客户端把它写入命令体;命令体指明了另一个 ID 时,在发出任何请求之前抛出 TypeError。计算出的 ID 属于服务:为它给出的 id 会被忽略,因为服务从字段算出 ID,所以不要传 id。
等待命令带来的结果
不指定等待时,调用在快照包含这次提交后回答(聚合不保存快照时则是在提交后),时长在服务的默认超时之内。wait 说明要等到哪里,是与 Kotlin 一致的 WaitPlan:WaitPlan.ACCEPTED、COMMITTED 和 SNAPSHOT,或用 WaitPlan.of(...hops) 组成自己的计划,用 .within(ms) 给它超时:
import { WaitPlan } from '@ahoo-wang/wow-client';
const accepted = await orders.cancelOrder(
'order-1',
{ reason: 'changed my mind' },
{ wait: WaitPlan.ACCEPTED },
);组成计划的跳:
| 跳 | 何时回答 |
|---|---|
wait.accepted() | 命令通过了准入;路由以 202 回答 |
wait.committed() | 它的事件已在日志中 |
wait.snapshot() | 快照包含这次提交,所以读取能看到它 |
wait.handled('Processor.function') | 那个订阅处理了这次提交 |
计划可以跟随命令走得更远:经过 Saga 的 handled 那一跳,到达 Saga 发出的、位于它们各自上下文中的命令。每个上下文的 contract.ts 包含它的各跳,以它的订阅名提供类型。为购物车结账,并一直等到它提交的订单在 ordering 上下文中提交:
import { WaitPlan } from '@ahoo-wang/wow-client';
import { eshopBasketWait } from './generated/basket/index.js';
import { eshopOrderingWait } from './generated/ordering/index.js';
const placed = await baskets.checkoutBasketOwn(
{},
{
wait: WaitPlan.of(
eshopBasketWait.committed(),
eshopBasketWait.handled('BasketCheckoutSaga.onBasketCheckedOut'),
eshopOrderingWait.committed(),
),
timeout: 10_000,
},
);
console.log(`order ${placed.ref.id} at version ${placed.version}`);调用以最后一跳的结果回答:这里是订单的提交,所以 placed.ref 是订单。timeout 以毫秒计,覆盖计划自己的超时(除非 .within 另给,计划的超时是 10 秒)。parseWaitPlan 读取一个 Wow-Wait 值,formatWaitPlan 写出一个。用完时间的等待以 504 wow.wait.timeout reject;命令仍可能已经提交。服务怎样跟随一条链,见等待结果。
以流的形式跟随等待
每个命令方法都有一个 …Stream 孪生方法,随着等待推进,每一跳回答一个结果,用于展示一条长链进度的页面。生命周期方法(deleteAggregate 等)没有。
const stream = await baskets.checkoutBasketOwnStream(
{},
{
wait: WaitPlan.of(
eshopBasketWait.committed(),
eshopBasketWait.handled('BasketCheckoutSaga.onBasketCheckedOut'),
eshopOrderingWait.committed(),
),
},
);
const reader = stream.getReader();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
console.log(value.hop.stage, value.status);
if (value.status !== 'succeeded' || value.chainEnded) break;
}在准入时被拒绝的命令,会在任何结果流出之前 reject 这次调用,抛出的是 result.status 为 rejected 的 WowProblemError:流式调用不会像 wait 那样把它转成 CommandRejectedError,所以要检查 error.result?.status。准入之后的拒绝是流的最后一个结果,带着它的 status 和 error。当某个 Saga 不再发出任何东西时,链会在计划结束之前结束:那个结果带有 chainEnded,pending 指明不会到来的那些跳。eShop 的结账页面就是这样把它十五跳的链一直跟随到支付的(examples/eshop/web/src/shop/checkout.ts)。流开始之后连接断开时,读取以网络的错误失败,requestIdOf(error) 交回它发送时的请求 ID:命令可能已经提交,所以用这个 ID 再发一次。
命令失败时
没有成功的调用会 reject,按结果的状态区分,与 Kotlin 的 CommandClient 一致:
- 被拒绝的命令,无论是聚合拒绝的(领域的错误码),还是在准入时拒绝的(未知的命令、无效的命令、授权方禁止的调用方),以
CommandRejectedErrorreject:它的code、它的result(尚未决定聚合 ID 时ref.id为null),以及服务回答了 problem 时的problem。用同一个请求 ID 再次发送,仍会被拒绝。 - 其他情况以
WowProblemErrorreject:在准入之前被拒绝的请求(无法解析的请求体、边缘拒绝的调用方)、技术性故障(繁忙或正在关闭的节点、宕掉的存储、用完时间的等待)、失败的结果。它带着 RFC 9457 problem、它的status、Wowcode、violations,服务发送了结果时的result,以及retry。
领域的拒绝与生成的契约中的 <Context>Contract.<Aggregate>.Errors 比较,框架的拒绝与 FrameworkErrorCodes 比较:
import {
CommandRejectedError,
FrameworkErrorCodes,
WowProblemError,
} from '@ahoo-wang/wow-client';
import { EshopOrderingContract } from './generated/ordering/index.js';
try {
await orders.payOrder('order-1', { amount: '39.80', paymentId: 'pay-1' });
} catch (error) {
if (error instanceof CommandRejectedError) {
switch (error.code) {
case EshopOrderingContract.Order.Errors.WRONG_AMOUNT:
console.warn(error.result.error?.message);
break;
case FrameworkErrorCodes.COMMAND_INVALID:
for (const violation of error.result.error?.violations ?? [])
console.warn(violation.path, violation.message);
break;
default:
throw error;
}
} else if (!(error instanceof WowProblemError) || error.retry === 'none') {
throw error;
}
}WowProblemError 的 retry 说明重试能做什么,由错误目录按它的代码声明:none,重试同样失败;same-request,以同一个请求 ID 再发;new-request,原样再发。retryAfterMs 说明要先等多久(Retry-After)。每条命令都带着一个请求 ID(Wow-Request-Id):选项中给出的,或者客户端生成的,两种错误的 requestId 都会把它交回来。用同一个 ID 重试,会被当作第一次提交的重复请求来回答,绝不会执行两次,所以用它重试 wow.command.unknown 和 wow.wait.timeout。重试可能来自别处时(例如页面重新加载),从一开始就传入 requestId:
import { newRequestId } from '@ahoo-wang/wow-client';
const requestId = newRequestId();
await orders.shipOrder('order-1', {}, { requestId, expectedVersion: 4 });expectedVersion 发送 If-Match:作用于处于其他版本的聚合的命令,会以 wow.aggregate.version-mismatch 被拒绝。每个框架错误码及其状态见错误码。
生命周期命令、作用域和空间
聚合的客户端还发送它的 Manifest 列出的生命周期命令:deleteAggregate、restoreAggregate、transferOwner 和 transferSpace。
其他人能否读取或修改一个聚合,取决于它的状态声明的访问属性(@Access)和服务的访问策略(归属与访问):授予访问是聚合自己的命令(如 shareDocument),不是生命周期命令。记录以 access 携带这些属性,类型是生成的 <Aggregate>Access(查询快照)。
10.0.0 预发布版的客户端有 applyTags,记录有 tags。把客户端迁到 10.0.0:
- 用这一版的
wow-generator,按服务的 Manifest 重新生成客户端。 - 原来应用标签的地方,改为发送聚合自己改变该属性的命令。
- 原来读取
tags的地方,改读access.<name>。整数属性是string,即它的精确文本。 - 原来按
tags过滤的查询,改按access.<name>过滤;事件流上还可以按access.owner与access.space过滤。
每个方法的最后一个参数都是请求选项。space 选择调用方的一个空间(Wow-Space;CoSec 服务改读 CoSec-Space-Id);targetTenant 和 targetScope({ ownerId?, spaceId? })在另一个租户、所有者或空间中行事(Wow-Scope),供被允许跨作用域行事的调用方使用;headers 加上你自己的请求头;signal 取消请求。请求是哪个调用方、能做什么,由服务根据它的凭据决定(身份)。