服务系统:provide 与 Service 基类

专栏:Cordis 插件框架 · 第 4 / 12 篇
Cordis服务

:::info 学习目标 完成本篇后你能够:解释 ctx.reflect.providectx.provide 的关系;读懂 Service 基类构造器的每一步;理解拦截配置(intercept)的合并顺序。 前置:第 3 篇完成。预计时长:50 分钟。 :::

服务是 Cordis 里”能力”的载体。注册一条服务有两条路径:函数式 ctx.provide(name, value)(直接登记),或类式 class X extends Service(构造时自动登记)。两条路径最终都汇入 reflect 层

Service 基类:构造器逐行读

// vendor/cordis/src/service.ts:42-59
constructor(protected ctx: Context, name: string) {
  name ??= this.constructor['provide'] as string   // 类可声明静态 provide 字段

  let self = this
  const tracker: Tracker = {
    associate: name,        // 诊断:这个实例关联哪个服务名
    property: 'ctx',
  }
  if (self[symbols.invoke]) {
    // 声明了 [Service.invoke] 的服务是"可调用"的:
    // 把实例与 Function.prototype 拼成一个可 call 的代理
    self = createCallable(name, joinPrototype(Object.getPrototypeOf(this), Function.prototype), tracker)
  }
  self.ctx = ctx
  self.name = name
  defineProperty(self, symbols.tracker, tracker)

  // ★ 核心动作:登记进 reflect 层——服务随 owning fiber 卸载自动注销
  self.ctx.reflect.provide(name, self, this[symbols.check])
  return self
}

最后那行 provide 是服务”上线”的瞬间。从这一刻起,等待 'name' 服务的插件会被唤醒(第 5 篇的依赖门控);fiber 卸载时它自动消失。self[symbols.invoke] 分支解释了一个此前费解的现象——ctx.logger('name') 为什么能像函数一样调用 logger:LoggerService 声明了 invoke 符号,框架把”实例”与”函数”拼成同一个可调用对象

作用域过滤:服务的 isolate 判定

// vendor/cordis/src/service.ts:61-63
protected [symbols.filter](ctx: Context) {
  return ctx[symbols.isolate][this.name] === this.ctx[symbols.isolate][this.name]
}

两行实现”这个服务对哪个上下文可见”:比较请求方与服务提供方的 isolate 标签——标签相同(同一作用域)才可见。dsh 的”每个 agent 一套工具”就是靠这个判定(第 10 篇)。

拦截配置的合并顺序

[symbols.resolveConfig](service.ts

)实现了 intercept 配置的合并:

// vendor/cordis/src/service.ts:86-102(节选)
[symbols.resolveConfig](base?: T, head?: T): T {
  let intercept = this.ctx[Context.intercept]
  const configs: any[] = []
  while (this.name in intercept) {           // 沿原型链向上收集
    if (Object.hasOwn(intercept, this.name)) {
      configs.unshift(intercept[this.name])  // 越靠近根的越先(低优先)
    }
    intercept = Object.getPrototypeOf(intercept)
  }
  if (base) configs.unshift(base)
  if (head) configs.push(head)
  ...
  return Object.assign({}, ...configs)       // 后面的覆盖前面的
}

合并顺序:根侧的拦截最先,当前 ctx 的最后(最高优先),base 垫底、head 盖顶。服务声明了 Config.merge 时用它合并(处理需要深合并的场景),否则浅 assign。

一次 provide 的完整路径

图表(cordis-services.md)

与其他模块的联系

与其他模块的联系

  • fiber(第 8 篇)provide 的第三参数 check 是可用性判定,注册本身登记为 fiber 的 effect——fiber 卸载即注销;
  • 依赖注入(第 5 篇):provide 会 notify 等待该服务的全部 fiber,触发级联加载;
  • dshToolRuntime extends Service(构造器还顺手向 systemPrompt 注册工具提供方)、FileSystem extends Service(抽象基类等子类实现)、LlmRuntime extends Service——你在 dsh 里见到的每个 ctx 键都长这样。

常见踩坑

  1. 同名服务二次 provide——被 reflect 层拒绝(重复提供服务),一个 ctx 一个实现;
  2. 在构造器里读 this.config——拦截配置的合并发生在 resolveConfig,时机由框架控制,插件应通过注入的 config 参数拿配置;
  3. 以为 ctx.x = y 也能注册服务——Proxy 的 set 走 internal/set 校验,正规路径是 provide/Service。

随堂练习(带验收标准)

  1. 写一个 class Clock extends Service 提供 now() 方法,并在另一插件注入使用。验收:注入处有完整类型(记得声明合并);
  2. 给 Clock 加 [Service.invoke],使 ctx.clock('now') 可调用。验收:调用返回时间字符串;
  3. 构造两层 ctx 各自 intercept 不同配置,打印 resolveConfig 的合并结果,验证”根先子后、子覆盖根”。

← 返回文章列表