为同一能力接口编写两个 Provider 并通过配置切换,验证 Consumer 无需修改即可获得新实现。
上一课用电源插座看清了能力接缝:Definition 声明接口,Provider 提供可替换实现,Consumer 只依赖接口;也看到切换 Provider 时改动范围只落在 Provider 与配置两处。现在把这个结论落地成能跑的代码。
你的任务:为同一个时钟接口写两个 Provider,一个返回 UTC 时间,一个返回本地时间;再写一个 Consumer 打印读数。最后只改配置行,输出就从 UTC 变成本地时间,Consumer 代码不动——改动正好落在上一课说的那两处。
挂载哪个 Provider 由配置行决定,换能力不动主程序。
核心概念
配置行就是切换开关
类比:手机只认识 USB-C 口,不关心另一头是哪种充电头。换充电头,手机照常充电,只是快慢不同。USB-C 口是接口,充电头是 Provider,手机是 Consumer。
技术定义:接口由 Definition 声明。每个 Provider 以插件形式向共享上下文注册同一个服务键;Consumer 用 inject 请求这个键,运行时拿到的永远是当前被挂载的实现。切换 Provider 就是替换配置行:补丁按行 id 替换整行配置,新 Provider 挂载、旧 Provider 卸载,Consumer 毫无感知。
DeepSeek Harness 是 developer preview,破坏性变更随时可能出现。动手前先钉住 @deepseek-ai/dsh 的版本,再核对文档。本课代码以 /docs/capability-layering 与 /docs/cordis-tutorial/services 为基准;签名若与你钉住的版本不一致,以文档为准。
定义统一能力接口
- 1
新建实验目录,先写接口文件。接口只声明方法,不含实现,也不依赖任何 Provider。
typescript// definition.ts export interface Clock { now(): string } - 2
定下规则:Consumer 只 import 这个文件,不 import 任何 Provider。目录里唯一被三方共享的就是它。
- 3
把接口文件提交入库。之后所有 Provider 与 Consumer 都从这里出发,本文件不再变动。
你应该看到
definition.ts 只包含 Clock 接口与一个方法 now(): string,不引用任何实现。
编写两个 Provider 实现
- 1
写 Provider A。
UtcClock继承 Service 基类,构造时以clock为键注册进上下文;now()返回 UTC 字符串。typescriptimport { Context, Service } from 'cordis' import type { Clock } from './definition' class UtcClock extends Service implements Clock { constructor(ctx: Context) { super(ctx, 'clock') } now() { return new Date().toUTCString() } } export const name = 'clock-utc' export function apply(ctx: Context) { ctx.plugin(UtcClock) } - 2
写 Provider B。结构与 A 完全一致,只有
now()的返回值不同。注册键仍然是clock。typescriptimport { Context, Service } from 'cordis' import type { Clock } from './definition' class LocalClock extends Service implements Clock { constructor(ctx: Context) { super(ctx, 'clock') } now() { return new Date().toString() } } export const name = 'clock-local' export function apply(ctx: Context) { ctx.plugin(LocalClock) } - 3
两个 Provider 共用
clock键,同一时刻只能挂载一个。这个约束正是后面切换的基础。
你应该看到
得到 provider-utc.ts 与 provider-local.ts 两个插件文件:注册键相同,行为不同。
编写 Consumer:注入接口而非实现
- 1
写 Consumer。声明
inject: ['clock'],等clock服务可用后再启动;ready 时打印一次读数。typescriptimport { Context } from 'cordis' import type { Clock } from './definition' export const name = 'clock-consumer' export const inject = ['clock'] export function apply(ctx: Context) { ctx.on('ready', () => { const clock = ctx.clock as Clock console.log('[clock-consumer] now =', clock.now()) }) } - 2
自查三处:Consumer 只依赖
Clock接口;inject 键名与注册键一致;文件里找不到UtcClock或LocalClock。 - 3
记住结论:将来出现 Provider C,Consumer 依旧不改。
你应该看到
consumer.ts 没有任何 Provider 类的 import;唯一外部依赖是 Clock 接口与 clock 服务键。
挂载、运行、切换、验证
- 1
在
cordis.patch.yml写一行,把clock-utc挂进插件树。补丁按行 id 替换整行配置,行 id 是切换的把手。yaml# cordis.patch.yml(第一次:UTC) plugins: clock-utc: { $local: ./provider-utc } clock-consumer: { $local: ./consumer } - 2
启动 dsh,看终端里的启动日志(Web UI 默认在 http://127.0.0.1:3080)。
bashnpx @deepseek-ai/dsh web - 3
应看到 UTC 输出,例如
[clock-consumer] now = Fri, 14 Aug 2026 08:00:00 GMT。 - 4
编辑补丁:Consumer 行不动,只把 Provider 行换成
clock-local。重启 dsh,或触发自动重载。yaml# cordis.patch.yml(第二次:本地时间) plugins: clock-local: { $local: ./provider-local } clock-consumer: { $local: ./consumer } - 5
再看日志,输出变成本地时间格式,例如
now = Fri Aug 14 2026 16:00:00 GMT+0800。Consumer 行一字未改。 - 6
回滚 Provider 行到
clock-utc,输出应回到 UTC。重复切换,直到你能预测每次输出。
你应该看到
只改动补丁中的 Provider 行,Consumer 输出在 UTC 与本地时间之间切换;consumer.ts 全程未修改。
遇到问题:切换配置后,Consumer 输出仍是旧 Provider 的行为。
可能原因
- 补丁行 id 与目标行不一致:补丁按 id 替换整行,id 对不上等于没改。
- 两个 Provider 同时挂载:都注册
clock键,注册结果相互冲突。 - Consumer 的 inject 键名与 Provider 注册键拼写不一致。
- 改动后没有重启,也没有触发自动重载。
排查步骤
- 先打印实际启动的插件树(架构文档有对应命令),确认只有目标 Provider 一行。
- 切换时整行替换,不要新增一行;同一时刻只保留一个 Provider。
- 对照三处键名:Provider 的
super(ctx, 'clock')、Consumer 的inject: ['clock']与类型声明。 - 重启 dsh 再看日志。若支持自动重载,确认保存补丁后重载已触发。
自检:为什么切换 Provider 不需要修改 Consumer?Consumer 依赖的是接口还是实现?
Consumer 通过 inject 依赖的是接口:只声明需要 clock 服务键,并把它当作 Clock 接口使用,从不 import 任何 Provider 类。运行时框架把配置行当前挂载的实现注入进来,所以换 Provider 只换配置,Consumer 代码不变。