生成客户端
wow-generator 读取服务的 Manifest,为每个聚合写出一个类型化客户端:每条命令一个方法,以及它的快照和事件查询、状态读取、错误码和等待的各跳。应用只通过这些客户端调用服务,所以服务改变了某个路由或字段,表现为编译错误,而不是一次失败的请求。
Manifest 从哪里来
服务在 GET /wow/manifest 上提供它的 Manifest,它的构建也把同一份文档提交为 manifest/baseline.json,由 check 保证它与服务提供的一致(Manifest 与兼容)。从基线生成:不需要运行中的服务,它是经过评审的版本,它的改变就是客户端的改变。
pnpm add @ahoo-wang/fetcher @ahoo-wang/fetcher-eventstream @ahoo-wang/wow-client
pnpm add -D @ahoo-wang/wow-generator typescript
pnpm exec wow-generator generate \
--input ../ordering-server/manifest/baseline.json \
--output src/generated/ordering \
--strict--input 也接受 http(s) URL,并用 -H 'Name: value' 带上需要的请求头。--strict 让记录了警告的运行失败。一次运行读取一份 Manifest;面向多个服务的前端,为每个服务运行一次生成器,各自输出到一个目录。eShop 的 Web 界面为它的五个服务这样做:
// Generates the clients of the eShop's five services from their Manifest
// baselines (`examples/eshop/<context>/<context>-server/manifest/baseline.json`,
// which each server's `check` holds to what it serves) into
// src/generated/<context>/, one wow-generator run per service:
//
// pnpm --filter @ahoo-wang/wow-generator... build
// pnpm --filter wow-eshop-web generate
//
// The output is committed byte for byte; src/generated.test.ts fails when it
// differs from what this writes.
import { execFileSync } from 'node:child_process';
import { rmSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const CONTEXTS = ['catalog', 'basket', 'ordering', 'inventory', 'payment'];
const WEB = fileURLToPath(new URL('..', import.meta.url));
export const ESHOP = join(WEB, '..');
export const CLI = join(WEB, '../../../typescript/wow-generator/dist/cli.js');
/** The Manifest baseline of the service of `context`. */
export function baselineOf(context) {
return join(ESHOP, context, `${context}-server`, 'manifest', 'baseline.json');
}
/** Writes the clients of `context` into `output`. */
export function generate(context, output) {
execFileSync(
process.execPath,
[CLI, 'generate', '-i', baselineOf(context), '-o', output, '--strict', '--quiet'],
{ stdio: 'inherit' },
);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
for (const context of CONTEXTS) {
const output = join(WEB, 'src', 'generated', context);
rmSync(output, { recursive: true, force: true });
generate(context, output);
}
}所有选项和退出码见 CLI 参考。
它写出什么
每个限界上下文一个以它命名的目录:
| 文件 | 包含 |
|---|---|
contract.ts | <Context>Contract:上下文的名字、上下文级的错误码和它的各个聚合(各带自己的错误码);<Context>ErrorCode 联合类型,以及在其中等待的各跳 |
manifest.ts | <Context>Manifest:上下文各命令的路由与它的服务路由,以及命令体指明了聚合的命令里承载 ID 的属性(idProperty),与 Manifest 列出的一致,不含 schema。命令客户端按它发送,因此不必获取 Manifest |
client.ts | <Context>Client:上下文的 CommandClient(commands),以及每个聚合的命令、快照、事件和状态客户端;有服务路由时还有它们(routes) |
routes.ts | <Context>Routes:上下文每个服务路由一个方法,基于 @ahoo-wang/wow-client/routes 的 ServiceRoutes,返回路由的应答类型;Manifest 标为 responseOptional 的路由(null 以 204 No Content 应答)再加上 undefined,没有应答的路由返回 void;只在 Manifest 列出服务路由时写出 |
types.ts | Manifest 的 schema 中的命令、事件和状态,各以其类的简单名命名。两个类共用的名字,或生成的模块已经占用的名字(它们用到的 TypeScript 全局类型,如 Promise、Record;它们从 @ahoo-wang/wow-client 导入的名字,如 CommandClient;生成器自己声明的名字,如 <Aggregate>Event),会以类的包名限定:shop.Record 生成为 ShopRecord |
<aggregate>/aggregate.ts | 聚合的描述符、查询能指明的 state.* 字段,以及它的事件的联合类型 |
<aggregate>/commandClient.ts | <Aggregate>CommandClient:每条命令一个方法,每个方法有一个 …Stream 孪生方法,以及聚合列出的生命周期命令。内部命令("internal": true,只在内部受理的命令)没有方法,manifest.ts 里也没有它的条目,只有它用到的类型也不生成:公开端口不提供它 |
<aggregate>/queryClient.ts | <Aggregate>SnapshotQueryClient、<Aggregate>EventQueryClient 和 <Aggregate>StateClient |
index.ts | 以上全部,再导出一次 |
.wow-generator.json | 这次运行写出的文件,以便之后的运行删除它不再写出的文件 |
eShop ordering 的契约。它的错误码只是领域的拒绝:违反约束的命令回答框架的 wow.command.invalid(FrameworkErrorCodes.COMMAND_INVALID)及其 violations(发送命令)。
// Code generated by wow-generator. DO NOT EDIT.
import { type Hop, wait } from '@ahoo-wang/wow-client';
import { OrderAggregate } from './order/aggregate.js';
/**
* The bounded context `eshop-ordering`: its name, its error codes and its aggregates, as the Kotlin `EshopOrderingContract` names them. A problem's `code` is one of its `Errors`, or of an aggregate's.
* The eShop ordering: orders from placement to shipment
*/
export const EshopOrderingContract = {
NAME: 'eshop-ordering',
Order: OrderAggregate,
} as const;
/** An error code of `eshop-ordering`, of the context or of one of its aggregates. */
export type EshopOrderingErrorCode = 'eshop-ordering.order.wrong-amount' | 'eshop-ordering.order.wrong-status';
/** The subscriptions of `eshop-ordering` a `handled` hop names. */
export type EshopOrderingSubscription = 'OrderProcessSaga.onOrderAwaitingValidation' | 'OrderProcessSaga.onOrderCancelled' | 'OrderProcessSaga.onOrderShipped' | 'OrderProcessSaga.onOrderSubmitted' | 'OrderProcessSaga.onReleaseRejected' | 'OrderProcessSaga.onReserveCommitted' | 'OrderProcessSaga.onReserveRejected' | 'OrderSummaryProjection.onEvent' | 'ShipmentNotifier.onOrderShipped';
/** The hops of a wait chain (`Wow-Wait`) in `eshop-ordering`. */
export const eshopOrderingWait = {
/** Until a command of `eshop-ordering` passed admission. */
accepted: (): Hop => wait.accepted(EshopOrderingContract.NAME),
/** Until a command's events are appended to the log of `eshop-ordering`. */
committed: (): Hop => wait.committed(EshopOrderingContract.NAME),
/** Until the snapshot of `eshop-ordering` holds the commit. */
snapshot: (): Hop => wait.snapshot(EshopOrderingContract.NAME),
/** Until a subscription of `eshop-ordering` handled the commit. */
handled: (subscription: EshopOrderingSubscription): Hop => wait.handled(subscription, EshopOrderingContract.NAME),
} as const;每个文件都以 // Code generated by wow-generator. DO NOT EDIT. 开头,下一次运行会覆盖它。把输出连同 .wow-generator.json 一起提交。
输出是普通的 TypeScript,不带装饰器,它的相对导入以 .js 结尾,所以在 "moduleResolution": "bundler" 和 "nodenext" 下都能编译。打包工具项目保留它自己的设置;运行编译后输出、并在 package.json 中带 "type": "module" 的 Node 项目,可以使用这个 tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}CI 正是用这个 tsconfig.json 编译本页的代码。
精确数字
Kotlin 的 Long、BigInteger 或 BigDecimal 字段(schema 格式为 int64、bigint 或 decimal),在生成的类型中是 string,按服务写出的样子精确地保存数字;JavaScript 的 number 会对它们取整。线上仍然传输 JSON 数字:聚合的描述符列出这些字段,客户端从响应的文本中读取它们,并在命令体中不加引号地写回去。
这在任何深度、Manifest 描述了其值的每条路径上都成立:状态、事件、命令体、查询的记录,以及服务路由的请求体和响应(它没有描述的两类值见下文)。递归类型(children 仍是树的树)只在上下文的 textNumbers.ts 中标记一次,值到达哪里就在哪里读取。联合类型的各个分支保留各自的数字:一个值是 decimal 或由它们组成的对象时,按它实际是哪一种读取;由某个取常量值的成员区分的对象(Jackson 的 @JsonTypeInfo 写出子类型的方式)各自按自己的标记读取。如果分支对同一个数字的读法不同(一个精确、一个按数字),又没有这样的成员可以区分,生成就会失败(退出码 4),并指明 schema、路径和两种格式:给这些分支加上判别字段,或者使用同一种数字格式。
// Code generated by wow-generator. DO NOT EDIT.
import type { CommittedEvent, NoAccess, SystemEvent } from '@ahoo-wang/wow-client';
import type { OrderAwaitingValidation, OrderCancelled, OrderPaid, OrderShipped, OrderStockConfirmed, OrderStockItemConfirmed, OrderSubmitted } from '../types.js';
/**
* The aggregate `eshop-ordering.order`.
* A buyer's order, from its submission to its shipment
*/
export const OrderAggregate = {
context: 'eshop-ordering',
name: 'order',
path: '/eshop-ordering/order',
ownership: 'owner',
snapshot: true,
textNumbers: {
state: { items: { '*': { unitPrice: true } }, totalAmount: true },
events: {
'eshop-ordering.order.OrderPaid': { amount: true },
'eshop-ordering.order.OrderSubmitted': { items: { '*': { unitPrice: true } } },
},
commands: {
PayOrder: { amount: true },
SubmitOrder: { items: { '*': { unitPrice: true } } },
},
},
Errors: {
/** 422: A payment pays the order's total amount. */
WRONG_AMOUNT: 'eshop-ordering.order.wrong-amount',
/** 409: The order's status does not allow the command. */
WRONG_STATUS: 'eshop-ordering.order.wrong-status',
},
} as const;
/** The `state.*` paths of `order`'s snapshots a query filters, sorts and projects on. */
export type OrderStateField = 'state.cancelReason' | 'state.confirmedProducts' | 'state.items' | 'state.paymentId' | 'state.status' | 'state.totalAmount';
/** The access attributes of `eshop-ordering.order`: it declares none. */
export type OrderAccess = NoAccess;
/** The events of `order`, named as the server sends them, `eshop-ordering.order.<Event>` at its current revision (the `events` query upgrades older records), and the system events every aggregate has. */
export type OrderEvent = CommittedEvent<'eshop-ordering.order.OrderAwaitingValidation', OrderAwaitingValidation> | CommittedEvent<'eshop-ordering.order.OrderCancelled', OrderCancelled> | CommittedEvent<'eshop-ordering.order.OrderPaid', OrderPaid> | CommittedEvent<'eshop-ordering.order.OrderShipped', OrderShipped> | CommittedEvent<'eshop-ordering.order.OrderStockConfirmed', OrderStockConfirmed> | CommittedEvent<'eshop-ordering.order.OrderStockItemConfirmed', OrderStockItemConfirmed> | CommittedEvent<'eshop-ordering.order.OrderSubmitted', OrderSubmitted> | SystemEvent;@ahoo-wang/wow-client 的 parseJson 和 stringifyJson 为你自己的请求做同样的事;手写的客户端接收同样的标记(TextNumbers):ServiceRoutes 通过 textNumbers: { 'context.route': { request, response } },数据集的行通过 DatasetClient.dataset(context, dataset, numbers)。标记有这些形式:
| 形式 | 标记的是 | 示例 |
|---|---|---|
true | 这个值是精确数字 | { price: true } |
成员、'*' | 对象按名字的成员;'*' 是数组的每一项和映射的每个值 | { lines: { '*': { amount: true } } } |
() => marks | 只在值到达那里时才读取的标记:递归类型的标记 | const tree: TextNumbers = { amount: true, children: { '*': () => tree } } |
textNumberOr(marks) | 精确数字,或以 marks 标记的对象或数组,取决于值是哪一种 | decimal | { amount: decimal } 用 textNumberOr({ amount: true }) |
textNumbersBy(member, cases) | 按对象成员 member 的字符串值选择该对象的标记 | textNumbersBy('kind', { decimal: { amount: true } }) |
有两类值读取时不带标记,其中的大数字仍会被取整:
- 命令结果的
results是服务自己的值,Manifest 没有给出它们的 schema:命令在其中返回的Long或BigDecimal会以 JavaScriptnumber到达。请把这样的值以字符串返回,或者从聚合的状态中读取。 - 聚合查询的指标值:对精确字段求和或取最大值,结果超出
number能表示的范围时会被取整。
让客户端保持同步
基线改变时就重新生成,并让 CI 在提交的客户端与生成器现在写出的不同时失败:
pnpm exec wow-generator generate --input ../ordering-server/manifest/baseline.json --output src/generated/ordering --strict
git diff --exit-code -- src/generated然后编译:命令体改变了、字段被删除了或聚合改了名,都会让使用它的代码在 tsc 中失败。eShop 在一个测试 src/generated.test.ts 中让它的客户端与基线保持一致,这个测试生成到一个临时目录,并比较每个文件。
让生成器与生成代码所编译依赖的 wow-client 保持同一个版本,也就是它所调用的服务的版本。
基于任何 OpenAPI 文档
生成器也能从不是来自 Wow 的 OpenAPI 3 文档(例如某个第三方 API 的)写出 TypeScript 模型和 Fetcher 装饰器客户端;做法见从任意 OpenAPI 文档生成客户端。Wow 服务自己的 OpenAPI 文档(OpenAPI 与 springdoc)供其他工具使用:它的 TypeScript 客户端要从 Manifest 生成。