插件生命周期
English | 中文
@snail-js/api插件的完整契约。如果你在写插件,这份文档就是规范;如果你在读核心实现,它就是那张地图。
1. 决定一切的那条规则
核心只负责三件事,其余一概不管:
- 装饰器写入的元数据;
- 请求管线;
- 下文描述的生命周期。
缓存、版本管理、拦截器、zod 校验、JSON→class 转换与请求池通通都是插件,而且它们用的就是第三方插件能拿到的那套 API。不存在内部特权通道。如果某个内置插件需要一个尚不存在的钩子,那就把这个钩子补进公开契约,而不是在内部偷偷模拟一个。
框架适配器(SnailAdapter / VueRef / ReactState)是这条规则的例外:它不是插件,而是 @Server({ stateAdapter }) 这个 server 选项。核心按它创建 method.meta 上的五个句柄, ctx.meta 因此不再需要任何插件去初始化。
插件就是一个普通对象:
interface SnailPluginObject<O = unknown> {
readonly name: string; // 每个 server 内唯一
readonly priority?: number; // 数值越大,在正向阶段越先执行
readonly dependsOn?: readonly string[];
// ……可选钩子,见 §4
}插件工厂负责把选项变成上面这个对象:
type SnailPlugin<O = unknown> = (options?: O) => SnailPluginObject<O>;2. 请求管线
一次 send() 调用会严格按下面的顺序走完。§4 中的每个钩子都归属于其中某一根箭头。
userApi.getUser("1")
│
▼
┌────── SnailMethod 构造完成 ─────┐
│ ctx 创建(state、meta、 │
│ 请求配置、descriptors) │
│ 核心按 stateAdapter 创建 │
│ 五个 meta 句柄;initMeta 是 │
│ 插件追加自己句柄的扩展点 │
└─────────────────────────────────┘
│
method.send(...args)
│
await pluginManager.ready ← 异步 `install` 钩子就绪
│
beforeCreate ← 副作用钩子,正向
│
应用 @Params/@Query/@Data/@Header
补齐 url(:placeholders)
│
╔═════════ beforeRequest(链式) ═════════╗
║ 最高优先级 ────────────────────────▶ ║
║ 插件可以在这里停止整条链: ║
║ 不调用 next() ║
╚═════════════════════════════════════════╝
│
│ ┌── 短路:某个插件设置了 ctx.response
│ │ 并拒绝调用 next()(一次缓存命中)
│ │
▼ ▼
┌───────────────────────────┐
│ 网络步骤(命中时跳过, │
│ 见 §2.3): │
│ requestInterceptor │
│ axios.request │
│ JSON 字符串强制转换 │
│ responseInterceptor │
└───────────────────────────┘
│
╔═════════ afterResponse(链式) ═════════╗
║ 最低优先级 ────────────────────────▶ ║
║ 既对网络响应执行, ║
║ 也对来自缓存的响应执行 ║
╚═════════════════════════════════════════╝
│
信封 + 业务状态码校验
构建 SnailResult,派发事件
│
┌─────────────────┴─────────────────┐
成功路径 失败路径
│ │
派发 "success" onError(副作用,反向)
│ 派发 "error" / "codeError"
└─────────────────┬─────────────────┘
▼
afterRequest(副作用,反向) ← 始终执行,位于 `finally`
│
派发 "finish"收尾阶段的实际顺序
afterRequest 与 finish 事件都在 finally 里,顺序是先执行 afterRequest 钩子,然后核心把 meta.loading 写回 false,最后才派发 finish。这个顺序是刻意的:如果先派发 finish, 调用方的 onFinish 就会看到过期的 loading === true —— 而它恰恰是 UI 用来关掉加载态的标志。 afterRequest 钩子、loading 复位与 finish 三者都保证会跑(成功、失败、取消皆然)。
2.1 优先级与方向
插件按 priority 降序排列;优先级相同时,按注册顺序决定先后。
| 阶段 | 方向 | 原因 |
|---|---|---|
configureServer、configureApi、configureMethod、initMeta、beforeCreate、beforeRequest、requestInterceptor | 正向——优先级高者先执行 | 拦截器(100)必须在缓存(-100)计算哈希之前改写请求 |
afterResponse、responseInterceptor、onError、afterRequest | 反向——优先级低者先执行 | 这样洋葱模型才会闭合:离网络最近的插件最先看到响应 |
因此请求池(-150,最低的一档)是正向链的最后一道闸门:排在缓存之后,所以一次缓存能回答的请求 永远到不了池,也永远不占用池的槽位。这一条只能靠这个顺序成立,理由见请求池。
下面这些是具名的参考档位,每个都导出了对应的常量,第三方插件可以据此插进内置插件之间:
| 优先级 | 常量 | 插件 |
|---|---|---|
100 | INTERCEPTOR_PRIORITY | 拦截器 |
50 | VERSIONING_PRIORITY | 版本管理 |
20 | TOKEN_AUTH_PRIORITY | useTokenAuth 返回的插件 |
0 | TRANSFORM_PRIORITY | 用户插件、转换 |
-50 | VALIDATE_PRIORITY | 校验 |
-100 | CACHE_PRIORITY | 缓存 |
-150 | POOL_PRIORITY | 请求池 |
六个插件常量从 @snail-js/api/plugins 导出,TOKEN_AUTH_PRIORITY 从 @snail-js/api/strategies 导出。
priority 是一个无上界的数字:任何整数都合法,二十几个插件也不会互相碰撞,因为只有 完全相等才算并列,而并列时按注册顺序决定先后。这张表描述的是参考位置,不是「一共只有七 个槽位」。要让自己的插件紧挨着某个内置插件,请相对它定位:
import { CACHE_PRIORITY } from "@snail-js/api/plugins";
createPlugin({
name: "key-rewrite",
priority: CACHE_PRIORITY + 1, // 正向紧挨着缓存之前,反向紧挨着它之后
/* … */
});谁占用哪个优先级
内置插件与策略贡献的插件,各自落在哪一档、为什么,以及它们的参考手册:
| 优先级 | 插件 name | 主要钩子 | 为什么在这一档 | 参考 |
|---|---|---|---|---|
100 | interceptor | beforeRequest / afterResponse | 正向最先跑,才能让 @BeforeRequest() 在缓存哈希之前改写 config;反向最后跑,才能改写最终响应 | 拦截器 |
50 | versioning | beforeRequest | 在拦截器之后(看到已改写的 url)、缓存之前(让缓存哈希带版本的 url) | 版本 |
20 | token-auth(useTokenAuth 返回的插件) | beforeRequest(包住 await next()) | 高于默认值,好让它包住 beforeRequest 链的其余部分并观察下游抛出的 401 | useTokenAuth |
0 | transform | afterResponse | 在反向链里排在 validate(-50)之后:先校验原始 JSON,再水合成类实例 | 转换 |
-50 | validate | beforeRequest / afterResponse | 正向在塑形之后、缓存要键之前;反向在缓存之后、转换之前 | 校验 |
-100 | cache | beforeRequest / afterResponse | 正向最后,url / params / body 已定稿;反向最先,存下原始信封 | 缓存 |
-150 | pool | beforeRequest | 正向最低一档,紧贴传输:排在缓存之后,所以缓存能回答的请求既不进入池、也不占用槽位;槽位只为网络步骤持有,响应一到就归还 | 请求池 |
框架适配器不在这张表里 —— 它不是插件。SnailAdapter / VueRef / ReactState 由 @Server({ stateAdapter }) 选择,核心据此创建 method.meta 上的五个句柄,见 框架适配器。
第三方插件如果需要高于所有默认值(0)、又低于内置的请求改写插件,请相对某个常量定位, 例如 TOKEN_AUTH_PRIORITY - 1:硬编码一个与 token-auth 相等的数字只会把自己塞进一次并列, 而且内置档位调整时不会跟着走。
2.2 为什么是两种顺序,而不是一种
如果只有一层洋葱,afterResponse 也会按正向顺序执行,而这对缓存来说是错的:缓存插件是最后一个看到请求的(它只在 url 和 body 都定稿之后才运行),却必须是第一个看到响应的(它要在校验和转换动手之前,把原始信封存下来)。两种方向,一条规则:正向钩子由外向内执行,反向钩子由内向外执行。
2.3 短路不等于绕过
当插件自己提供了响应时——内置插件里只有缓存插件会在命中时这么做——网络步骤会被跳过,但 afterResponse 依然会执行。
这是刻意为之的,而且这个理由值得说清楚,因为最直觉的那种实现恰恰会做错。载荷类插件就住在 afterResponse 里:zod 响应校验和 JSON→class 转换都在那儿。命中时跳过它,会让同一次调用第一次返回填充好的 DTO,第二次却返回一个普通对象。链是属于响应的,不是属于传输层的。
命中时真正不会执行的,是网络步骤:requestInterceptor、axios.request 和 responseInterceptor。它们存在的意义就是改写真实的请求和真实的响应。
有两条后果,插件作者必须认账:
- 会对命中做出反应的插件,都应当检查
ctx.isCacheHit。缓存插件正是这么做的:命中时它既不入库,也不清理标签,因为那个本该打到服务端的操作根本没发生。少了这道检查,一次 stale-while-revalidate 命中就会把它自己后台刷新刚取回的新值覆盖掉。 - 插件交还给调用方的任何东西,只要它自己也留了一份,就必须拷贝。缓存在写入和读取时都会克隆;让缓存和调用方共享同一个对象,就意味着调用方的一次原地修改会悄无声息地改写缓存里的条目。
3. 注册
const Service = new BackEnd();
Service.use(Interceptor()).use(Version({ defaultVersion: "1.0.0" }));use() 是同步的,而且可以链式调用。它会当场做校验——插件没名字、名字重复,或者 dependsOn 没被满足,都会在任何东西被改动之前抛出 SnailPluginError。
install 既可以是同步的,也可以是异步的:
- 同步的
install在use()期间执行,所以它在install里写下的状态、注册的钩子在第一次调用createApi()之前就已经存在; - 异步的
install会被记录下来,并在第一个请求发出之前,通过pluginManager.ready统一 await 一次。
用 remove(name) / remove(plugin) 注销插件(会执行 uninstall),用 Service.dispose() 注销全部插件。
await Service.remove("cache");4. 钩子
4.1 注册钩子
install(context, options)
在插件被添加到某个 server 时执行一次。
install?(context: SnailPluginInstallContext, options: O): void | Promise<void>;interface SnailPluginInstallContext {
readonly serverName: string;
readonly serverOptions: ResolvedServerOptions;
readonly pluginNames: readonly string[];
}用它来注册参数来源、贡献消息、打开资源,或者在闭包里捕获选项。install 一旦抛错,整个注册就会回滚,所以绝不会出现半安装状态的插件。
uninstall(context, options)
在插件被移除时执行一次。定时器、socket 和监听器都在这里释放。
4.2 配置钩子
这些钩子对每个被装饰的目标只执行一次,而不是每次请求一次。要调整选项就用它们——绝不要拿它们做单次请求的活儿。
configureServer(options)
在 server 实例构造期间执行。options 是解析后的 ResolvedServerOptions,可以直接原地修改。
configureServer(options) {
options.timeout ??= 30000;
}configureApi(options, apiClass)
某个类第一次调用 createApi(apiClass) 时执行。
configureMethod(options, methodName, apiName)
某个被装饰的方法第一次被代理时执行。options 是合并后的方法选项(axios 配置字段加上 url)。
4.3 单次请求钩子
initMeta(ctx) —— 同步,正向
纯粹的扩展点:核心已经在 SnailMethod 构造时按 @Server({ stateAdapter }) 创建了五个标准句柄(dataKey / codeKey / messageKey 与固定的 loading / error),不需要任何插件再装一次适配器。只有想往 ctx.meta 上追加自己的句柄的插件才需要这个钩子:
initMeta(ctx) {
ctx.meta.progress = ctx.serverOptions.stateAdapter.create(0);
}它在 SnailMethod 构造时执行一次——不是每次 send() 一次——这样 UI 拿到手的那些句柄在多次重发之间始终是同一个。ctx.state 每次发送都会清空,ctx.meta 不会。
beforeCreate(ctx) —— 同步,正向
在每次 send() 开始时执行,位于 initMeta 之后、参数装饰器生效之前。这里是重置单次调用簿记的地方:
beforeCreate(ctx) {
ctx.serverOptions.stateAdapter.write(ctx.meta.loading, true);
}beforeRequest(ctx, next) —— 链式,正向
请求的闸门。
async beforeRequest(ctx, next) {
await next(); // 继续
}不调用
next()就会终止管道。如果此时ctx.response已被设置,就用这个响应;否则send()会以SnailCancelledError拒绝。设置
ctx.response并调用ctx.interrupt(response),可以在完全不发起网络请求的情况下返回一个响应——这正是缓存命中:tsbeforeRequest(ctx) { const cached = cache.get(key); if (cached) { ctx.markCacheHit(); ctx.interrupt(makeResponse(cached, ctx.request)); return; // 没有 next() → axios 不会执行 } return next(); }多次调用
next()会抛出SnailHookError(§6)。
requestInterceptor(config, ctx) —— 同步,reduce,正向
改写即将发出的 axios 配置的最后机会。每个插件拿到的都是上一个插件返回的配置;返回 undefined 表示保持不变。
requestInterceptor(config, ctx) {
config.headers.set("x-request-id", crypto.randomUUID());
}和 beforeRequest 不同,它拦不住请求——轮到它执行时,发送这个决定早已做出。控制流请交给 beforeRequest,纯粹改写才用它。
afterResponse(ctx, next) —— 链式,反向
在响应已经存在、信封尚未校验之前执行。next() 的规则与 beforeRequest 相同;跳过 next() 的插件会提前结束响应链,后续插件和信封校验看到的都是 ctx.response 当时持有的内容。
因为这是反向钩子,所以缓存插件(-100)最先执行,拦截器(100)最后执行。
responseInterceptor(response, ctx) —— reduce,反向
改写 axios 响应。返回 undefined 表示保留上一个值。
onError(ctx, error) —— 副作用,反向
观察失败。它无法从失败中恢复:所有 onError 钩子跑完之后,错误仍会被重新抛出。负责恢复的是 beforeRequest。
抛错的 onError 钩子会被记入日志并吞掉,这样一个坏掉的错误处理器就不会掩盖最初的失败。
afterRequest(ctx) —— 副作用,反向
在 finally 中执行,成功、失败、取消一视同仁。单次请求的资源在这里释放。
afterRequest(ctx) {
clearTimeout(ctx.state.get("timer"));
}meta.loading 的复位由核心负责,发生在所有 afterRequest 钩子跑完之后、finish 事件之前, 插件不需要(也不应该)在这里重复写它。
5. 编写插件
createPlugin 是官方支持的入口。它校验名称、接好 install/uninstall,并把一套带作用域的 API 交给你的 setup。
import { createPlugin } from "@snail-js/api";
export interface TraceOptions {
/** 要写入的请求头,默认为 `x-trace-id`。 */
header?: string;
}
export const Trace = createPlugin<TraceOptions>({
name: "trace",
priority: 20,
setup(options, api) {
const header = options?.header ?? "x-trace-id";
api.addMessages({ "trace.generated": "[%s] trace id %s" });
return {
requestInterceptor(config, ctx) {
config.headers.set(header, ctx.state.get("traceId") ?? crypto.randomUUID());
},
afterRequest(ctx) {
ctx.state.delete(header);
}
};
}
});
Service.use(Trace({ header: "x-trace-id" }));setup API
| 成员 | 用途 |
|---|---|
serverName | 该插件所安装到的 server 名称 |
serverOptions | 解析后的 server 选项 |
installedPlugins | 在它之前注册的插件名 |
defineParamSource(source, resolver) | 注册一个 @Source("key") 参数来源 |
addMessages(messages) | 贡献已翻译的消息 |
onDispose(fn) | 注册清理函数,由 uninstall 执行 |
添加装饰器
第三方装饰器用的,就是核心自己也在用的那套工厂函数。
// 1. 在 setup 中:注册参数来源
api.defineParamSource("tenant", ({ ctx, value, key }) => {
ctx.request.headers.set(key ?? "x-tenant", String(value));
});
// 2. 导出一个装饰器
import { createParamDecorator } from "@snail-js/api";
export const Tenant = createParamDecorator("tenant");
// 3. 用它
@Get("/orders")
orders(@Tenant("x-org-tenant") tenantId: string) {}只用 defineParamSource 就够了——createParamDecorator("tenant") 会自己从注册表里把对应的解析器取出来,这样装饰器和插件就彼此解耦了。
此外还有:createClassDecorator、createMethodDecorator、createPropertyDecorator,以及用来给自己的键加命名空间的 customMetadataKey(name)("acme/tenant" → Symbol.for("@snail-js/api:custom:acme/tenant"))。
细节见编写插件。
6. 链式不变量
composeChain 强制实施 Koa 那套契约:
- 每个钩子最多只能调用一次
next()。 第二次调用会抛出SnailHookError,并指名违规的插件。没有这道防线,链的剩余部分会悄无声息地跑两遍——表现出来就是同一个请求被发了两次。 - 必须 await
next(),后续部分才会执行完。 在awaitresolve 之前就返回,会让send()带着一个只处理了一半的上下文继续往下走。 - 从不调用
next()的钩子会终止整条链。 对beforeRequest来说,这意味着不发任何 HTTP 请求;结果就是ctx.response当时持有的东西,或者一个SnailCancelledError。
7. ctx 参考
| 成员 | 说明 |
|---|---|
ctx.server、ctx.serverOptions | 所属的 server 及其解析后的选项 |
ctx.apiClass、ctx.api、ctx.apiName、ctx.apiOptions | 被装饰的 api |
ctx.methodName、ctx.methodType、ctx.route、ctx.fullName | 请求的身份标识 |
ctx.request | 实时的 InternalAxiosRequestConfig;就地修改 |
ctx.pathParams | 由 @Params() 收集到的值 |
ctx.descriptors | 每一次参数装饰器应用记录,按索引排序 |
ctx.response / setResponse / getResponse / requireResponse | axios 响应 |
ctx.result / setResult | 组装出来的 SnailResult |
ctx.error | 在失败路径上被设置 |
ctx.meta | 调用方可见的响应式值;五个标准句柄由核心按 stateAdapter 创建,initMeta 只用来追加插件自己的句柄 |
ctx.state | 插件的暂存空间(StateBag),每次发送都会清空 |
ctx.logger | 受级别控制的 logger,遵循 @Server({ logLevel }) |
ctx.interrupt(response?) | 中止请求,可选地直接返回一个响应 |
ctx.isInterrupted | 链是否已被中止 |
ctx.markCacheHit() / ctx.isCacheHit | 标记结果里的 fromCache 字段 |
ctx.elapsed | 自 send() 开始以来经过的毫秒数 |
ctx.describe() | 用于日志的浅层快照 |
ctx.state 的生命周期是一次 send();ctx.meta 则贯穿整个 SnailMethod。把簿记塞进 meta,等于把内部状态泄漏到 UI 渲染的东西里——请用 state。
8. 测试插件
插件不需要网络也能测:把它装到一个 adapter 是函数的 server 上,然后对记录下来的请求做断言。
import { Server, SnailServer, Api, Get } from "@snail-js/api";
const requests: unknown[] = [];
@Server({
baseURL: "/api",
adapter: async (config) => {
requests.push(config);
return {
data: { code: 0, message: "ok", data: { id: 1 } },
status: 200,
statusText: "OK",
headers: {},
config
};
}
})
class TestServer extends SnailServer {}
const Service = new TestServer();
Service.use(Trace());
@Api("/user")
class UserApi {
@Get("/:id")
getUser(): Promise<{ id: number }> {
return null!;
}
}
await Service.createApi(UserApi).getUser("1").send();
expect(requests).toHaveLength(1);任何插件都建议覆盖这几种情况:相对一个已知优先级的第二个插件,它按预期方向执行;自身条件不满足时它是空操作;它在 afterRequest 里释放自己的资源;以及它在失败路径上不抛任何东西。
更多测试写法(包括如何构造一个记录请求的 adapter 并断言请求内容)见 编写插件。
9. 本地化
消息使用 %s 占位符,存放在 locale/zh.ts / locale/en.ts。插件可以在 setup 里用 api.addMessages({...}) 贡献自己的消息,也可以在任意位置用 registerMessages({...})。
api.addMessages({
"cache.hit": "[%s] 缓存命中",
"cache.set": "[%s] 写入缓存"
});缺失的键会返回键本身,而不是空字符串,所以拼错时会直接暴露出来,而不是渲染出一条空白的错误消息。
详见本地化。
10. 本契约的版本演进
| 变更 | 原因 |
|---|---|
PluginManager 改为按 server 隔离 | 过去的全局管理器外加一个可变的 switchServer() 指针,会让两个并发的 server 互相读到对方的插件 |
| 两种链式方向(正向 / 反向) | 单层洋葱无法表达「缓存最后进入、最先退出」 |
next() 最多只能调用一次 | 旧的派发器会悄无声息地重跑链的尾部 |
state 从 meta 中拆出 | 插件的簿记泄漏进了调用方的响应式状态 |
install 可以是同步的 | 插件必须在第一次 createApi() 之前完成注册,而延迟安装保证不了这一点 |
五个 meta 句柄改由核心创建 | 它们曾经由一个适配器插件创建,而策略句柄又走另一个进程级注册表;同一个框架要在两处声明,且全局那一半是 import 副作用。现在统一由 @Server({ stateAdapter }) 驱动,initMeta 只剩「追加自己的句柄」这一个用途 |
| 框架适配器不再是插件 | 旧的两个适配器/三个策略入口让两个 server 无法使用不同框架;现在适配器是 server 选项,只为 method.meta 与策略提供句柄 |
新增 createPlugin | 插件作者不得不手搓对象结构,且得不到任何校验 |
afterResponse 移出传输步骤 | 缓存命中会跳过它,导致响应校验和转换在第二次调用时悄无声息地什么都不做 |
| 缓存在写入和读取时都做克隆 | 缓存与调用方共享同一个对象,任何一方就地修改都会改写另一方(§2.3) |
| 缓存命中时跳过入库和标签清理 | 一次过期命中会把旧 body 重新存回去,覆盖掉它自己后台刷新刚取回的新值 |
afterRequest 在 finish 事件之前执行 | 核心正是在这个窗口里把 meta.loading 置回 false,而 onFinish 之前看到的是过期的 loading === true |