插件与 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 独立。这个不对称正是插件能被独立卸载的原因:卸载一个插件只回收它自己的作用域,不影响它看到的服务与事件。
| 成员 | 是什么 | 共享性 |
|---|---|---|
ctx.Registry | 服务仓库 + 插件激活图 | 整棵树共享根实例 |
ctx.Events | 事件总线 | 整棵树共享根实例 |
ctx.Scope | effect 作用域(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,本作用域或任一祖先开始回收时被取消,适合传给长跑任务。
回收一个作用域的顺序是固定的:
- 取消
Cancellation; - 先回收全部子作用域;
- 以该上下文为参数发出
dispose事件; - 再按注册的逆序(LIFO)执行自己的 disposer。
作用域状态可以从 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() 即可参与启动,无需自己接线。