diff --git a/packages/skills/CLAUDE.md b/packages/skills/CLAUDE.md index 1394ad5c82..5cb31fd373 100644 --- a/packages/skills/CLAUDE.md +++ b/packages/skills/CLAUDE.md @@ -2,7 +2,7 @@ `packages/skills/` 目录包含 AI agent skills — 纯 markdown 文档,指导 AI 助手使用 Egg 框架。以 `@eggjs/skills` npm 包发布,仅含 `.md` 文件。 -> **Skill 编写基础知识**:SKILL.md 格式、frontmatter 规范、目录结构、progressive disclosure、写作风格等通用知识请使用 `/ant-skill-creator` skill 获取指导。以下仅记录 Egg 项目特有的约定。 +> **Skill 编写基础知识**:SKILL.md 格式、frontmatter 规范、目录结构、progressive disclosure、写作风格等通用知识请使用 `/skill-creator` skill 获取指导。以下仅记录 Egg 项目特有的约定。 ## Egg Skills 架构 @@ -10,8 +10,8 @@ Skills 采用分层路由模式: - **入口 skill** (`egg/`) — 分析用户意图,通过关键词匹配和决策逻辑路由到专业 skill - **专业 skills** — 提供特定领域的深度指导: - - `egg-core/` — 核心概念:模块、依赖注入、生命周期、AccessLevel - - `controller/` — 实现指导:HTTPController、MCPController、Schedule + - `egg-core/` — 核心概念:模块、依赖注入、生命周期、AccessLevel、后台任务 + - `egg-controller/` — 实现指导:HTTPController、MCPController、Schedule、Ajv 校验 ## Egg Skill Frontmatter 约定 @@ -25,7 +25,7 @@ Skills 采用分层路由模式: **入口 Skill(如 `egg/`)编写要点:** -对应 ant-skill-creator 的 **Workflow-Based** 模式。 +对应 /skill-creator 的 **Workflow-Based** 模式。 1. 决策框架包含明确步骤:识别意图 → 检查模糊意图 → 协议/用例特定指示 2. 为每个专业 skill 列出中英文触发关键词 @@ -36,10 +36,10 @@ Skills 采用分层路由模式: **专业 Skill 两种组织模式:** -| 模式 | ant-skill-creator 对应 | 适用场景 | SKILL.md 内容 | references/ 用途 | -| ------------------------------ | ---------------------- | ------------------ | ----------------------- | ------------------ | -| **概念型**(如 `egg-core/`) | Reference-Based | 概念解释、架构理解 | 自包含的深度内容 | 更深入的专题文档 | -| **索引型**(如 `controller/`) | Workflow-Based | 多种实现方式的选择 | 精简的决策树 + 快速参考 | 每种实现的详细指南 | +| 模式 | /skill-creator 对应 | 适用场景 | SKILL.md 内容 | references/ 用途 | +| ---------------------------------- | ------------------- | ------------------ | ----------------------- | ------------------ | +| **概念型**(如 `egg-core/`) | Reference-Based | 概念解释、架构理解 | 自包含的深度内容 | 更深入的专题文档 | +| **索引型**(如 `egg-controller/`) | Workflow-Based | 多种实现方式的选择 | 精简的决策树 + 快速参考 | 每种实现的详细指南 | **概念型 Skill 内容结构:** @@ -92,11 +92,11 @@ Skill 的价值 = 文档 + 实践经验 - 重复内容。如果内容和 `site/d ## 添加新 Skill 1. 在 `packages/skills/` 下创建目录:`packages/skills//` -2. 创建 `SKILL.md`(格式规范参考 `/ant-skill-creator`,frontmatter 遵循上述 Egg 约定) +2. 创建 `SKILL.md`(格式规范参考 `/skill-creator`,frontmatter 遵循上述 Egg 约定) 3. 创建 `references/` 目录(初始为空时放置 `.gitkeep`) 4. 按需在 `references/*.md` 中添加详细参考文档 5. 更新入口 skill(`egg/SKILL.md`)的路由逻辑以包含新 skill -6. 如果 skill 涉及 controller 类型,同时更新 `controller/SKILL.md` 决策树 +6. 如果 skill 涉及 controller 类型,同时更新 `egg-controller/SKILL.md` 决策树 ## 添加新 Reference 文档 @@ -112,7 +112,7 @@ Skill 的价值 = 文档 + 实践经验 - 重复内容。如果内容和 `site/d **评测文件结构:** -``` +```text packages/skills/eval/ ├── evals-egg-core.json # egg-core skill 评测用例 ├── evals-egg-controller.json # egg-controller skill 评测用例 @@ -169,7 +169,7 @@ packages/skills/eval/ with-skill 环境: -``` +```text 你是 EGG 框架开发专家。你只能通过 Read 工具读取 packages/skills/ 目录下的文件,不能访问 site/docs/ 或项目源码。 {egg/SKILL.md 的完整内容} @@ -180,7 +180,7 @@ with-skill 环境: site-docs 环境: -``` +```text 你是 EGG 框架开发专家。你只能通过 Read 工具读取 site/docs/ 目录下的文件,不能访问 packages/skills/ 或项目源码。项目文档目录如下: {完整的 site/docs/ 文件列表,通过 find site/docs -name '*.md' | sort 生成} diff --git a/packages/skills/egg-controller/SKILL.md b/packages/skills/egg-controller/SKILL.md index dcd31f248c..c936b74605 100644 --- a/packages/skills/egg-controller/SKILL.md +++ b/packages/skills/egg-controller/SKILL.md @@ -1,6 +1,6 @@ --- name: egg-controller -description: Use when creating API endpoints, implementing protocol handlers, or exposing interfaces for specific clients. Covers HTTP, MCP and Schedule controllers for EGG framework applications. +description: Use when creating API endpoints, implementing protocol handlers, exposing interfaces for specific clients, or adding parameter validation. Covers HTTP, MCP and Schedule controllers, and Ajv/TypeBox parameter validation for EGG framework applications. allowed-tools: Read --- diff --git a/packages/skills/egg-controller/references/ajv-validate.md b/packages/skills/egg-controller/references/ajv-validate.md index 638042c0b4..a9d0c8a108 100644 --- a/packages/skills/egg-controller/references/ajv-validate.md +++ b/packages/skills/egg-controller/references/ajv-validate.md @@ -31,7 +31,7 @@ import { Ajv, Type, Static, TransformEnum } from 'egg/ajv'; ### 完整示例 ```typescript -// app/modules/demo/FooController.ts +// app/userModule/UserController.ts import { HTTPController, HTTPMethod, @@ -66,7 +66,7 @@ export class UserController { path: '/api/users', }) async create(@HTTPBody() body: CreateUserParams) { - // 校验失败自动抛出 AjvInvalidParamError(422) + // 校验失败自动抛出 AjvInvalidParamError this.ajv.validate(CreateUserSchema, body); // 校验通过,body 已经有完整类型提示 @@ -142,5 +142,5 @@ Type.String({ - **Schema 与 Controller 同文件** — Schema 定义放在使用它的 Controller 文件中,保持就近原则 - **校验在 Controller 层** — 不要在 Service 层做入参校验,Service 信任上层传入的数据 -- **善用 Optional** — 非必填字段用 `Type.Optional()` 包装,避免前端遗漏字段导致 422 +- **善用 Optional** — 非必填字段用 `Type.Optional()` 包装,避免前端遗漏字段导致校验失败 - **善用 transform** — 对用户输入做 trim 预处理,减少脏数据 diff --git a/packages/skills/egg-core/SKILL.md b/packages/skills/egg-core/SKILL.md index 50ee249ad1..a10f891450 100644 --- a/packages/skills/egg-core/SKILL.md +++ b/packages/skills/egg-core/SKILL.md @@ -1,18 +1,20 @@ --- name: egg-core -description: 本技能用于处理 EGG 基础核心概念,包括模块架构、@SingletonProto、@ContextProto、@Inject 装饰器和动态注入。用于理解 EGG 的基础构建块、依赖注入、对象生命周期管理和运行时多实现动态选择。 +description: 本技能用于处理 EGG 基础核心概念,包括模块架构、@SingletonProto、@ContextProto、@Inject 装饰器、动态注入、BackgroundTaskHelper 后台任务、EventBus 事件总线和 AOP 切面编程。用于理解 EGG 的基础构建块、依赖注入、对象生命周期管理、运行时多实现动态选择、请求返回后的异步任务处理、事件驱动架构和横切关注点。 allowed-tools: Read --- # egg 核心概念 -## Step 1: 代码写在 module 中 +## 代码组织与依赖注入 -### 什么是模块? +### Step 1: 代码写在 module 中 + +#### 什么是模块? 模块是 EGG 中基础的代码组织单元。只有模块内的代码会被框架扫描和加载。模块之间相互独立,但可以通过 `@Inject` 装饰器访问其他模块的对象。 -### 定义 module +#### 定义 module 在目录中添加包含 `eggModule.name` 字段的 `package.json` 文件来声明该目录为模块: @@ -97,9 +99,9 @@ import { SingletonProto } from '@eggjs/tegg'; --- -## Step 2: 用 Proto 实现 Service +### Step 2: 用 Proto 实现 Service -### SingletonProto +#### SingletonProto 应用启动时立即创建,整个应用生命周期内只有一个实例,性能更好,应该作为默认选择。 @@ -114,7 +116,7 @@ export class HelloService { } ``` -### ContextProto +#### ContextProto 请求到达时按需创建,每个请求一个实例,请求结束自动销毁。仅在需要隔离不同请求的上下文信息时使用。 @@ -129,7 +131,7 @@ export class RequestContext { **重要提示**:大多数服务应该使用 `SingletonProto` 以获得更好的性能。只有当请求上下文必须在服务之间共享以确保请求之间隔离时,才使用 `ContextProto`。 -### AccessLevel +#### AccessLevel proto 对象默认 accessLevel 为 `AccessLevel.PRIVATE`,仅在当前 module 内使用。可以设置为 `AccessLevel.PUBLIC`,进行跨模块访问。 @@ -143,9 +145,9 @@ export class SharedService {} export class SharedContextService {} ``` -## Step 3: 通过 Inject 使用 Service +### Step 3: 通过 Inject 使用 Service -### 基本用法 +#### 基本用法 使用 `@Inject()` 注入其他 Proto 或 Egg 对象: @@ -167,29 +169,34 @@ export class HelloService { } ``` -### 动态注入 +#### 动态注入 当同一个抽象有多种实现,需要在运行时动态选择时,通过 `EggObjectFactory` 按类型获取实现,无需 if/else。详见 `references/dynamic-inject.md`。 -### 重要约束 +#### 重要约束 - **不能有循环依赖**:Proto 或模块之间都不能有循环依赖 - **不能有同名对象**:一个模块不能有相同名称和初始化类型的 Proto - **按需注入**:不要直接注入 `app` 或 `ctx`,按需注入特定对象 -## 快速决策指南 +### 快速决策指南 + +| 场景 | 使用方式 | +| -------------------------------- | ------------------------------------------------------ | +| 无状态服务 | `@SingletonProto()` | +| 跨服务共享的请求级状态 | `@ContextProto()` | +| 需要跨模块访问 | `@SingletonProto({ accessLevel: AccessLevel.PUBLIC })` | +| 注入依赖 | `@Inject()` | +| 使用自定义名称注入 | `@Inject({ name: 'customName' })` | +| 同一抽象多种实现,运行时动态选择 | `QualifierImplDecoratorUtil` + `EggObjectFactory` | -| 场景 | 使用装饰器 | -| -------------------------------- | ------------------------------------------------------------------ | -| 无状态服务 | `@SingletonProto()` | -| 跨服务共享的请求级状态 | `@ContextProto()` | -| 需要跨模块访问 | `@SingletonProto({ accessLevel: AccessLevel.PUBLIC })` | -| 注入依赖 | `@Inject()` | -| 使用自定义名称注入 | `@Inject({ name: 'customName' })` | -| 同一抽象多种实现,运行时动态选择 | `QualifierImplDecoratorUtil` + `EggObjectFactory` | -| 请求返回后执行异步任务 | `BackgroundTaskHelper.run()`,详见 `references/background-task.md` | +## 异步任务 -### 异步任务选型 +| 特性 | BackgroundTaskHelper | EventBus | +| ---------- | ----------------------------- | ----------------------------------------- | +| **耦合** | 高 — 异步逻辑写在触发者代码中 | 低 — handler 独立,新增处理者不修改触发者 | +| **上下文** | 共享触发者的请求上下文 | handler 运行在独立的新上下文中 | +| **扩展** | 需要修改触发者代码 | 新增 `@Event` handler 类即可 | ``` 需要在请求之外执行任务? @@ -197,13 +204,27 @@ export class HelloService { ├─ 请求返回后执行,依赖当前请求上下文 │ └─ → BackgroundTaskHelper(references/background-task.md) │ -├─ 请求返回后执行,不依赖当前请求上下文 -│ └─ → EventBus +├─ 请求返回后执行,不依赖当前请求上下文,需要解耦 +│ └─ → EventBus(references/eventbus.md) │ └─ 定时或周期执行 └─ → Schedule(参考 egg-controller skill) ``` +## AOP 切面编程 + +AOP 用于将日志、鉴权、缓存、事务等横切关注点从业务代码中分离。AOP 装饰器从 `egg/aop` 导入(不是 `egg`)。 + +``` +需要在方法执行前后添加通用逻辑? +│ +├─ 针对特定方法 → @Pointcut(在目标方法上声明) +│ +└─ 批量切入多个类/方法 → @Crosscut(在 Advice 类上声明匹配规则) +``` + +详细用法(Advice 生命周期、AdviceContext、Pointcut/Crosscut 选型、参数透传、执行顺序)请参阅 `references/aop.md`。 + ## 常见问题排查 | 现象 | 原因 | 解决方案 | @@ -221,3 +242,5 @@ export class HelloService { - SingletonProto 和 ContextProto 详情,请参阅:`references/proto.md` - 动态注入(Qualifier 动态注入),请参阅:`references/dynamic-inject.md` - 请求后异步任务(BackgroundTaskHelper),请参阅:`references/background-task.md` +- 事件总线(EventBus),请参阅:`references/eventbus.md` +- AOP 切面编程(Advice、Pointcut、Crosscut),请参阅:`references/aop.md` diff --git a/packages/skills/egg-core/references/aop.md b/packages/skills/egg-core/references/aop.md new file mode 100644 index 0000000000..55a1a32cc0 --- /dev/null +++ b/packages/skills/egg-core/references/aop.md @@ -0,0 +1,219 @@ +# AOP 切面编程指南 + +## 常见错误 + +| 错误写法 | 正确写法 | 说明 | +| ------------------------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------- | +| `import { Advice } from 'egg'` | `import { Advice } from 'egg/aop'` | AOP 装饰器从 `egg/aop` 导入,不是 `egg` | +| `import { Advice } from '@eggjs/tegg/aop'` | `import { Advice } from 'egg/aop'` | 统一从 `egg/aop` 导入 | +| Advice 类没有加 `@Advice()` 装饰器 | 必须同时有 `@Advice()` | `@Pointcut` 和 `@Crosscut` 都要求目标是 Advice 类 | +| `@Crosscut` 直接切 Egg 内置对象 | 只能切 tegg Proto 对象 | Egg 中的对象(如 app、ctx)无法被 Crosscut | +| Advice 中用实例属性存状态 | 使用 `ctx.set()`/`ctx.get()` 共享状态 | Advice 默认是 Singleton,实例属性会被并发请求共享,必须用 AdviceContext 传递调用级状态 | + +--- + +## 核心概念 + +### Advice(切面逻辑) + +Advice 是 AOP 的核心,定义了在目标方法执行前后要做什么。Advice 本身也是一种 Proto,默认 initType 为 Singleton(全局单例),可以使用 `@Inject` 注入依赖。如需每请求一个实例,显式指定 `@Advice({ initType: ObjectInitType.CONTEXT })`。 + +```typescript +import { Advice, IAdvice, AdviceContext } from 'egg/aop'; +import { Inject, Logger } from 'egg'; + +@Advice() +export class LogAdvice implements IAdvice { + @Inject() + private logger: Logger; + + async beforeCall(ctx: AdviceContext): Promise { + ctx.set('startTime', Date.now()); + } + + async afterReturn(ctx: AdviceContext, result: any): Promise { + // 方法成功返回后执行 + } + + async afterThrow(ctx: AdviceContext, error: Error): Promise { + // 方法抛出异常后执行 + } + + async afterFinally(ctx: AdviceContext): Promise { + const duration = Date.now() - ctx.get('startTime'); + this.logger.info('%s cost %dms', String(ctx.method), duration); + } + + async around(ctx: AdviceContext, next: () => Promise): Promise { + // 类似 koa 中间件,可以包裹方法执行 + return await next(); + } +} +``` + +**执行顺序:** + +```typescript +await beforeCall(ctx); +try { + const result = await around(ctx, next); // next 执行目标方法 + await afterReturn(ctx, result); + return result; +} catch (e) { + await afterThrow(ctx, e); + throw e; +} finally { + await afterFinally(ctx); +} +``` + +**关键点:** + +- 所有 hook 方法都是可选的,按需实现 +- 只有 `around` 可以修改返回值 +- `beforeCall` 中可以通过修改 `ctx.args` 改变方法入参 +- 多个 Advice 之间通过 `ctx.set(key, value)` / `ctx.get(key)` 共享状态 + +### AdviceContext + +```typescript +interface AdviceContext { + that: T; // 被切的对象实例 + method: PropertyKey; // 被切的方法名 + args: any[]; // 方法参数(可修改) + adviceParams?: K; // 装饰器透传的参数 + get(key: PropertyKey): any; // 获取共享状态 + set(key: PropertyKey, value: any): this; // 设置共享状态 +} +``` + +--- + +## Pointcut vs Crosscut + +### Pointcut — 精确切入 + +在特定类的特定方法上声明 Advice,适合精确控制: + +```typescript +import { SingletonProto } from 'egg'; +import { Pointcut } from 'egg/aop'; + +@SingletonProto() +export class OrderService { + @Pointcut(LogAdvice) + async createOrder(data: any) { + // 业务逻辑 + } + + // 可以传参给 Advice + @Pointcut(TransactionAdvice, { adviceParams: { propagation: 'REQUIRED' } }) + async updateOrder(id: string, data: any) { + // 业务逻辑 + } +} +``` + +### Crosscut — 批量切入 + +在 Advice 类上声明切入规则,适合横切多个类/方法: + +```typescript +import { Crosscut, Advice, IAdvice, PointcutType } from 'egg/aop'; + +// 模式 1:指定类和方法 +@Crosscut({ + type: PointcutType.CLASS, + clazz: OrderService, + methodName: 'createOrder', +}) +@Advice() +export class AuditAdvice implements IAdvice { + async afterReturn(ctx: AdviceContext): Promise { + // 审计日志 + } +} + +// 模式 2:正则匹配 +@Crosscut({ + type: PointcutType.NAME, + className: /.*Service$/i, + methodName: /^(create|update|delete)/, +}) +@Advice() +export class OperationLogAdvice implements IAdvice { + async around(ctx: AdviceContext, next: () => Promise): Promise { + // 记录所有 Service 的写操作 + return await next(); + } +} + +// 模式 3:自定义回调 +@Crosscut({ + type: PointcutType.CUSTOM, + callback: (clazz, method) => { + return clazz.name.endsWith('Repository') && method !== 'constructor'; + }, +}) +@Advice() +export class DbMetricsAdvice implements IAdvice {} +``` + +### 执行顺序 + +- `@Crosscut` 默认 order: `100` +- `@Pointcut` 默认 order: `1000` +- order 越小越先执行 +- 同一方法上多个 Advice 按 order 升序排列 + +通过 `order` 参数自定义顺序: + +```typescript +// Pointcut:第二个参数中设置 order +@Pointcut(LogAdvice, { order: 50 }) +async createOrder(data: any) {} + +// Crosscut:第二个参数中设置 order +@Crosscut( + { type: PointcutType.NAME, className: /.*Service$/i, methodName: /.+/ }, + { order: 200 }, +) +@Advice() +export class MetricsAdvice implements IAdvice {} +``` + +--- + +## 参数透传 + +同一个 Advice 在不同方法上可能需要不同的行为,通过 `adviceParams` 传参: + +```typescript +@Advice() +export class CacheAdvice implements IAdvice { + async around(ctx: AdviceContext, next: () => Promise): Promise { + const ttl = ctx.adviceParams?.ttl ?? 60; + // 根据 ttl 实现缓存逻辑 + return await next(); + } +} + +@SingletonProto() +export class UserService { + @Pointcut(CacheAdvice, { adviceParams: { ttl: 3600 } }) + async getUser(id: string) { /* ... */ } + + @Pointcut(CacheAdvice, { adviceParams: { ttl: 60 } }) + async getUserList() { /* ... */ } +} +``` + +--- + +## 典型场景 + +| 场景 | 推荐方式 | 说明 | +| --------------------------- | ------------------------ | ----------------- | +| 特定方法加日志/缓存 | `@Pointcut(Advice)` | 精确控制 | +| 所有 Service 的写操作加审计 | `@Crosscut(NAME)` + 正则 | 批量匹配 | +| 事务包裹 | `@Pointcut(TxAdvice)` | around 中管理事务 | diff --git a/packages/skills/egg-core/references/background-task.md b/packages/skills/egg-core/references/background-task.md index bd9a9bbe7f..61936f6b80 100644 --- a/packages/skills/egg-core/references/background-task.md +++ b/packages/skills/egg-core/references/background-task.md @@ -16,7 +16,7 @@ ### 基本用法 -通过 `@Inject()` 注入 `BackgroundTaskHelper`,调用 `run()` 方法。`run()` 接受一个异步函数,在请求返回后执行。 +通过 `@Inject()` 注入 `BackgroundTaskHelper`,调用 `run()` 方法。`run()` 接受一个异步函数,任务会立即开始执行但不阻塞当前流程。框架在请求结束(Context preDestroy)时会等待所有后台任务完成(最多等待 timeout),然后再释放上下文。 ```typescript import { BackgroundTaskHelper, Inject, SingletonProto, AccessLevel } from 'egg'; @@ -27,10 +27,9 @@ export class MetricsService { private backgroundTaskHelper: BackgroundTaskHelper; async reportAfterResponse(data: Record) { - // run() 是非阻塞的,调用后立即返回 + // run() 是非阻塞的,任务立即开始执行但不会被 await this.backgroundTaskHelper.run(async () => { - // 这里的代码在 HTTP 响应发送后执行 - // 可以安全访问注入的服务和上下文 + // 框架会保持上下文存活直到任务完成或超时 await this.sendMetrics(data); }); } @@ -86,7 +85,7 @@ BackgroundTaskHelper 内部会捕获所有错误并记录日志,**不会**抛 this.backgroundTaskHelper.run(async () => { // 即使这里抛出异常,也不会影响其他后台任务或框架 throw new Error('something went wrong'); - // 错误会被记录为: [BackgroundTaskHelper] background throw error: something went wrong + // 错误会被记录为: [BackgroundTaskHelper] background throw error:something went wrong }); // 如果需要自定义错误处理 @@ -108,9 +107,9 @@ this.backgroundTaskHelper.run(async () => { BackgroundTaskHelper 的作用是: -1. 告诉框架"先不要释放上下文" -2. 排队执行异步任务 -3. 等待完成(或超时) -4. 然后再释放上下文 +1. `run()` 被调用时,任务立即开始执行(不是延后到响应发送后) +2. 任务不会被 await,因此不阻塞当前请求的返回 +3. 请求结束时(Context preDestroy),框架等待所有后台任务完成(最多等待 timeout) +4. 等待结束后才释放上下文 -这就是为什么必须用 `backgroundTaskHelper.run()` 而不是 `setTimeout` — 后者绕过了框架的上下文生命周期管理。 +这就是为什么必须用 `backgroundTaskHelper.run()` 而不是 `setTimeout` — 后者绕过了框架的上下文生命周期管理,执行时上下文可能已被释放。 diff --git a/packages/skills/egg-core/references/eventbus.md b/packages/skills/egg-core/references/eventbus.md new file mode 100644 index 0000000000..0a2235710a --- /dev/null +++ b/packages/skills/egg-core/references/eventbus.md @@ -0,0 +1,173 @@ +# EventBus 事件总线指南 + +## 常见错误 + +| 错误写法 | 正确写法 | 说明 | +| ------------------------------------------------ | ------------------------------------------- | ------------------------------------------------ | +| `import { EventBus } from '@eggjs/tegg'` | `import { EventBus } from 'egg'` | 统一从 `egg` 导入 | +| `import { Event } from '@eggjs/tegg'` | `import { Event } from 'egg'` | 装饰器也从 `egg` 导入 | +| 在 handler 的 `handle` 方法中直接访问请求上下文 | 使用 `@EventContext()` 注入 `IEventContext` | handler 运行在独立的上下文中,不是触发者的上下文 | +| `@Event('hello')` 但没有声明 Events 接口 | 先在 `declare module 'egg'` 中声明事件类型 | 不声明会导致类型检查报错 | +| handler 的 `handle` 方法签名与 Events 声明不一致 | 确保参数类型与 Events 中定义一致 | TypeScript 会检查签名是否匹配 | + +--- + +## 使用步骤 + +### 1. 声明事件类型 + +在模块中创建类型声明文件,定义事件名称和参数签名: + +```typescript +// app/myModule/event.ts +import 'egg'; + +declare module 'egg' { + interface Events { + orderCreated: (orderId: string, userId: string) => void; + paymentCompleted: (orderId: string, amount: number) => void; + } +} +``` + +**注意:** 文件开头必须有 `import 'egg'`,否则 `declare module` 会覆盖而非合并模块声明。 + +### 2. 触发事件 + +通过 `@Inject()` 注入 `EventBus` 或 `ContextEventBus`,调用 `emit()` 触发事件: + +```typescript +import { SingletonProto, Inject, EventBus, AccessLevel } from 'egg'; + +@SingletonProto({ accessLevel: AccessLevel.PUBLIC }) +export class OrderService { + @Inject() + private eventBus: EventBus; + + async createOrder(userId: string, items: Item[]): Promise { + const orderId = await this.saveOrder(userId, items); + // 触发事件,不阻塞当前流程 + this.eventBus.emit('orderCreated', orderId, userId); + return orderId; + } +} +``` + +`emit()` 的参数类型由 Events 接口约束,TypeScript 会自动提示和校验。 + +### 3. 消费事件 + +用 `@Event()` 装饰器标记 handler 类。handler 必须实现 `handle` 方法,参数签名与 Events 声明一致: + +```typescript +import { Event, Inject } from 'egg'; +import type { EggLogger } from 'egg'; + +@Event('orderCreated') +export class OrderNotificationHandler { + @Inject() + private logger: EggLogger; + + async handle(orderId: string, userId: string): Promise { + this.logger.info('[OrderNotification] order %s created by user %s', orderId, userId); + // 发送通知等业务逻辑 + } +} +``` + +**handler 的关键特性:** + +- handler 运行在独立的上下文中,不是触发者的上下文 +- 同一事件可以有多个 handler,它们并行执行 +- handler 文件放在模块目录中,框架自动扫描和注册 + +### 4. 消费多个事件 + +一个 handler 可以处理多个事件,通过 `@EventContext()` 获取事件上下文来区分: + +```typescript +import { Event, EventContext, type IEventContext } from 'egg'; + +@Event('orderCreated') +@Event('paymentCompleted') +export class AuditLogHandler { + async handle(@EventContext() ctx: IEventContext, ...args: unknown[]): Promise { + console.log('event:', ctx.eventName, 'args:', args); + } +} +``` + +`@EventContext()` 只能修饰 `handle` 方法的第一个参数。不需要区分事件时可以省略。 + +--- + +## Cork/Uncork 事件缓冲 + +当需要在一个操作中触发多个事件,但希望它们在操作全部完成后才被处理时,使用 cork/uncork: + +```typescript +import { ContextProto, Inject, type ContextEventBus } from 'egg'; + +@ContextProto() +export class BatchService { + @Inject() + private eventBus: ContextEventBus; // 注意:cork/uncork 需要 ContextEventBus + + async processBatch(items: string[]): Promise { + this.eventBus.cork(); // 开始缓冲,事件不会立即派发 + + for (const item of items) { + this.eventBus.emit('orderCreated', item, 'batch-user'); + } + + this.eventBus.uncork(); // 释放缓冲,所有事件一次性派发 + } +} +``` + +**Cork/Uncork 行为:** + +- 支持嵌套调用 — 内层 uncork 不会释放事件,只有最外层 uncork 才会 +- `ContextEventBus` 的 cork/uncork 自动管理 corkId,无需手动指定 +- cork 期间 emit 的事件会被暂存,uncork 后按顺序派发 + +--- + +## 单元测试 + +使用 `app.getEventWaiter()` 等待事件被处理完成,避免使用 `sleep`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { mm, type MockApplication } from '@eggjs/mock'; +import { OrderService } from './path/to/OrderService.ts'; + +describe('order events', () => { + let app: MockApplication; + + it('should emit orderCreated event', async () => { + await app.mockModuleContextScope(async (ctx) => { + const orderService = await ctx.getEggObject(OrderService); + const eventWaiter = await app.getEventWaiter(); + + // 准备等待事件 + const eventPromise = eventWaiter.await('orderCreated'); + + // 触发业务逻辑 + await orderService.createOrder('user1', []); + + // 等待事件处理完成 + await eventPromise; + + // 断言 handler 的执行结果 + }); + }); +}); +``` + +**EventWaiter API:** + +- `eventWaiter.await('eventName')` — 等待指定事件触发 +- `eventWaiter.awaitFirst('event1', 'event2')` — 等待多个事件中最先触发的一个 + +**注意:** `eventWaiter.await()` 必须在 `emit()` 之前调用(先注册等待,再触发事件),否则可能错过事件。 diff --git a/packages/skills/egg/SKILL.md b/packages/skills/egg/SKILL.md index 878cf0758e..5b0a99c383 100644 --- a/packages/skills/egg/SKILL.md +++ b/packages/skills/egg/SKILL.md @@ -1,6 +1,6 @@ --- name: egg -description: 本技能用于处理 EGG 框架。它提供基于用户意图在核心概念和控制器之间做选择的决策指导。作为所有 EGG 相关问题的入口点使用。 +description: 本技能用于处理 EGG 框架。它提供基于用户意图在核心概念和控制器之间做选择的决策指导。作为所有 EGG 相关问题的入口点使用。覆盖模块架构、依赖注入、后台任务、EventBus 事件总线、AOP 切面编程、HTTP/MCP/Schedule 控制器、Ajv 参数校验等。 allowed-tools: Read --- @@ -10,7 +10,7 @@ allowed-tools: Read 本技能帮助根据用户意图和任务类型确定使用哪个专用的 EGG 技能。EGG 文档组织为两个主要领域: -1. **核心概念**(`egg-core` skill):模块架构、依赖注入、对象生命周期 +1. **核心概念**(`egg-core` skill):模块架构、依赖注入、对象生命周期、EventBus 事件总线、AOP 切面编程 2. **控制器**(`egg-controller` skill):用于 API 端点的各种协议特定控制器 ## 技能选择逻辑 @@ -27,6 +27,8 @@ allowed-tools: Read - 模块配置(`module.yml`、`package.json`) - 使用限定符解决命名冲突 - 请求返回后执行异步任务(BackgroundTaskHelper) +- 事件驱动架构(EventBus、@Event) +- AOP 切面编程(@Advice、@Pointcut、@Crosscut) **触发关键词:** @@ -36,6 +38,8 @@ allowed-tools: Read - inject、injection、dependency injection、@Inject - prototype、lifecycle、实例化 - background task、异步任务、后台任务、BackgroundTaskHelper +- eventbus、event bus、事件总线、事件驱动、@Event、emit、发布订阅、解耦 +- aop、切面、aspect、advice、pointcut、crosscut、拦截器、横切关注点 - access level、private、public、@ModuleQualifier - configuration、module config @@ -46,6 +50,9 @@ allowed-tools: Read - "如何注入服务?" - "如何访问其他模块的对象?" - "EGG 中的 AccessLevel 是什么?" +- "如何用 EventBus 解耦异步任务?" +- "EventBus 和 BackgroundTaskHelper 有什么区别?" +- "如何用 AOP 给所有 Service 加日志?" ### 使用 `egg-controller` skill 当用户询问: @@ -56,6 +63,7 @@ allowed-tools: Read - 连接到外部系统或客户端 - 处理传入的请求/响应 - 控制器级别的装饰器和模式 +- 参数校验(Ajv + TypeBox) **触发关键词:** @@ -64,12 +72,14 @@ allowed-tools: Read - MCP、LLM、AI、tool - schedule、timer、cron、scheduled、定时 - SSE、streaming、server-sent events +- validate、校验、参数校验、ajv、typebox、schema **示例查询:** - "如何创建 HTTP controller?" - "如何实现 MCP 接口?" - "怎么实现定时任务?" +- "帮我给接口加上参数校验" --- @@ -108,14 +118,18 @@ allowed-tools: Read ### 步骤 3:协议/用例特定指示器 -| 协议/用例 | 主要技能 | 次要技能 | -| ---------------------- | ---------------- | -------- | -| HTTP API | `egg-controller` | - | -| MCP | `egg-controller` | - | -| Scheduled Tasks | `egg-controller` | - | -| Cross-module injection | `egg-core` | - | -| Module structure | `egg-core` | - | -| Object lifecycle | `egg-core` | - | +| 协议/用例 | 主要技能 | 次要技能 | +| ----------------------- | ---------------- | -------- | +| HTTP API | `egg-controller` | - | +| MCP | `egg-controller` | - | +| Scheduled Tasks | `egg-controller` | - | +| Parameter validation | `egg-controller` | - | +| Background tasks | `egg-core` | - | +| Event-driven / EventBus | `egg-core` | - | +| AOP / 切面编程 | `egg-core` | - | +| Cross-module injection | `egg-core` | - | +| Module structure | `egg-core` | - | +| Object lifecycle | `egg-core` | - | ## 冲突解决规则 @@ -149,15 +163,19 @@ allowed-tools: Read ## 快速参考表 -| 用户意图 | 关键词 | 使用技能 | -| -------------------- | ------------------------------- | ---------------------- | -| Module architecture | module、workspace、organization | `egg-core` skill | -| Object lifecycle | singleton、context、lifecycle | `egg-core` skill | -| Dependency injection | inject、@Inject、dependency | `egg-core` skill | -| Access control | private、public、cross-module | `egg-core` skill | -| HTTP endpoints | HTTP、API、REST | `egg-controller` skill | -| LLM/AI integration | MCP、tool、prompt | `egg-controller` skill | -| Scheduling | schedule、cron、timer | `egg-controller` skill | +| 用户意图 | 关键词 | 使用技能 | +| -------------------- | ------------------------------------- | ---------------------- | +| Module architecture | module、workspace、organization | `egg-core` skill | +| Object lifecycle | singleton、context、lifecycle | `egg-core` skill | +| Dependency injection | inject、@Inject、dependency | `egg-core` skill | +| Access control | private、public、cross-module | `egg-core` skill | +| Background tasks | background task、异步任务、后台任务 | `egg-core` skill | +| Event-driven | eventbus、事件总线、@Event、emit | `egg-core` skill | +| AOP 切面编程 | aop、切面、advice、pointcut、crosscut | `egg-core` skill | +| HTTP endpoints | HTTP、API、REST | `egg-controller` skill | +| LLM/AI integration | MCP、tool、prompt | `egg-controller` skill | +| Scheduling | schedule、cron、timer | `egg-controller` skill | +| Param validation | validate、校验、ajv、typebox、schema | `egg-controller` skill | --- diff --git a/packages/skills/eval/evals-egg-core.json b/packages/skills/eval/evals-egg-core.json index 2d6a604f4f..e22bc7b0d6 100644 --- a/packages/skills/eval/evals-egg-core.json +++ b/packages/skills/eval/evals-egg-core.json @@ -1,6 +1,6 @@ { "skill_name": "egg-core", - "description": "核心概念评测:覆盖 module、proto、inject、dynamic-inject、background-task", + "description": "核心概念评测:覆盖 module、proto、inject、dynamic-inject、background-task、eventbus、aop", "evals": [ { "id": 1, @@ -231,6 +231,111 @@ "id": 26, "prompt": "我有个后台任务可能要跑 30 秒,怎么配置超时不被框架提前中断", "expected_output": "设置 backgroundTaskHelper.timeout = 30000 或全局 config backgroundTask.timeout: 30000" + }, + { + "id": 27, + "prompt": "订单创建后要发通知、记日志、更新统计,但不想耦合在一起,怎么做", + "expected_output": "使用 EventBus 解耦:声明 Events 接口 + emit 触发事件 + 多个 @Event handler 分别处理通知、日志、统计" + }, + { + "id": 28, + "prompt": "用 EventBus 实现订单事件,要求类型安全,帮我写 Events 声明和 handler", + "expected_output": "declare module 'egg' { interface Events { ... } } + @Event handler + 从 egg 导入 EventBus/Event,事件声明文件开头有 import 'egg'" + }, + { + "id": 29, + "prompt": "EventBus 和 BackgroundTaskHelper 什么区别,我该用哪个", + "expected_output": "对比耦合度(强耦合 vs 解耦)、上下文(共享 vs 独立)、扩展性(改触发者 vs 加 handler),给出选型建议" + }, + { + "id": 30, + "prompt": "EventBus 的 handler 没有被触发,帮我看看", + "expected_output": "指出 handler 文件不在 module 目录中,框架无法扫描到,应移到模块目录下", + "files": [ + { + "path": "app/orderModule/package.json", + "content": "{\n \"name\": \"orderModule\",\n \"eggModule\": { \"name\": \"orderModule\" }\n}" + }, + { + "path": "app/orderModule/OrderService.ts", + "content": "import { SingletonProto, Inject, EventBus } from 'egg';\n\n@SingletonProto()\nexport class OrderService {\n @Inject()\n private eventBus: EventBus;\n\n async createOrder(userId: string) {\n this.eventBus.emit('orderCreated', '123', userId);\n return { orderId: '123' };\n }\n}" + }, + { + "path": "app/handler/OrderNotificationHandler.ts", + "content": "import { Event } from 'egg';\n\n@Event('orderCreated')\nexport class OrderNotificationHandler {\n async handle(orderId: string, userId: string): Promise {\n console.log('notify:', orderId, userId);\n }\n}" + } + ] + }, + { + "id": 31, + "prompt": "帮我用 EventBus 实现用户注册后异步发送欢迎邮件", + "expected_output": "完整代码:Events 类型声明(import 'egg' + declare module)+ 注册 Service 中 emit + @Event handler 发送邮件" + }, + { + "id": 32, + "prompt": "帮我把这个 BackgroundTaskHelper 的写法改成 EventBus 解耦", + "expected_output": "拆分为 EventBus.emit() + 独立 @Event handler 类,去掉 BackgroundTaskHelper", + "files": [ + { + "path": "app/orderModule/OrderService.ts", + "content": "import { SingletonProto, AccessLevel, Inject, BackgroundTaskHelper } from 'egg';\n\n@SingletonProto({ accessLevel: AccessLevel.PUBLIC })\nexport class OrderService {\n @Inject()\n private backgroundTaskHelper: BackgroundTaskHelper;\n\n @Inject()\n private notifyService: any;\n\n @Inject()\n private statsService: any;\n\n async createOrder(data: any) {\n const order = { id: Date.now(), ...data };\n this.backgroundTaskHelper.run(async () => {\n await this.notifyService.send(order);\n await this.statsService.record(order);\n });\n return order;\n }\n}" + } + ] + }, + { + "id": 33, + "prompt": "EventBus 的 cork/uncork 怎么用,我想批量操作完成后再统一触发事件", + "expected_output": "注入 ContextEventBus,cork() 开始缓冲 → emit 多个事件 → uncork() 释放,支持嵌套调用" + }, + { + "id": 34, + "prompt": "所有 Service 的方法调用都要记录耗时日志,不想在每个方法里写", + "expected_output": "使用 AOP @Crosscut + PointcutType.NAME 正则匹配 Service,Advice 中 beforeCall 记录开始时间,afterFinally 计算耗时并打日志" + }, + { + "id": 35, + "prompt": "用 @Pointcut 给 OrderService.createOrder 加事务包裹", + "expected_output": "@Advice 实现 around hook 管理事务(beginTransaction/commit/rollback)+ @Pointcut(TxAdvice) 装饰目标方法,从 egg/aop 导入" + }, + { + "id": 36, + "prompt": "Pointcut 和 Crosscut 有什么区别,怎么选", + "expected_output": "Pointcut 在目标方法上声明,精确切入单个方法;Crosscut 在 Advice 类上声明匹配规则,批量切入多个类/方法" + }, + { + "id": 37, + "prompt": "AOP 的 Advice 不生效,帮我看看", + "expected_output": "指出缺少 @Advice() 装饰器,@Crosscut 和 @Advice() 必须同时使用", + "files": [ + { + "path": "app/logModule/package.json", + "content": "{\n \"name\": \"logModule\",\n \"eggModule\": { \"name\": \"logModule\" }\n}" + }, + { + "path": "app/logModule/OperationLogAdvice.ts", + "content": "import { Crosscut, IAdvice, AdviceContext, PointcutType } from 'egg/aop';\nimport { Inject, Logger } from 'egg';\n\n@Crosscut({\n type: PointcutType.NAME,\n className: /.*Service$/i,\n methodName: /^(create|update|delete)/,\n})\nexport class OperationLogAdvice implements IAdvice {\n @Inject()\n private logger: Logger;\n\n async afterFinally(ctx: AdviceContext): Promise {\n this.logger.info('called %s', String(ctx.method));\n }\n}" + } + ] + }, + { + "id": 38, + "prompt": "帮我用 AOP 实现统一的异常捕获和错误日志", + "expected_output": "Advice 实现 afterThrow hook 记录异常信息,使用 @Crosscut 或 @Pointcut 应用到目标方法,从 egg/aop 导入" + }, + { + "id": 39, + "prompt": "adviceParams 怎么用,同一个 Advice 不同方法需要不同参数", + "expected_output": "@Pointcut 第二个参数传 { adviceParams: {...} },Advice 中通过 ctx.adviceParams 获取" + }, + { + "id": 40, + "prompt": "同一个方法上有 Crosscut 和 Pointcut 两个 Advice,执行顺序是什么,能控制吗", + "expected_output": "Crosscut 默认 order 100 先执行,Pointcut 默认 order 1000 后执行,可通过 order 参数调整顺序" + }, + { + "id": 41, + "prompt": "帮我用 Crosscut 的 CUSTOM 模式,对所有 Repository 类的非 constructor 方法加监控", + "expected_output": "使用 PointcutType.CUSTOM + callback 函数匹配 Repository 类并排除 constructor,从 egg/aop 导入" } ] } diff --git a/packages/skills/eval/evals-routing.json b/packages/skills/eval/evals-routing.json index c161acbe3d..872ebcec08 100644 --- a/packages/skills/eval/evals-routing.json +++ b/packages/skills/eval/evals-routing.json @@ -41,6 +41,21 @@ "id": 8, "prompt": "不同环境的模块配置怎么管理", "expected_output": "路由到 egg-core skill" + }, + { + "id": 9, + "prompt": "帮我用事件总线解耦订单通知", + "expected_output": "路由到 egg-core skill" + }, + { + "id": 10, + "prompt": "EventBus 和 BackgroundTaskHelper 怎么选", + "expected_output": "路由到 egg-core skill" + }, + { + "id": 11, + "prompt": "帮我用 AOP 给所有 Service 加日志", + "expected_output": "路由到 egg-core skill" } ] }