8.9 KiB
8.9 KiB
插件系统
所属项目: free-code .NET 10 重写 所属模块: UI 与扩展设计 原始代码:
../../src/plugins/
概述
插件系统允许第三方代码在运行时扩展 free-code 的能力,包括注册新技能、新斜杠命令和新 MCP 服务器配置。原始实现通过 Node.js 的动态 import() 加载插件模块,利用模块缓存隔离各插件的命名空间。
.NET 重写用 AssemblyLoadContext(ALC)替代动态导入,实现更强的隔离性和确定性卸载。每个插件运行在独立的 PluginLoadContext 实例中,其依赖项从插件目录优先解析,不与宿主进程或其他插件共享程序集版本。PluginManager 管理所有插件的生命周期,支持安装、卸载、启用和禁用操作。
17.1 接口定义
/// <summary>
/// 插件管理器 — AssemblyLoadContext 隔离加载
/// 对应原始 plugin system
/// </summary>
public interface IPluginManager
{
Task<IReadOnlyList<LoadedPlugin>> LoadAllPluginsAsync();
Task InstallPluginAsync(string pluginId);
Task UninstallPluginAsync(string pluginId);
Task EnablePluginAsync(string pluginId);
Task DisablePluginAsync(string pluginId);
Task RefreshAsync();
}
17.2 LoadedPlugin 与 PluginManifest
/// <summary>
/// 已加载的插件实例描述
/// </summary>
public sealed record LoadedPlugin
{
/// <summary>插件唯一标识符(目录名)</summary>
public required string Id { get; init; }
/// <summary>插件显示名称</summary>
public required string Name { get; init; }
/// <summary>版本字符串</summary>
public required string Version { get; init; }
/// <summary>来源:marketplace | local | github</summary>
public required string Source { get; init; }
/// <summary>主程序集路径</summary>
public required string AssemblyPath { get; init; }
/// <summary>是否启用</summary>
public bool IsEnabled { get; init; }
/// <summary>插件清单</summary>
public PluginManifest Manifest { get; init; } = new();
}
/// <summary>
/// 插件清单 — plugin.json 反序列化目标
/// 描述插件向宿主暴露的所有扩展点
/// </summary>
public sealed record PluginManifest
{
/// <summary>插件提供的技能列表</summary>
public IReadOnlyList<SkillDefinition> Skills { get; init; } = [];
/// <summary>插件提供的斜杠命令列表</summary>
public IReadOnlyList<ICommand> Commands { get; init; } = [];
/// <summary>插件注册的 MCP 服务器配置(服务器名 → 配置)</summary>
public IReadOnlyDictionary<string, ScopedMcpServerConfig> McpServers { get; init; }
= new Dictionary<string, ScopedMcpServerConfig>();
}
设计意图
PluginManifest 直接对应 plugin.json 的顶层结构,三个扩展点(技能、命令、MCP 服务器)分别映射为 SkillLoader、CommandRegistry、McpClientManager 的动态注册入口。插件卸载时,这三个注册表各自移除该插件贡献的条目。
17.3 PluginLoadContext
/// <summary>
/// 可卸载的程序集加载上下文
/// 对应原始动态 import() 的隔离语义
/// </summary>
public sealed class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
public PluginLoadContext(string pluginPath) : base(isCollectible: true)
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
// 优先从插件目录解析,回退到默认加载上下文
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
return assemblyPath != null
? LoadFromAssemblyPath(assemblyPath)
: null; // null 触发回退到默认 ALC
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
var libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return libraryPath != null
? LoadUnmanagedDllFromPath(libraryPath)
: IntPtr.Zero;
}
}
设计意图
isCollectible: true 是关键参数。普通 AssemblyLoadContext 加载的程序集在整个进程生命周期内无法卸载。可回收 ALC 允许在所有对该上下文中类型的强引用归零后,GC 完整回收其托管内存和元数据,这是 /plugin uninstall 命令实现热卸载的基础。
AssemblyDependencyResolver 读取插件主程序集旁边的 .deps.json 文件,自动解析所有传递依赖的路径。插件作者只需将所有依赖打包到插件目录,无需关心宿主进程中已有哪些程序集版本。
17.4 PluginManager 实现
public sealed class PluginManager : IPluginManager
{
private readonly ConcurrentDictionary<string, PluginLoadContext> _contexts = new();
private readonly ConcurrentDictionary<string, LoadedPlugin> _plugins = new();
public async Task<IReadOnlyList<LoadedPlugin>> LoadAllPluginsAsync()
{
var pluginDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".free-code", "plugins");
if (!Directory.Exists(pluginDir)) return [];
foreach (var dir in Directory.GetDirectories(pluginDir))
{
var manifestPath = Path.Combine(dir, "plugin.json");
if (!File.Exists(manifestPath)) continue;
var manifest = JsonSerializer.Deserialize<PluginManifest>(
await File.ReadAllTextAsync(manifestPath));
var dllPath = Directory.GetFiles(dir, "*.dll").FirstOrDefault();
if (dllPath == null) continue;
var context = new PluginLoadContext(dllPath);
var assembly = context.LoadFromAssemblyPath(dllPath);
_contexts[dir] = context;
_plugins[dir] = new LoadedPlugin
{
Id = Path.GetFileName(dir),
Name = manifest?.Skills.FirstOrDefault()?.Name
?? Path.GetFileName(dir),
Version = "1.0",
Source = "local",
AssemblyPath = dllPath,
IsEnabled = true,
};
}
return _plugins.Values.ToList();
}
public async Task UninstallPluginAsync(string pluginId)
{
if (_contexts.TryRemove(pluginId, out var context))
{
context.Unload(); // 触发 GC 可回收流程
}
_plugins.TryRemove(pluginId, out _);
}
public Task RefreshAsync()
{
// 重新扫描目录,加载新插件,卸载已删除的
return LoadAllPluginsAsync();
}
public Task InstallPluginAsync(string pluginId)
{
// 从 marketplace / GitHub 下载到 ~/.free-code/plugins/<id>/
// 然后调用 LoadAllPluginsAsync 触发加载
throw new NotImplementedException("待实现:marketplace 下载逻辑");
}
public Task EnablePluginAsync(string pluginId)
{
if (_plugins.TryGetValue(pluginId, out var plugin))
_plugins[pluginId] = plugin with { IsEnabled = true };
return Task.CompletedTask;
}
public Task DisablePluginAsync(string pluginId)
{
if (_plugins.TryGetValue(pluginId, out var plugin))
_plugins[pluginId] = plugin with { IsEnabled = false };
return Task.CompletedTask;
}
}
插件目录结构
~/.free-code/plugins/
└── my-plugin/
├── plugin.json # 插件清单
├── MyPlugin.dll # 主程序集
├── MyPlugin.deps.json # 依赖清单(供 AssemblyDependencyResolver 使用)
└── SomeDependency.dll # 插件私有依赖
plugin.json 示例
{
"skills": [
{
"name": "my-skill",
"description": "自定义技能示例",
"content": "你是一个帮助用户完成特定任务的助手。"
}
],
"commands": [],
"mcpServers": {
"my-mcp": {
"command": "npx",
"args": ["-y", "my-mcp-server"],
"scope": "local"
}
}
}
生命周期图
LoadAllPluginsAsync()
│
├── 扫描 ~/.free-code/plugins/ 目录
├── 读取 plugin.json → 反序列化 PluginManifest
├── 创建 PluginLoadContext(dllPath)
├── LoadFromAssemblyPath(dllPath)
└── 注册到 _plugins / _contexts
UninstallPluginAsync(id)
│
├── context.Unload() [ALC 标记为待回收]
├── 移除 _contexts[id] [WeakReference 归零后 GC 回收]
└── 移除 _plugins[id] [插件对象消失]