实现并热替换一个能力 Provider

约 25 分钟

为同一能力接口编写两个 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 毫无感知。

定义统一能力接口

  1. 1

    新建实验目录,先写接口文件。接口只声明方法,不含实现,也不依赖任何 Provider。

    typescript
    // definition.ts
    export interface Clock {
      now(): string
    }
  2. 2

    定下规则:Consumer 只 import 这个文件,不 import 任何 Provider。目录里唯一被三方共享的就是它。

  3. 3

    把接口文件提交入库。之后所有 Provider 与 Consumer 都从这里出发,本文件不再变动。

你应该看到

definition.ts 只包含 Clock 接口与一个方法 now(): string,不引用任何实现。

编写两个 Provider 实现

  1. 1

    写 Provider A。UtcClock 继承 Service 基类,构造时以 clock 为键注册进上下文;now() 返回 UTC 字符串。

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

    写 Provider B。结构与 A 完全一致,只有 now() 的返回值不同。注册键仍然是 clock。

    typescript
    import { 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. 3

    两个 Provider 共用 clock 键,同一时刻只能挂载一个。这个约束正是后面切换的基础。

你应该看到

得到 provider-utc.ts 与 provider-local.ts 两个插件文件:注册键相同,行为不同。

编写 Consumer:注入接口而非实现

  1. 1

    写 Consumer。声明 inject: ['clock'],等 clock 服务可用后再启动;ready 时打印一次读数。

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

    自查三处:Consumer 只依赖 Clock 接口;inject 键名与注册键一致;文件里找不到 UtcClock 或 LocalClock。

  3. 3

    记住结论:将来出现 Provider C,Consumer 依旧不改。

你应该看到

consumer.ts 没有任何 Provider 类的 import;唯一外部依赖是 Clock 接口与 clock 服务键。

挂载、运行、切换、验证

  1. 1

    在 cordis.patch.yml 写一行,把 clock-utc 挂进插件树。补丁按行 id 替换整行配置,行 id 是切换的把手。

    yaml
    # cordis.patch.yml(第一次:UTC)
    plugins:
      clock-utc: { $local: ./provider-utc }
      clock-consumer: { $local: ./consumer }
  2. 2

    启动 dsh,看终端里的启动日志(Web UI 默认在 http://127.0.0.1:3080)。

    bash
    npx @deepseek-ai/dsh web
  3. 3

    应看到 UTC 输出,例如 [clock-consumer] now = Fri, 14 Aug 2026 08:00:00 GMT。

  4. 4

    编辑补丁:Consumer 行不动,只把 Provider 行换成 clock-local。重启 dsh,或触发自动重载。

    yaml
    # cordis.patch.yml(第二次:本地时间)
    plugins:
      clock-local: { $local: ./provider-local }
      clock-consumer: { $local: ./consumer }
  5. 5

    再看日志,输出变成本地时间格式,例如 now = Fri Aug 14 2026 16:00:00 GMT+0800。Consumer 行一字未改。

  6. 6

    回滚 Provider 行到 clock-utc,输出应回到 UTC。重复切换,直到你能预测每次输出。

你应该看到

只改动补丁中的 Provider 行,Consumer 输出在 UTC 与本地时间之间切换;consumer.ts 全程未修改。

遇到问题:切换配置后,Consumer 输出仍是旧 Provider 的行为。

可能原因

  • 补丁行 id 与目标行不一致:补丁按 id 替换整行,id 对不上等于没改。
  • 两个 Provider 同时挂载:都注册 clock 键,注册结果相互冲突。
  • Consumer 的 inject 键名与 Provider 注册键拼写不一致。
  • 改动后没有重启,也没有触发自动重载。

排查步骤

  1. 先打印实际启动的插件树(架构文档有对应命令),确认只有目标 Provider 一行。
  2. 切换时整行替换,不要新增一行;同一时刻只保留一个 Provider。
  3. 对照三处键名:Provider 的 super(ctx, 'clock')、Consumer 的 inject: ['clock'] 与类型声明。
  4. 重启 dsh 再看日志。若支持自动重载,确认保存补丁后重载已触发。
自检:为什么切换 Provider 不需要修改 Consumer?Consumer 依赖的是接口还是实现?

Consumer 通过 inject 依赖的是接口:只声明需要 clock 服务键,并把它当作 Clock 接口使用,从不 import 任何 Provider 类。运行时框架把配置行当前挂载的实现注入进来,所以换 Provider 只换配置,Consumer 代码不变。