使用 Schemastery 编写类型安全的插件配置项,并利用 ctx.effect 实现热重载下的资源可逆清理。
上一课时我们完成了插件四大导出(name、inject、Config、apply)的逐项剖析,并基于 Context 容器理解了服务解耦查找与 inject 拓扑就绪的底层原理——这套结构基础让我们看清了一个插件应当如何被声明、如何被定位、如何被装配。然而,仅有结构还不足以应对真实场景:硬编码的插件逻辑难以适配多变的环境。
DeepSeek Harness 依托底层 Cordis 内核,推崇“配置即数据”与“可逆副作用”的设计哲学。本节课我们将通过 Schemastery 为插件声明结构化配置,并使用 ctx.effect() 注册受控的有副作用逻辑,确保插件在热重载与卸载时能够无缝清理资源。
核心概念
基于 Schemastery 的声明式插件配置 (Config)
就像家用电器使用防呆插座与电压规格说明书来确保安全通电一样,插件配置也需要一份明确的“规格说明”。如果用户在配置文件中提供了错误的数据类型或遗漏了必填字段,系统应在启动前精确拦截。
在 DeepSeek Harness 中,声明式配置是通过导出与 TypeScript 接口同名的 Config 模式对象(基于 @deepseek-ai/schemastery)实现的。Schemastery 允许开发者定义对象字段、数据类型、默认值以及描述信息。Loader 在挂载插件前会依据该模式对 YAML/JSON 传入的原始配置进行校验与反序列化,确保传入 apply(ctx, config) 函数的 config 严格符合类型约束。
核心概念
可逆生命周期与自动清理机制 (ctx.effect)
就像租借共享办公位并在退租时必须将私人物品带走、钥匙归还一样,插件在运行时挂载的所有状态在插件停用时都必须原样撤回。
在 Cordis 架构中,可逆生命周期(Reversible Effects)是指插件对系统上下文施加的任何改变(如启动定时器、占用网络端口、注册工具定义、绑定事件监听器)都必须具备对应的反向逆操作。通过 ctx.effect(() => { ... return disposer }) 注册逻辑时,返回的 Disposer 清理函数会被 Harness 的 Fiber 状态机追踪。当插件因热重载更新、依赖卸载或进程退出而被销毁时,系统会自动倒序执行这些 Disposer,保证零内存泄漏与无污染回滚。
编写标准 Config 与 ctx.effect 插件
下面展示一个标准的可逆配置插件实现。注意 Config 接口与同名 Config Schema 的双重导出:
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
export const name = 'demo-heartbeat'
// 1. 定义 TypeScript 配置接口
export interface Config {
intervalMs: number
message: string
debug?: boolean
}
// 2. 导出同名 Schemastery Schema(含默认值与约束)
export const Config: Schema<Config> = Schema.object({
intervalMs: Schema.number().default(5000).description('心跳间隔毫秒数'),
message: Schema.string().default('Ping').description('心跳日志消息'),
debug: Schema.boolean().default(false).description('是否开启调试日志'),
})
// 3. 在 apply 中接收类型化的 config 并注册可逆 effect
export function apply(ctx: Context, config: Config) {
ctx.effect(() => {
if (config.debug) {
console.log(`[heartbeat] 启动心跳监听,间隔: ${config.intervalMs}ms`)
}
const timer = setInterval(() => {
console.log(`[heartbeat] ${config.message} (at ${Date.now()})`)
}, config.intervalMs)
// 核心:返回 Disposer 函数,用于在重载或卸载时销毁定时器
return () => {
clearInterval(timer)
if (config.debug) {
console.log('[heartbeat] 心跳定时器已成功注销')
}
}
})
}实操步骤:配置校验与热重载生命周期验证
- 1
创建插件文件
heartbeat.ts,写入包含 Schemastery 配置模式与 ctx.effect 清理逻辑的完整代码。typescriptimport type { Context } from '@deepseek-ai/cordis' import Schema from '@deepseek-ai/schemastery' export const name = 'heartbeat' export interface Config { intervalMs: number message: string } export const Config: Schema<Config> = Schema.object({ intervalMs: Schema.number().default(1000), message: Schema.string().default('tick'), }) export function apply(ctx: Context, config: Config) { ctx.effect(() => { console.log(`[Plugin] 启动,消息内容: ${config.message}`) const timer = setInterval(() => { console.log(`[Tick] ${config.message}`) }, config.intervalMs) return () => { clearInterval(timer) console.log('[Plugin] 已安全清理定时器') } }) } - 2
创建
cordis.yml挂载配置文件,为插件指定非默认配置参数。yaml- name: './heartbeat.ts' config: intervalMs: 2000 message: 'Hello DeepSeek Harness' - 3
使用 Harness / Cordis 运行时启动应用并观察定时器输出。
bashdsh --profile headless - 4
修改
cordis.yml中的message为Reloaded Harness,保存后触发热重载,观察控制台生命周期流转。yaml- name: './heartbeat.ts' config: intervalMs: 2000 message: 'Reloaded Harness'
你应该看到
在修改配置文件后,终端首先输出旧实例的清理日志 [Plugin] 已安全清理定时器,紧接着输出新实例的初始化日志 [Plugin] 启动,消息内容: Reloaded Harness,未出现多个定时器并发叠加输出的情况。
如果一个插件内涉及多个需要清理的资源(例如定时器、文件流句柄和套接字监听),应尽量将它们封装在同一个 ctx.effect() 中或为每个独立功能分别建立独立的 effect 块。Cordis 会按照注册的严格逆序执行清理,确保下游依赖最先被释放。
遇到问题:修改 cordis.yml 配置文件后,控制台抛出 TypeError 或插件直接停止挂载。
可能原因
- YAML 中的配置键类型与 Schemastery Schema 中定义的类型不匹配(例如将 number 字段写成了字符串格式的数字)。
- 必填字段未在 YAML 中提供,且 Schema 中未配置
.default()默认值兜底。
排查步骤
- 检查终端中的 Schema 校验错误堆栈,确认报错字段的具体位置。
- 在插件的 Config Schema 定义中为非必填参数添加
.default(...)或标记可选。 - 使用
dsh --dump-config检查当前合成的最终配置树,确认反序列化结果是否合法。
自检:为什么在 DeepSeek Harness 插件开发中,推荐使用 ctx.effect() 注册带副作用的逻辑,而不是直接在 apply() 函数体顶层执行?
直接在 apply() 顶层执行副作用逻辑无法向 Cordis 框架注册销毁钩子(Disposer)。在插件配置更新发生热重载或插件被卸载时,原有的定时器、网络连接或事件监听无法被自动注销,会导致内存泄漏和重复触发。使用 ctx.effect() 返回清理函数可以确保资源生命周期与插件上下文完全同步绑定。