3. 服务

服务是一个插件提供、其他插件通过 ctx 消费的具名能力。在 harness 中,IToolRegistry、 ILlmClient、IAgentRegistry 都是服务。消费方只指定接口,而不引用其提供方,因此组合可以替换提供方 而不修改消费方。

dsh 用字符串键(ctx.greeter);C# 版用 Type 键——接口通常定义在独立的 Service Definition 程序集里, 提供方与消费方都只引用接口。这就是仓库里 Tether.Core.Contracts 与 Tether.Core.Tools 分层的原因。

提供服务

public interface IGreeter                      // Service Definition(真实仓库里在独立程序集)
{
    string Greet(string who);
}

public sealed class GreeterService : CordisService, IGreeter   // Provider
{
    public GreeterService(Context context) : base(context)
        => context.Provide<IGreeter>(this);   // 注册即 effect:卸载提供方时移除

    public string Greet(string who) => $"Hello, {who}!";
}

ctx.Plugin(Plugin.From(c => _ = new GreeterService(c)));

两部分协同工作:

  • 运行时:context.Provide<IGreeter>(this) 以 typeof(IGreeter) 为键注册实例,任何插件都可以通过 ctx.Get<IGreeter>() 访问。注册属于 effect,卸载提供方时服务随之移除,并等待依赖方清理完毕 才结束 scope disposal。
  • 生命周期:CordisService 构造函数(src/Cordis/Service.cs)把 StartAsync 挂到 LifecycleEvents.Ready(应用就绪才启动)、把 StopAsync 挂到 OnDispose(随 scope 停止)。 dsh 用 super(ctx, 'greeter') 一行完成;C# 版拆成”基类管生命周期 + Provide<T> 注册”两步, 换来编译期类型安全。

使用 inject 消费服务

ctx.Plugin(Plugin.From(
    c => Console.WriteLine(c.Require<IGreeter>().Greet("world")),
    typeof(IGreeter)));            // ← dsh 的 export const inject = ['greeter']

inject 列出该插件需要的服务键。Cordis 让插件保持 PENDING,直到列出的每个服务都出现在 Registry 里, 因此 Apply 内可以保证 Require<IGreeter>() 不会抛。两个 ctx.Plugin 调用的顺序无关紧要: 决定插件何时启动的是依赖关系,而不是挂载顺序。

类插件用 attribute 声明依赖(src/Cordis/InjectAttribute.cs):

[Inject(typeof(IGreeter))]
public sealed class ConsumerPlugin : Plugin
{
    protected override void Apply(Context context)
        => Console.WriteLine(context.Require<IGreeter>().Greet("world"));
}

加载后仍会跟踪依赖关系

时间 → IGreeter 未提供 Provide<IGreeter> 被移除 SetAsync(新实例) Consumer PENDING active(apply 完成) PENDING 重启后 active
每个迁移都由服务键的出现、消失、替换驱动;消费方的 scope 随之建立或回收,时间轴上没有"顺序挂载"这件事。

inject 不是一次性的启动检查,而是激活图(src/Cordis/Registry.cs)里的持续约束:

  • 运行期间服务消失(提供方被卸载或热替换)→ 每个依赖插件回收 scope,回到 PENDING。
  • 服务恢复 → 依赖插件重新激活。
  • 同一提供方 SetAsync 更新服务实例 → 依赖它的活跃插件重启以绑定新实例。

结合 effect(第 2 章),这保证运行中的消费方 不会持有已失效服务的引用。这也是组合可以替换服务的原因:把一个 IShell 提供方行换成另一个实现, 所有注入 IShell 的插件都会用新实现重启。

可选依赖

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

ctx.Plugin(Plugin.From(c =>
{
    var greeter = c.Get<IGreeter>();   // 无提供方时返回 null,插件照常运行
    Console.WriteLine(greeter?.Greet("maybe") ?? "no greeter available");
}));

命名

dsh 的字符串键共享扁平命名空间,需要前缀防碰撞;C# 的 Type 键天然带命名空间,但有一条对应纪律: 服务键用接口,不用实现类——只有接口键才能让组合在不改消费方的前提下替换提供方。

下一章:事件:无需共享服务即可通信。

在 GitHub 上编辑此页