声明式配置与可逆效果注册实操

约 35 分钟

使用 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 的双重导出:

typescript
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. 1

    创建插件文件 heartbeat.ts,写入包含 Schemastery 配置模式与 ctx.effect 清理逻辑的完整代码。

    typescript
    import 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. 2

    创建 cordis.yml 挂载配置文件,为插件指定非默认配置参数。

    yaml
    - name: './heartbeat.ts'
      config:
        intervalMs: 2000
        message: 'Hello DeepSeek Harness'
  3. 3

    使用 Harness / Cordis 运行时启动应用并观察定时器输出。

    bash
    dsh --profile headless
  4. 4

    修改 cordis.yml 中的 message 为 Reloaded Harness,保存后触发热重载,观察控制台生命周期流转。

    yaml
    - name: './heartbeat.ts'
      config:
        intervalMs: 2000
        message: 'Reloaded Harness'

你应该看到

在修改配置文件后,终端首先输出旧实例的清理日志 [Plugin] 已安全清理定时器,紧接着输出新实例的初始化日志 [Plugin] 启动,消息内容: Reloaded Harness,未出现多个定时器并发叠加输出的情况。

遇到问题:修改 cordis.yml 配置文件后,控制台抛出 TypeError 或插件直接停止挂载。

可能原因

  • YAML 中的配置键类型与 Schemastery Schema 中定义的类型不匹配(例如将 number 字段写成了字符串格式的数字)。
  • 必填字段未在 YAML 中提供,且 Schema 中未配置 .default() 默认值兜底。

排查步骤

  1. 检查终端中的 Schema 校验错误堆栈,确认报错字段的具体位置。
  2. 在插件的 Config Schema 定义中为非必填参数添加 .default(...) 或标记可选。
  3. 使用 dsh --dump-config 检查当前合成的最终配置树,确认反序列化结果是否合法。
自检:为什么在 DeepSeek Harness 插件开发中,推荐使用 ctx.effect() 注册带副作用的逻辑,而不是直接在 apply() 函数体顶层执行?

直接在 apply() 顶层执行副作用逻辑无法向 Cordis 框架注册销毁钩子(Disposer)。在插件配置更新发生热重载或插件被卸载时,原有的定时器、网络连接或事件监听无法被自动注销,会导致内存泄漏和重复触发。使用 ctx.effect() 返回清理函数可以确保资源生命周期与插件上下文完全同步绑定。