lakutata 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -30
- package/README.zh-CN.md +130 -0
- package/dist/cjs/exports/QueueComponent.d.ts +1 -0
- package/dist/cjs/exports/QueueComponent.js +20 -0
- package/dist/esm/exports/QueueComponent.js +1 -0
- package/dist/types/exports/QueueComponent.d.ts +1 -0
- package/doc/en/Application.md +107 -0
- package/doc/en/CLI.md +274 -0
- package/doc/en/Cache.md +379 -0
- package/doc/en/Command.md +110 -0
- package/doc/en/ContainerdIntegration.md +17 -16
- package/doc/en/Controllers.md +256 -0
- package/doc/en/DBus.md +291 -0
- package/doc/en/DTOValidation.md +72 -43
- package/doc/en/Database.md +188 -0
- package/doc/en/DatabaseDrivers.md +287 -0
- package/doc/en/Decorator.md +52 -40
- package/doc/en/DockerIntegration.md +7 -6
- package/doc/en/Entities.md +240 -0
- package/doc/en/Entrypoint.md +216 -0
- package/doc/en/Exceptions.md +174 -0
- package/doc/en/GRPC.md +251 -0
- package/doc/en/GettingStarted.md +165 -0
- package/doc/en/HTTP.md +507 -0
- package/doc/en/HelperFunctions.md +11 -11
- package/doc/en/I18n.md +119 -115
- package/doc/en/Injection.md +144 -0
- package/doc/en/Installers.md +280 -0
- package/doc/en/Logger.md +206 -0
- package/doc/en/MCP.md +293 -0
- package/doc/en/Middlewares.md +213 -0
- package/doc/en/Migrations.md +202 -0
- package/doc/en/MongoEmbedded.md +149 -0
- package/doc/en/Monitor.md +164 -0
- package/doc/en/Mutex.md +27 -25
- package/doc/en/NATS.md +246 -0
- package/doc/en/NativeAddons.md +212 -0
- package/doc/en/Objects.md +143 -0
- package/doc/en/Overview.md +62 -85
- package/doc/en/Packaging.md +193 -0
- package/doc/en/ProjectStructure.md +124 -0
- package/doc/en/Queue.md +269 -0
- package/doc/en/Schedules.md +217 -0
- package/doc/en/Security.md +10 -9
- package/doc/en/ServiceCalls.md +504 -0
- package/doc/en/Time.md +19 -20
- package/doc/en/UpgradingFrom1.md +435 -0
- package/doc/en/UpgradingFrom2.md +315 -0
- package/doc/en/Workspaces.md +189 -0
- package/doc/en/examples/BackgroundJobs.md +677 -0
- package/doc/en/examples/CommandLineTool.md +664 -0
- package/doc/en/examples/DesktopApp.md +809 -0
- package/doc/en/examples/DeviceService.md +607 -0
- package/doc/en/examples/MCPServer.md +653 -0
- package/doc/en/examples/README.md +16 -0
- package/doc/en/examples/RestAPI.md +490 -0
- package/doc/en/examples/ServiceCalls.md +829 -0
- package/doc/en/examples/WebApp.md +568 -0
- package/doc/zh/Application.md +75 -0
- package/doc/zh/CLI.md +250 -0
- package/doc/zh/Cache.md +328 -0
- package/doc/zh/Command.md +78 -0
- package/doc/zh/ContainerdIntegration.md +9 -9
- package/doc/zh/Controllers.md +228 -0
- package/doc/zh/DBus.md +257 -0
- package/doc/zh/DTOValidation.md +49 -19
- package/doc/zh/Database.md +175 -0
- package/doc/zh/DatabaseDrivers.md +261 -0
- package/doc/zh/Decorator.md +25 -18
- package/doc/zh/DockerIntegration.md +6 -6
- package/doc/zh/Entities.md +224 -0
- package/doc/zh/Entrypoint.md +193 -0
- package/doc/zh/Exceptions.md +158 -0
- package/doc/zh/GRPC.md +226 -0
- package/doc/zh/GettingStarted.md +148 -0
- package/doc/zh/HTTP.md +445 -0
- package/doc/zh/HelperFunctions.md +2 -2
- package/doc/zh/I18n.md +26 -26
- package/doc/zh/Injection.md +129 -0
- package/doc/zh/Installers.md +232 -0
- package/doc/zh/Logger.md +188 -0
- package/doc/zh/MCP.md +265 -0
- package/doc/zh/Middlewares.md +198 -0
- package/doc/zh/Migrations.md +178 -0
- package/doc/zh/MongoEmbedded.md +118 -0
- package/doc/zh/Monitor.md +145 -0
- package/doc/zh/Mutex.md +10 -10
- package/doc/zh/NATS.md +207 -0
- package/doc/zh/NativeAddons.md +180 -0
- package/doc/zh/Objects.md +119 -0
- package/doc/zh/Overview.md +46 -71
- package/doc/zh/Packaging.md +154 -0
- package/doc/zh/ProjectStructure.md +102 -0
- package/doc/zh/Queue.md +214 -0
- package/doc/zh/Schedules.md +193 -0
- package/doc/zh/Security.md +6 -6
- package/doc/zh/ServiceCalls.md +459 -0
- package/doc/zh/Time.md +2 -2
- package/doc/zh/UpgradingFrom1.md +347 -0
- package/doc/zh/UpgradingFrom2.md +230 -0
- package/doc/zh/Workspaces.md +150 -0
- package/doc/zh/examples/BackgroundJobs.md +618 -0
- package/doc/zh/examples/CommandLineTool.md +618 -0
- package/doc/zh/examples/DesktopApp.md +748 -0
- package/doc/zh/examples/DeviceService.md +546 -0
- package/doc/zh/examples/MCPServer.md +604 -0
- package/doc/zh/examples/README.md +14 -0
- package/doc/zh/examples/RestAPI.md +458 -0
- package/doc/zh/examples/ServiceCalls.md +765 -0
- package/doc/zh/examples/WebApp.md +520 -0
- package/package.json +46 -17
- package/doc/en/ActionPattern.md +0 -408
- package/doc/en/Core.md +0 -312
- package/doc/en/ORMIntegration.md +0 -341
- package/doc/zh/ActionPattern.md +0 -334
- package/doc/zh/Core.md +0 -256
- package/doc/zh/ORMIntegration.md +0 -319
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
# 后台任务
|
|
2
|
+
|
|
3
|
+
这个示例构建一个在后台生成报表的服务:请求把一份报表加入队列,立即得到它的 id;队列的动作负责生成报表,客户端则反复查询报表的状态,直到报表生成完毕。一个定时任务负责删除旧报表,同一时间只在一个服务实例中执行。示例从 `service` 模板开始,展示队列组件及其动作、重试和死信队列、定时任务、各实例共用的锁,以及测试。整个示例不需要单独的服务器:队列在内存中,报表和锁都是文件。
|
|
4
|
+
|
|
5
|
+
| 动作 | 名称 | 作用 |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `POST` | `/reports` | 把某个月的报表加入队列(`{"month": "2026-09"}`),返回它的 id |
|
|
8
|
+
| `GET` | `/reports/:id` | 取报表的状态(`queued`、`running`、`done`、`failed`)和结果 |
|
|
9
|
+
| 队列 | `report.build` | 生成报表:同时处理 2 份,最多尝试 3 次 |
|
|
10
|
+
| 队列 | `report.build.dead` | 把尝试次数已用完的报表标为失败 |
|
|
11
|
+
| 定时任务 | `*/5 * * * *` | 删除结束超过一天的报表,只在一个实例中执行 |
|
|
12
|
+
|
|
13
|
+
## 1. 创建项目
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @lakutata/cli create --name reports --template service --yes
|
|
17
|
+
cd reports
|
|
18
|
+
rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
|
|
19
|
+
echo 'data/' >> .gitignore
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
项目是一个工作区,服务在 `packages/app` 中(见 [ProjectStructure](../ProjectStructure.md))。队列(`lakutata/com/queue`)和锁(`lakutata/com/mutex`)都包含在 `lakutata` 包中,它们的内存驱动和文件驱动不需要其他包,所以这里什么都不用安装。这里还删掉了模板中的示例控制器,并让 git 忽略存放报表和锁的 `data` 目录。
|
|
23
|
+
|
|
24
|
+
## 2. 输入
|
|
25
|
+
|
|
26
|
+
**`packages/app/src/dtos/ReportDTO.ts`**
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
import {DTO} from 'lakutata'
|
|
30
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 新报表:某个月的销售额
|
|
34
|
+
*/
|
|
35
|
+
export class CreateReportDTO extends DTO {
|
|
36
|
+
@Expect(DTO.String().pattern(/^\d{4}-(0[1-9]|1[0-2])$/, 'YYYY-MM').required())
|
|
37
|
+
public month: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 按 id 指定的报表
|
|
42
|
+
*/
|
|
43
|
+
export class ReportIdDTO extends DTO {
|
|
44
|
+
@Expect(DTO.String().uuid().required())
|
|
45
|
+
public id: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 任务的消息:要生成的报表
|
|
50
|
+
*/
|
|
51
|
+
export class BuildReportDTO extends CreateReportDTO {
|
|
52
|
+
@Expect(DTO.String().uuid().required())
|
|
53
|
+
public id: string
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- `CreateReportDTO` 是 `POST /reports` 的请求体。`pattern()` 的第二个参数是这条规则的名字,错误消息中会用到它:`"month" with value "2026-13" fails to match the YYYY-MM pattern`。
|
|
58
|
+
- `BuildReportDTO` 是队列传递的消息:报表的 id 和月份。队列的动作和其他动作一样,用自己的 DTO 校验输入,这里的输入就是消息(见 [DTOValidation](../DTOValidation.md))。
|
|
59
|
+
|
|
60
|
+
## 3. 报表
|
|
61
|
+
|
|
62
|
+
**`packages/app/src/components/Reports.ts`**
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import {randomUUID} from 'node:crypto'
|
|
66
|
+
import {mkdir, readdir, readFile, rename, rm, writeFile} from 'node:fs/promises'
|
|
67
|
+
import path from 'node:path'
|
|
68
|
+
import {Component, DTO} from 'lakutata'
|
|
69
|
+
import {Configurable} from 'lakutata/decorator/di'
|
|
70
|
+
import {type SalesSummary} from './Sales.js'
|
|
71
|
+
import {ReportNotFoundException} from '../exceptions/ReportNotFoundException.js'
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 一份报表:从请求到结果
|
|
75
|
+
*/
|
|
76
|
+
export interface Report {
|
|
77
|
+
id: string
|
|
78
|
+
month: string
|
|
79
|
+
state: 'queued' | 'running' | 'done' | 'failed'
|
|
80
|
+
attempts: number
|
|
81
|
+
createdAt: string
|
|
82
|
+
finishedAt?: string
|
|
83
|
+
result?: SalesSummary
|
|
84
|
+
error?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 报表:每份报表是目录中的一个 JSON 文件,同一台机器上的各个服务实例共用这个目录
|
|
89
|
+
*/
|
|
90
|
+
export class Reports extends Component {
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 报表所在的目录
|
|
94
|
+
*/
|
|
95
|
+
@Configurable(DTO.String().required())
|
|
96
|
+
protected readonly directory: string
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 已结束的报表保留多久(毫秒)
|
|
100
|
+
*/
|
|
101
|
+
@Configurable(DTO.Number().positive().required())
|
|
102
|
+
protected readonly keep: number
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 目录不存在时创建它
|
|
106
|
+
*/
|
|
107
|
+
protected async init(): Promise<void> {
|
|
108
|
+
await mkdir(this.directory, {recursive: true})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 报表的文件
|
|
113
|
+
* @param id
|
|
114
|
+
*/
|
|
115
|
+
protected file(id: string): string {
|
|
116
|
+
return path.join(this.directory, `${id}.json`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 写入报表:先写到临时文件再重命名,读取的一方就不会读到写了一半的报表
|
|
121
|
+
* @param report
|
|
122
|
+
*/
|
|
123
|
+
protected async save(report: Report): Promise<Report> {
|
|
124
|
+
const temporary: string = `${this.file(report.id)}.${process.pid}.tmp`
|
|
125
|
+
await writeFile(temporary, JSON.stringify(report))
|
|
126
|
+
await rename(temporary, this.file(report.id))
|
|
127
|
+
return report
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 新报表,处于排队状态
|
|
132
|
+
* @param month
|
|
133
|
+
*/
|
|
134
|
+
public async create(month: string): Promise<Report> {
|
|
135
|
+
return await this.save({id: randomUUID(), month: month, state: 'queued', attempts: 0, createdAt: new Date().toISOString()})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 取一份报表,不存在时抛出 404 错误
|
|
140
|
+
* @param id
|
|
141
|
+
*/
|
|
142
|
+
public async get(id: string): Promise<Report> {
|
|
143
|
+
try {
|
|
144
|
+
return JSON.parse(await readFile(this.file(id), 'utf8')) as Report
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new ReportNotFoundException(`The report ${id} does not exist`)
|
|
147
|
+
throw error
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 修改报表
|
|
153
|
+
* @param id
|
|
154
|
+
* @param changes
|
|
155
|
+
*/
|
|
156
|
+
public async update(id: string, changes: Partial<Report>): Promise<Report> {
|
|
157
|
+
return await this.save({...await this.get(id), ...changes})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 删除结束时间超过保留期限的报表
|
|
162
|
+
* @param now 清理的时间
|
|
163
|
+
* @returns 删除的报表数
|
|
164
|
+
*/
|
|
165
|
+
public async clean(now: Date = new Date()): Promise<number> {
|
|
166
|
+
let deleted: number = 0
|
|
167
|
+
for (const name of await readdir(this.directory)) {
|
|
168
|
+
if (!name.endsWith('.json')) continue
|
|
169
|
+
const report: Report = await this.get(path.basename(name, '.json'))
|
|
170
|
+
if (report.finishedAt && now.getTime() - Date.parse(report.finishedAt) > this.keep) {
|
|
171
|
+
await rm(this.file(report.id), {force: true})
|
|
172
|
+
deleted++
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return deleted
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**`packages/app/src/exceptions/ReportNotFoundException.ts`**
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import {Exception} from 'lakutata'
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 请求的报表不存在
|
|
187
|
+
*/
|
|
188
|
+
export class ReportNotFoundException extends Exception {
|
|
189
|
+
public errno: string = 'E_REPORT_NOT_FOUND'
|
|
190
|
+
|
|
191
|
+
public readonly statusCode: number = 404
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
报表先是 `queued`(排队中),然后是 `running`(生成中),最后要么是 `done`(完成)并带有结果,要么是 `failed`(失败)并带有错误。`Reports` 组件把每份报表保存为一个 JSON 文件:
|
|
196
|
+
|
|
197
|
+
- 组件只创建一次,由整个服务共用:HTTP 动作、队列动作和定时任务用的是同一个实例(见 [Objects](../Objects.md))。它的 `@Configurable` 属性就是它的选项,在 `Config.ts` 中给出;目录由它的 `init()` 创建。
|
|
198
|
+
- 报表先写入临时文件,再重命名,所以请求不会读到写了一半的报表。
|
|
199
|
+
- `clean()` 删除结束后已超过 `keep` 时长的报表,这就是定时任务要做的工作。它把当前时间作为参数,这样测试就能模拟明天执行时的情形。
|
|
200
|
+
- 同一台机器上的各个服务实例共用这个目录。如果服务有数据库,报表就会是一张表,方法保持不变。
|
|
201
|
+
|
|
202
|
+
## 4. 销售数据
|
|
203
|
+
|
|
204
|
+
**`packages/app/src/components/Sales.ts`**
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
import {setTimeout} from 'node:timers/promises'
|
|
208
|
+
import {Component} from 'lakutata'
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 一个月的销售额
|
|
212
|
+
*/
|
|
213
|
+
export interface SalesSummary {
|
|
214
|
+
orders: number
|
|
215
|
+
revenue: number
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 商店的销售数据。本例中是编造的;在真实的服务中,这里是对数据库的查询,也是生成报表时最慢的部分
|
|
220
|
+
*/
|
|
221
|
+
export class Sales extends Component {
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 一个月的订单数和收入
|
|
225
|
+
* @param month 2026-09
|
|
226
|
+
* @param signal 任务被停止时中止:到了时间上限,或者服务正在停止
|
|
227
|
+
*/
|
|
228
|
+
public async summarize(month: string, signal?: AbortSignal): Promise<SalesSummary> {
|
|
229
|
+
//慢的部分:这里只等半秒
|
|
230
|
+
await setTimeout(500, undefined, {signal: signal})
|
|
231
|
+
//编造的数字,同一个月每次都相同
|
|
232
|
+
const orders: number = Number(month.replace('-', '')) % 500 + 100
|
|
233
|
+
return {orders: orders, revenue: orders * 37.5}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
报表统计一个月的订单数和收入。这里的数字是编造的,前面等待的半秒代表的是真实服务中缓慢的查询。`summarize()` 接收任务的信号,任务被中止时,等待也随之结束。`Sales` 是一个单独的组件,所以测试可以替换它的 `summarize()`,让任务失败。
|
|
239
|
+
|
|
240
|
+
## 5. 控制器
|
|
241
|
+
|
|
242
|
+
**`packages/app/src/controllers/ReportController.ts`**
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
import {Controller, Result, type StatusResult} from 'lakutata/com/entrypoint'
|
|
246
|
+
import {type Logger} from 'lakutata/com/logger'
|
|
247
|
+
import {type Queue, QueueAction, type QueueContext, type QueueDeadLetter} from 'lakutata/com/queue'
|
|
248
|
+
import {GET, POST, Prefix, Schedule} from 'lakutata/decorator/ctrl'
|
|
249
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
250
|
+
import {type Report, type Reports} from '../components/Reports.js'
|
|
251
|
+
import {type Sales, type SalesSummary} from '../components/Sales.js'
|
|
252
|
+
import {BuildReportDTO, CreateReportDTO, ReportIdDTO} from '../dtos/ReportDTO.js'
|
|
253
|
+
|
|
254
|
+
@Prefix({route: '/reports'})
|
|
255
|
+
export class ReportController extends Controller {
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 服务的队列(选项中的 "queue" 组件)
|
|
259
|
+
*/
|
|
260
|
+
@Inject('queue')
|
|
261
|
+
protected readonly queue: Queue
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 报表("reports" 组件)
|
|
265
|
+
*/
|
|
266
|
+
@Inject('reports')
|
|
267
|
+
protected readonly reports: Reports
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 商店的销售数据("sales" 组件)
|
|
271
|
+
*/
|
|
272
|
+
@Inject('sales')
|
|
273
|
+
protected readonly sales: Sales
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 服务的日志
|
|
277
|
+
*/
|
|
278
|
+
@Inject('log')
|
|
279
|
+
protected readonly log: Logger
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* POST /reports {"month": "2026-09"}:报表加入队列,立即返回它的 id
|
|
283
|
+
* @param input
|
|
284
|
+
*/
|
|
285
|
+
@POST('/', CreateReportDTO)
|
|
286
|
+
public async create(input: CreateReportDTO): Promise<StatusResult> {
|
|
287
|
+
const report: Report = await this.reports.create(input.month)
|
|
288
|
+
await this.queue.add('report.build', {id: report.id, month: report.month}, {jobId: report.id})
|
|
289
|
+
return Result.status(202, report, {Location: `/reports/${report.id}`})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* GET /reports/:id:报表的状态,完成后还有结果
|
|
294
|
+
* @param input
|
|
295
|
+
*/
|
|
296
|
+
@GET('/:id', ReportIdDTO)
|
|
297
|
+
public async get(input: ReportIdDTO): Promise<Report> {
|
|
298
|
+
return await this.reports.get(input.id)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 生成队列 report.build 中的报表:同时处理 2 份,最多尝试 3 次,间隔先 1 秒后 2 秒,每次最长 1 分钟
|
|
303
|
+
* @param input
|
|
304
|
+
*/
|
|
305
|
+
@QueueAction('report.build', BuildReportDTO, {concurrency: 2, attempts: 3, backoff: {type: 'exponential', delay: '1s'}, timeout: '1m'})
|
|
306
|
+
public async build(input: BuildReportDTO): Promise<SalesSummary> {
|
|
307
|
+
const context: QueueContext = this.context as QueueContext
|
|
308
|
+
await this.reports.update(input.id, {state: 'running', attempts: context.attempt})
|
|
309
|
+
try {
|
|
310
|
+
const result: SalesSummary = await this.sales.summarize(input.month, this.signal)
|
|
311
|
+
await this.reports.update(input.id, {state: 'done', result: result, error: undefined, finishedAt: new Date().toISOString()})
|
|
312
|
+
return result
|
|
313
|
+
} catch (error) {
|
|
314
|
+
//重新排队:还有尝试次数时,等待退避时间后重新投递;次数用完后发往 report.build.dead
|
|
315
|
+
await this.reports.update(input.id, {state: 'queued', error: (error as Error).message})
|
|
316
|
+
throw error
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* report.build 中尝试次数已用完的任务
|
|
322
|
+
* @param input
|
|
323
|
+
*/
|
|
324
|
+
@QueueAction('report.build.dead')
|
|
325
|
+
public async failed(input: QueueDeadLetter): Promise<void> {
|
|
326
|
+
const job: BuildReportDTO = input.input as BuildReportDTO
|
|
327
|
+
await this.reports.update(job.id, {state: 'failed', error: input.error.message, finishedAt: input.failedAt})
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 每 5 分钟一次,同一时间只在一个实例中执行:删除旧报表
|
|
332
|
+
*/
|
|
333
|
+
@Schedule({cron: '*/5 * * * *', exclusive: true})
|
|
334
|
+
public async clean(): Promise<void> {
|
|
335
|
+
const deleted: number = await this.reports.clean()
|
|
336
|
+
this.log.info('%d old reports deleted', deleted)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
一个控制器包含了三种传输方式的动作:HTTP、队列和定时任务。每个请求、每条消息、每次定时执行都会得到一个新的控制器实例,组件已经注入其中(见 [Controllers](../Controllers.md))。
|
|
342
|
+
|
|
343
|
+
- `create()` 保存报表,用 `queue.add()` 向队列 `report.build` 添加一条消息,不等报表生成就立即回复:`Result.status()` 发送状态码 202(Accepted)、报表本身,以及指向这份报表的 `Location` 响应头(见 [HTTP](../HTTP.md))。消息的 `jobId` 就是报表的 id。
|
|
344
|
+
- `@QueueAction` 让 `build()` 成为队列 `report.build` 的消费者(见 [Queue](../Queue.md))。它同时处理 2 条消息(`concurrency`),每条最长 1 分钟(`timeout`:超时后 `this.signal` 被中止,消息会重试)。它的上下文是 `QueueContext`,从中可以知道这是第几次尝试(`attempt`,第一次投递为 1),以及一共允许尝试几次(`attempts`)。
|
|
345
|
+
- 动作正常返回时消息被确认,动作的返回值就是这条消息的结果,由队列保存。动作抛出错误时,`build()` 把报表改回 `queued` 状态并记下错误,消息在等待 `backoff`(先 1 秒,再 2 秒)后重新投递,最多尝试 3 次。3 次都失败后,消息进入死信队列 `report.build.dead`,它和普通队列一样:`failed()` 消费其中的消息,把报表标为失败。
|
|
346
|
+
- 消息至少投递一次:进程崩溃后,如果使用的是有服务端的驱动,这条消息会再次投递,`build()` 会把同一份报表再生成一遍,这不会造成问题。
|
|
347
|
+
- `@Schedule` 按 cron 表达式的时间执行 `clean()`,也就是每 5 分钟一次(见 [Schedules](../Schedules.md))。设置了 `exclusive: true` 后,每次执行前先尝试从 `mutex` 组件获取锁 `schedule:ReportController:clean`,没拿到锁的实例跳过这一次(见 [Mutex](../Mutex.md))。
|
|
348
|
+
|
|
349
|
+
## 6. 服务的选项
|
|
350
|
+
|
|
351
|
+
**`packages/app/src/config/Config.ts`**
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
import path from 'node:path'
|
|
355
|
+
import type {ApplicationOptions} from 'lakutata'
|
|
356
|
+
import {buildEntrypoints, scheduleEntrypoint} from 'lakutata/com/entrypoint'
|
|
357
|
+
import {Mutex} from 'lakutata/com/mutex'
|
|
358
|
+
import {buildQueueOptions, queueEntrypoint} from 'lakutata/com/queue'
|
|
359
|
+
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
|
|
360
|
+
import compression from 'compression'
|
|
361
|
+
import {authenticate} from '../middlewares/Authenticate.js'
|
|
362
|
+
import {HealthController} from '../controllers/HealthController.js'
|
|
363
|
+
import {ReportController} from '../controllers/ReportController.js'
|
|
364
|
+
import {Reports} from '../components/Reports.js'
|
|
365
|
+
import {Sales} from '../components/Sales.js'
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 服务的选项
|
|
369
|
+
* @param port HTTP 服务器的端口,默认取环境变量 PORT,没有时为 8080
|
|
370
|
+
* @param data 报表和锁所在的目录,默认取环境变量 DATA,没有时为 data
|
|
371
|
+
*/
|
|
372
|
+
export function config(port: number = Number(process.env.PORT ?? 8080), data: string = process.env.DATA ?? 'data'): ApplicationOptions {
|
|
373
|
+
return {
|
|
374
|
+
id: 'reports',
|
|
375
|
+
name: 'reports',
|
|
376
|
+
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
|
377
|
+
components: {
|
|
378
|
+
//消息保存在进程的内存中,它们的结果保留一小时
|
|
379
|
+
queue: buildQueueOptions({results: {keep: '1h'}}),
|
|
380
|
+
//锁是文件,同一台机器上的各个服务实例共用
|
|
381
|
+
mutex: {class: Mutex, driver: {type: 'file', directory: path.join(data, 'locks')}},
|
|
382
|
+
//已结束的报表保留一天
|
|
383
|
+
reports: {class: Reports, directory: path.join(data, 'reports'), keep: 24 * 60 * 60 * 1000},
|
|
384
|
+
sales: {class: Sales},
|
|
385
|
+
entrypoint: buildEntrypoints({
|
|
386
|
+
controllers: [HealthController, ReportController],
|
|
387
|
+
http: expressHTTPEntrypoint({
|
|
388
|
+
port: port,
|
|
389
|
+
//结果和错误都是 {errno, errMsg, data},错误保留各自的状态码(400、404 等)
|
|
390
|
+
respond: jsonEnvelope(),
|
|
391
|
+
body: {limit: '100mb'},
|
|
392
|
+
server: {keepAliveTimeout: 60000, headersTimeout: 65000},
|
|
393
|
+
middlewares: [authenticate],
|
|
394
|
+
before: (app): void => void app.use(compression())
|
|
395
|
+
}),
|
|
396
|
+
//把队列的消息投递给 @QueueAction 动作
|
|
397
|
+
queue: queueEntrypoint('queue'),
|
|
398
|
+
//执行 @Schedule 动作
|
|
399
|
+
schedule: scheduleEntrypoint()
|
|
400
|
+
})
|
|
401
|
+
},
|
|
402
|
+
bootstrap: ['entrypoint']
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
这是模板中的 `Config.ts`,改动如下:
|
|
408
|
+
|
|
409
|
+
- 新增 `queue` 组件,由 `buildQueueOptions()` 生成:没有指定 `driver`,所以消息保存在进程的内存中。队列把消息的结果保留一小时(默认一天):测试会读取其中一个结果,服务本身则从文件中读取报表;
|
|
410
|
+
- 新增 `mutex` 组件,使用 `file` 驱动:每把锁是 `data/locks` 中的一个文件,同一台机器上使用这个目录的各个进程共用这些锁;
|
|
411
|
+
- 新增本例的 `reports` 和 `sales` 组件,以及它们的选项;
|
|
412
|
+
- 用 `ReportController` 替换示例控制器;
|
|
413
|
+
- 入口多了两个适配器:`queueEntrypoint('queue')` 把 `queue` 组件的消息投递给 `@QueueAction` 动作,`scheduleEntrypoint()` 执行 `@Schedule` 动作(见 [Entrypoint](../Entrypoint.md))。没有对应的适配器,装饰器就不起作用;
|
|
414
|
+
- `jsonEnvelope()` 不再设置 `errorStatus`,这样错误保留各自的状态码(400、404),这是 REST 客户端所期望的。
|
|
415
|
+
|
|
416
|
+
`bootstrap: ['entrypoint']` 在启动时创建入口,队列也随之创建:服务启动完成后,动作就开始消费消息。模板中的中间件 `Authenticate.ts` 保留不变:它是 HTTP 适配器的中间件,队列的消息和定时任务的执行都不经过它。
|
|
417
|
+
|
|
418
|
+
## 7. 运行
|
|
419
|
+
|
|
420
|
+
在同一台机器上运行两个服务实例,它们共用 `data` 目录:
|
|
421
|
+
|
|
422
|
+
```bash
|
|
423
|
+
npm run dev
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
第一个实例启动后,在第二个终端中运行:
|
|
427
|
+
|
|
428
|
+
```bash
|
|
429
|
+
PORT=8081 npm start
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
在第三个终端中,向第一个实例提交一份报表,再从第二个实例读取:
|
|
433
|
+
|
|
434
|
+
```bash
|
|
435
|
+
curl -i -X POST localhost:8080/reports -H 'Content-Type: application/json' -d '{"month": "2026-09"}'
|
|
436
|
+
# HTTP/1.1 202 Accepted
|
|
437
|
+
# Location: /reports/eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9
|
|
438
|
+
# {"errno":null,"errMsg":null,"data":{"id":"eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9","month":"2026-09","state":"queued","attempts":0,"createdAt":"2026-10-17T15:00:46.797Z"}}
|
|
439
|
+
|
|
440
|
+
curl localhost:8081/reports/eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9
|
|
441
|
+
# {"errno":null,"errMsg":null,"data":{"id":"eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9","month":"2026-09","state":"running","attempts":1,"createdAt":"2026-10-17T15:00:46.797Z"}}
|
|
442
|
+
|
|
443
|
+
curl localhost:8081/reports/eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9
|
|
444
|
+
# {"errno":null,"errMsg":null,"data":{"id":"eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9","month":"2026-09","state":"done","attempts":1,"createdAt":"2026-10-17T15:00:46.797Z","result":{"orders":209,"revenue":7837.5},"finishedAt":"2026-10-17T15:00:47.303Z"}}
|
|
445
|
+
|
|
446
|
+
curl -i -X POST localhost:8080/reports -H 'Content-Type: application/json' -d '{"month": "2026-13"}'
|
|
447
|
+
# HTTP/1.1 400 Bad Request
|
|
448
|
+
# {"errno":"E_INVALID_VALUE","errMsg":"\"month\" with value \"2026-13\" fails to match the YYYY-MM pattern","data":null}
|
|
449
|
+
|
|
450
|
+
curl -i localhost:8081/reports/5b0f8a52-2f4e-4a3c-9a55-0b1c3c4d5e6f
|
|
451
|
+
# HTTP/1.1 404 Not Found
|
|
452
|
+
# {"errno":"E_REPORT_NOT_FOUND","errMsg":"The report 5b0f8a52-2f4e-4a3c-9a55-0b1c3c4d5e6f does not exist","data":null}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
报表由收到请求的第一个实例生成,用时半秒;第二个实例读取的是它写下的文件。
|
|
456
|
+
|
|
457
|
+
每 5 分钟(:00、:05、:10……)两个实例中会有一个删除旧报表。这次是第一个实例,它在终端中输出:
|
|
458
|
+
|
|
459
|
+
```
|
|
460
|
+
app │ [23:05:00.009] INFO (reports/97338): 0 old reports deleted
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
第二个实例没有拿到锁,跳过了这一次,日志中没有任何输出。这次没有删除任何报表:报表要保留一天。
|
|
464
|
+
|
|
465
|
+
## 8. 测试
|
|
466
|
+
|
|
467
|
+
模板中的测试换成了针对报表的测试。它启动服务,把报表和锁放在临时目录中:
|
|
468
|
+
|
|
469
|
+
**`packages/app/src/tests/App.spec.ts`**
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
import {after, before, describe, it, type TestContext} from 'node:test'
|
|
473
|
+
import assert from 'node:assert'
|
|
474
|
+
import {mkdtemp, rm} from 'node:fs/promises'
|
|
475
|
+
import os from 'node:os'
|
|
476
|
+
import path from 'node:path'
|
|
477
|
+
import {setTimeout} from 'node:timers/promises'
|
|
478
|
+
import {Application} from 'lakutata'
|
|
479
|
+
import {type Queue} from 'lakutata/com/queue'
|
|
480
|
+
import {config} from '../config/Config.js'
|
|
481
|
+
import {type Report, type Reports} from '../components/Reports.js'
|
|
482
|
+
import {type Sales} from '../components/Sales.js'
|
|
483
|
+
|
|
484
|
+
const PORT: number = Number(process.env.TEST_PORT ?? 18087)
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* 向服务发一个请求,返回状态码和 {errno, errMsg, data}
|
|
488
|
+
* @param method
|
|
489
|
+
* @param route
|
|
490
|
+
* @param body
|
|
491
|
+
*/
|
|
492
|
+
async function request(method: string, route: string, body?: unknown): Promise<{ status: number, errno: string | null, data: any }> {
|
|
493
|
+
const response: Response = await fetch(`http://127.0.0.1:${PORT}${route}`, {
|
|
494
|
+
method: method,
|
|
495
|
+
headers: {'Authorization': 'Bearer test-token', 'Content-Type': 'application/json'},
|
|
496
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
497
|
+
})
|
|
498
|
+
const envelope = await response.json() as { errno: string | null, data: any }
|
|
499
|
+
return {status: response.status, errno: envelope.errno, data: envelope.data}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* 反复读取一份报表,直到它的任务结束(完成或失败)
|
|
504
|
+
* @param id
|
|
505
|
+
*/
|
|
506
|
+
async function finished(id: string): Promise<Report> {
|
|
507
|
+
for (; ;) {
|
|
508
|
+
const report: Report = (await request('GET', `/reports/${id}`)).data
|
|
509
|
+
if (report.state === 'done' || report.state === 'failed') return report
|
|
510
|
+
await setTimeout(100)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
describe('reports', (): void => {
|
|
515
|
+
|
|
516
|
+
let app: Application
|
|
517
|
+
let data: string
|
|
518
|
+
|
|
519
|
+
before(async (): Promise<void> => {
|
|
520
|
+
process.env.API_TOKEN = 'test-token'
|
|
521
|
+
//报表和锁放在临时目录中
|
|
522
|
+
data = await mkdtemp(path.join(os.tmpdir(), 'reports-'))
|
|
523
|
+
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
|
|
524
|
+
Application.run(config(PORT, data)).onLaunched((launched: Application): void => resolve(launched))
|
|
525
|
+
})
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
after(async (): Promise<void> => {
|
|
529
|
+
await app?.dispose()
|
|
530
|
+
await rm(data, {recursive: true, force: true})
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('builds a report in the background', async (): Promise<void> => {
|
|
534
|
+
const created = await request('POST', '/reports', {month: '2026-09'})
|
|
535
|
+
assert.strictEqual(created.status, 202)
|
|
536
|
+
assert.strictEqual(created.data.state, 'queued')
|
|
537
|
+
//任务的结果,由队列保存
|
|
538
|
+
const queue: Queue = await app.getObject<Queue>('queue')
|
|
539
|
+
assert.deepStrictEqual(await queue.result('report.build', created.data.id), {orders: 209, revenue: 7837.5})
|
|
540
|
+
const report = await request('GET', `/reports/${created.data.id}`)
|
|
541
|
+
assert.strictEqual(report.data.state, 'done')
|
|
542
|
+
assert.deepStrictEqual(report.data.result, {orders: 209, revenue: 7837.5})
|
|
543
|
+
assert.strictEqual(report.data.attempts, 1)
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
it('retries a job that failed', async (t: TestContext): Promise<void> => {
|
|
547
|
+
const sales: Sales = await app.getObject<Sales>('sales')
|
|
548
|
+
//第一次调用失败,之后的调用执行真正的代码
|
|
549
|
+
const summarize = t.mock.method(sales, 'summarize')
|
|
550
|
+
summarize.mock.mockImplementationOnce(async (): Promise<never> => {
|
|
551
|
+
throw new Error('The database did not answer')
|
|
552
|
+
})
|
|
553
|
+
const created = await request('POST', '/reports', {month: '2026-08'})
|
|
554
|
+
const report: Report = await finished(created.data.id)
|
|
555
|
+
assert.strictEqual(report.state, 'done')
|
|
556
|
+
assert.strictEqual(report.attempts, 2)
|
|
557
|
+
assert.strictEqual(summarize.mock.callCount(), 2)
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
it('fails a job after its attempts', async (t: TestContext): Promise<void> => {
|
|
561
|
+
const sales: Sales = await app.getObject<Sales>('sales')
|
|
562
|
+
t.mock.method(sales, 'summarize', async (): Promise<never> => {
|
|
563
|
+
throw new Error('The database did not answer')
|
|
564
|
+
})
|
|
565
|
+
const created = await request('POST', '/reports', {month: '2026-07'})
|
|
566
|
+
const report: Report = await finished(created.data.id)
|
|
567
|
+
assert.strictEqual(report.state, 'failed')
|
|
568
|
+
assert.strictEqual(report.attempts, 3)
|
|
569
|
+
assert.strictEqual(report.error, 'The database did not answer')
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('refuses an invalid month and an unknown report', async (): Promise<void> => {
|
|
573
|
+
const invalid = await request('POST', '/reports', {month: '2026-13'})
|
|
574
|
+
assert.strictEqual(invalid.status, 400)
|
|
575
|
+
assert.strictEqual(invalid.errno, 'E_INVALID_VALUE')
|
|
576
|
+
const unknown = await request('GET', '/reports/5b0f8a52-2f4e-4a3c-9a55-0b1c3c4d5e6f')
|
|
577
|
+
assert.strictEqual(unknown.status, 404)
|
|
578
|
+
assert.strictEqual(unknown.errno, 'E_REPORT_NOT_FOUND')
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it('deletes the reports finished for more than a day', async (): Promise<void> => {
|
|
582
|
+
//直接调用定时任务要做的工作:先用现在的时间,再用明天的时间
|
|
583
|
+
const reports: Reports = await app.getObject<Reports>('reports')
|
|
584
|
+
assert.strictEqual(await reports.clean(new Date()), 0)
|
|
585
|
+
assert.strictEqual(await reports.clean(new Date(Date.now() + 25 * 60 * 60 * 1000)), 3)
|
|
586
|
+
})
|
|
587
|
+
})
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
- 第一个测试用 `queue.result()` 读取任务的结果(它会一直等到消息处理完),然后读取报表。
|
|
591
|
+
- 为了让任务失败,测试用 `node:test` 的 `t.mock.method()` 替换 `sales` 组件的 `summarize()`:一个测试只替换一次调用,任务在第二次尝试时成功;另一个测试替换所有调用,任务在 3 次尝试后失败,由于有退避等待,这需要 3 秒。组件是共用的,所以动作调用的就是替换后的方法;测试结束时方法会恢复原样。
|
|
592
|
+
- 最后一个测试直接调用 `clean()`,模拟明天执行的情形,不必等到 cron 表达式指定的时间。
|
|
593
|
+
|
|
594
|
+
```bash
|
|
595
|
+
npm test
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
```
|
|
599
|
+
app │ ✔ builds a report in the background
|
|
600
|
+
app │ ✔ retries a job that failed
|
|
601
|
+
app │ ✔ fails a job after its attempts
|
|
602
|
+
app │ ✔ refuses an invalid month and an unknown report
|
|
603
|
+
app │ ✔ deletes the reports finished for more than a day
|
|
604
|
+
app │ ℹ pass 5
|
|
605
|
+
✔ test ran in app
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
服务会记录每次失败的尝试及其错误:`The queue action ReportController:build failed on the message <id> of report.build (attempt 1 of 3)`。`queueEntrypoint('queue', {onError})` 可以替换这条日志。
|
|
609
|
+
|
|
610
|
+
## 9. 部署到多台机器
|
|
611
|
+
|
|
612
|
+
本例的实例都运行在同一台机器上。部署到多台机器时,每个部分换用一个共享的驱动即可,动作的代码不变:
|
|
613
|
+
|
|
614
|
+
- 队列:内存驱动只在一个进程中保存消息。一个实例只生成它自己收到的报表;它停止时仍在等待的消息会丢失,对应的报表停留在 `queued` 状态,定时任务也不会删除它们。换用 Redis(`driver: {type: 'redis', url: process.env.REDIS_URL}`,并运行 `npm install bullmq ioredis -w app`)后,消息在重启后仍然保留,任何实例都可以处理,结果也由各实例共享(见 [Queue](../Queue.md))。
|
|
615
|
+
- 锁:文件驱动只在一台机器的各进程之间共享。Redis(`driver: {type: 'redis', url}`,并运行 `npm install redis -w app`)或 PostgreSQL 可以在多台机器之间共享锁(见 [Mutex](../Mutex.md))。
|
|
616
|
+
- 报表:放进数据库的一张表(见 [带数据库的 REST API](RestAPI.md)),或者放在一个卷上,每个实例都把它挂载为 `DATA`。
|
|
617
|
+
|
|
618
|
+
`npm start` 运行构建好的服务,`npm run docker:build` 用模板中的 `Dockerfile` 构建镜像。
|