@eggjs/skills 4.1.2-beta.4 → 4.1.2-beta.6
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/egg/SKILL.md +93 -19
- package/egg-controller/SKILL.md +36 -6
- package/egg-controller/references/ajv-validate.md +146 -0
- package/egg-controller/references/middleware.md +133 -0
- package/egg-core/SKILL.md +70 -14
- package/egg-core/references/aop.md +219 -0
- package/egg-core/references/background-task.md +121 -0
- package/egg-core/references/eventbus.md +138 -0
- package/egg-unittest/SKILL.md +152 -0
- package/egg-unittest/references/background-task-test.md +55 -0
- package/egg-unittest/references/eventbus-test.md +47 -0
- package/egg-unittest/references/http-test.md +160 -0
- package/egg-unittest/references/mock.md +114 -0
- package/egg-unittest/references/service-test.md +75 -0
- package/package.json +5 -2
- package/PLAN.md +0 -396
package/egg-core/SKILL.md
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: egg-core
|
|
3
|
-
description: 本技能用于处理 EGG 基础核心概念,包括模块架构、@SingletonProto、@ContextProto、@Inject
|
|
3
|
+
description: 本技能用于处理 EGG 基础核心概念,包括模块架构、@SingletonProto、@ContextProto、@Inject 装饰器、动态注入、BackgroundTaskHelper 后台任务、EventBus 事件总线和 AOP 切面编程。用于理解 EGG 的基础构建块、依赖注入、对象生命周期管理、运行时多实现动态选择、请求返回后的异步任务处理、事件驱动架构和横切关注点。
|
|
4
4
|
allowed-tools: Read
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# egg 核心概念
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## 代码组织与依赖注入
|
|
10
10
|
|
|
11
|
-
###
|
|
11
|
+
### Step 1: 代码写在 module 中
|
|
12
|
+
|
|
13
|
+
#### 什么是模块?
|
|
12
14
|
|
|
13
15
|
模块是 EGG 中基础的代码组织单元。只有模块内的代码会被框架扫描和加载。模块之间相互独立,但可以通过 `@Inject` 装饰器访问其他模块的对象。
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
#### 定义 module
|
|
16
18
|
|
|
17
19
|
在目录中添加包含 `eggModule.name` 字段的 `package.json` 文件来声明该目录为模块:
|
|
18
20
|
|
|
@@ -83,9 +85,23 @@ export class ConfigService {
|
|
|
83
85
|
- 存量应用:保留老的 egg 代码在 `app/controller`/`app/service`,将新增的 module 代码放在 `app/module/`
|
|
84
86
|
- 可以在 `dependencies` 中导入 npm 包作为额外模块
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
### 导入路径
|
|
89
|
+
|
|
90
|
+
所有装饰器和类型统一从 `egg` 导入,不要从 `@eggjs/tegg` 导入:
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
// ✅ 正确
|
|
94
|
+
import { SingletonProto, ContextProto, Inject, AccessLevel } from 'egg';
|
|
87
95
|
|
|
88
|
-
|
|
96
|
+
// ❌ 错误 — 不要从 @eggjs/tegg 导入
|
|
97
|
+
import { SingletonProto } from '@eggjs/tegg';
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
### Step 2: 用 Proto 实现 Service
|
|
103
|
+
|
|
104
|
+
#### SingletonProto
|
|
89
105
|
|
|
90
106
|
应用启动时立即创建,整个应用生命周期内只有一个实例,性能更好,应该作为默认选择。
|
|
91
107
|
|
|
@@ -100,7 +116,7 @@ export class HelloService {
|
|
|
100
116
|
}
|
|
101
117
|
```
|
|
102
118
|
|
|
103
|
-
|
|
119
|
+
#### ContextProto
|
|
104
120
|
|
|
105
121
|
请求到达时按需创建,每个请求一个实例,请求结束自动销毁。仅在需要隔离不同请求的上下文信息时使用。
|
|
106
122
|
|
|
@@ -115,7 +131,7 @@ export class RequestContext {
|
|
|
115
131
|
|
|
116
132
|
**重要提示**:大多数服务应该使用 `SingletonProto` 以获得更好的性能。只有当请求上下文必须在服务之间共享以确保请求之间隔离时,才使用 `ContextProto`。
|
|
117
133
|
|
|
118
|
-
|
|
134
|
+
#### AccessLevel
|
|
119
135
|
|
|
120
136
|
proto 对象默认 accessLevel 为 `AccessLevel.PRIVATE`,仅在当前 module 内使用。可以设置为 `AccessLevel.PUBLIC`,进行跨模块访问。
|
|
121
137
|
|
|
@@ -129,9 +145,9 @@ export class SharedService {}
|
|
|
129
145
|
export class SharedContextService {}
|
|
130
146
|
```
|
|
131
147
|
|
|
132
|
-
|
|
148
|
+
### Step 3: 通过 Inject 使用 Service
|
|
133
149
|
|
|
134
|
-
|
|
150
|
+
#### 基本用法
|
|
135
151
|
|
|
136
152
|
使用 `@Inject()` 注入其他 Proto 或 Egg 对象:
|
|
137
153
|
|
|
@@ -153,19 +169,19 @@ export class HelloService {
|
|
|
153
169
|
}
|
|
154
170
|
```
|
|
155
171
|
|
|
156
|
-
|
|
172
|
+
#### 动态注入
|
|
157
173
|
|
|
158
174
|
当同一个抽象有多种实现,需要在运行时动态选择时,通过 `EggObjectFactory` 按类型获取实现,无需 if/else。详见 `references/dynamic-inject.md`。
|
|
159
175
|
|
|
160
|
-
|
|
176
|
+
#### 重要约束
|
|
161
177
|
|
|
162
178
|
- **不能有循环依赖**:Proto 或模块之间都不能有循环依赖
|
|
163
179
|
- **不能有同名对象**:一个模块不能有相同名称和初始化类型的 Proto
|
|
164
180
|
- **按需注入**:不要直接注入 `app` 或 `ctx`,按需注入特定对象
|
|
165
181
|
|
|
166
|
-
|
|
182
|
+
### 快速决策指南
|
|
167
183
|
|
|
168
|
-
| 场景 |
|
|
184
|
+
| 场景 | 使用方式 |
|
|
169
185
|
| -------------------------------- | ------------------------------------------------------ |
|
|
170
186
|
| 无状态服务 | `@SingletonProto()` |
|
|
171
187
|
| 跨服务共享的请求级状态 | `@ContextProto()` |
|
|
@@ -174,6 +190,41 @@ export class HelloService {
|
|
|
174
190
|
| 使用自定义名称注入 | `@Inject({ name: 'customName' })` |
|
|
175
191
|
| 同一抽象多种实现,运行时动态选择 | `QualifierImplDecoratorUtil` + `EggObjectFactory` |
|
|
176
192
|
|
|
193
|
+
## 异步任务
|
|
194
|
+
|
|
195
|
+
| 特性 | BackgroundTaskHelper | EventBus |
|
|
196
|
+
| ---------- | ----------------------------- | ----------------------------------------- |
|
|
197
|
+
| **耦合** | 高 — 异步逻辑写在触发者代码中 | 低 — handler 独立,新增处理者不修改触发者 |
|
|
198
|
+
| **上下文** | 共享触发者的请求上下文 | handler 运行在独立的新上下文中 |
|
|
199
|
+
| **扩展** | 需要修改触发者代码 | 新增 `@Event` handler 类即可 |
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
需要在请求之外执行任务?
|
|
203
|
+
│
|
|
204
|
+
├─ 请求返回后执行,依赖当前请求上下文
|
|
205
|
+
│ └─ → BackgroundTaskHelper(references/background-task.md)
|
|
206
|
+
│
|
|
207
|
+
├─ 请求返回后执行,不依赖当前请求上下文,需要解耦
|
|
208
|
+
│ └─ → EventBus(references/eventbus.md)
|
|
209
|
+
│
|
|
210
|
+
└─ 定时或周期执行
|
|
211
|
+
└─ → Schedule(参考 egg-controller skill)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## AOP 切面编程
|
|
215
|
+
|
|
216
|
+
AOP 用于将日志、鉴权、缓存、事务等横切关注点从业务代码中分离。AOP 装饰器从 `egg/aop` 导入(不是 `egg`)。
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
需要在方法执行前后添加通用逻辑?
|
|
220
|
+
│
|
|
221
|
+
├─ 针对特定方法 → @Pointcut(在目标方法上声明)
|
|
222
|
+
│
|
|
223
|
+
└─ 批量切入多个类/方法 → @Crosscut(在 Advice 类上声明匹配规则)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
详细用法(Advice 生命周期、AdviceContext、Pointcut/Crosscut 选型、参数透传、执行顺序)请参阅 `references/aop.md`。
|
|
227
|
+
|
|
177
228
|
## 常见问题排查
|
|
178
229
|
|
|
179
230
|
| 现象 | 原因 | 解决方案 |
|
|
@@ -190,3 +241,8 @@ export class HelloService {
|
|
|
190
241
|
- Inject 装饰器使用,请参阅:`references/inject.md`
|
|
191
242
|
- SingletonProto 和 ContextProto 详情,请参阅:`references/proto.md`
|
|
192
243
|
- 动态注入(Qualifier 动态注入),请参阅:`references/dynamic-inject.md`
|
|
244
|
+
- 请求后异步任务(BackgroundTaskHelper),请参阅:`references/background-task.md`
|
|
245
|
+
- 事件总线(EventBus),请参阅:`references/eventbus.md`
|
|
246
|
+
- AOP 切面编程(Advice、Pointcut、Crosscut),请参阅:`references/aop.md`
|
|
247
|
+
|
|
248
|
+
单元测试(`egg-unittest` skill):Service 测试、BackgroundTask 测试、EventBus 测试
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# AOP 切面编程指南
|
|
2
|
+
|
|
3
|
+
## 常见错误
|
|
4
|
+
|
|
5
|
+
| 错误写法 | 正确写法 | 说明 |
|
|
6
|
+
| ------------------------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
7
|
+
| `import { Advice } from 'egg'` | `import { Advice } from 'egg/aop'` | AOP 装饰器从 `egg/aop` 导入,不是 `egg` |
|
|
8
|
+
| `import { Advice } from '@eggjs/tegg/aop'` | `import { Advice } from 'egg/aop'` | 统一从 `egg/aop` 导入 |
|
|
9
|
+
| Advice 类没有加 `@Advice()` 装饰器 | 必须同时有 `@Advice()` | `@Pointcut` 和 `@Crosscut` 都要求目标是 Advice 类 |
|
|
10
|
+
| `@Crosscut` 直接切 Egg 内置对象 | 只能切 tegg Proto 对象 | Egg 中的对象(如 app、ctx)无法被 Crosscut |
|
|
11
|
+
| Advice 中用实例属性存状态 | 使用 `ctx.set()`/`ctx.get()` 共享状态 | Advice 默认是 Singleton,实例属性会被并发请求共享,必须用 AdviceContext 传递调用级状态 |
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 核心概念
|
|
16
|
+
|
|
17
|
+
### Advice(切面逻辑)
|
|
18
|
+
|
|
19
|
+
Advice 是 AOP 的核心,定义了在目标方法执行前后要做什么。Advice 本身也是一种 Proto,默认 initType 为 Singleton(全局单例),可以使用 `@Inject` 注入依赖。如需每请求一个实例,显式指定 `@Advice({ initType: ObjectInitType.CONTEXT })`。
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { Advice, IAdvice, AdviceContext } from 'egg/aop';
|
|
23
|
+
import { Inject, Logger } from 'egg';
|
|
24
|
+
|
|
25
|
+
@Advice()
|
|
26
|
+
export class LogAdvice implements IAdvice {
|
|
27
|
+
@Inject()
|
|
28
|
+
private logger: Logger;
|
|
29
|
+
|
|
30
|
+
async beforeCall(ctx: AdviceContext): Promise<void> {
|
|
31
|
+
ctx.set('startTime', Date.now());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async afterReturn(ctx: AdviceContext, result: any): Promise<void> {
|
|
35
|
+
// 方法成功返回后执行
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async afterThrow(ctx: AdviceContext, error: Error): Promise<void> {
|
|
39
|
+
// 方法抛出异常后执行
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async afterFinally(ctx: AdviceContext): Promise<void> {
|
|
43
|
+
const duration = Date.now() - ctx.get('startTime');
|
|
44
|
+
this.logger.info('%s cost %dms', String(ctx.method), duration);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async around(ctx: AdviceContext, next: () => Promise<any>): Promise<any> {
|
|
48
|
+
// 类似 koa 中间件,可以包裹方法执行
|
|
49
|
+
return await next();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**执行顺序:**
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
await beforeCall(ctx);
|
|
58
|
+
try {
|
|
59
|
+
const result = await around(ctx, next); // next 执行目标方法
|
|
60
|
+
await afterReturn(ctx, result);
|
|
61
|
+
return result;
|
|
62
|
+
} catch (e) {
|
|
63
|
+
await afterThrow(ctx, e);
|
|
64
|
+
throw e;
|
|
65
|
+
} finally {
|
|
66
|
+
await afterFinally(ctx);
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**关键点:**
|
|
71
|
+
|
|
72
|
+
- 所有 hook 方法都是可选的,按需实现
|
|
73
|
+
- 只有 `around` 可以修改返回值
|
|
74
|
+
- `beforeCall` 中可以通过修改 `ctx.args` 改变方法入参
|
|
75
|
+
- 多个 Advice 之间通过 `ctx.set(key, value)` / `ctx.get(key)` 共享状态
|
|
76
|
+
|
|
77
|
+
### AdviceContext
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
interface AdviceContext<T = object, K = any> {
|
|
81
|
+
that: T; // 被切的对象实例
|
|
82
|
+
method: PropertyKey; // 被切的方法名
|
|
83
|
+
args: any[]; // 方法参数(可修改)
|
|
84
|
+
adviceParams?: K; // 装饰器透传的参数
|
|
85
|
+
get(key: PropertyKey): any; // 获取共享状态
|
|
86
|
+
set(key: PropertyKey, value: any): this; // 设置共享状态
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Pointcut vs Crosscut
|
|
93
|
+
|
|
94
|
+
### Pointcut — 精确切入
|
|
95
|
+
|
|
96
|
+
在特定类的特定方法上声明 Advice,适合精确控制:
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
import { SingletonProto } from 'egg';
|
|
100
|
+
import { Pointcut } from 'egg/aop';
|
|
101
|
+
|
|
102
|
+
@SingletonProto()
|
|
103
|
+
export class OrderService {
|
|
104
|
+
@Pointcut(LogAdvice)
|
|
105
|
+
async createOrder(data: any) {
|
|
106
|
+
// 业务逻辑
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 可以传参给 Advice
|
|
110
|
+
@Pointcut(TransactionAdvice, { adviceParams: { propagation: 'REQUIRED' } })
|
|
111
|
+
async updateOrder(id: string, data: any) {
|
|
112
|
+
// 业务逻辑
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Crosscut — 批量切入
|
|
118
|
+
|
|
119
|
+
在 Advice 类上声明切入规则,适合横切多个类/方法:
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
import { Crosscut, Advice, IAdvice, PointcutType } from 'egg/aop';
|
|
123
|
+
|
|
124
|
+
// 模式 1:指定类和方法
|
|
125
|
+
@Crosscut({
|
|
126
|
+
type: PointcutType.CLASS,
|
|
127
|
+
clazz: OrderService,
|
|
128
|
+
methodName: 'createOrder',
|
|
129
|
+
})
|
|
130
|
+
@Advice()
|
|
131
|
+
export class AuditAdvice implements IAdvice {
|
|
132
|
+
async afterReturn(ctx: AdviceContext): Promise<void> {
|
|
133
|
+
// 审计日志
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 模式 2:正则匹配
|
|
138
|
+
@Crosscut({
|
|
139
|
+
type: PointcutType.NAME,
|
|
140
|
+
className: /.*Service$/i,
|
|
141
|
+
methodName: /^(create|update|delete)/,
|
|
142
|
+
})
|
|
143
|
+
@Advice()
|
|
144
|
+
export class OperationLogAdvice implements IAdvice {
|
|
145
|
+
async around(ctx: AdviceContext, next: () => Promise<any>): Promise<any> {
|
|
146
|
+
// 记录所有 Service 的写操作
|
|
147
|
+
return await next();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 模式 3:自定义回调
|
|
152
|
+
@Crosscut({
|
|
153
|
+
type: PointcutType.CUSTOM,
|
|
154
|
+
callback: (clazz, method) => {
|
|
155
|
+
return clazz.name.endsWith('Repository') && method !== 'constructor';
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
@Advice()
|
|
159
|
+
export class DbMetricsAdvice implements IAdvice {}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### 执行顺序
|
|
163
|
+
|
|
164
|
+
- `@Crosscut` 默认 order: `100`
|
|
165
|
+
- `@Pointcut` 默认 order: `1000`
|
|
166
|
+
- order 越小越先执行
|
|
167
|
+
- 同一方法上多个 Advice 按 order 升序排列
|
|
168
|
+
|
|
169
|
+
通过 `order` 参数自定义顺序:
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
// Pointcut:第二个参数中设置 order
|
|
173
|
+
@Pointcut(LogAdvice, { order: 50 })
|
|
174
|
+
async createOrder(data: any) {}
|
|
175
|
+
|
|
176
|
+
// Crosscut:第二个参数中设置 order
|
|
177
|
+
@Crosscut(
|
|
178
|
+
{ type: PointcutType.NAME, className: /.*Service$/i, methodName: /.+/ },
|
|
179
|
+
{ order: 200 },
|
|
180
|
+
)
|
|
181
|
+
@Advice()
|
|
182
|
+
export class MetricsAdvice implements IAdvice {}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## 参数透传
|
|
188
|
+
|
|
189
|
+
同一个 Advice 在不同方法上可能需要不同的行为,通过 `adviceParams` 传参:
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
@Advice()
|
|
193
|
+
export class CacheAdvice implements IAdvice {
|
|
194
|
+
async around(ctx: AdviceContext<any, { ttl: number }>, next: () => Promise<any>): Promise<any> {
|
|
195
|
+
const ttl = ctx.adviceParams?.ttl ?? 60;
|
|
196
|
+
// 根据 ttl 实现缓存逻辑
|
|
197
|
+
return await next();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@SingletonProto()
|
|
202
|
+
export class UserService {
|
|
203
|
+
@Pointcut(CacheAdvice, { adviceParams: { ttl: 3600 } })
|
|
204
|
+
async getUser(id: string) { /* ... */ }
|
|
205
|
+
|
|
206
|
+
@Pointcut(CacheAdvice, { adviceParams: { ttl: 60 } })
|
|
207
|
+
async getUserList() { /* ... */ }
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## 典型场景
|
|
214
|
+
|
|
215
|
+
| 场景 | 推荐方式 | 说明 |
|
|
216
|
+
| --------------------------- | ------------------------ | ----------------- |
|
|
217
|
+
| 特定方法加日志/缓存 | `@Pointcut(Advice)` | 精确控制 |
|
|
218
|
+
| 所有 Service 的写操作加审计 | `@Crosscut(NAME)` + 正则 | 批量匹配 |
|
|
219
|
+
| 事务包裹 | `@Pointcut(TxAdvice)` | around 中管理事务 |
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# BackgroundTask 异步任务指南
|
|
2
|
+
|
|
3
|
+
## 常见错误
|
|
4
|
+
|
|
5
|
+
| 错误写法 | 正确写法 | 说明 |
|
|
6
|
+
| ----------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------- |
|
|
7
|
+
| `setTimeout(() => { ... }, 0)` | `backgroundTaskHelper.run(async () => { ... })` | setTimeout 执行时上下文已释放,会导致访问已销毁对象的错误 |
|
|
8
|
+
| `setImmediate(() => { ... })` | `backgroundTaskHelper.run(async () => { ... })` | 同上,框架不会等待 setImmediate 的回调 |
|
|
9
|
+
| `process.nextTick(() => { ... })` | `backgroundTaskHelper.run(async () => { ... })` | 同上 |
|
|
10
|
+
| 在 Service 中 `await` 后台任务 | 直接调用 `run()` 不 await | `run()` 是"发射后不管",不返回 Promise |
|
|
11
|
+
| `import { BackgroundTaskHelper } from '@eggjs/tegg/helper'` | `import { BackgroundTaskHelper } from 'egg'` | 统一从 `egg` 导入,不要从 `@eggjs/tegg` 子路径导入 |
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 使用方式
|
|
16
|
+
|
|
17
|
+
### 基本用法
|
|
18
|
+
|
|
19
|
+
通过 `@Inject()` 注入 `BackgroundTaskHelper`,调用 `run()` 方法。`run()` 接受一个异步函数,任务会立即开始执行但不阻塞当前流程。框架在请求结束(Context preDestroy)时会等待所有后台任务完成(最多等待 timeout),然后再释放上下文。
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { BackgroundTaskHelper, Inject, SingletonProto, AccessLevel } from 'egg';
|
|
23
|
+
|
|
24
|
+
@SingletonProto({ accessLevel: AccessLevel.PUBLIC })
|
|
25
|
+
export class MetricsService {
|
|
26
|
+
@Inject()
|
|
27
|
+
private backgroundTaskHelper: BackgroundTaskHelper;
|
|
28
|
+
|
|
29
|
+
async reportAfterResponse(data: Record<string, unknown>) {
|
|
30
|
+
// run() 是非阻塞的,任务立即开始执行但不会被 await
|
|
31
|
+
this.backgroundTaskHelper.run(async () => {
|
|
32
|
+
// 框架会保持上下文存活直到任务完成或超时
|
|
33
|
+
await this.sendMetrics(data);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private async sendMetrics(data: Record<string, unknown>) {
|
|
38
|
+
// 实际上报逻辑
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 超时配置
|
|
46
|
+
|
|
47
|
+
框架默认等待 5 秒,超时后放弃等待并释放上下文。
|
|
48
|
+
|
|
49
|
+
### 按请求调整超时
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
this.backgroundTaskHelper.timeout = 10000; // 10 秒
|
|
53
|
+
this.backgroundTaskHelper.run(async () => {
|
|
54
|
+
await heavyWork();
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
注意:timeout 是 context 级别的设置,同一请求中多次设置以最后一次为准。
|
|
59
|
+
|
|
60
|
+
### 全局配置
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
// config/config.default.ts
|
|
64
|
+
export default {
|
|
65
|
+
backgroundTask: {
|
|
66
|
+
timeout: 10000, // 全局默认 10 秒
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 无限等待
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
// 不推荐在生产环境使用,除非确定任务一定会完成
|
|
75
|
+
this.backgroundTaskHelper.timeout = Infinity;
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 错误处理
|
|
81
|
+
|
|
82
|
+
BackgroundTaskHelper 内部会捕获所有错误并记录日志,**不会**抛出异常或影响其他后台任务。
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
this.backgroundTaskHelper.run(async () => {
|
|
86
|
+
// 即使这里抛出异常,也不会影响其他后台任务或框架
|
|
87
|
+
throw new Error('something went wrong');
|
|
88
|
+
// 错误会被记录为: [BackgroundTaskHelper] background throw error:something went wrong
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// 如果需要自定义错误处理
|
|
92
|
+
this.backgroundTaskHelper.run(async () => {
|
|
93
|
+
try {
|
|
94
|
+
await unreliableOperation();
|
|
95
|
+
} catch (e) {
|
|
96
|
+
// 自定义错误处理:重试、告警等
|
|
97
|
+
await this.alertService.notify(e);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 原理
|
|
105
|
+
|
|
106
|
+
请求处理完成后,框架会立即释放上下文(防止内存泄漏)。如果用 `setTimeout` 等方式在上下文释放后访问注入的服务,会因为上下文已销毁而出错。
|
|
107
|
+
|
|
108
|
+
BackgroundTaskHelper 的作用是:
|
|
109
|
+
|
|
110
|
+
1. `run()` 被调用时,任务立即开始执行(不是延后到响应发送后)
|
|
111
|
+
2. 任务不会被 await,因此不阻塞当前请求的返回
|
|
112
|
+
3. 请求结束时(Context preDestroy),框架等待所有后台任务完成(最多等待 timeout)
|
|
113
|
+
4. 等待结束后才释放上下文
|
|
114
|
+
|
|
115
|
+
这就是为什么必须用 `backgroundTaskHelper.run()` 而不是 `setTimeout` — 后者绕过了框架的上下文生命周期管理,执行时上下文可能已被释放。
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 单元测试
|
|
120
|
+
|
|
121
|
+
BackgroundTaskHelper 的测试方法参考 `egg-unittest` skill 的 `references/background-task-test.md`。
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# EventBus 事件总线指南
|
|
2
|
+
|
|
3
|
+
## 常见错误
|
|
4
|
+
|
|
5
|
+
| 错误写法 | 正确写法 | 说明 |
|
|
6
|
+
| ------------------------------------------------ | ------------------------------------------- | ------------------------------------------------ |
|
|
7
|
+
| `import { EventBus } from '@eggjs/tegg'` | `import { EventBus } from 'egg'` | 统一从 `egg` 导入 |
|
|
8
|
+
| `import { Event } from '@eggjs/tegg'` | `import { Event } from 'egg'` | 装饰器也从 `egg` 导入 |
|
|
9
|
+
| 在 handler 的 `handle` 方法中直接访问请求上下文 | 使用 `@EventContext()` 注入 `IEventContext` | handler 运行在独立的上下文中,不是触发者的上下文 |
|
|
10
|
+
| `@Event('hello')` 但没有声明 Events 接口 | 先在 `declare module 'egg'` 中声明事件类型 | 不声明会导致类型检查报错 |
|
|
11
|
+
| handler 的 `handle` 方法签名与 Events 声明不一致 | 确保参数类型与 Events 中定义一致 | TypeScript 会检查签名是否匹配 |
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 使用步骤
|
|
16
|
+
|
|
17
|
+
### 1. 声明事件类型
|
|
18
|
+
|
|
19
|
+
在模块中创建类型声明文件,定义事件名称和参数签名:
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
// app/myModule/event.ts
|
|
23
|
+
import 'egg';
|
|
24
|
+
|
|
25
|
+
declare module 'egg' {
|
|
26
|
+
interface Events {
|
|
27
|
+
orderCreated: (orderId: string, userId: string) => void;
|
|
28
|
+
paymentCompleted: (orderId: string, amount: number) => void;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**注意:** 文件开头必须有 `import 'egg'`,否则 `declare module` 会覆盖而非合并模块声明。
|
|
34
|
+
|
|
35
|
+
### 2. 触发事件
|
|
36
|
+
|
|
37
|
+
通过 `@Inject()` 注入 `EventBus` 或 `ContextEventBus`,调用 `emit()` 触发事件:
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import { SingletonProto, Inject, EventBus, AccessLevel } from 'egg';
|
|
41
|
+
|
|
42
|
+
@SingletonProto({ accessLevel: AccessLevel.PUBLIC })
|
|
43
|
+
export class OrderService {
|
|
44
|
+
@Inject()
|
|
45
|
+
private eventBus: EventBus;
|
|
46
|
+
|
|
47
|
+
async createOrder(userId: string, items: Item[]): Promise<string> {
|
|
48
|
+
const orderId = await this.saveOrder(userId, items);
|
|
49
|
+
// 触发事件,不阻塞当前流程
|
|
50
|
+
this.eventBus.emit('orderCreated', orderId, userId);
|
|
51
|
+
return orderId;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`emit()` 的参数类型由 Events 接口约束,TypeScript 会自动提示和校验。
|
|
57
|
+
|
|
58
|
+
### 3. 消费事件
|
|
59
|
+
|
|
60
|
+
用 `@Event()` 装饰器标记 handler 类。handler 必须实现 `handle` 方法,参数签名与 Events 声明一致:
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
import { Event, Inject } from 'egg';
|
|
64
|
+
import type { EggLogger } from 'egg';
|
|
65
|
+
|
|
66
|
+
@Event('orderCreated')
|
|
67
|
+
export class OrderNotificationHandler {
|
|
68
|
+
@Inject()
|
|
69
|
+
private logger: EggLogger;
|
|
70
|
+
|
|
71
|
+
async handle(orderId: string, userId: string): Promise<void> {
|
|
72
|
+
this.logger.info('[OrderNotification] order %s created by user %s', orderId, userId);
|
|
73
|
+
// 发送通知等业务逻辑
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**handler 的关键特性:**
|
|
79
|
+
|
|
80
|
+
- handler 运行在独立的上下文中,不是触发者的上下文
|
|
81
|
+
- 同一事件可以有多个 handler,它们并行执行
|
|
82
|
+
- handler 文件放在模块目录中,框架自动扫描和注册
|
|
83
|
+
|
|
84
|
+
### 4. 消费多个事件
|
|
85
|
+
|
|
86
|
+
一个 handler 可以处理多个事件,通过 `@EventContext()` 获取事件上下文来区分:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
import { Event, EventContext, type IEventContext } from 'egg';
|
|
90
|
+
|
|
91
|
+
@Event('orderCreated')
|
|
92
|
+
@Event('paymentCompleted')
|
|
93
|
+
export class AuditLogHandler {
|
|
94
|
+
async handle(@EventContext() ctx: IEventContext, ...args: unknown[]): Promise<void> {
|
|
95
|
+
console.log('event:', ctx.eventName, 'args:', args);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`@EventContext()` 只能修饰 `handle` 方法的第一个参数。不需要区分事件时可以省略。
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Cork/Uncork 事件缓冲
|
|
105
|
+
|
|
106
|
+
当需要在一个操作中触发多个事件,但希望它们在操作全部完成后才被处理时,使用 cork/uncork:
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import { ContextProto, Inject, type ContextEventBus } from 'egg';
|
|
110
|
+
|
|
111
|
+
@ContextProto()
|
|
112
|
+
export class BatchService {
|
|
113
|
+
@Inject()
|
|
114
|
+
private eventBus: ContextEventBus; // 注意:cork/uncork 需要 ContextEventBus
|
|
115
|
+
|
|
116
|
+
async processBatch(items: string[]): Promise<void> {
|
|
117
|
+
this.eventBus.cork(); // 开始缓冲,事件不会立即派发
|
|
118
|
+
|
|
119
|
+
for (const item of items) {
|
|
120
|
+
this.eventBus.emit('orderCreated', item, 'batch-user');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.eventBus.uncork(); // 释放缓冲,所有事件一次性派发
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Cork/Uncork 行为:**
|
|
129
|
+
|
|
130
|
+
- 支持嵌套调用 — 内层 uncork 不会释放事件,只有最外层 uncork 才会
|
|
131
|
+
- `ContextEventBus` 的 cork/uncork 自动管理 corkId,无需手动指定
|
|
132
|
+
- cork 期间 emit 的事件会被暂存,uncork 后按顺序派发
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 单元测试
|
|
137
|
+
|
|
138
|
+
EventBus 的测试方法参考 `egg-unittest` skill 的 `references/eventbus-test.md`。
|