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,102 @@
|
|
|
1
|
+
# Lakutata 项目结构
|
|
2
|
+
|
|
3
|
+
本页介绍 `lakutata create` 创建的项目:`default` 模板的文件、其他模板的工作区、它们的脚本和可选功能,以及如何制作自己的模板。
|
|
4
|
+
|
|
5
|
+
## 1. 默认模板
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
my-app/
|
|
9
|
+
package.json
|
|
10
|
+
tsconfig.json
|
|
11
|
+
README.md
|
|
12
|
+
.gitignore
|
|
13
|
+
src/
|
|
14
|
+
App.ts 启动应用
|
|
15
|
+
config/Config.ts 应用的选项:组件、入口、适配器
|
|
16
|
+
controllers/ExampleController.ts 动作
|
|
17
|
+
dtos/GreetDTO.ts 动作的输入
|
|
18
|
+
tests/App.spec.ts 测试(node --test)
|
|
19
|
+
build/ 编译后的应用(npm run build)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`src/App.ts` 用 `src/config/Config.ts` 中的选项启动应用,并记录启动日志:
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import {Application, type ILogger} from 'lakutata'
|
|
26
|
+
import {config} from './config/Config.js'
|
|
27
|
+
|
|
28
|
+
Application
|
|
29
|
+
.run(config)
|
|
30
|
+
.onLaunched((app: Application, log: ILogger): void => log.info('%s started in %s mode', app.appName, app.mode()))
|
|
31
|
+
.onFatalException((error: Error, log: ILogger): void => log.error('The application cannot start: %s', error.message))
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`config()` 返回应用的选项:应用的 ID 和名称、运行模式(`NODE_ENV=production` 时为 `production`),以及 `entrypoint` 组件,它在环境变量 `PORT` 指定的端口(默认 8080)上通过 HTTP 提供 `ExampleController`。它写成函数,是为了让测试可以在自己的端口上启动应用:`src/tests/App.spec.ts` 运行 `Application.run(config(18080))`,请求 `/example?name=you`,最后销毁应用。
|
|
35
|
+
|
|
36
|
+
| 脚本 | 作用 |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `dev` | 先 `build`,再启动应用 |
|
|
39
|
+
| `build` | 删除 `build/`(`lakutata clean`),再把 `src/` 编译到其中(`tsc`) |
|
|
40
|
+
| `start` | 启动编译好的应用(`node build/App.js`) |
|
|
41
|
+
| `test` | 先 `build`,再运行测试(`node --test "build/tests/**/*.spec.js"`) |
|
|
42
|
+
| `migration:generate`、`migration:create`、`migration:run`、`migration:revert`、`migration:show` | 数据库迁移(`lakutata migration`,见 [Migrations](Migrations.md)) |
|
|
43
|
+
|
|
44
|
+
这个模板在 `tsconfig.json` 中写了自己的编译选项(取 `lakutata/tsconfig.json` 中它需要的那些,并使用 `module: "NodeNext"` 和 source map);项目也可以像 `cli` 以外的工作区模板那样继承 `lakutata/tsconfig.json`。
|
|
45
|
+
|
|
46
|
+
## 2. 工作区模板
|
|
47
|
+
|
|
48
|
+
其他模板都是 npm 工作区:应用是 `packages/app` 这个包,带界面或安装包的模板还会加上 `packages/ui`(网页,或 Electron 的渲染进程)和 `packages/resources`(图标)。`service` 模板:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
shop/
|
|
52
|
+
package.json 工作区:workspaces ["packages/*"],以及脚本
|
|
53
|
+
Dockerfile 服务的镜像
|
|
54
|
+
packages/
|
|
55
|
+
app/
|
|
56
|
+
package.json
|
|
57
|
+
tsconfig.json extends lakutata/tsconfig.json
|
|
58
|
+
src/
|
|
59
|
+
App.ts
|
|
60
|
+
config/Config.ts express 适配器、中间件、compression
|
|
61
|
+
controllers/ExampleController.ts
|
|
62
|
+
controllers/HealthController.ts GET /health
|
|
63
|
+
dtos/GreetDTO.ts
|
|
64
|
+
middlewares/Authenticate.ts 设置了 API_TOKEN 时校验 Bearer 令牌
|
|
65
|
+
tests/App.spec.ts
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| 脚本(根目录) | 作用 |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `npm run build` | 按依赖顺序构建每个包(`lakutata run rebuild`) |
|
|
71
|
+
| `npm test` | 先构建每个包,再运行它们的测试(`npm run build && lakutata run test`) |
|
|
72
|
+
| `npm run dev` | 同时启动每个包的 `dev` 脚本(`lakutata run dev --parallel`):应用,以及界面的开发服务器(如果有界面) |
|
|
73
|
+
| `npm start` | 启动编译好的应用(`npm start -w app`) |
|
|
74
|
+
| `npm run version:patch` | 给每个包设置下一个补丁版本(`lakutata version patch --yes`) |
|
|
75
|
+
|
|
76
|
+
其他模板在这个结构上增加了:
|
|
77
|
+
|
|
78
|
+
- `web-react` 和 `web-vue`:`packages/ui`,一个 Vite 应用。开发时,它的开发服务器(`http://localhost:5173`)把 `/api` 转发给应用;构建之后,由应用自己提供这些页面(动作都在 `/api` 之下)。应用的 `lakutata.resources` 会在打包时带上构建好的页面。
|
|
79
|
+
- `web-next` 和 `web-nuxt`:`packages/ui`,一个 Next.js 或 Nuxt 应用,由应用在同一个进程中渲染。
|
|
80
|
+
- `cli`:一个命令行工具(`commanderCLIEntrypoint`),带有 `package:bundle` 和 `package:exe` 脚本,把它打包到 `dist/`([Packaging](Packaging.md))。
|
|
81
|
+
- `electron-react` 和 `electron-vue`:`packages/app` 中是 Electron 的主进程(它的窗口,以及通过 IPC 提供的服务动作),`packages/ui` 中是渲染进程,还有制作安装包的脚本。
|
|
82
|
+
|
|
83
|
+
打包产物写到工作区根目录的 `dist/` 中;`.gitignore` 忽略 `build`、`dist` 以及界面的构建输出。
|
|
84
|
+
|
|
85
|
+
## 3. 可选功能
|
|
86
|
+
|
|
87
|
+
`lakutata create --with <features>`(或在终端里列出的选项)给模板加上可选功能:
|
|
88
|
+
|
|
89
|
+
| 功能 | 模板 | 加入的内容 |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `i18n` | 全部 | `src/locales` 中各语言的消息(`en`、`zh-CN`)、`i18n` 组件、语言中间件,以及 `i18n:check` 和 `i18n:types` 脚本([I18n](I18n.md)) |
|
|
92
|
+
| `installer` | `cli`、`service`、`web-*` | 应用的 `lakutata.installer`、制作安装包的脚本(在本机制作,以及在 Docker 中制作 Linux 的安装包),服务类模板还会注册系统服务([Installers](Installers.md)) |
|
|
93
|
+
|
|
94
|
+
Electron 模板不需要这个功能也能制作安装包。
|
|
95
|
+
|
|
96
|
+
## 4. 自己的模板
|
|
97
|
+
|
|
98
|
+
`--template` 也可以指定不在官方列表中的模板:`npm:<package>[@<version>]`、`github:<owner>/<repo>[#<ref>]`,或者一个目录。没有 `template/` 子目录的目录会原样复制;模板包(和官方模板一样)包含:
|
|
99
|
+
|
|
100
|
+
- `template/`:项目的文件。npm 不会发布某些点文件,所以它们以 `_` 开头命名,创建时再改名(`_gitignore` 变成 `.gitignore`)。文本文件中的 `{$APP_NAME}`、`{$APP_ID}`、`{$APP_DESC}`、`{$APP_AUTHOR}`、`{$APP_LICENSE}` 和 `{$LAKUTATA_VERSION}` 会被替换。
|
|
101
|
+
- `features/<name>/`:每个功能的文件,在模板 `package.json` 的 `lakutata.features` 中声明并附上说明。这些文件会加入项目:项目中已有的 JSON 文件与之合并(数组整体替换),其他文件替换模板中的同名文件。
|
|
102
|
+
- 只对某个功能保留的代码块:位于 `//@if i18n` 和 `//@end` 之间的行(可以有 `//@else`,`@if !i18n` 表示相反的条件),写成该文件的注释形式(`#@if`、`<!--@if-->`、`/*@if*/`)。
|
package/doc/zh/Queue.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Lakutata 消息队列
|
|
2
|
+
|
|
3
|
+
**Queue 组件**(`lakutata/com/queue`,`@lakutata/queue` 包)把消息加入队列,用 `@QueueAction` 声明的控制器动作通过 `queueEntrypoint` 适配器处理这些消息,和其他传输方式一样使用输入 DTO、访问控制和中间件。动作正常返回时消息被确认;失败后重新投递,次数用完后进入死信队列;动作的返回值就是这条消息的结果。队列可以放在内存、Redis、NATS JetStream、RabbitMQ 或 Kafka 中,换驱动不需要改动作的代码。更多细节见 `@lakutata/queue` 的 README。
|
|
4
|
+
|
|
5
|
+
## 1. 配置
|
|
6
|
+
|
|
7
|
+
用 `buildQueueOptions` 在应用的 `components` 中声明 `Queue`(按惯例命名为 `queue`),把队列的适配器交给 entrypoint,并让 entrypoint 在启动时创建(`bootstrap: ['entrypoint']`;否则它要到第一次被使用时才创建,动作不会开始消费)。不指定 `driver` 时,消息保存在进程的内存中。
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import {Application} from 'lakutata'
|
|
11
|
+
import {buildEntrypoints} from 'lakutata/com/entrypoint'
|
|
12
|
+
import {buildQueueOptions, queueEntrypoint} from 'lakutata/com/queue'
|
|
13
|
+
|
|
14
|
+
Application.run(() => ({
|
|
15
|
+
id: 'image.app',
|
|
16
|
+
name: 'Images',
|
|
17
|
+
components: {
|
|
18
|
+
queue: buildQueueOptions({
|
|
19
|
+
driver: {type: 'redis', url: 'redis://127.0.0.1:6379'},
|
|
20
|
+
//消费者的默认设置
|
|
21
|
+
defaults: {attempts: 5, backoff: {type: 'exponential', delay: '1s', max: '1m'}, concurrency: 4},
|
|
22
|
+
//为 result() 和 request() 保存的结果
|
|
23
|
+
results: {keep: '1d'}
|
|
24
|
+
}),
|
|
25
|
+
entrypoint: buildEntrypoints({
|
|
26
|
+
controllers: [],
|
|
27
|
+
//把消息投递给 @QueueAction 动作
|
|
28
|
+
queue: queueEntrypoint('queue')
|
|
29
|
+
})
|
|
30
|
+
},
|
|
31
|
+
//在启动时创建:动作从此开始消费
|
|
32
|
+
bootstrap: ['entrypoint']
|
|
33
|
+
}))
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| 驱动 | 需要安装 | 服务端 | 说明 |
|
|
37
|
+
|---|---|---|---|
|
|
38
|
+
| `{type: 'memory'}`(默认) | | | 只在当前进程内有效:进程退出后消息丢失;用于开发、测试、桌面应用 |
|
|
39
|
+
| `{type: 'redis', url}` | `bullmq`、`ioredis` | Redis 5.0 起(建议 6.2 起) | 基于 BullMQ:支持优先级、延迟、去重;请使用 `maxmemory-policy noeviction` |
|
|
40
|
+
| `{type: 'nats', servers}` | `nats` | NATS 2.9.4 起,需开启 JetStream(`-js`) | stream 保留消息 `maxAge`(7 天);2 分钟内去重;不支持优先级 |
|
|
41
|
+
| `{type: 'rabbitmq', url}` | `amqplib` | RabbitMQ 3.6 起 | 优先级 0–10,支持延迟;需要 `results.store` |
|
|
42
|
+
| `{type: 'kafka', brokers}` | `@platformatic/kafka`(Node.js 22.22 / 24.6 起) | Kafka 3.5 起 | 按键保序,`partitions`(默认 1);需要 `results.store` |
|
|
43
|
+
| `{type: 'custom', driver}` | | | 应用自己实现的 `QueueDriver` |
|
|
44
|
+
|
|
45
|
+
客户端都是可选的 peer 依赖,在组件启动时才加载:缺少客户端包时启动失败并抛出 `QueueDriverNotFoundException`,服务端版本太旧时抛出 `QueueDriverVersionException`,连不上服务端时抛出连接错误。启动之后,驱动会自动重连。使用服务端的驱动还接受 `prefix`(默认 `lakutata`),作为它们在服务端创建的名称的前缀,用来隔开共用同一个服务端的应用。
|
|
46
|
+
|
|
47
|
+
组件的其他选项:
|
|
48
|
+
|
|
49
|
+
- `defaults`:消费者的默认设置:`attempts`(3)、`backoff`(指数退避,从 1 秒到 1 分钟)、`concurrency`(1)、`timeout`(不限)、`group`(应用的 `appId`)。
|
|
50
|
+
- `results`:`keep` 是结果保留多久(默认 1 天),`store` 是代替驱动保存结果的 `Cacher` 组件的名称。RabbitMQ 和 Kafka 自身不保存结果,需要指定一个各应用共用的存储(例如 Redis)。`false` 表示不保存结果。
|
|
51
|
+
- `codec`:`'messagepack'`(默认,和 `@lakutata/nats` 相同)、`'json'`(方便其他语言的服务对接)或自定义的 `QueueCodec`。
|
|
52
|
+
- `requestTimeout`:`request()` 和 `result()` 的等待时间(30 秒)。
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import {Application} from 'lakutata'
|
|
56
|
+
import {buildCacherOptions} from 'lakutata/com/cacher'
|
|
57
|
+
import {buildQueueOptions} from 'lakutata/com/queue'
|
|
58
|
+
|
|
59
|
+
Application.run(() => ({
|
|
60
|
+
id: 'billing.app',
|
|
61
|
+
name: 'Billing',
|
|
62
|
+
components: {
|
|
63
|
+
//各应用共用的结果(对 RabbitMQ 来说还有各队列的组)
|
|
64
|
+
cache: buildCacherOptions({stores: [{type: 'redis', host: '127.0.0.1'}]}),
|
|
65
|
+
queue: buildQueueOptions({
|
|
66
|
+
driver: {type: 'rabbitmq', url: 'amqp://guest:guest@127.0.0.1:5672', prefix: 'billing'},
|
|
67
|
+
results: {keep: '2h', store: 'cache'}
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
}))
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 2. 消费者:`@QueueAction`
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
@QueueAction(queue, dto?, options?)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
被装饰的方法处理一个队列的消息:输入就是消息,经过 DTO 校验;`this.context` 是 `QueueContext`。方法的返回值就是这条消息的结果。同一个方法还可以同时带有其他传输方式的装饰器(HTTP 路由、服务动作等)。选项包括所有动作通用的选项(`name`、`acl`、`rule`、`middlewares` 等),以及:
|
|
80
|
+
|
|
81
|
+
- `group`:消费这个队列的组(默认是队列的 `defaults.group`,否则是应用的 `appId`);
|
|
82
|
+
- `concurrency`:一个实例同时处理的消息数;
|
|
83
|
+
- `attempts`、`backoff`:消息进入死信队列之前的投递次数,以及两次投递之间的等待;
|
|
84
|
+
- `timeout`:处理的时间上限(到时中止,然后重试);
|
|
85
|
+
- `ordered`:同一个 `key` 的消息按顺序逐条处理(内存和 Kafka 驱动);
|
|
86
|
+
- `output`:把返回值作为一条新消息发到这个队列(返回值须为对象;返回 `undefined` 时不发送)。
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
import {DTO} from 'lakutata'
|
|
90
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
91
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
92
|
+
import {QueueAction, type QueueContext, type QueueDeadLetter} from 'lakutata/com/queue'
|
|
93
|
+
|
|
94
|
+
class ExtractInput extends DTO {
|
|
95
|
+
@Expect(DTO.Number().integer().required())
|
|
96
|
+
public id: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
class ImageController extends Controller {
|
|
100
|
+
//同时处理 4 条,最多 5 次尝试,每次最长 10 分钟,结果发往 image.index
|
|
101
|
+
@QueueAction('image.extract', ExtractInput, {concurrency: 4, attempts: 5, timeout: '10m', output: 'image.index'})
|
|
102
|
+
public async extract(input: ExtractInput): Promise<{id: number, pages: number}> {
|
|
103
|
+
const context: QueueContext = this.context as QueueContext
|
|
104
|
+
console.log(`消息 ${context.jobId} 的第 ${context.attempt} 次尝试,共 ${context.attempts} 次`)
|
|
105
|
+
//到达处理时限时被中止:this.signal
|
|
106
|
+
this.signal?.throwIfAborted()
|
|
107
|
+
return {id: input.id, pages: 12}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@QueueAction('image.index')
|
|
111
|
+
public async index(input: {id: number, pages: number}): Promise<void> {
|
|
112
|
+
console.log(`图片 ${input.id}:${input.pages} 页`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
//尝试次数用完的消息
|
|
116
|
+
@QueueAction('image.extract.dead')
|
|
117
|
+
public async failed(input: QueueDeadLetter): Promise<void> {
|
|
118
|
+
console.error(input.jobId, input.attempts, input.error.message)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
上下文中包含 `queue`、`jobId`、`group`、`attempt`(首次投递为 1)、允许的 `attempts`、应用的 `headers` 和 `publishedAt`;它的 `type` 是 `'QUEUE'`,供多种传输方式共用的中间件和访问规则区分。
|
|
124
|
+
|
|
125
|
+
`queueEntrypoint(componentName, {name, middlewares, onError})` 在应用启动时检查各动作的选项并登记它们的组,应用启动完成后开始消费。每次处理失败(无论是否重试)都会调用 `onError(error, action, context)`;默认把失败写入应用的日志。
|
|
126
|
+
|
|
127
|
+
不使用控制器时,可以用 `queue.consume(name, processor, options)` 以函数 `(input, context, signal) => result` 处理队列,它返回一个可以 `stop()` 的订阅:
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
import type {Queue, QueueJobContext} from 'lakutata/com/queue'
|
|
131
|
+
|
|
132
|
+
declare const queue: Queue
|
|
133
|
+
const subscription = await queue.consume('mail.send', async (input: Record<string, any>, context: QueueJobContext, signal: AbortSignal): Promise<void> => {
|
|
134
|
+
signal.throwIfAborted()
|
|
135
|
+
console.log(`发邮件给 ${input.to}(${context.jobId})`)
|
|
136
|
+
}, {group: 'mailer', concurrency: 2})
|
|
137
|
+
await subscription.stop()
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## 3. 生产者:`add`、`request`、`result`
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
import {Component} from 'lakutata'
|
|
144
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
145
|
+
import {type Job, type Queue, QueueJobFailedException} from 'lakutata/com/queue'
|
|
146
|
+
|
|
147
|
+
class Images extends Component {
|
|
148
|
+
@Inject('queue')
|
|
149
|
+
protected readonly queue: Queue
|
|
150
|
+
|
|
151
|
+
public async work(): Promise<void> {
|
|
152
|
+
//发出即不管:10 秒后投递;同一编号的消息还在等待时丢弃(dedupe)
|
|
153
|
+
await this.queue.add('image.extract', {id: 7}, {jobId: 'image-7', dedupe: true, delay: '10s'})
|
|
154
|
+
|
|
155
|
+
//发出并等待结果:动作的返回值,或最后一次的错误
|
|
156
|
+
try {
|
|
157
|
+
const result: {pages: number} = await this.queue.request('image.extract', {id: 8}, {timeout: '30s'})
|
|
158
|
+
console.log(result.pages)
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error instanceof QueueJobFailedException) console.error(error.error.message, error.attempts)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
//现在发出,之后凭编号读取结果(任何实例都可以读),只要结果还保留着
|
|
164
|
+
const job: Job = await this.queue.add('image.extract', {id: 9}, {priority: 5, headers: {origin: 'upload'}})
|
|
165
|
+
console.log(await job.result({timeout: '1m'}))
|
|
166
|
+
console.log(await this.queue.result('image.extract', job.id, {wait: false}))
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
消息的选项:`jobId`(默认随机 UUID)、`dedupe`、`delay`、`priority`(越大越先)、`key`(保序的键)、`attempts` 和 `backoff`(代替消费者的设置)、`headers`(前缀 `x-lkt-` 保留给框架)。驱动不支持的选项会抛出 `QueueCapabilityException`;`queue.capabilities` 给出驱动支持的能力。
|
|
172
|
+
|
|
173
|
+
`request()` 以动作的返回值完成;消息失败时以 `QueueJobFailedException` 拒绝;超时(`QueueRequestTimeoutException`)时消息仍留在队列中,之后还能读取它的结果。`result()` 和 `job.result()` 会等到消息处理完(`wait: false` 只读取当前已保存的结果);没有在时间内处理完时抛出 `QueueResultNotFoundException`,结果已不再保留时抛出 `QueueResultExpiredException`。有多个组消费同一个队列时,`request()` 和 `result()` 要用 `group` 指明要哪个组的结果(否则抛出 `QueueAmbiguousGroupException`)。
|
|
174
|
+
|
|
175
|
+
## 4. 投递
|
|
176
|
+
|
|
177
|
+
- **至少一次。** 动作正常返回时消息被确认。抛出错误时,等待 `backoff` 后重新投递,最多 `attempts` 次,之后作为 `QueueDeadLetter`(`{queue, jobId, group, input, error, attempts, failedAt}`)进入 `<队列名>.dead`,它和普通队列一样。一条消息可能被处理两次(进程崩溃、停机),动作应当是幂等的。
|
|
178
|
+
- **重试解决不了的不重试。** 输入不合法(`InvalidValueException`)、没有访问权限、动作不存在、返回值校验失败(`@Return`)、消息或结果无法编码:消息直接进入死信队列。动作抛出 `QueueDiscardException` 时丢弃这条消息:确认它,不进死信,也不保存结果。
|
|
179
|
+
- **处理时限。** 处理超过 `timeout` 时以 `QueueProcessingTimeoutException` 失败并重试:它的 signal 被中止,之后的返回值被丢弃。投递有租约的驱动(NATS)会在处理期间自动续租。
|
|
180
|
+
- **停机。** 应用停止时,消费者停止取新消息,正在处理的消息有 entrypoint 的 `shutdownTimeout` 时间完成;届时仍未完成的消息以后重新投递,既不确认也不进死信。
|
|
181
|
+
- **组。** 同一个组的多个实例分摊消息;不同的组各收一份。动作的组默认是所在应用的 `appId`:两个应用消费同一个队列时会各收一份,除非它们指定相同的 `group`。组在登记之后才开始接收消息(应用启动时登记其动作的组,`consume()` 和 `queue.prepare(name, {group})` 也会登记):使用服务端的驱动时,队列在任何组登记之前收到的消息不会被投递;内存驱动则把这些消息留给第一个登记的组,每个队列最多保留 `backlog` 条(默认为 `{type: 'memory', backlog: 1000}`),超出时丢弃最早的消息。所以没有人消费的死信队列(`<queue>.dead`)只在内存中保留最近的 1000 条消息。组登记后一直保存在服务端,所以即使组的所有实例都没有运行,发给它的消息也会保留。
|
|
182
|
+
- **消息内容。** 消息是一个对象,也就是动作的输入。使用默认编解码时,`Date` 会变成 ISO 字符串(输入 DTO 可以把它转回 `Date`;返回值仍是字符串),`bigint` 会被拒绝。
|
|
183
|
+
|
|
184
|
+
## 5. 各驱动的比较
|
|
185
|
+
|
|
186
|
+
| | 内存 | Redis | NATS JetStream | RabbitMQ | Kafka |
|
|
187
|
+
|---|---|---|---|---|---|
|
|
188
|
+
| 延迟 | ✓ | ✓ | ✓ | ✓(延迟队列) | — |
|
|
189
|
+
| 优先级 | ✓ | ✓ | — | ✓(0–10) | — |
|
|
190
|
+
| 去重 | 消息保留期间 | 消息保留期间 | 2 分钟内 | — | — |
|
|
191
|
+
| 按 `key` 保序 | ✓ | — | — | — | ✓(分区) |
|
|
192
|
+
| 结果 | ✓ | ✓ | ✓ | `results.store` | `results.store` |
|
|
193
|
+
| `request()` | 回复通道 | 回复通道 | 回复通道 | 回复通道 | 读取结果 |
|
|
194
|
+
| 重试 | 延迟重投 | 延迟重投 | 延迟重投 | 经延迟队列发送副本 | 原地等待,占住分区 |
|
|
195
|
+
| 停机时放弃计为一次尝试 | 否 | 是 | 是 | 否 | 否 |
|
|
196
|
+
| 重启后消息仍在 | 否 | 是 | 是 | 是 | 是 |
|
|
197
|
+
|
|
198
|
+
在 Kafka 上,同一个分区的消息按顺序逐条处理:`concurrency` 表示一个实例同时处理几个分区;一条消息的尝试次数由正在处理它的实例计数。RabbitMQ 无法列出一个队列有哪些组:组件把它们记在 `results.store` 中,各应用必须共用这个存储,`request()` 和 `result()` 才能在不指定组时找到组。
|
|
199
|
+
|
|
200
|
+
## 6. 异常
|
|
201
|
+
|
|
202
|
+
| 异常 | 什么时候 |
|
|
203
|
+
|---|---|
|
|
204
|
+
| `QueueInvalidMessageException`(400) | 消息不是对象、队列名为空、消息头使用了 `x-lkt-`、值无法编码 |
|
|
205
|
+
| `QueueCapabilityException` | 驱动不支持的选项(`priority`、`dedupe`、`delay`、`ordered`),没有保存结果 |
|
|
206
|
+
| `QueueRequestTimeoutException`(504) | `request()` 没有在时间内得到结果 |
|
|
207
|
+
| `QueueResultNotFoundException`(404) | `result()` 时消息还没处理完,或编号不存在 |
|
|
208
|
+
| `QueueResultExpiredException`(410) | `result()` 时结果已不再保留 |
|
|
209
|
+
| `QueueAmbiguousGroupException`(400) | 有多个组消费队列时,`request()` 或 `result()` 没有指定 `group` |
|
|
210
|
+
| `QueueJobFailedException` | `request()` 或 `result()` 的消息处理失败(带有它的 `error` 和 `attempts`) |
|
|
211
|
+
| `QueueProcessingTimeoutException`(504) | 处理超过 `timeout` 而失败(会重试) |
|
|
212
|
+
| `QueueDiscardException` | 由动作抛出,表示丢弃这条消息 |
|
|
213
|
+
| `QueueDriverNotFoundException` | 驱动的客户端包没有安装 |
|
|
214
|
+
| `QueueDriverVersionException` | 服务端版本低于驱动支持的最低版本 |
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Lakutata 定时任务
|
|
2
|
+
|
|
3
|
+
`@Schedule` 让控制器的方法按计划执行:每隔固定时间执行一次,或者在 cron 表达式指定的时间执行。负责执行这些方法的是定时适配器,即 `lakutata/com/entrypoint` 中的 `scheduleEntrypoint()`,不需要安装其他包。
|
|
4
|
+
|
|
5
|
+
## 1. 第一个定时任务
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import {Application} from 'lakutata'
|
|
9
|
+
import {buildEntrypoints, Controller, scheduleEntrypoint} from 'lakutata/com/entrypoint'
|
|
10
|
+
import {Schedule} from 'lakutata/decorator/ctrl'
|
|
11
|
+
|
|
12
|
+
class JobController extends Controller {
|
|
13
|
+
//每 30 秒一次
|
|
14
|
+
@Schedule('30s')
|
|
15
|
+
public async heartbeat(): Promise<void> {
|
|
16
|
+
console.log('Still alive')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
//每天上海时间 03:00
|
|
20
|
+
@Schedule({cron: '0 3 * * *', timezone: 'Asia/Shanghai'})
|
|
21
|
+
public async purge(): Promise<void> {
|
|
22
|
+
console.log('Purging the expired sessions')
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Application.run(() => ({
|
|
27
|
+
id: 'jobs.app',
|
|
28
|
+
name: 'Jobs',
|
|
29
|
+
components: {
|
|
30
|
+
entrypoint: buildEntrypoints({
|
|
31
|
+
controllers: [JobController],
|
|
32
|
+
schedule: scheduleEntrypoint()
|
|
33
|
+
})
|
|
34
|
+
},
|
|
35
|
+
bootstrap: ['entrypoint']
|
|
36
|
+
}))
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- 定时任务在应用启动完成后才开始。入口必须列在 `bootstrap` 里,并且要配置 `schedule` 适配器:没有它,标了 `@Schedule` 的方法永远不会执行。
|
|
40
|
+
- 按间隔执行的任务,第一次执行是在启动后过了一个间隔,而不是启动时立即执行(如果启动时也要执行一次,见下文的 `immediate`)。
|
|
41
|
+
- 应用的每个实例都会执行定时任务。如果同一时间只想让一个实例执行,见第 4 节。
|
|
42
|
+
|
|
43
|
+
## 2. 计划的写法
|
|
44
|
+
|
|
45
|
+
`@Schedule(schedule, options?)` 的第一个参数是计划。第二个参数是所有动作通用的动作选项(`name`、`description`、`acl`、`rule`、`middlewares`、`groups`)。和其他动作装饰器不同,它不接受 DTO:定时任务没有输入。控制器的 `@Prefix` 对定时任务不起作用。
|
|
46
|
+
|
|
47
|
+
计划可以是:
|
|
48
|
+
|
|
49
|
+
| 计划 | 含义 |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `'30s'`、`'1h30m'`、`'1.5h'` | 用时长表示的间隔 |
|
|
52
|
+
| `5000` | 以毫秒表示的间隔 |
|
|
53
|
+
| `'0 */5 * * * *'`、`'@daily'` | cron 表达式(不是时长的字符串都按 cron 表达式处理) |
|
|
54
|
+
| `{every: '30s', ...}` | 间隔,可加下表中的选项 |
|
|
55
|
+
| `{cron: '0 3 * * *', timezone: 'Asia/Shanghai', ...}` | cron 表达式,可指定时区并加下表中的选项 |
|
|
56
|
+
|
|
57
|
+
时长由数字加单位组成,单位是 `ms`、`s`、`m`、`h` 或 `d`。多段会相加(`1h30m`),可以带小数(`1.5h`),中间可以有空格。没有单位的数字(`'30'`)不算时长:它会被当成 cron 表达式,然后报错。
|
|
58
|
+
|
|
59
|
+
对象形式的选项:
|
|
60
|
+
|
|
61
|
+
| 选项 | 默认值 | 含义 |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `overlap` | `false` | 上一次还没执行完时,也开始新的一次。默认会跳过这一次 |
|
|
64
|
+
| `immediate` | `false` | 应用启动完成后立即额外执行一次 |
|
|
65
|
+
| `exclusive` | `false` | 同一时间只在应用的一个实例中执行(见第 4 节) |
|
|
66
|
+
| `timezone` | 进程所在的时区 | cron 表达式使用的 IANA 时区 |
|
|
67
|
+
|
|
68
|
+
### cron 表达式
|
|
69
|
+
|
|
70
|
+
cron 表达式有 6 个字段;省略秒时是 5 个字段,秒取 0:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
┌──────────── 秒(0-59),可省略
|
|
74
|
+
│ ┌────────── 分(0-59)
|
|
75
|
+
│ │ ┌──────── 时(0-23)
|
|
76
|
+
│ │ │ ┌────── 日(1-31)
|
|
77
|
+
│ │ │ │ ┌──── 月(1-12,或 jan-dec)
|
|
78
|
+
│ │ │ │ │ ┌── 星期(0-7,或 sun-sat;0 和 7 都是星期日)
|
|
79
|
+
│ │ │ │ │ │
|
|
80
|
+
* * * * * *
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
每个字段可以写 `*`(或 `?`)、单个值、范围(`1-5`)、步长(`*/15`、`10/15`、`10-40/15`),或用逗号分隔的列表(`1,15,30`)。月份和星期的英文缩写不区分大小写(`mon-fri`)。日和星期两个字段都有限制时,满足其中任意一个就算匹配,与传统 cron 相同。
|
|
84
|
+
|
|
85
|
+
支持的宏有 `@yearly`(或 `@annually`)、`@monthly`、`@weekly`、`@daily`(或 `@midnight`)和 `@hourly`。不支持 `L`、`W`、`#` 和 `@reboot`。
|
|
86
|
+
|
|
87
|
+
| 表达式 | 执行时间 |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `'*/10 * * * * *'` | 每 10 秒 |
|
|
90
|
+
| `'0 */5 * * *'` | 每 5 分钟 |
|
|
91
|
+
| `'30 9 * * mon-fri'` | 周一到周五的 09:30 |
|
|
92
|
+
| `'0 0 1 * *'` | 每月 1 日零点 |
|
|
93
|
+
|
|
94
|
+
**夏令时。** 在会调整时钟的时区里,春季被跳过的时刻(纽约 2026 年 3 月 8 日的 02:30)当天不会执行,秋季重复出现的时刻只执行一次。
|
|
95
|
+
|
|
96
|
+
**写错时。** 间隔或 cron 表达式不合法会抛出 `SyntaxError`,时区不存在会抛出 `RangeError`,这些错误在定义控制器类时就会抛出,应用还没启动。没有任何日期能匹配的表达式(比如 2 月 30 日,`'0 0 30 2 *'`)同样会抛出 `SyntaxError`:它永远不会执行。
|
|
97
|
+
|
|
98
|
+
## 3. 什么时候执行
|
|
99
|
+
|
|
100
|
+
- **间隔:** 启动后过一个间隔执行第一次,之后每隔一个间隔执行一次。
|
|
101
|
+
- **cron:** 每次都安排在下一个与表达式匹配的时间。
|
|
102
|
+
- **错过的执行**会被跳过,不会补上:进程被挂起过(比如笔记本休眠)之后,任务在下一个计划时间执行。
|
|
103
|
+
- **重叠:** 默认情况下,上一次还在执行时,这一次会被跳过。设置 `overlap: true` 后,同一个任务可以同时有多次在执行。
|
|
104
|
+
- **`immediate: true`** 会在应用启动完成后立即额外执行一次。
|
|
105
|
+
|
|
106
|
+
## 4. 只在一个实例中执行
|
|
107
|
+
|
|
108
|
+
应用常常同时运行多个实例(多个容器、多台服务器)。声明了 `exclusive: true` 的任务,同一时间只会在其中一个实例中执行:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
112
|
+
import {Schedule} from 'lakutata/decorator/ctrl'
|
|
113
|
+
|
|
114
|
+
class ReportController extends Controller {
|
|
115
|
+
//每 5 分钟,只在一个实例中执行
|
|
116
|
+
@Schedule({every: '5m', exclusive: true})
|
|
117
|
+
public async build(): Promise<void> {
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
//每天 03:00,使用组件 locks 中名为 schedule:purge 的锁
|
|
121
|
+
@Schedule({cron: '0 3 * * *', exclusive: {mutex: 'locks', key: 'purge'}})
|
|
122
|
+
public async purge(): Promise<void> {
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- 应用需要一个各实例共用的互斥锁组件,默认名为 `mutex`,也可以用 `exclusive.mutex` 指定(见 [Mutex](Mutex.md) 第 4 节)。没有这个组件时,任务不会执行,错误会写入日志。
|
|
128
|
+
- 每次执行前先尝试获取锁 `schedule:<key>`,key 默认是动作名,也可以用 `exclusive.key` 指定。没拿到锁的实例跳过这一次。
|
|
129
|
+
- 独占任务的间隔按时钟对齐(每 5 分钟就是 :00、:05、:10……),这样所有实例安排的时间都相同。
|
|
130
|
+
- 执行结束后,锁会一直保留到计划时间之后半个周期(最多 30 秒),这样即使某个实例的时钟稍慢,也不会把任务再执行一遍。
|
|
131
|
+
|
|
132
|
+
## 5. 执行过程中
|
|
133
|
+
|
|
134
|
+
每次执行都像处理一个请求:它在自己的作用域里得到一个新的控制器,经过入口的中间件和访问控制。方法不会收到输入,返回值也会被忽略。
|
|
135
|
+
|
|
136
|
+
`this.context` 是一个 `ScheduleContext`:
|
|
137
|
+
|
|
138
|
+
| 属性 | 含义 |
|
|
139
|
+
|---|---|
|
|
140
|
+
| `schedule` | 计划的文字形式:`'every 30s'`、`'cron 0 3 * * *'` |
|
|
141
|
+
| `scheduledAt` | 这次执行的计划时间(`Date`);实际开始可能晚几毫秒 |
|
|
142
|
+
| `run` | 这是本进程中的第几次执行,从 1 开始(被跳过的不计入) |
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import {Controller, type ScheduleContext} from 'lakutata/com/entrypoint'
|
|
146
|
+
import {Schedule} from 'lakutata/decorator/ctrl'
|
|
147
|
+
|
|
148
|
+
class JobController extends Controller {
|
|
149
|
+
@Schedule({every: '1m', immediate: true})
|
|
150
|
+
public async sync(): Promise<void> {
|
|
151
|
+
const context: ScheduleContext = this.context as ScheduleContext
|
|
152
|
+
console.log(`Run ${context.run} of ${context.schedule}, planned at ${context.scheduledAt.toISOString()}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## 6. 错误与跳过
|
|
158
|
+
|
|
159
|
+
某一次执行抛出错误,不会影响这个任务:后面的执行照常进行。默认情况下,错误由应用的日志组件(`log`)记录,没有日志组件时写到控制台。适配器的选项可以替换这一行为:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
import {scheduleEntrypoint} from 'lakutata/com/entrypoint'
|
|
163
|
+
|
|
164
|
+
export const schedule = scheduleEntrypoint({
|
|
165
|
+
//某次执行失败
|
|
166
|
+
onError: (error, action, context): void => {
|
|
167
|
+
console.error(`${action.name} failed at ${context.scheduledAt.toISOString()}: ${(error as Error).message}`)
|
|
168
|
+
},
|
|
169
|
+
//某次执行被跳过:上一次还没结束('overlap'),或者锁在别的实例手里('exclusive')
|
|
170
|
+
onSkip: (action, scheduledAt, reason): void => {
|
|
171
|
+
console.warn(`${action.name} skipped at ${scheduledAt.toISOString()} (${reason})`)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
适配器还接受 `middlewares`(定时任务专用的中间件,在入口的中间件之后执行)和 `name`。
|
|
177
|
+
|
|
178
|
+
## 7. 停止
|
|
179
|
+
|
|
180
|
+
应用停止时,不再开始新的执行,独占任务会释放它们的锁。正在执行的任务有入口的 `shutdownTimeout`(默认 30 秒)可以完成,超时后会被中止:`this.signal` 会被触发,耗时长的任务可以检查它并提前结束。
|
|
181
|
+
|
|
182
|
+
## 8. 计算下一次的时间
|
|
183
|
+
|
|
184
|
+
`lakutata/com/entrypoint` 中的 `Cron` 和 `parseDuration` 就是定时任务使用的解析器:
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
import {Cron, parseDuration} from 'lakutata/com/entrypoint'
|
|
188
|
+
|
|
189
|
+
const cron: Cron = new Cron('30 9 * * mon-fri', 'Asia/Shanghai')
|
|
190
|
+
//给定时间之后的下一个时间(精确到秒),永远不匹配时为 undefined
|
|
191
|
+
console.log(cron.next(new Date('2026-10-16T12:00:00Z'))) //2026-10-19T01:30:00.000Z,即上海时间周一 09:30
|
|
192
|
+
console.log(parseDuration('1h30m')) //5400000
|
|
193
|
+
```
|
package/doc/zh/Security.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Lakutata 安全
|
|
2
2
|
|
|
3
|
-
`lakutata/security`(`@lakutata/security` 包)提供基于 bcrypt 的密码哈希(`PasswordHash`)、基于 HMAC 的消息签名(`Mac
|
|
3
|
+
`lakutata/security`(`@lakutata/security` 包)提供基于 bcrypt 的密码哈希(`PasswordHash`)、基于 HMAC 的消息签名(`Mac`)和基于 AES-256-GCM 的认证加密(`Crypt`),以及它们所基于的函数;其中 `Mac` 和 `Crypt` 支持密钥轮换。它基于 `node:crypto` 实现(bcrypt 用 JavaScript 实现),没有可选依赖。`lakutata/provider/passwordHash` 只导出 `PasswordHash`。更多细节见 `@lakutata/security` 的 README。
|
|
4
4
|
|
|
5
5
|
## 1. 提供者
|
|
6
6
|
|
|
@@ -33,7 +33,7 @@ Application
|
|
|
33
33
|
const token: string = mac.signMessage('user:42')
|
|
34
34
|
//消息被篡改时抛出 InvalidSignatureException
|
|
35
35
|
console.log(mac.unsignMessage(token))
|
|
36
|
-
|
|
36
|
+
//单独的签名,不附在消息上
|
|
37
37
|
const signature: string = mac.sign('payload')
|
|
38
38
|
console.log(mac.verify('payload', signature))
|
|
39
39
|
|
|
@@ -46,7 +46,7 @@ Application
|
|
|
46
46
|
|
|
47
47
|
- **密码**:`PasswordHash.hash()` 拒绝 UTF-8 编码超过 72 字节的密码(bcrypt 会忽略超出的字节),抛出 `PasswordTooLongException`;对这类密码,`validate()` 返回 `false`。哈希值请原样保存,并用 `validate()` 校验,不要用 `===` 比较。
|
|
48
48
|
- **密钥**:`Mac` 和 `Crypt` 的密钥是至少 16 个字符的字符串。请用 `generateSecret()` 生成(32 个随机字节,base64url):把密码当作密钥使用容易被暴力破解。
|
|
49
|
-
-
|
|
49
|
+
- **密钥轮换**:把新密钥放在最前面;只要还要读取用旧密钥签名或加密的数据,就保留旧密钥。
|
|
50
50
|
- **格式**:签名和加密数据都是不带填充的 base64url 字符串,可以安全地用于 URL 和 Cookie。
|
|
51
51
|
|
|
52
52
|
## 2. 按用途选择工具
|
|
@@ -81,7 +81,7 @@ const body: string = JSON.stringify({order: 42})
|
|
|
81
81
|
const signature: string = hmac(body, key, 'sha512')
|
|
82
82
|
console.log(verifyHMAC(body, signature, key, 'sha512'))
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
//用一个密钥加密,用给出的任一密钥解密:当前的或之前的
|
|
85
85
|
const encrypted: string = encryptByKey('card ending 4242', key)
|
|
86
86
|
console.log(decryptByKey(encrypted, [key, generateSecret()]).toString('utf8'))
|
|
87
87
|
|
|
@@ -95,10 +95,10 @@ console.log(constantTimeEqual('presented-token', process.env.API_TOKEN ?? ''))
|
|
|
95
95
|
|
|
96
96
|
## 4. 错误
|
|
97
97
|
|
|
98
|
-
| 异常 |
|
|
98
|
+
| 异常 | 什么时候 |
|
|
99
99
|
|---|---|
|
|
100
100
|
| `PasswordTooLongException` | `PasswordHash.hash()` 的密码 UTF-8 编码超过 72 字节 |
|
|
101
|
-
| `InvalidSignatureException` | `Mac.unsignMessage()`
|
|
101
|
+
| `InvalidSignatureException` | `Mac.unsignMessage()` 的消息被篡改过、没有签名,或者由未知的密钥签名 |
|
|
102
102
|
| `DecryptionFailedException` | `Crypt.decrypt()`、`decryptString()`、`decryptByKey()`、`decryptByPassword()`:密钥或口令错误,数据被篡改或截断 |
|
|
103
103
|
| `InvalidObjectOptionsException`(来自 `lakutata`) | 应用启动时提供者的选项不合法:没有密钥、密钥短于 16 个字符、未知算法、`saltRounds` 不在 4 到 31 之间 |
|
|
104
104
|
|