
如果你在构建一个允许第三方(或其他团队)扩展功能的系统,最核心的设计决策就是:插件契约怎么定义。定义好了,契约就是一个稳固的边界,能支撑多年的迭代。定义草率了,每次版本升级都是一次破坏性的重构,所有依赖你契约的插件都得跟着改。
本文从”插件契约是什么”出发,依次介绍接口设计原则、元数据与发现机制、抽象基类的定位、版本演化策略,以及为什么契约代码必须单独放在一个 NuGet 包里——最后给出一个完整的数据处理插件系统示例。
插件契约是什么
插件契约就是一组接口、抽象类和属性(Attribute),定义了插件必须满足哪些条件才能被宿主加载和调用。它是宿主和插件之间的正式协议。
宿主说:“只要你实现了这个接口,我就承诺在特定时机调用这些方法。“插件作者说:“我会实现这些方法,你的生命周期我信得过。”
最精简的插件接口长这样:
// 每个插件都必须满足的核心契约
public interface IPlugin
{
string Name { get; }
// 宿主加载插件时调用一次
Task InitializeAsync(IPluginContext context, CancellationToken cancellationToken = default);
// 宿主关闭或卸载插件时调用
Task ShutdownAsync(CancellationToken cancellationToken = default);
}
宿主不关心插件内部怎么实现,只关心这三个成员在约定的时机能被调用。这是松耦合的基础:宿主和插件通过契约沟通,而不是通过彼此的具体实现。
接口设计:越小越稳定
接口隔离原则(ISP)在任何 .NET 代码里都重要,但在插件契约里尤其关键——一旦插件作者实现了你的接口,修改就是破坏性变更,没有后悔的机会。
一个过于臃肿的契约是这样的:
// ❌ 接口过于臃肿 — 违反 ISP,日后会很痛苦
public interface IPlugin
{
string Name { get; }
string Version { get; }
string Description { get; }
string Author { get; }
IReadOnlyList<string> Tags { get; }
Task InitializeAsync(IPluginContext context, CancellationToken cancellationToken = default);
Task ShutdownAsync(CancellationToken cancellationToken = default);
Task<bool> CanHandleAsync(string eventType);
Task HandleEventAsync(IEvent @event);
Task<IPluginHealthStatus> GetHealthAsync();
Task<IPluginMetrics> GetMetricsAsync();
void Configure(IPluginConfiguration config);
IPluginConfiguration GetDefaultConfiguration();
}
元数据、生命周期、事件处理、健康检查、指标上报、配置管理全堆在一个接口里。一个只需要处理事件的插件,也得实现健康检查,哪怕它根本不需要。
分离后的版本:
// ✅ 每个接口只有一个职责
public interface IPlugin
{
string Name { get; }
Task InitializeAsync(IPluginContext context, CancellationToken cancellationToken = default);
Task ShutdownAsync(CancellationToken cancellationToken = default);
}
// 可选:需要处理事件的插件额外实现这个
public interface IEventHandler
{
Task<bool> CanHandleAsync(string eventType);
Task HandleEventAsync(IEvent @event, CancellationToken cancellationToken = default);
}
// 可选:需要上报健康状态的插件额外实现这个
public interface IHealthReporter
{
Task<IPluginHealthStatus> GetHealthAsync(CancellationToken cancellationToken = default);
}
IPlugin 保持精简和稳定。处理事件的插件同时实现 IPlugin 和 IEventHandler。宿主在运行时通过 if (plugin is IEventHandler handler) 检测能力。小接口更容易版本化、更容易文档化、也更容易测试。
元数据与发现
宿主在调用 InitializeAsync 之前,通常需要了解每个插件的基本信息:名称、版本、支持的能力。元数据有两种常见方式:接口属性和 Attribute。
接口属性由编译器强制执行:
public interface IPluginMetadata
{
string Name { get; }
string Version { get; }
string Description { get; }
IReadOnlyList<string> SupportedCapabilities { get; }
}
public interface IPlugin : IPluginMetadata
{
Task InitializeAsync(IPluginContext context, CancellationToken cancellationToken = default);
Task ShutdownAsync(CancellationToken cancellationToken = default);
}
基于 Attribute 的元数据可以在实例化插件之前读取:
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class PluginMetadataAttribute : Attribute
{
public string Name { get; }
public string Version { get; }
public string Description { get; }
public PluginMetadataAttribute(string name, string version, string description)
{
Name = name;
Version = version;
Description = description;
}
}
// 插件作者在类上标注
[PluginMetadata("My Processor", "1.0.0", "处理传入的数据记录")]
public sealed class MyDataProcessorPlugin : IPlugin
{
public string Name => "My Processor";
public Task InitializeAsync(IPluginContext context, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task ShutdownAsync(CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
推荐混合使用:发现阶段(扫描程序集、实例化之前)用 Attribute 读取元数据,更轻量也更安全;运行时需要动态计算的属性才放进接口。
抽象基类的定位
纯接口是主契约,抽象基类是可选的便利工具。区别很重要:
- 接口:定义契约,所有插件必须实现,不提供默认实现
- 抽象基类:提供便利,插件作者可以选择继承,也可以直接实现接口
混合模式:
// 契约 — 所有插件必须实现
public interface IPlugin
{
string Name { get; }
Task InitializeAsync(IPluginContext context, CancellationToken cancellationToken = default);
Task ShutdownAsync(CancellationToken cancellationToken = default);
}
// 可选基类 — 为想要快速起步的插件作者提供默认实现
public abstract class PluginBase : IPlugin
{
public abstract string Name { get; }
// 默认:空操作
public virtual Task InitializeAsync(IPluginContext context, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
// 默认:空操作
public virtual Task ShutdownAsync(CancellationToken cancellationToken = default)
=> Task.CompletedTask;
// 插件作者常用的辅助方法
protected void LogInfo(string message)
=> Console.WriteLine($"[{Name}] {message}");
}
想快速起步的作者从 PluginBase 继承,只覆盖需要的方法;已经有自己继承链的作者直接实现 IPlugin。宿主始终面向 IPlugin 编程,从不引用 PluginBase——基类只是给实现者的便利,不是契约面的一部分。
版本演化:不破坏现有插件
这是大多数团队出问题的地方。你发布了 v1 的插件契约,几十个插件已经实现了它。现在你需要给 IPlugin 加一个方法——这是破坏性变更,所有现有插件立刻无法编译。
有三种可行策略:
策略一:命名空间版本化。为每个主要版本创建新的命名空间,宿主在运行时通过接口检测来确认插件支持的版本。
策略二:默认接口方法(C# 8+)。可以给接口新成员提供默认实现,现有插件不会中断,因为它们继承了默认实现。谨慎使用:不符合实际插件行为的默认实现会掩盖 bug。
策略三:新接口 + 适配器(推荐)。新旧接口并存,在契约包里附带兼容适配器:
// V1 契约 — 发布后永不修改
public interface IPluginV1
{
string Name { get; }
Task InitializeAsync(IPluginContext context, CancellationToken cancellationToken = default);
Task ShutdownAsync(CancellationToken cancellationToken = default);
}
// V2 契约 — 扩展了新能力
public interface IPluginV2
{
string Name { get; }
Task InitializeAsync(IPluginContext context, CancellationToken cancellationToken = default);
Task ShutdownAsync(CancellationToken cancellationToken = default);
Task<IPluginStatus> GetStatusAsync(CancellationToken cancellationToken = default);
}
// 适配器:将 V1 插件包装为满足 V2 接口
public sealed class PluginV1ToV2Adapter : IPluginV2
{
private readonly IPluginV1 _inner;
public PluginV1ToV2Adapter(IPluginV1 inner) => _inner = inner;
public string Name => _inner.Name;
public Task InitializeAsync(IPluginContext context, CancellationToken cancellationToken = default)
=> _inner.InitializeAsync(context, cancellationToken);
public Task ShutdownAsync(CancellationToken cancellationToken = default)
=> _inner.ShutdownAsync(cancellationToken);
// V1 插件没有状态能力 — 返回安全默认值
public Task<IPluginStatus> GetStatusAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IPluginStatus>(new DefaultPluginStatus());
}
V1 插件通过适配器在 V2 宿主里继续工作,V2 插件作者有了干净的新接口,两组人的迁移时间表互不干扰。
契约程序集:单独的 NuGet 包
契约程序集——包含接口、抽象基类和 Attribute 的项目——必须单独放在一个项目里,与宿主应用和任何插件实现都隔离。原因有三:
- 插件作者不应该依赖宿主:宿主可能有几十个传递依赖,插件作者不需要也不想要这些依赖。
- AssemblyLoadContext 隔离:.NET 加载插件到隔离上下文时,类型身份由程序集路径决定。如果契约程序集从不同路径加载了两次,
IPlugin的两个实例不是同一个类型。独立的轻量契约包能最大限度降低这个风险。 - 稳定的 NuGet 版本语义:插件作者可以固定到特定版本的契约包,明确知道自己在实现什么。
项目结构:
MyApp.Contracts/ ← NuGet 包: MyApp.Contracts
IPlugin.cs
IPluginContext.cs
IPluginMetadata.cs
PluginMetadataAttribute.cs
PluginBase.cs
MyApp.Host/ ← 宿主应用;引用 MyApp.Contracts
PluginLoader.cs
Program.cs
SamplePlugin/ ← 第三方插件;只引用 MyApp.Contracts
MyPlugin.cs
SamplePlugin 通过 NuGet 引用 MyApp.Contracts,不引用 MyApp.Host。宿主在运行时把插件加载到 AssemblyLoadContext,双方引用同一个契约程序集,类型身份能正确解析。
完整示例:数据处理插件系统
把上面所有要素结合在一起:
// MyApp.Contracts — 只包含契约的 NuGet 包
namespace MyApp.Contracts;
// 元数据 Attribute — 发现阶段在实例化之前读取
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class DataProcessorMetadataAttribute : Attribute
{
public string Name { get; }
public string Version { get; }
public string[] SupportedFormats { get; }
public DataProcessorMetadataAttribute(string name, string version, params string[] supportedFormats)
{
Name = name;
Version = version;
SupportedFormats = supportedFormats;
}
}
// 核心插件契约
public interface IDataProcessor
{
string Name { get; }
Task InitializeAsync(IProcessorContext context, CancellationToken cancellationToken = default);
Task<ProcessResult> ProcessAsync(
ReadOnlyMemory<byte> data,
string format,
CancellationToken cancellationToken = default);
Task ShutdownAsync(CancellationToken cancellationToken = default);
}
// 可选基类 — 生命周期方法提供空操作默认实现
public abstract class DataProcessorBase : IDataProcessor
{
public abstract string Name { get; }
public virtual Task InitializeAsync(IProcessorContext context, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public abstract Task<ProcessResult> ProcessAsync(
ReadOnlyMemory<byte> data,
string format,
CancellationToken cancellationToken = default);
public virtual Task ShutdownAsync(CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
// 结果类型 — 也是契约包的一部分
public sealed record ProcessResult(
bool Success,
string? ErrorMessage,
IReadOnlyDictionary<string, object>? Metadata);
两个实现了这个契约的插件:
// 插件 A:JSON 处理器 — 继承基类,使用生命周期默认实现
[DataProcessorMetadata("JSON Processor", "1.0.0", "application/json")]
public sealed class JsonDataProcessor : DataProcessorBase
{
public override string Name => "JSON Processor";
public override async Task<ProcessResult> ProcessAsync(
ReadOnlyMemory<byte> data,
string format,
CancellationToken cancellationToken = default)
{
await Task.Delay(1, cancellationToken); // 模拟异步处理
return new ProcessResult(true, null, null);
}
}
// 插件 B:CSV 处理器 — 直接实现 IDataProcessor,不使用基类
[DataProcessorMetadata("CSV Processor", "1.0.0", "text/csv")]
public sealed class CsvDataProcessor : IDataProcessor
{
private IProcessorContext? _context;
public string Name => "CSV Processor";
public Task InitializeAsync(IProcessorContext context, CancellationToken cancellationToken = default)
{
_context = context;
return Task.CompletedTask;
}
public async Task<ProcessResult> ProcessAsync(
ReadOnlyMemory<byte> data,
string format,
CancellationToken cancellationToken = default)
{
await Task.Delay(1, cancellationToken);
return new ProcessResult(true, null, null);
}
public Task ShutdownAsync(CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
两个插件都满足 IDataProcessor。宿主通过 DataProcessorMetadataAttribute 发现它们,按支持的格式过滤,然后统一调用 InitializeAsync 和 ProcessAsync。任何一个插件都不知道宿主应用的存在——它们只知道契约包。
版本不匹配的运行时风险
如果宿主加载了 MyApp.Contracts 1.0.0,而某个插件编译时引用的是 MyApp.Contracts 1.1.0,CLR 可能会把两个版本的 IPlugin 当作不同类型——即便结构完全相同。结果可能是 TypeLoadException、MissingMethodException、FileLoadException、ReflectionTypeLoadException,或者强制转换失败,或者静默的行为不一致。
解决方法:配置 AssemblyLoadContext 让所有加载上下文共享契约程序集,而不是每个插件独立加载一份;同时对契约包保持保守的版本策略,尽量让它轻量且少变动。
小结
设计插件契约,考验的是克制,而不是技巧。接口保持精简,元数据与行为分离,契约单独打包,版本策略在发布 v1 之前就要想清楚。
契约是你向整个插件生态做出的承诺。这个承诺越小、越稳定,别人在上面构建的东西就越可靠。契约定好了,插件系统的其他部分——加载程序集、解析依赖、管理生命周期——都会自然而然地从这个基础上延伸出来。