插件与 Context

Cordis 里一切皆插件。Context 是插件与框架之间唯一的接触面——插件获得的每一项能力, 以及它注册的每一样东西,都经由这个对象。对应实现:src/Cordis/Context.cs、 src/Cordis/Plugin.cs、src/Cordis/PluginHandle.cs。

插件是什么

在 Tether 里,插件是一个继承 Plugin 基类(或提供 Apply 方法)的 .NET 类型。框架在挂载时调用 Apply,传入一个 Context——你通过它注册能力:

public sealed class HelloPlugin : Plugin
{
    protected override void Apply(Context context)
    {
        // 在这里注册能力;依赖的服务在 Apply 运行前已就绪。
        Console.WriteLine("[hello] plugin loaded!");
    }
}

通过 Context 注册的任何东西——事件监听、工具、定时器——在插件卸载时都会被自动回收,不需要手动摘除。这就是”注册即回收”纪律(见下文)的日常形态。

Context 是三件东西的合体

一个 Context 同时是服务仓库、事件总线和 effect 作用域。前两者在整棵树的根上共享,第三者按 fork 独立。这个不对称正是插件能被独立卸载的原因:卸载一个插件只回收它自己的作用域,不影响它看到的服务与事件。

整棵树共享,只有一份 Registry 服务仓库 + 激活图 EventService 事件总线 fork 之后 仍是同一份 每次 fork 各自独立 root 的 EffectScope new Context() 插件 A 的 Scope 订阅 · 服务 · disposer 插件 B 的 Scope 订阅 · 服务 · disposer 卸载 A 只回收这一块 B 与共享部分不受影响
不对称是刻意的:共享的部分让插件之间能相遇,独立的部分让插件能被单独摘除。
成员是什么共享性
ctx.Registry服务仓库 + 插件激活图整棵树共享根实例
ctx.Events事件总线整棵树共享根实例
ctx.Scopeeffect 作用域(cordis 的 fiber)每次 fork 独立

另外 ctx.Root 指向树根,ctx.IsRoot 判断是否为根。只有根上下文可以调用 StartAsync(),它幂等,并以根上下文自身为参数发出 ready 事件;在非根上下文上调用会抛 InvalidOperationException。

挂载插件

ctx.Fork() 只产生一个共享根仓库与总线、但拥有独立作用域的子上下文。ctx.Plugin(...) 则等于 fork + apply + 纳入激活图,是日常使用的入口。

var ctx = new Context();

// 实例挂载,可附带配置对象
var handle = ctx.Plugin(new Heartbeat(), config: null);

// 类型挂载,要求无参构造
ctx.Plugin<Heartbeat>();

// 委托挂载并声明依赖:IClock 出现后才 apply
ctx.Inject([typeof(IClock)], c =>
{
    var clock = c.Require<IClock>();
    c.On<string>("message", text => Console.WriteLine($"{clock.Now}: {text}"));
});

await ctx.StartAsync();   // 根上下文,发出 "ready"

挂载不等于已激活

ctx.Plugin(...) 立即返回句柄,但插件只在 Inject 声明的服务全部就位后才 apply; 否则它停在 pending 集合里等待。激活时机由根 Registry 驱动,详见 服务与依赖注入。

编写插件

IPlugin 只有两个成员:ApplyAsync(Context),以及默认为空的依赖声明 Inject(类型为 IReadOnlyList<Type>)。同步插件可以继承 Plugin 基类,它会从具体类型上读取 [Inject] 特性。

[Inject(typeof(IClock))]
public sealed class Heartbeat : Plugin
{
    protected override void Apply(Context ctx)
    {
        var clock = ctx.Require<IClock>();
        var timer = new Timer(_ => Console.WriteLine(clock.Now), null, 0, 1000);

        // 注册即回收:把 timer 的释放挂到当前 scope 上
        ctx.Effect(() => timer.Dispose());
    }
}

需要接收配置时实现 IConfigurablePlugin,框架会在 apply 之前调用 AcceptConfig(object?) 送达挂载时传入的对象。不想写类的场合用 Plugin.From(...) 由委托构造插件,它有同步、异步和带类型化配置三个重载。

注册即回收

这是整套框架最硬的一条纪律,也是热重载与插件动态卸载的前提:插件通过 ctx 注册的一切都必须挂在当前作用域上,卸载时逆序回收。框架里不存在“注册后无法回收”的 API。

  • ctx.Effect(...) / ctx.OnDispose(...) 登记一个 disposer,同步与异步两种签名都支持。
  • ctx.On(...) 订阅事件时自动绑定作用域,返回的 IDisposable 只是提前摘除的手段,作用域回收始终兜底。
  • ctx.Scope.Cancellation 是一个 CancellationToken,本作用域或任一祖先开始回收时被取消,适合传给长跑任务。

回收一个作用域的顺序是固定的:

  1. 取消 Cancellation;
  2. 先回收全部子作用域;
  3. 以该上下文为参数发出 dispose 事件;
  4. 再按注册的逆序(LIFO)执行自己的 disposer。
1 取消 Cancellation 2 先回收 全部子作用域 3 发出 dispose 4 逆序跑 自己的 disposer 注册顺序 effect₁ → effect₂ → effect₃ 回收顺序 effect₃ → effect₂ → effect₁ (LIFO,后注册的先回收)
顺序是固定的。子作用域先于自身回收,因此子插件永远不会在父资源已释放的情况下继续运行。

作用域状态可以从 ctx.Scope.State 读到,取值为 Active、Unloading、Disposed。进入 Unloading 后,该作用域及其全部后代都拒绝新的注册。

PluginHandle:状态与重启

ctx.Plugin(...) 返回的句柄是操作已挂载插件的全部入口。

成员语义
IsActive插件当前是否处于已 apply 状态。
Completion本次 apply 完成时结束;apply 抛异常时以 PluginApplyException 失败。每次重新激活都会被替换成新的 Task。
AwaitSettledAsync()等到最新请求的激活代次稳定,返回该代号。被取代的旧代次会被忽略。
RestartAsync()回收当前 apply 并重新对照注册表评估:依赖仍满足就立即重新 apply,否则回到 pending。也能复活上次 apply 失败的插件。
DisposeAsync()终态卸载:从激活图摘除并回收作用域。

清理回调里不要 await 自己的生命周期

在插件自己的清理路径上 await handle.RestartAsync() 或 await handle.DisposeAsync() 会形成生命周期环。已经让出过的清理代码必须改用不等待的变体 RequestRestart() 与 RequestDispose()。

apply 失败时会发生什么

错误一律可见,不存在静默吞掉:

  • 该次激活预留的服务整批作废,不会有“半公开”的服务留在注册表里;
  • 已经建立的作用域被回收;
  • handle.Completion 以 PluginApplyException 失败,异常上带着 Plugin 与 CleanupFailed——后者表示回收失败的活动时又报了一次错,此时内层异常是一个聚合了 apply 与清理两个失败的 AggregateException。

生命周期事件

内置事件名集中在 LifecycleEvents 常量类里,用法与普通事件完全一致。

常量事件名触发时机参数
Ready"ready"根上下文 StartAsync()根 Context
Dispose"dispose"作用域回收,在子作用域之后、自身 disposer 之前被回收的 Context
Fork"fork"产生子上下文时(contained 派发:不阻塞 Fork() 返回,监听器失败路由到 internal/error)新的子 Context
Service"service"服务发布、更新或移除收敛时服务键 Type
Error"internal/error"监听器异常等失败的汇集点Exception、来源标签 string
CompositionUpdateEvents.VolatileUpdate"loader/volatile-update"volatile 配置值提交进运行中 fiber 的活引用、不经 remount;实例本地派发(仅该 fiber 激活作用域下的监听器可见,监听器失败只记日志;Loader 侧非 durable 事件)更新的属性路径 IReadOnlyList<string[]>

服务基类跟着 ready 启动

CordisService 在构造时以 ctx.Once("ready", StartAsync) 订阅就绪事件, 因此派生服务重写 StartAsync() 即可参与启动,无需自己接线。

下一步

在 GitHub 上编辑此页