---
url: /zh/typescript/generate.md
description: 用 wow-generator 从 Manifest 生成 Wow 服务的类型化客户端，在 CI 中让它们与服务保持同步，并了解每个生成的文件包含什么。
---

# 生成客户端

`wow-generator` 读取服务的 Manifest，为每个聚合写出一个类型化客户端：每条命令一个方法，以及它的快照和事件查询、状态读取、错误码和等待的各跳。应用只通过这些客户端调用服务，所以服务改变了某个路由或字段，表现为编译错误，而不是一次失败的请求。

## Manifest 从哪里来 {#where-the-manifest-comes-from}

服务在 `GET /wow/manifest` 上提供它的 Manifest，它的构建也把同一份文档提交为 `manifest/baseline.json`，由 `check` 保证它与服务提供的一致（[Manifest 与兼容](../guide/manifest)）。从基线生成：不需要运行中的服务，它是经过评审的版本，它的改变就是客户端的改变。

```bash
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 界面为它的五个服务这样做：

```js
// 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 参考](../reference/typescript/wow-generator/cli)。

## 它写出什么 {#what-it-writes}

每个限界上下文一个以它命名的目录：

| 文件 | 包含 |
| --- | --- |
| `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`（[发送命令](./commands)）。

```ts
// 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`：

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "skipLibCheck": true,
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src/**/*.ts"]
}
```

CI 正是用这个 `tsconfig.json` 编译本页的代码。

### 精确数字 {#exact-numbers}

Kotlin 的 `Long`、`BigInteger` 或 `BigDecimal` 字段（schema 格式为 `int64`、`bigint` 或 `decimal`），在生成的类型中是 `string`，按服务写出的样子精确地保存数字；JavaScript 的 `number` 会对它们取整。线上仍然传输 JSON 数字：聚合的描述符列出这些字段，客户端从响应的文本中读取它们，并在命令体中不加引号地写回去。

```ts
// 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` 为你自己的请求做同样的事。

## 让客户端保持同步 {#keep-the-clients-in-step}

基线改变时就重新生成，并让 CI 在提交的客户端与生成器现在写出的不同时失败：

```bash
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 文档 {#from-any-openapi-document}

生成器也能从不是来自 Wow 的 OpenAPI 3 文档（例如某个第三方 API 的）写出 TypeScript 模型和 Fetcher 装饰器客户端；做法见[从任意 OpenAPI 文档生成客户端](./openapi-client)。Wow 服务自己的 OpenAPI 文档（[OpenAPI 与 springdoc](../guide/openapi)）供其他工具使用：它的 TypeScript 客户端要从 Manifest 生成。
