惰性服务访问(Lazy Service Access)
写给第三方插件作者 / 维护者。读完你会知道:
- 为什么每次用都要重新
ctx.getService(),不能把裸引用缓存进类字段或闭包;- 什么时候用
ctx.whenService()订阅"晚到的服务";requiresBounceOnDepChange这个逃生舱什么时候才该用;- 以及
*-api包的"惰性网关"(createStorageGateway/createProcessGateway)为什么是推荐默认姿势。
相关阅读(同级 concept / 服务文档):
- DI 服务模型(同名多实现的胜者解析:偏好 > 优先级 > 注册顺序)
- Manifest 双来源(
provides/required/optional声明 vs 运行时 DI) - storage URI 文法(网关按 URI 跨 root 路由)
- 服务文档(forward-ref):
docs/services/storage.md、docs/services/process.md - 内核参考:
docs/core/service.md、docs/core/context.md
1. 为什么要"惰性"
provider 会在你脚下换人。Aalis 是热重载友好的:插件可以在运行时被 bounce(dispose 旧 ctx → 重新 apply),也可以被用户切换偏好 provider。这两件事都会让"某个服务名当前的胜者实例"发生变化。
如果你在 apply() 里这样写:
// ❌ 反模式:把裸实例缓存到长寿命对象里
export function apply(ctx: Context) {
const storage = ctx.getService('storage'); // 当时点的裸实例
ctx.middleware('inbound:message', async (data, next) => {
await storage.writeFile('data:/log.txt', data.text); // storage 可能早已失效
await next();
});
}那么一旦 storage 提供方被 bounce,你闭包里的 storage 引用就指向了一个已 dispose 的旧实例(旧连接、旧句柄)。
ctx.getService() 的文档对此说得很直白:返回的是"当时点的裸实例,调用后 provider 发生换跳不会跟随"(core/src/context.ts)。
正确姿势——每次用时即取即用:
// ✅ 在函数作用域内重新查询,不存入类字段/闭包
export function apply(ctx: Context) {
ctx.middleware('inbound:message', async (data, next) => {
const storage = ctx.getService('storage'); // 每次都是当前胜者
await storage?.writeFile('data:/log.txt', data.text);
await next();
});
}getService() 只查一次容器(内部即 return this._services.get<T>(name),core/src/context.ts),代价极低。
容器按 偏好 > 优先级 > 注册顺序 解析当前胜者(ServiceContainer.get → resolveEntries,core/src/service.ts)。
所以"每次查"既便宜、又总拿到最新提供方。
2. 核心规则
规则一:默认不级联 bounce 下游
早期 core 对所有 active 下游做级联 bounce,前提是"大家都缓存裸引用"。现在的契约反过来了:
插件应在每次访问时通过
ctx.getService(...)惰性查询;这样 provider 切换天然跟随,无需级联 bounce。 ——core/src/plugin-topology.ts
因此当一个 provider 被 bounce 时,evictDownstreamConsumers 默认只重挂那些显式声明了 requiresBounceOnDepChange: true 的下游,其余下游原地不动(core/src/plugin-topology.ts)。
computeTargetState 里 service-down reason 也只对声明了该标志的 entry 转 pending(core/src/plugin-activation.ts)。
言下之意:如果你没有惰性查询、又没有声明
requiresBounceOnDepChange,provider 一换人你就持有了僵尸引用,而 core 不会救你。惰性是你这一侧的责任。
规则二:bounce = dispose 旧 ctx → 重激活
bouncePlugin(updatePluginConfig 也是它的薄别名,core/src/plugin.ts)的流程:
持久化新 config / 替换 module → evictDownstreamConsumers → entry.context.dispose() → 转 pending → softReload() 重新 apply(core/src/plugin.ts)。
dispose() 会经 unregisterByContext(this.id) 把该插件注册的所有 service entry 摘掉,并发出 service:unregistered(core/src/context.ts)。随后重激活时新实例重新 provide,发出 service:registered。
实例换了,名字没变——这正是缓存裸引用会出事的根因。
规则三:三种"换人"信号
都由容器当前态决定胜者。
| 信号 | 触发 | 事件 |
|---|---|---|
| provider 注册 | ctx.provide(name, inst) | service:registered(context.ts) |
| provider 注销 | dispose / 手动 dispose 返回值 | service:unregistered(context.ts) |
| 偏好切换 | ctx.preferService(name, ctxId) / unpreferService | service:preference-changed(context.ts) |
偏好切换很特殊:它不改变 entry 集合,只改变 getService(name) 的胜者,所以单独有一个 service:preference-changed 事件,不能复用 registered/unregistered(事件定义见 core/src/types/core.ts)。
whenService 三个事件都监听。
3. ctx.whenService(name, cb)
订阅"晚到 / 会换人"的服务。
getService() 解决的是"每次读最新",但有一类副作用是一次性注册:你想把某个工具/监听器注册进一个 hub 服务(如 tools),而那个 hub 可能在你之后才上线,或者中途被 bounce 换了实例。
手动监听 service:registered 既啰嗦又容易漏掉 cleanup。whenService 把这件事收成一行(core/src/context.ts)。
语义(逐条对应源码注释,context.ts):
- 调用时服务已就绪 → 立即触发首次
cb(sync()在末尾立即跑一次,context.ts)。 - provider 重新 provide(unregister → register)会先调上次 cleanup、再用新 svc 调一次
cb,保证你手里永远不是失效引用(runCleanup→ 新winner调cb,context.ts)。 cb可返回一个 cleanup 函数;返回的 dispose 与ctx.dispose()都会调它。- 返回的 dispose 幂等,手动多调安全(
disposed守卫,context.ts)。 - 胜者不变则不动:败者 entry(低优先级并存的 provider)上下线不会触发重挂;只有"胜者换人"(含偏好切换、胜者注销后由次优顶上)才 cleanup + 重挂(
sync()核心:if (winner === attached) return;,context.ts)。
例 A:把工具注册进 tools hub
最常见用法。
export function apply(ctx: Context) {
// tools 服务可能晚于本插件就绪;whenService 保证就绪即注册、换人即重挂
ctx.whenService('tools', svc => svc.register(myTool, ctx.id));
}例 B:订阅 provider 内部状态,返回 cleanup
ctx.whenService('llm', llm => {
const handle = llm.onModelChange(updateUI);
return () => handle.dispose(); // llm 被 bounce / 换人时自动调用
});选型:"每次读一个值"用
getService();"挂一次副作用并随 provider 跟随"用whenService()。 二者都不要把裸引用存进类字段。
4. 惰性网关模式
*-api 包的推荐默认姿势。
storage / process 这类服务是按子粒度多 entry 注册的(storage 每个 root 一个 entry、process 单实例),消费者通常不想关心"当前哪个 root 由哪个后端提供"。
*-api 包提供惰性网关工厂:构造出一个看起来普通的 StorageService / ProcessService 句柄,但它的每个方法调用内部都重新查容器——本质上是把"每次 getService"封装进了句柄。
createProcessGateway(ctx) 最小示范
plugin-process-api/src/index.ts:
export function createProcessGateway(ctx: Context): ProcessService {
const pick = (): ProcessService => {
const inst = ctx.getService<ProcessService>('process'); // 每次调用都重新拿
if (!inst) throw new Error('未找到 process 服务(请启用 @aalis/plugin-process-local …)');
return inst;
};
return {
spawn: (cmd, args, opts) => pick().spawn(cmd, args, opts),
execFile: (cmd, args, opts) => pick().execFile(cmd, args, opts),
makeTempDir: prefix => pick().makeTempDir(prefix),
readExternalFile: path => pick().readExternalFile(path),
};
}关键点:网关对象本身可以长期持有(存进类字段没问题),因为它不捕获裸实例——每个方法在调用瞬间才 pick()。
所以下面这种写法是安全的,与第 1 节的反模式相反:
export function apply(ctx: Context) {
const proc = createProcessGateway(ctx); // 句柄长寿命 OK——它内部惰性
ctx.onDispose(/* ... */);
ctx.middleware('inbound:command', async (data, next) => {
await proc.execFile('echo', ['hi']); // 这一刻才解析当前 process 提供方
await next();
});
}createStorageGateway(ctx):按 URI 跨 root 路由
plugin-storage-api/src/index.ts:网关的每个方法对传入的 storage URI 调 dispatch(uri, caps) → resolveStorageByPath(ctx, uri, caps),后者每次都重新 getStorageEntries(ctx)(即 ctx.getAllServices('storage'),plugin-storage-api/src/index.ts)。
所以它既是惰性、又是按 <root>:/path 文法路由的多 entry 聚合器:
const storage = createStorageGateway(ctx);
await storage.writeFile('data:/notes/today.md', text); // 路由到提供 data 根的 entry
await storage.readFile('cache:/x.bin'); // 路由到提供 cache 根的 entry何时直接
getServicevs 用网关:
- 服务是单实例且你只要当前胜者 →
getService即可(或干脆用网关,二者都惰性);- 服务是 per-root / per-model 多 entry 且你想按 URI/模型透明调度 → 用对应
*-api的网关 /resolveXxxhelper,别自己重抄聚合逻辑(契约级文法见service.ts的ServicePriority注释,core/src/types/service.ts)。storage 的 URI 文法细节见 storage URI 文法。
社区里这是绝对主流:createStorageGateway / createProcessGateway 被几十个 first-party 插件复用(authority / checkpoint / scheduler / commands / media / tool-* …),全部走惰性句柄。
5. requiresBounceOnDepChange:逃生舱,不是默认
// core/src/types/plugin.ts
requiresBounceOnDepChange?: boolean;声明在你的插件模块上(PluginModule)。设为 true 后,当你依赖(required 或 optional)的某个 provider 被 bounce / 下线时,core 会把你也降级为 pending 并重新 apply(evictDownstreamConsumers,core/src/plugin-topology.ts;computeTargetState 的 service-down 分支,core/src/plugin-activation.ts)。
它是给少数无法响应式处理状态的插件、或迁移成本高的第三方插件准备的逃生舱(plugin-topology.ts)。代价是:依赖一抖动你就整体重启,比惰性查询昂贵得多,还可能放大级联。
优先级判断:
- 你能改成"每次
getService()/ 用网关句柄"吗?→ 能就这么做,不要设这个标志。 - 你的副作用是"一次性注册进 hub"吗?→ 用
whenService(),它已经帮你处理换人重挂。 - 实在做不到响应式(比如你在
apply里基于 provider 当前态构建了大量难以增量更新的内部结构)→ 才设requiresBounceOnDepChange: true。
注意:required 依赖消失时,无论是否设此标志,
computeTargetState都会把你转 pending(reqUnmet → 'pending',core/src/plugin-activation.ts)——因为没了 required 依赖你本就不该运行。该标志真正改变的是 provider 仅仅 bounce(随后会回来) 时要不要跟着重启,以及 optional 依赖下线 时的行为。
6. 审计标记过的坑 / 边界情形
- 裸引用进类字段 / 闭包 = 僵尸引用。 第 1 节的根因。默认无级联 bounce 兜底,这是你的责任(
plugin-topology.ts)。 - 不要
ctx.on('app:stopping', …)做资源清理。 那只在 app 全局停机时触发一次,不会在插件 bounce / hot reload 时触发,旧连接/旧定时器会泄漏。清理副作用的唯一正确 API 是ctx.onDispose(fn)(在 bounce / unload / updatePluginConfig / softReload 级联 evict 的任何 dispose 路径上都会触发,core/src/context.ts)。 whenService的 cb 里同步触发自身 dispose 也安全。 core 处理了"cb 执行期间 disposed 变 true"的竞态——此时返回的 cleanup 会被立即执行而非挂起泄漏(core/src/context.ts)。- 败者上下线不会触发
whenService重挂。 只看胜者。如果你真的要枚举所有并存 provider(罕见,多为管控/展示场景),用ctx.getAllServices(name)/ctx.getServiceEntries(name),且同样每次重新枚举(context.ts)。 - 手动 dispose 后闭包自移除。
provide/whenService返回的 dispose 调用后会把自己从 disposable 链摘掉,避免持有 entry/handler 引用阻碍 GC(context.ts);你不需要、也不应该缓存实例去"帮忙"延长生命周期。
7. 一页速查
| 你想做的事 | 用什么 | 不要 |
|---|---|---|
| 偶尔读一次某服务的当前胜者 | ctx.getService(name)(即取即用) | 别存进类字段 / 闭包 |
| 长期持有一个会自动跟随换人的句柄 | createStorageGateway(ctx) / createProcessGateway(ctx)(句柄惰性,可缓存) | 别 getService() 一次后缓存裸实例 |
| 把副作用一次性注册进 hub,且随 provider 重挂 | ctx.whenService(name, cb)(cb 可返回 cleanup) | 别手写 on('service:registered', …) |
| 跨 root / 跨 model 透明路由 | *-api 的 resolveXxx / 网关 helper | 别自己重抄聚合逻辑 |
| 清理资源(连接/定时器/外部句柄) | ctx.onDispose(fn) | 别用 on('app:stopping', …) |
| 依赖 provider 抖动时整体重启(最后手段) | requiresBounceOnDepChange: true | 别当默认;优先惰性 / whenService |
一句话记忆: Aalis 的服务图是活的——名字稳定、实例会换。 每次用都查、句柄要惰性、清理走 onDispose、整体重启是逃生舱。