03 · 服务:提供方与依赖消费

目标:掌握「服务是插件提供、其他插件通过 ctx 消费的具名能力」——空间可组合性(响应式 coeffect)的落地。 对应快速入门项目:npm run lesson:03

核心机制

  • 服务占据稳定的 ctx.<key>;消费方按 key 查找而非 import 实现 → 配置层可换提供方而不改消费方(这就是 ctx.tools、ctx.llm 的玩法)
  • 提供方两部分协同:运行时 super(ctx, 'greeter') 注册(注册属于 effect,卸载即移除服务)+ 编译时 declare module 声明合并给 ctx.greeter 上类型
  • inject 声明依赖:插件保持 PENDING 直到所列服务全部就绪;加载顺序由依赖决定,与 yml 书写顺序无关
  • inject 是持续跟踪的:运行中服务消失(提供方被卸载/热替换),依赖它的插件跟着卸载;服务恢复,再自动加载

实例 A:提供方(Service 子类)

// greeter.ts
import { Service, type Context } from 'cordis'
 
// 编译时:声明合并把 greeter 加入 Context 接口,使 ctx.greeter 有类型。
// 不写这段运行时也能工作,只是消费方失去类型安全。
declare module 'cordis' {
  interface Context {
    greeter: GreeterService
  }
}
 
export class GreeterService extends Service {
  constructor(ctx: Context) {
    // 运行时:以名称 greeter 注册该实例
    super(ctx, 'greeter')
  }
 
  greet(who: string) {
    return `Hello, ${who}!`
  }
}
 
export const name = 'greeter'
 
// Service 子类本身就是插件(类形态),像普通插件一样挂载
export function apply(ctx: Context) {
  ctx.plugin(GreeterService)
}

实例 B:消费方(inject)

// consumer.ts
import type { Context } from 'cordis'
 
export const name = 'consumer'
export const inject = ['greeter']        // 等 greeter 服务就绪才启动
 
export function apply(ctx: Context) {
  console.log(ctx.greeter.greet('world'))
}
# cordis.yml
- name: './greeter.ts'
- name: './consumer.ts'

运行:

npm run lesson:03
# Hello, world!

交换 yml 两行顺序再运行,输出不变——决定启动时机的是依赖,不是文件顺序。

可选依赖

inject 用于硬性依赖。某项功能缺失时插件仍可运行的,跳过 inject,在使用处探测:

export function apply(ctx: Context) {
  // 无提供方时为 undefined,插件照样运行
  const greeter = ctx.get('greeter')
  console.log(greeter?.greet('maybe') ?? 'no greeter available')
}

命名约定

每个应用中服务名共用一个扁平命名空间。自有服务要加有辨识度的前缀或命名空间(harness 已占用 tools、llm、agents、shell 等普通名称)。

动手练习

  1. 注释掉 cordis.yml 里 ./greeter.ts 一行再运行:consumer 安静保持 PENDING,不崩溃、不半运行,进程以码 0 静默退出(PENDING fiber 不会让事件循环保持活跃)。这就是「依赖恢复则复活」的静态版本
  2. 把 declare module 块删掉再运行:运行时正常,但 ctx.greeter 在 IDE 里变红——体会声明合并只影响类型不影响运行时
  3. 写第二个消费方插件(inject = ['greeter']),观察两个消费方都不需要知道提供方是谁

上一课:02-lifecycle-effect | 下一课:04-events