Cordis 插件系统
dsh 的一切能力都挂在 Cordis 这棵树上。Cordis 是一个依赖注入框架,它的核心主张只有一句话。插件向一个共享的上下文贡献服务、类型化事件和可撤销的效果。本章把这句话拆开讲清楚,它是后面所有章节的出发点。
先看插件长什么样。一个 Cordis 插件就是一段声明式的代码单元。它先通过 inject 声明自己依赖哪些服务,装载时框架把一个上下文对象交给它,习惯上写作 ctx。插件在自己的装配函数里拿到 ctx,向它注册服务、订阅事件、登记副作用。框架按依赖关系把插件排成偏序,依次装配。依赖其他服务的插件,装配时能确定自己要的服务已经就位。
ctx 是理解 Cordis 的第一个关键。它就是一张按名字索引的服务注册表。模型适配注册在 ctx.llm 上,工具注册在 ctx.tools 上,会话服务注册在 ctx.sessions 上。插件之间不直接 import 对方的实例,都通过 ctx 这个中转站查找。这样的好处在第 1 章讲过,服务实现可以整块替换,只要名字不变,使用方无感。
第二个关键是事件。服务之间除了调用,还需要广播和拦截。Cordis 把事件的类型定义用 TypeScript 的声明合并机制挂在服务接口上,事件名和负载类型因此是编译期可查的。事件的分发有四种模式,emit、waterfall、parallel、serial,分别对应观察、包装、并发、顺序四种协作形态。下一章会专门讲其中最常用的 waterfall,这里先记住一件事,事件是 dsh 里最重要的扩展点,几乎所有策略注入都通过事件完成。
第三个关键是可撤销的效果,这是 Cordis 最有辨识度的设计。插件注册一个服务、订阅一个事件、启动一个后台任务,这些动作在 Cordis 里都叫效果(effect),统一通过 ctx.effect() 登记。登记什么,就附带登记怎么撤销。注册服务时留下注销函数,订阅事件时留下退订函数。当插件被卸载,框架按登记的逆序把这些撤销动作全部执行一遍,服务从注册表消失,事件监听退干净,后台任务停掉,就像这个插件从未来过。
把注册做成可撤销的效果,直接的受益者是热更新和组合实验。dsh 支持在运行中替换插件,旧插件卸载、新插件装载,中间不留残渣。对使用者来说,调整行为从此不需要重启进程。对开发者来说,测试一个新插件,测完就卸载,测试环境自动回到初始状态。dsh 仓库甚至为这一点定了规矩,每个包都要证明自己贡献的注册在插件卸载后确实被移除,这样的测试是仓库门禁的一部分。
传统框架里也有插件系统,为什么说 Cordis 的更彻底。差别在于地位。多数框架的插件运行在核心预留的插槽里,核心定义了哪些位置可以插、插进去能做什么。Cordis 没有这个分层,dsh 的执行循环、工具注册表、会话日志这些所谓核心件,与其他插件在同一个注册表里排队,接受同样的卸载规则。第 1 章那句一切皆插件,落到代码上就是这个事实。
装载这一切的入口同样值得一看。第 1 章提过 profile 与 bundle 的叠加,落到 Cordis 这边就是一张有序的配置表,每行指向一个插件,附带它需要的配置段。配置文件允许少量受控的动态能力,除此之外全部是字面量,叠加层通过替换或插入整行来生效。dsh 对配置有一条明确的态度,配错了要当场大声失败。能自证的错误在装载时就报出来,做不到的推迟到最早可解析的时刻,静默跳过缺失的引用是被禁止的。用过各种框架的人都知道,配置错误在运行三小时后才暴露是什么体验,这条态度值得单独记一笔。
另一个容易混淆的概念是可选依赖。一个插件声明强依赖,框架装配时保证服务就位。某个服务可能不存在时,插件改用查询式的获取,查不到拿到空值,由插件自己决定降级行为。两种写法分工明确,依赖的确定性写进声明,可缺失性写进查询,不靠约定俗成。
总结一下本章的三个名词,后面会反复出现。服务是插件贡献的能力,挂在 ctx 的名字下。事件是插件之间的协作通道,类型在编译期可查。效果是一切注册动作的记账单位,登记与撤销成对出现。下一章我们深入事件机制,看 dsh 如何用一个 waterfall 模式统一了从权限检查到请求改写的所有中间件需求。