Skip to content

惰性服务访问(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.mddocs/services/process.md
  • 内核参考:docs/core/service.mddocs/core/context.md

1. 为什么要"惰性"

provider 会在你脚下换人。Aalis 是热重载友好的:插件可以在运行时被 bounce(dispose 旧 ctx → 重新 apply),也可以被用户切换偏好 provider。这两件事都会让"某个服务名当前的胜者实例"发生变化。

如果你在 apply() 里这样写:

typescript
// ❌ 反模式:把裸实例缓存到长寿命对象里
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)。

正确姿势——每次用时即取即用:

typescript
// ✅ 在函数作用域内重新查询,不存入类字段/闭包
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.getresolveEntriescore/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)。

computeTargetStateservice-down reason 也只对声明了该标志的 entry 转 pending(core/src/plugin-activation.ts)。

言下之意:如果你没有惰性查询、又没有声明 requiresBounceOnDepChange,provider 一换人你就持有了僵尸引用,而 core 不会救你。惰性是你这一侧的责任。

规则二:bounce = dispose 旧 ctx → 重激活

bouncePluginupdatePluginConfig 也是它的薄别名,core/src/plugin.ts)的流程:

持久化新 config / 替换 module → evictDownstreamConsumersentry.context.dispose() → 转 pendingsoftReload() 重新 applycore/src/plugin.ts)。

dispose() 会经 unregisterByContext(this.id) 把该插件注册的所有 service entry 摘掉,并发出 service:unregisteredcore/src/context.ts)。随后重激活时新实例重新 provide,发出 service:registered

实例换了,名字没变——这正是缓存裸引用会出事的根因。

规则三:三种"换人"信号

都由容器当前态决定胜者。

信号触发事件
provider 注册ctx.provide(name, inst)service:registeredcontext.ts
provider 注销dispose / 手动 dispose 返回值service:unregisteredcontext.ts
偏好切换ctx.preferService(name, ctxId) / unpreferServiceservice:preference-changedcontext.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):

  • 调用时服务已就绪 → 立即触发首次 cbsync() 在末尾立即跑一次,context.ts)。
  • provider 重新 provide(unregister → register)会先调上次 cleanup、再用新 svc 调一次 cb,保证你手里永远不是失效引用(runCleanup → 新 winnercbcontext.ts)。
  • cb 可返回一个 cleanup 函数;返回的 dispose 与 ctx.dispose() 都会调它。
  • 返回的 dispose 幂等,手动多调安全(disposed 守卫,context.ts)。
  • 胜者不变则不动:败者 entry(低优先级并存的 provider)上下线不会触发重挂;只有"胜者换人"(含偏好切换、胜者注销后由次优顶上)才 cleanup + 重挂(sync() 核心:if (winner === attached) return;context.ts)。

例 A:把工具注册进 tools hub

最常见用法。

typescript
export function apply(ctx: Context) {
  // tools 服务可能晚于本插件就绪;whenService 保证就绪即注册、换人即重挂
  ctx.whenService('tools', svc => svc.register(myTool, ctx.id));
}

例 B:订阅 provider 内部状态,返回 cleanup

typescript
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

typescript
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 节的反模式相反:

typescript
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 聚合器:

typescript
const storage = createStorageGateway(ctx);
await storage.writeFile('data:/notes/today.md', text); // 路由到提供 data 根的 entry
await storage.readFile('cache:/x.bin');                // 路由到提供 cache 根的 entry

何时直接 getService vs 用网关:

  • 服务是单实例且你只要当前胜者 → getService 即可(或干脆用网关,二者都惰性);
  • 服务是 per-root / per-model 多 entry 且你想按 URI/模型透明调度 → 用对应 *-api 的网关 / resolveXxx helper,别自己重抄聚合逻辑(契约级文法见 service.tsServicePriority 注释,core/src/types/service.ts)。

storage 的 URI 文法细节见 storage URI 文法

社区里这是绝对主流:createStorageGateway / createProcessGateway 被几十个 first-party 插件复用(authority / checkpoint / scheduler / commands / media / tool-* …),全部走惰性句柄。


5. requiresBounceOnDepChange:逃生舱,不是默认

typescript
// core/src/types/plugin.ts
requiresBounceOnDepChange?: boolean;

声明在你的插件模块上(PluginModule)。设为 true 后,当你依赖(required 或 optional)的某个 provider 被 bounce / 下线时,core 会把也降级为 pending 并重新 applyevictDownstreamConsumerscore/src/plugin-topology.tscomputeTargetStateservice-down 分支,core/src/plugin-activation.ts)。

它是给少数无法响应式处理状态的插件、或迁移成本高的第三方插件准备的逃生舱(plugin-topology.ts)。代价是:依赖一抖动你就整体重启,比惰性查询昂贵得多,还可能放大级联。

优先级判断:

  1. 你能改成"每次 getService() / 用网关句柄"吗?→ 能就这么做,不要设这个标志。
  2. 你的副作用是"一次性注册进 hub"吗?→ 用 whenService(),它已经帮你处理换人重挂。
  3. 实在做不到响应式(比如你在 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 透明路由*-apiresolveXxx / 网关 helper别自己重抄聚合逻辑
清理资源(连接/定时器/外部句柄)ctx.onDispose(fn)别用 on('app:stopping', …)
依赖 provider 抖动时整体重启(最后手段)requiresBounceOnDepChange: true别当默认;优先惰性 / whenService

一句话记忆: Aalis 的服务图是活的——名字稳定、实例会换。 每次用都查、句柄要惰性、清理走 onDispose、整体重启是逃生舱。