跳到主内容
创建时间:最后更新:作者:白川

开发具有明确契约的插件

用契约、facet 和 host 建立可调用的插件,管理对象引用与调用生命周期。

开发具有明确契约的插件

如果一个检查能力要供桌面界面、后台任务和多个进程使用,只在命令行注册工具还不够。调用者需要稳定的接口、可观察状态,以及明确的执行位置。本文用 Chord 演示这种应用内插件组织方式。

这里把“完成了一次检查”简化成计数增加,专门观察契约和生命周期,不读取源码,也不调用模型。它是可运行的最小协议实验,不是完整代码审查服务。

示例位于 columns/coding-agent/examples/plugin-development/,依赖固定为 Chord 1.5.0。在该目录执行 npm install 后运行 npm start。

契约先定义调用者能看到什么

ts
import { hostNamed, method, remote } from "@earendil-works/chord"
import { num, obj } from "@earendil-works/pi-schema"

export const Inspection = remote("lesson.inspection", {
  version: 1,
  input: obj({}),
  host: hostNamed("server"),
  variants: {
    default: {
      state: { count: num() },
      methods: { run: method(obj({})) },
    },
  },
})

契约名称和版本标识接口,input 参与对象寻址,hostNamed('server') 声明对象所在 host。这里输入为空,因此在本例相同 principal 下请求同一个对象地址。

state 是可观察数据,methods 是可调用动作。调用者得到引用,不直接拿到实现对象,更不应随意改写状态。Schema 在运行时仍存在,用于约束跨边界数据;TypeScript 类型在编译后不会替你验证网络 JSON。

facet 负责提供实现

ts
import { defineFacet, type Facet } from "@earendil-works/chord"
import { num } from "@earendil-works/pi-schema"
import { Inspection } from "./contract.js"

export function inspectionFacet(by: number, stateVersion = 1): Facet {
  return {
    name: "lesson/inspection",
    ...defineFacet((host) => {
      host.serve(Inspection, (_input, _principal, self) => {
        const count = self.state("count", num(), 0, {
          version: stateVersion,
          migrate(old, from) {
            if (from !== 1 || typeof old !== "number" || !Number.isFinite(old)) {
              throw new Error("不能迁移该计数版本")
            }
            return old + 100
          },
        })
        return {
          variant: "default",
          count,
          run: async (_args, ctx) => {
            count.change(ctx, (value) => value + by)
          },
        }
      })
    }),
  }
}

name 是 facet 的稳定身份,后续重载会用到。host.serve 把契约连接到工厂,工厂通过 self.state 声明对象拥有的状态。这里工厂返回实现,真正修改发生在 run 的调用上下文内。

迁移函数先保留给下一篇使用;状态版本不变时不会每次初始化都加 100。状态 cell 名称 count 是恢复匹配的一部分,随意改名会变成另一个状态,不能把改名误认为迁移。

host 持有运行生命周期

ts
import { createHost } from "@earendil-works/chord"
import { Inspection } from "./contract.js"
import { inspectionFacet } from "./facet.js"

const host = createHost({ name: "server", self: { kind: "server" } })
try {
  await host.load([inspectionFacet(1)])
  const ref = await host.get(Inspection, {})
  try {
    await host.root((ctx) => ref.run({}, ctx))
    console.log(JSON.stringify({ count: ref.count.value }))
  } finally {
    ref.release()
  }
} finally {
  await host.close()
}

运行应输出 {"count":1}。host.get 持有引用,host.root 给本次调用提供上下文,ref.release 释放引用,host.close 关闭宿主。finally 保证调用出错时仍释放。

也可以在 root 内使用 ctx.get 获取随调用范围释放的引用。本文显式持有引用,为下一篇演示跨重载继续使用同一个引用;这不是建议把所有引用都做成全局变量。

为什么要显式传调用上下文

上下文携带取消、调用归属等信息。把旧上下文保存到下一次请求再复用,会把新调用绑到已经结束的生命周期。实际服务应在每个请求创建 root,并把同一调用范围的 ctx 向下传。

对象引用默认按引用生命周期管理,最后一个引用释放后可能进入回收流程。需要常驻的服务要按契约类型和 host 生命周期规则声明,不要依靠忘记 release 维持存活。资源通过对象的资源声明管理,也不应在工厂里裸开连接后失去所有者。

从同进程到多进程,还差哪些工作

本例只有一个 host,在同一 Node 进程运行。Chord 也提供内存、IPC 和 WebSocket link;真实多进程接入仍要配置 host 路由、连接、principal、断线与取消,不能只把 remote 写进契约就宣称跨进程已经完成。

principal 用于识别对象和调用权限相关的身份,仍需宿主实施认证与授权。remote、Schema 和代码热重载都不是执行不可信插件的沙箱。先验证一个 host 内的接口与释放,再扩展传输边界,出现失败时才容易定位。

依据与版本

示例依据已发布的 @earendil-works/chord@1.5.0 包内 README、类型声明与本地执行结果。在线主分支已有 v2 设计材料,createPlugins、plugins/runtime 等写法不能直接混入本篇的 1.5 示例。