ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cordis 的设计哲学:一个只做三件事的插件内核

Cordis 的设计哲学:一个只做三件事的插件内核 搭过一个插件系统的人大概都经历过同一种「内核膨胀」最开始内核只管加载插件后来你发现插件要互相依赖于是加依赖管理依赖理清了插件又要共享状态于是加全局注册表再后来还要热更新、要配置、要生命周期钩子……不知不觉内核变成了一坨谁都不敢动的意大利面而插件反而越来越薄成了内核的「配角」。Cordiscordis.js.org也就是 Koishi 的底层插件内核走了一条完全相反的路。它的设计哲学极其专注——只做三件事生命周期管理插件的加载与卸载、插件解析处理插件间的依赖关系、上下文传播管理插件间的数据共享。除此之外路由、命令、网络、存储、权限统统交给插件。这篇文章我想把这三件事彻底拆开它到底做了什么、没做什么、为什么这么设计是对的最后再给你一份能直接照抄的最佳实践。我默认你写过至少一个带插件机制的 demo所以不从头讲什么是依赖注入。先说清楚它是一个内核不是一个框架很多人第一次看 Cordis 会困惑「这也叫插件框架连个路由都没有」说得对因为它本来就不是框架而是一个容器——一个依赖注入容器外加一棵作用域树。框架会递给你勺子「来用我的 Router 写路由用我的 Command 写命令。」容器不递勺子它只负责把插件装进去、把依赖理清楚、把上下文传下去。至于勺子长什么样是插件自己的事。这张图是理解后面一切的关键中间是极薄的内核外圈是各种插件Database、Logger、HTTP、Cache、Auth……底部三根支柱就是它仅有的三件事。内核不碰业务逻辑它只是让插件们能「装得上、理得清、传得开」。为什么这种克制值得专门写一篇因为「内核只做三件事」不是偷懒而是把复杂度锁死在插件层、把稳定性锁死在内核层的最优解。下面逐根支柱拆。第一件事生命周期管理加载与卸载生命周期听起来平平无奇但 Cordis 的实现方式才是精髓。加载一行ctx.plugin(Plugin, options)背后发生了三件事Cordis 为这个插件创建一个子 Context也就是一个作用域执行插件的 setup 函数把ctx传进去插件在 setup 里通过ctx注册事件、服务、命令——这些注册项全部属于这个子 Context。卸载时你只需要ctx.dispose()。注意这里不需要手动off掉事件、不需要手动关闭连接、不需要手动从注册表里移除自己。因为所有注册都挂在子 Context 上dispose 会级联回收整个子树。这和传统插件系统最大的区别是清理从「手动、易漏」变成了「确定性、自动」。过去的写法你注册十个监听器卸载时得记得解绑十个漏一个就是内存泄漏Cordis 下作用域一销毁里面的一切随之归零。看一个最小可运行的插件import{Context}fromcordisconstrootnewContext()functionlogger(ctx){// 注册事件监听随 ctx 生命周期自动清理ctx.on(ready,()console.log(logger ready))// 提供一个名为 logger 的服务ctx.provide(logger)ctx.logger{info:(m)console.log(m)}// 需要显式释放的资源挂在 dispose 上ctx.on(dispose,()console.log(logger disposed))}root.plugin(logger)// 卸载上面所有注册项随之回收无需手动解绑root.dispose()我特别想强调一句生命周期管理不是「手动 register/unregister」的语法糖升级而是「基于作用域的级联清理」这件事本身。你写的插件越多越能体会到这个区别的价值——你永远不用再担心「我卸载的时候漏了哪根线」。第二件事插件解析依赖关系插件一多第一个真正让人头疼的问题是「谁先谁后」。手写初始化顺序既脆弱又难维护今天加个插件要改三处启动顺序明天换个依赖又得重排稍不留神就是启动期undefined is not a function。Cordis 的解法是依赖声明 自动解析。插件通过inject声明它需要的服务而不是去import另一个插件。这两者的差别比看起来大得多。机制是这样的当插件 A 声明inject: [database]但此刻还没有任何插件提供database服务A 会进入pending挂起状态——它不执行、也不报错只是安静地等着。一旦某个插件执行了ctx.database ...Cordis 会重新评估整张依赖图把 A 自动激活。依赖图必须是一张有向无环图DAG。如果出现循环依赖A 要 BB 又要 ACordis 在解析时会直接抛错而不是陷入死锁——它把「配置错误」暴露在你启动的那一刻而不是藏在某个不确定的运行时行为里。这才是真正的依赖注入插件之间不直接 import 彼此只声明「我需要什么」。解耦粒度是「能力」级别的而不是「模块」级别的。一个插件换掉数据库实现只要它仍然提供database服务依赖它的所有插件毫无感知。functiondatabase(ctx){ctx.provide(database)ctx.databasenewDatabase()}database.inject[]// 无依赖最先就绪functioncache(ctx){ctx.on(ready,(){ctx.cachenewCache(ctx.database)// 直接用注入的服务})}cache.inject[database]// 声明依赖必须先有 databaseroot.plugin(database)root.plugin(cache)// cache 会在 database 就绪后自动激活你看这里完全没有「先 plugin database 再 plugin cache」的隐含顺序约定——内核按拓扑排序自己搞定。这就是 pending 机制最大的价值启动顺序从你必须操心的事变成了内核自动处理的事。第三件事上下文传播数据共享既然插件不互相 import它们靠什么交换数据答案是那棵贯穿全文的Context 树。Context 是层级化的。根 Context 通过ctx.plugin()派生出子 Context子级又能派生孙级形成一棵作用域树。在这棵树上数据共享有两套机制第一套是服务继承长生命周期的共享能力。服务沿树向下继承底层用原型链实现根上提供的database子级、孙级都能直接ctx.database拿到。但反过来父级看不到子级的私有服务——这正是天然的隔离。一个插件在子作用域里提供了foo只有它自己和他的后代能用根和兄弟分支完全无感。第二套是事件传播短生命周期的数据流。ctx.emit / ctx.serial / ctx.parallel让数据沿树流动插件之间通过事件协作而不是直接拿彼此的引用。为什么这套机制比「全局单例」高级因为全局变量也能共享但它无法隔离、无法按作用域清理、无法在测试里被替换。Cordis 的 ctx 树把「共享」和「隔离」统一在同一个原语里你想共享就在上层 provide 一个服务下层自动继承你想隔离就开一个子 Context作用域之外根本看不见。在这个基础上ctx.intersect()、ctx.isolate()、ctx.groups()这类方法还能做更精细的作用域控制比如按用户分组、按条件隔离。共享与隔离的粒度完全由你决定。把三件事合起来看单独看每一件都谈不上惊艳但三件事拼在一起就是一个完整的最小插件运行时生命周期管「装得进、卸得掉」插件解析管「理得清依赖」上下文传播管「传得了数据」。有了这三件路由、命令、网络、存储、权限……全都可以是构建在 Cordis 之上的插件——Koishi 就是这样一路搭出来的。内核越薄上层越自由内核一旦开始掺和业务逻辑插件就再也薄不下来了。这其实就是「一切皆插件」思想最干净的工程落地它不是一句口号而是「内核只做三件事」这条纪律。最佳实践哪些最值得抄机制讲完落到「如果你要照着搭一套照抄什么」。下面这几条是我认为最值钱的也正好对应上面的图。1. 用服务解耦别用 import。插件之间一律通过ctx上的服务通信而不是相互 import 源码。这是 Cordis 解耦的根本也是「换实现不影响调用方」的前提。2. 依赖显式声明 inject。不要靠启动顺序的潜规则也不要在 setup 里偷偷require别的插件。用inject: [...]把依赖讲清楚让内核来掌控解析顺序和 pending。3. 子 Context 即功能开关。需要动态开关某组能力时用ctx.plugin()创建一棵子树卸载这棵子树就等于关掉这组功能——干净、无残留比一堆if (enabled)优雅得多。4. 避免循环依赖。依赖图保持 DAG。循环依赖会被内核检测并直接报错别等它变成运行时诡异 bug 才后悔。5. 善用 pending 管理启动顺序。别再手写「先初始化 A 再初始化 B」的编排代码。声明好 inject剩下的交给 pending 机制启动顺序自动正确。6. 内核保持极薄。这是纪律也是边界。能力路由、存储、权限全部做成插件机制生命周期、依赖、作用域留在内核且不要膨胀。内核每多一行业务插件的自由度就少一分。7. 资源释放挂在 dispose 上。对于需要显式释放的东西数据库连接、定时器、文件句柄用ctx.on(dispose, ...)注册回收逻辑。别指望 GC 替你收拾。8. 用 intersect / isolate / groups 控制共享粒度。共享不是非黑即白。该隔离的数据开子作用域该共享的能力提到上层 provide粒度由需求决定而非图省事全塞全局。我的看法Cordis 的「只做三件事」我愿意把它称作一种克制的美学而不仅仅是一个技术选择。太多框架死于「内核什么都要管」——管着管着开发者就只能在框架画的框里跳舞。Cordis 反过来了它把稳定且困难的事生命周期、依赖、作用域收在内核把多变且自由的事业务、能力、扩展全部推给插件。如果你正在设计自己的插件系统我建议你先问自己一个问题这件事是「机制」还是「能力」生命周期、作用域、依赖解析是机制留给内核具体能跑什么业务是能力做成插件。分清楚这条线你的内核自然就薄了插件自然就活了。好的插件内核从来不是功能最多的那个而是最薄的那个。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进