Skip to content
Go back

C# 插件契约与接口:如何设计可扩展的插件系统

C# 插件契约与接口

如果你在构建一个允许第三方(或其他团队)扩展功能的系统,最核心的设计决策就是:插件契约怎么定义。定义好了,契约就是一个稳固的边界,能支撑多年的迭代。定义草率了,每次版本升级都是一次破坏性的重构,所有依赖你契约的插件都得跟着改。

本文从”插件契约是什么”出发,依次介绍接口设计原则、元数据与发现机制、抽象基类的定位、版本演化策略,以及为什么契约代码必须单独放在一个 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 保持精简和稳定。处理事件的插件同时实现 IPluginIEventHandler。宿主在运行时通过 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 的项目——必须单独放在一个项目里,与宿主应用和任何插件实现都隔离。原因有三:

  1. 插件作者不应该依赖宿主:宿主可能有几十个传递依赖,插件作者不需要也不想要这些依赖。
  2. AssemblyLoadContext 隔离:.NET 加载插件到隔离上下文时,类型身份由程序集路径决定。如果契约程序集从不同路径加载了两次,IPlugin 的两个实例不是同一个类型。独立的轻量契约包能最大限度降低这个风险。
  3. 稳定的 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 发现它们,按支持的格式过滤,然后统一调用 InitializeAsyncProcessAsync。任何一个插件都不知道宿主应用的存在——它们只知道契约包。

版本不匹配的运行时风险

如果宿主加载了 MyApp.Contracts 1.0.0,而某个插件编译时引用的是 MyApp.Contracts 1.1.0,CLR 可能会把两个版本的 IPlugin 当作不同类型——即便结构完全相同。结果可能是 TypeLoadExceptionMissingMethodExceptionFileLoadExceptionReflectionTypeLoadException,或者强制转换失败,或者静默的行为不一致。

解决方法:配置 AssemblyLoadContext 让所有加载上下文共享契约程序集,而不是每个插件独立加载一份;同时对契约包保持保守的版本策略,尽量让它轻量且少变动。

小结

设计插件契约,考验的是克制,而不是技巧。接口保持精简,元数据与行为分离,契约单独打包,版本策略在发布 v1 之前就要想清楚。

契约是你向整个插件生态做出的承诺。这个承诺越小、越稳定,别人在上面构建的东西就越可靠。契约定好了,插件系统的其他部分——加载程序集、解析依赖、管理生命周期——都会自然而然地从这个基础上延伸出来。

参考


Tags


Next

解剖 .claude 文件夹:每个文件的作用详解(2026)