生成客户端
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 (`example/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 参考。
它写出什么
每个限界上下文一个以它命名的目录:
| 文件 | 包含 |
|---|---|
boundedContext.ts | 上下文的名字、它的领域错误码及其状态,以及在其中等待的各跳 |
types.ts | Manifest 的 schema 中的命令、事件和状态 |
<aggregate>/aggregate.ts | 聚合的描述符、查询能指明的 state.* 字段,以及它的事件的联合类型 |
<aggregate>/commandClient.ts | <Aggregate>CommandClient:每条命令一个方法,每个方法有一个 …Stream 孪生方法,以及聚合列出的生命周期命令 |
<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';
/**
* The bounded context `eshop-ordering`.
* The eShop ordering: orders from placement to shipment
*/
export const ESHOP_ORDERING_BOUNDED_CONTEXT = 'eshop-ordering';
/** The domain error codes of `eshop-ordering`, each a problem's `code` with the status the Manifest gives it. */
export const EshopOrderingErrorCodes = {
/** 422: A payment pays the order's total amount. */
ORDER_WRONG_AMOUNT: 'eshop-ordering.order.wrong-amount',
/** 409: The order's status does not allow the command. */
ORDER_WRONG_STATUS: 'eshop-ordering.order.wrong-status',
} as const;
/** A {@link EshopOrderingErrorCodes} value. */
export type EshopOrderingErrorCode = (typeof EshopOrderingErrorCodes)[keyof typeof EshopOrderingErrorCodes];
/** 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(ESHOP_ORDERING_BOUNDED_CONTEXT),
/** Until a command's events are appended to the log of `eshop-ordering`. */
committed: (): Hop => wait.committed(ESHOP_ORDERING_BOUNDED_CONTEXT),
/** Until the snapshot of `eshop-ordering` holds the commit. */
snapshot: (): Hop => wait.snapshot(ESHOP_ORDERING_BOUNDED_CONTEXT),
/** Until a subscription of `eshop-ordering` handled the commit. */
handled: (subscription: EshopOrderingSubscription): Hop => wait.handled(subscription, ESHOP_ORDERING_BOUNDED_CONTEXT),
} 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 数字:聚合的描述符列出这些字段,客户端从响应的文本中读取它们,并在命令体中不加引号地写回去。
// Code generated by wow-generator. DO NOT EDIT.
import type { EventView, 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 } } },
},
},
} 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 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 = EventView<'eshop-ordering.order.OrderAwaitingValidation', OrderAwaitingValidation> | EventView<'eshop-ordering.order.OrderCancelled', OrderCancelled> | EventView<'eshop-ordering.order.OrderPaid', OrderPaid> | EventView<'eshop-ordering.order.OrderShipped', OrderShipped> | EventView<'eshop-ordering.order.OrderStockConfirmed', OrderStockConfirmed> | EventView<'eshop-ordering.order.OrderStockItemConfirmed', OrderStockItemConfirmed> | EventView<'eshop-ordering.order.OrderSubmitted', OrderSubmitted> | SystemEvent;@ahoo-wang/wow-client 的 parseJson 和 stringifyJson 为你自己的请求做同样的事。
让客户端保持同步
基线改变时就重新生成,并让 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 生成。