281 lines
8.9 KiB
Markdown
281 lines
8.9 KiB
Markdown
# 插件系统
|
||
|
||
> 所属项目: free-code .NET 10 重写
|
||
> 所属模块: [UI 与扩展设计](UI与扩展设计.md)
|
||
> 原始代码: `../../src/plugins/`
|
||
|
||
---
|
||
|
||
## 概述
|
||
|
||
插件系统允许第三方代码在运行时扩展 free-code 的能力,包括注册新技能、新斜杠命令和新 MCP 服务器配置。原始实现通过 Node.js 的动态 `import()` 加载插件模块,利用模块缓存隔离各插件的命名空间。
|
||
|
||
.NET 重写用 `AssemblyLoadContext`(ALC)替代动态导入,实现更强的隔离性和确定性卸载。每个插件运行在独立的 `PluginLoadContext` 实例中,其依赖项从插件目录优先解析,不与宿主进程或其他插件共享程序集版本。`PluginManager` 管理所有插件的生命周期,支持安装、卸载、启用和禁用操作。
|
||
|
||
---
|
||
|
||
## 17.1 接口定义
|
||
|
||
```csharp
|
||
/// <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
|
||
|
||
```csharp
|
||
/// <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
|
||
|
||
```csharp
|
||
/// <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 实现
|
||
|
||
```csharp
|
||
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 示例
|
||
|
||
```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] [插件对象消失]
|
||
```
|
||
|
||
---
|
||
|
||
## 参考资料
|
||
|
||
- [UI 与扩展设计 — 总览](UI与扩展设计.md)
|
||
- [UI 与扩展设计 — 技能系统](UI与扩展设计-技能系统.md)
|
||
- [核心模块设计 — 命令系统](../核心模块设计/核心模块设计-命令系统.md)
|
||
- [基础设施设计 — MCP 协议集成](../基础设施设计/基础设施设计-MCP协议集成.md)
|