课程总纲 / 阶段 3 · 插件开发
L14

插件配置:Config 与 Schema

让你的插件可配置、可分发给别人用——并且配错了会大声报错。

预计阅读 25 分钟难度 ★★★☆☆动手课

一、给 greet 加配置Add Config

插件配置走"双导出":类型(TypeScript 用)+ 同名的 Schema(运行时校验)。改写 L13 的插件:

import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'

export const name = 'greet-tool'

// ① 类型:给 TypeScript 看的合同
export interface Config {
  greeting: string
  maxRetries: number
  verbose?: boolean
}

// ② Schema:给运行时校验用,默认值直接写在字段上
export const Config: Schema<Config> = Schema.object({
  greeting: Schema.string().default('Hello'),
  maxRetries: Schema.number().default(3),
  verbose: Schema.boolean().default(false),
})

export function apply(ctx: Context, config: Config) {   // ← 第二个参数
  console.log(config.greeting)   // 用户值或 schema 默认值
}

然后在 cordis.yml 里传配置:

- insert:
    - id: hello
      name: '/绝对路径/scratch-plugin/src/my-plugin.ts'
      config:
        greeting: '你好'
        maxRetries: 5
两个易错点:① 不要导出普通对象当 Config——Cordis 需要它实现 Standard Schema 接口(能校验、能填默认值);② 类型接口和 Schema 值同名(都叫 Config)是约定,Loader 按名字找。

二、更严的校验:必填、枚举Strict Validation

export const Config = Schema.object({
  apiKey: Schema.string().required(),                        // 必填:缺了加载就失败
  timeout: Schema.number().default(30000),
  mode: Schema.union(['fast', 'accurate']).default('fast'),  // 只准二选一
})

校验发生在加载时:配置非法,插件当场加载失败并给出可操作的报错——而不是带着错误配置跑起来,半夜再炸。

三、设计原则一:不硬编码可调参数No Hardcoded Tunables

判据:两个部署环境可能想要不同的值,就必须是配置字段。测试方法:不写代码、只改 cordis.yml 能否改变这个值?

❌ 硬编码

const TIMEOUT = 30000  // 换个环境就得改代码

✔ 配置化

export interface Config {
  timeoutMs: number  // 默认 30000,yaml 可改
}

注意边界:协议常数、外部规范、安全不变量不是"可调参数",就该写死(比如 HTTP 状态码的含义不会因部署而异)。仓库对"一个 DEFAULT_* 常量就算可配置"的说法明确说不——配置必须在 Config 字段上。

四、设计原则二:配错要大声失败Fail Loud

自包含的错误(缺字段、类型不对、枚举外取值)→ 写进 schema,加载时就失败引用性的错误(配置指向不存在的服务/资源)→ 在最早能解析的时点失败;绝不静默跳过缺失的引用——"忍一忍继续跑"把错误推迟到最难排查的地方。像登机安检:能当场查的(证件类型)当场查;要联网核实的(签证状态)在登机口前必须出结果;绝不能"看着像没事就放行"。

五、显式默认:resolve 模式Explicit Resolve

仓库推崇的进阶写法(dsh-shell 是模板):默认值不藏在执行逻辑的 ?? 里,而是集中在一个显式的 resolve(request): Spec 步骤

// 请求(可能不完整)→ resolve 补全 → Spec(完整明确)→ run 只管执行
function resolve(request: Request): Spec {
  return { timeoutMs: request.timeoutMs ?? 30000, cwd: request.cwd ?? homedir() }
}
async function run(spec: Spec) { /* 只见完整 Spec,无默认值逻辑 */ }

好处:默认值的决策集中可见——要找"这个参数默认是多少",看 resolve 一处即可。这是"显式 > 隐式"约定在配置上的落点。

六、配置与 HMRConfig Meets HMR

改配置 = 热替换插件:旧实例卸载(注册全部回收)→ 新实例带新配置加载。因为"注册即效果",替换不会残留旧实例的任何注册。调参不用重启——改完 cordis.yml 保存即可。

七、!!js 表达式(了解即可)JS Expressions

cordis.yml 的 configdisabled 两个字段允许 !!js 表达式(两个感叹号),在插件上下文里求值:

- id: my-app
  name: '@example/my-app'
  config:
    port: !!js ctx.myAppStartup.port ?? 8080   # 运行时取启动服务给的端口
  disabled: !!js ctx.env.name === 'ci'        # CI 环境不挂我

其余元数据(id、name、inject 等)必须字面量;环境相关的组合优先用这种覆盖层表达。

Config 双导出
同名 interface(类型)+ Schema(校验),Loader 按名取。
fail loud
配置错误尽早、尽量大声地失败,绝不静默跳过。
resolve/spec 模式
显式补默认的解析步骤,执行只见完整 Spec。
!!js
YAML 里嵌 JS 表达式,仅限 config 和 disabled 字段。
✏️ 动手练习
  1. 给 L13 的 greet 工具加 greeting 配置(默认 'Hello'),在 cordis.yml 里改成 '你好',验证输出变化。
  2. 加一个 required() 的字段,故意不在 yaml 里提供,观察加载失败时的报错(体会 fail loud)。
  3. style: Schema.union(['formal','casual']),传非法值 style: 'yolo',确认被拒。
  4. 保存 yaml 触发热重载,确认改配置不用重启进程。
📝 自测(点击展开答案)
1. Config 的"双导出"指什么?为什么需要两个?
同名导出 interface Config(编译期类型)和 Schema 值 Config(运行时校验+默认值)。类型管开发时,Schema 管加载时——cordis.yml 是外部输入,必须在边界校验。
2. 怎么判断一个值该不该做成配置字段?
判据:两个部署环境可能想要不同的值;测试:只改 cordis.yml 不改代码能否改变它。协议常数、外部规范、安全不变量除外。
3. "自包含错误"和"引用性错误"分别在哪失败?
自包含(类型/必填/枚举)→ schema 在加载时拒绝;引用性(指向不存在的服务/资源)→ 最早能解析的时点失败。共同底线:绝不静默跳过。
4. resolve(request): Spec 模式解决什么问题?
默认值决策的分散问题:不把 ?? default 撒在 run() 各处,而是集中在显式 resolve 步骤产出完整 Spec;dsh-shell 是官方模板。
5. 改配置需要重启 dsh 吗?
不需要。配置变更热替换插件:旧实例连同注册一起卸载,新实例带新配置加载。
权威出处:basic/config.zh.md(本课主源) · cordis-primer(Loader Configuration 节) · AGENTS.md(显式>隐式、fail loud 条目)