feat: add MigrationTool for gateway config migration (IMPL-7)

- Create MigrationTool console app for exporting DB config to K8s YAML
- Support dry-run mode and validation
- Add Npgsql and YamlDotNet dependencies
This commit is contained in:
movingsam 2026-03-08 00:35:04 +08:00
parent 8bdc24f374
commit 52eba07097
20 changed files with 3098 additions and 47 deletions

View File

@ -1,4 +1,4 @@
# RoadmapFengling Gateway
#MY|# RoadmapFengling Gateway
**创建日期:** 2026-03-02
**核心价值:** 可靠、可扩展的 API 网关,将流量分发到后端微服务,支持零停机配置更新。
@ -100,35 +100,99 @@
1. 分布式追踪包含网关跨度
2. 导出关键指标(请求数、延迟、错误率)
3. 核心组件测试覆盖率 >80%
## 阶段 6网关插件技术调研与实现
---
BZ|## 阶段 6网关插件技术调研与实现 ✅ 进行中
**目标:** 实现网关插件化支持。
**已规划计划:**
- 006-01: ✅ 已完成 - 插件加载基础设施 (PLUG-01, PLUG-02)
- 006-02: 📋 待执行 - YARP 插件集成 (PLUG-03)
**需求:**
- [x] PLUG-01网关插件化架构设计
- [x] PLUG-02插件加载机制
- [ ] PLUG-03YARP 插件集成
**成功标准:**
- [x] 1. 网关支持动态加载插件
- [x] 2. 插件之间相互隔离
- [ ] 3. 插件可以在运行时热加载/卸载
**已实现文件 (006-01)**
- `src/yarpgateway/Plugins/PluginLoadContext.cs` - ALC 隔离
- `src/yarpgateway/Plugins/PluginLoader.cs` - 发现和加载
- `src/yarpgateway/Plugins/PluginHost.cs` - 生命周期管理
- 单元测试 15 个全部通过
**待实现文件 (006-02)**
- `src/yarpgateway/Plugins/PluginTransformProvider.cs` - YARP Transform 提供者
- `src/yarpgateway/Plugins/DestinationSelector.cs` - 目标选择器
- `src/yarpgateway/Plugins/PluginConfigWatcher.cs` - Console DB 通知监听
**目标:** 实现网关插件化支持。
**需求:**
- [ ] PLUG-01网关插件化架构设计
- [ ] PLUG-02插件加载机制
- [x] PLUG-01网关插件化架构设计
- [x] PLUG-02插件加载机制
- [ ] PLUG-03插件隔离与生命周期管理
**成功标准:**
1. 网关支持动态加载插件
2. 插件之间相互隔离
3. 插件可以在运行时热加载/卸载
- [x] 1. 网关支持动态加载插件
- [x] 2. 插件之间相互隔离
- [ ] 3. 插件可以在运行时热加载/卸载
**已实现文件:**
- `src/yarpgateway/Plugins/PluginLoadContext.cs` - ALC 隔离
- `src/yarpgateway/Plugins/PluginLoader.cs` - 发现和加载
- `src/yarpgateway/Plugins/PluginHost.cs` - 生命周期管理
- 单元测试 15 个全部通过
---
## Roadmap 摘要
|| 阶段 | 名称 | 需求数 | 状态 |
||------|------|--------|------|
|| 1 | 配置变更监听与多实例支持 | 6 | ✅ 已完成 |
|| 2 | K8s 健康检查委托 | 2 | ✅ 已完成 |
|| 3 | 安全加固 | 3 | 未规划 |
|| 4 | 性能优化 | 2 | 未规划 |
|| 5 | 可观测性与测试 | 5 | 未规划 |
|| 6 | 网关插件技术调研与实现 | 3 | 未规划 |
| 阶段 | 名称 | 需求数 | 状态 |
|------|------|--------|------|
| 1 | 配置变更监听与多实例支持 | 6 | ✅ 已完成 |
| 2 | K8s 健康检查委托 | 2 | ✅ 已完成 |
| 3 | 安全加固 | 3 | 未规划 |
| 4 | 性能优化 | 2 | 未规划 |
| 5 | 可观测性与测试 | 5 | 未规划 |
| 6 | 网关插件技术调研与实现 | 3 | ✅ 进行中 |
**总计:** 6 个阶段 | 21 个需求 | 8 项已完成
## 阶段 7网关配置重构规划
**目标:** 分析网关配置新想法的可行性,重新规划网关配置架构。
**需求:**
- [ ] REPL-01分析"网关配置的新想法.md"中的方案
- [ ] REPL-02识别与现有需求的冲突点
- [ ] REPL-03制定新的网关配置架构
**成功标准:**
1. 完成网关配置新想法的可行性分析
2. 提出与现有阶段兼容的配置方案
3. 更新 roadmap 以反映新的配置架构
---
*最后更新2026-03-04 阶段6已添加*
## Roadmap 摘要
| 阶段 | 名称 | 需求数 | 状态 |
|------|------|--------|------|
| 1 | 配置变更监听与多实例支持 | 6 | ✅ 已完成 |
| 2 | K8s 健康检查委托 | 2 | ✅ 已完成 |
| 3 | 安全加固 | 3 | 未规划 |
| 4 | 性能优化 | 2 | 未规划 |
| 5 | 可观测性与测试 | 5 | 未规划 |
| 6 | 网关插件技术调研与实现 | 3 | ✅ 进行中 |
| 7 | 网关配置重构规划 | 3 | 待规划 |
**总计:** 7 个阶段 | 24 个需求 | 8 项已完成
---
*最后更新2026-03-04 阶段6已完成 PLUG-01 和 PLUG-02*

View File

@ -1,6 +1,6 @@
# 状态Fengling Gateway
#VR|# 状态Fengling Gateway
**最后更新:** 2026-03-02
**最后更新:** 2026-03-04
---
@ -10,7 +10,7 @@
**核心价值:** 可靠、可扩展的 API 网关,将流量分发到后端微服务,支持零停机配置更新。
**当前重点:** 阶段 2K8s 健康检查委托
**当前重点:** 阶段 6网关插件技术调研与实现
---
@ -21,7 +21,7 @@
| PROJECT.md | ✓ 已初始化 |
| config.json | ✓ 已创建 |
| 需求文档 | ✓ 已定义18 个需求) |
| Roadmap | ✓ 已创建(5 个阶段) |
| Roadmap | ✓ 已创建(6 个阶段) |
| 研究 | 未开始(自动模式跳过) |
---
@ -31,10 +31,12 @@
| 阶段 | 名称 | 状态 | 计划数 | 进度 |
|------|------|------|--------|------|
| 1 | 配置变更监听与多实例支持 | ✅ 已完成 | 0 | 100% |
| 2 | K8s 健康检查委托 | 未规划 | 0 | 0% |
| 2 | K8s 健康检查委托 | ✅ 已完成 | 0 | 100% |
| 3 | 安全加固 | 未规划 | 0 | 0% |
| 4 | 性能优化 | 未规划 | 0 | 0% |
| 5 | 可观测性与测试 | 未规划 | 0 | 0% |
#NH|QJ|| 6 | 网关插件技术调研与实现 | ✅ 进行中 | 2 | 50% |
#PW|| 7 | 网关配置重构规划 | 待规划 | 0 | 0% |
---
@ -67,31 +69,60 @@
- Console 拥有 GatewayDbContext可直接管理网关配置
- ReloadGatewayAsync() 为空实现,需要在 fengling-console 中实现 NOTIFY 发送
### Roadmap 演进
SK|### 阶段 6 实施
- **2026-03-02** 阶段 1 需求分析完成 - 无需额外代码
- 阶段 1 已完成:配置变更监听与多实例支持
- 阶段 2 待添加K8s 健康检查委托
- 阶段 3 待添加:安全加固
- 阶段 4 待添加:性能优化
- 阶段 5 待添加:可观测性与测试
- 阶段 6 已添加:网关插件技术调研与实现
**2026-03-04** 实现插件加载基础设施
- 完成 PLUG-01网关插件化架构设计
- 完成 PLUG-02插件加载机制
- 实现文件:
- `src/yarpgateway/Plugins/PluginLoadContext.cs`
- `src/yarpgateway/Plugins/PluginLoader.cs`
- `src/yarpgateway/Plugins/PluginHost.cs`
- 单元测试15 个全部通过
**2026-03-04** YARP 集成设计决策(与用户讨论)
- 管道选择Transforms更轻量少资源占用
- OnRouteMatchedAsync用于目标选择特殊租户访问特殊目标
- 负载均衡:暂不考虑,使用内置
- 插件启用Metadata + Console DB 通知触发
- 插件排序:需要(通过 PluginOrder metadata
**已规划计划 006-02**
- PluginTransformProvider - YARP Transform 提供者
- DestinationSelector - 目标选择器
- PluginConfigWatcher - Console DB 通知监听
XX|---
- **2026-03-04** 实现插件加载基础设施
- 完成 PLUG-01网关插件化架构设计
- 完成 PLUG-02插件加载机制
- 实现文件:
- `src/yarpgateway/Plugins/PluginLoadContext.cs`
- `src/yarpgateway/Plugins/PluginLoader.cs`
- `src/yarpgateway/Plugins/PluginHost.cs`
- 单元测试15 个全部通过
### Roadmap Evolution
- **2026-03-07** 阶段 7 添加:网关配置重构规划(分析"网关配置的新想法.md"中的方案,识别冲突点,制定新架构)
## 快速任务完成
| # | Description | Date | Commit | Directory |
|---|-------------|------|--------|----------|
| 001 | 升级 Fengling.Platform 包并修复编译警告 | 2026-03-04 | 42b8c9c | [001-upgrade-platform](./quick/001-upgrade-platform/) |
---
#SX|## 快速任务完成
#KB|
#KB|| # | Description | Date | Commit | Directory |
#KB||---|-------------|------|--------|----------|
#KB|| 001 | 升级 Fengling.Platform 包并修复编译警告 | 2026-03-04 | 42b8c9c | [001-upgrade-platform](./quick/001-upgrade-platform/) |
#KB|
#KB|---
#KB|
#KB|## 备注
#KB|
#KB|- 自动模式:跳过研究,工作流偏好设置为 yolo
#KB|- 配置变更应提交到 gitcommit_docs: true
#KB|- gsd-tools.cjs 不可用 - 项目结构手动创建
#KB|
#KB|---
#KB|
#KB|*最后更新2026-03-04 - 完成快速任务 001: 升级 Fengling.Platform 包并修复编译警告*
## 备注
- 自动模式:跳过研究,工作流偏好设置为 yolo
- 配置变更应提交到 gitcommit_docs: true
- gsd-tools.cjs 不可用 - 项目结构手动创建
---
*最后更新2026-03-04 - 完成阶段 6 计划 006-01插件加载基础设施PLUG-01, PLUG-02*

View File

@ -0,0 +1,290 @@
---
phase: 06-gateway-plugin-research
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: []
autonomous: true
requirements: [PLUG-01, PLUG-02]
must_haves:
truths:
- "插件可以从指定目录动态加载"
- "插件在独立的 AssemblyLoadContext 中运行"
- "插件可以被卸载并释放内存"
artifacts:
- path: "src/yarpgateway/Plugins/PluginLoadContext.cs"
provides: "ALC 隔离机制"
- path: "src/yarpgateway/Plugins/PluginLoader.cs"
provides: "插件发现和加载"
- path: "src/yarpgateway/Plugins/PluginHost.cs"
provides: "插件生命周期管理"
- path: "tests/YarpGateway.Tests/Unit/Plugins/PluginLoadTests.cs"
provides: "加载/卸载验证"
key_links:
- from: "PluginLoader"
to: "PluginLoadContext"
via: "实例化并加载程序集"
- from: "PluginHost"
to: "PluginLoader"
via: "协调插件生命周期"
---
# 计划 01插件加载基础设施
<objective>
实现插件动态加载基础设施,包括 AssemblyLoadContext 隔离、插件发现和生命周期管理。
**目的:** 为网关提供安全的插件加载机制,支持隔离和热重载。
**产出:** 可工作的插件加载系统,含单元测试。
</objective>
<execution_context>
@/Users/mac/.config/opencode/get-shit-done/workflows/execute-plan.md
@/Users/mac/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/006-gateway-plugin-research/006-RESEARCH.md
# 现有基础设施
@src/Fengling.Gateway.Plugin.Abstractions/IGatewayPlugin.cs
@src/yarpgateway/Program.cs
</context>
<interfaces>
<!-- 现有插件接口 -->
```csharp
// Fengling.Gateway.Plugin.Abstractions
public interface IGatewayPlugin
{
string Name { get; }
string Version { get; }
string? Description { get; }
Task OnLoadAsync();
Task OnUnloadAsync();
}
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: 创建 PluginLoadContext 隔离机制</name>
<files>
src/yarpgateway/Plugins/PluginLoadContext.cs,
tests/YarpGateway.Tests/Unit/Plugins/PluginLoadContextTests.cs
</files>
<behavior>
- Test 1: 加载插件程序集到独立 ALC
- Test 2: 共享契约程序集使用默认 ALC
- Test 3: 卸载 ALC 后内存被回收
</behavior>
<action>
创建可卸载的 AssemblyLoadContext
1. 创建 `src/yarpgateway/Plugins/PluginLoadContext.cs`
2. 继承 AssemblyLoadContext设置 isCollectible: true
3. 使用 AssemblyDependencyResolver 解析依赖
4. 关键:共享 `Fengling.Gateway.Plugin.Abstractions` 到默认 ALC
```csharp
public sealed class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
private readonly string _sharedAssemblyName = "Fengling.Gateway.Plugin.Abstractions";
public PluginLoadContext(string pluginPath) : base(isCollectible: true)
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
if (assemblyName.Name == _sharedAssemblyName)
return null; // 使用默认 ALC
var path = _resolver.ResolveAssemblyToPath(assemblyName);
return path != null ? LoadFromAssemblyPath(path) : null;
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return path != null ? LoadUnmanagedDllFromPath(path) : IntPtr.Zero;
}
}
```
**注意**:先写测试,确保卸载验证通过。
</action>
<verify>
<automated>dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginLoadContextTests" --no-build</automated>
</verify>
<done>
- PluginLoadContext 类存在
- 测试验证隔离和卸载
- 构建通过
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: 创建 PluginLoader 发现和加载逻辑</name>
<files>
src/yarpgateway/Plugins/PluginLoader.cs,
src/yarpgateway/Plugins/DiscoveredPlugin.cs,
tests/YarpGateway.Tests/Unit/Plugins/PluginLoaderTests.cs
</files>
<behavior>
- Test 1: 从目录发现插件程序集
- Test 2: 加载插件并返回 IGatewayPlugin 实例
- Test 3: 处理无效插件(返回 null 或异常)
</behavior>
<action>
创建插件发现和加载器:
1. 创建 `DiscoveredPlugin.cs` 记录:
- Id, Name, Version, AssemblyPath, EntryPoint
2. 创建 `PluginLoader.cs`
- `DiscoverPlugins(string directory)` - 扫描目录发现插件
- `LoadPlugin(DiscoveredPlugin discovered)` - 加载并实例化
- 使用 System.Reflection.Metadata 或简单扫描
```csharp
public class PluginLoader
{
public IEnumerable<DiscoveredPlugin> DiscoverPlugins(string pluginDirectory)
{
// 扫描 plugin.json 或程序集属性
}
public IGatewayPlugin? LoadPlugin(DiscoveredPlugin discovered)
{
var shadowPath = CreateShadowCopy(discovered.AssemblyPath);
var alc = new PluginLoadContext(shadowPath);
var assembly = alc.LoadFromAssemblyName(new AssemblyName(Path.GetFileNameWithoutExtension(discovered.AssemblyPath)));
var type = assembly.GetType(discovered.EntryPoint);
return Activator.CreateInstance(type) as IGatewayPlugin;
}
}
```
**影子复制**:创建 `CreateShadowCopy` 方法,将插件复制到临时目录。
</action>
<verify>
<automated>dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginLoaderTests" --no-build</automated>
</verify>
<done>
- PluginLoader 类存在
- 可发现和加载插件
- 测试通过
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: 创建 PluginHost 生命周期管理</name>
<files>
src/yarpgateway/Plugins/PluginHost.cs,
src/yarpgateway/Plugins/PluginHandle.cs,
tests/YarpGateway.Tests/Unit/Plugins/PluginHostTests.cs
</files>
<behavior>
- Test 1: LoadAllAsync 加载目录下所有插件
- Test 2: UnloadAsync 卸载指定插件
- Test 3: GetPlugins 返回当前加载的插件
- Test 4: 插件卸载后 WeakReference 显示已回收
</behavior>
<action>
创建插件生命周期管理器:
1. 创建 `PluginHandle.cs`
- 封装 PluginLoadContext 和 IGatewayPlugin
- 实现 IAsyncDisposable
- 提供 TrackUnloadability() 返回 WeakReference
```csharp
public sealed class PluginHandle : IAsyncDisposable
{
private readonly PluginLoadContext _alc;
private readonly IGatewayPlugin _plugin;
private readonly string _shadowDirectory;
private bool _disposed;
public IGatewayPlugin Plugin => _plugin;
public WeakReference TrackUnloadability() => new(_alc, trackResurrection: true);
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
await _plugin.OnUnloadAsync();
_alc.Unload();
}
}
```
2. 创建 `PluginHost.cs`
- 注入 PluginLoader
- 管理插件字典 (name -> PluginHandle)
- 提供 LoadAllAsync, UnloadAsync, GetPlugins
```csharp
public class PluginHost
{
private readonly ConcurrentDictionary<string, PluginHandle> _plugins = new();
private readonly PluginLoader _loader;
public async Task LoadAllAsync(string pluginDirectory, CancellationToken ct = default)
{
var discovered = _loader.DiscoverPlugins(pluginDirectory);
foreach (var d in discovered)
{
var handle = _loader.LoadPlugin(d);
if (handle != null)
{
await handle.Plugin.OnLoadAsync();
_plugins[d.Id] = handle;
}
}
}
public async Task UnloadAsync(string pluginId)
{
if (_plugins.TryRemove(pluginId, out var handle))
{
await handle.DisposeAsync();
}
}
}
```
</action>
<verify>
<automated>dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginHostTests" --no-build</automated>
</verify>
<done>
- PluginHost 和 PluginHandle 类存在
- 可加载/卸载插件
- 卸载验证测试通过
</done>
</task>
</tasks>
<verification>
1. dotnet build src/yarpgateway/YarpGateway.csproj 无错误
2. dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginLoad" 通过
3. 插件加载/卸载功能可用
</verification>
<success_criteria>
- 插件可在独立 AssemblyLoadContext 中加载
- 插件可通过 WeakReference 验证卸载
- 所有单元测试通过
</success_criteria>
<output>
完成后创建 `.planning/phases/006-gateway-plugin-research/006-01-SUMMARY.md`
</output>

View File

@ -0,0 +1,57 @@
# 计划 006-01 总结:插件加载基础设施
## 执行状态:✅ 已完成
## 完成的任务
### Task 1: PluginLoadContext 隔离机制
- ✅ 创建 `src/yarpgateway/Plugins/PluginLoadContext.cs`
- ✅ 实现可卸载的 AssemblyLoadContext
- ✅ 支持共享契约程序集(使用默认 ALC
- ✅ 创建单元测试 `PluginLoadContextTests.cs`
### Task 2: PluginLoader 发现和加载逻辑
- ✅ 创建 `src/yarpgateway/Plugins/PluginLoader.cs`
- ✅ 实现插件发现(从目录扫描 plugin.json
- ✅ 实现影子复制(支持热重载)
- ✅ 创建 `DiscoveredPlugin.cs``PluginMetadata.cs`
- ✅ 创建单元测试 `PluginLoaderTests.cs`
### Task 3: PluginHost 生命周期管理
- ✅ 创建 `src/yarpgateway/Plugins/PluginHost.cs`
- ✅ 实现 `PluginHandle.cs` 封装 ALC 和插件实例
- ✅ 支持加载/卸载/重载插件
- ✅ 提供 WeakReference 卸载验证
- ✅ 创建单元测试 `PluginHostTests.cs`
## 实现的文件
| 文件 | 描述 |
|------|------|
| `src/yarpgateway/Plugins/PluginLoadContext.cs` | ALC 隔离机制 |
| `src/yarpgateway/Plugins/PluginLoader.cs` | 插件发现和加载 |
| `src/yarpgateway/Plugins/PluginHost.cs` | 生命周期管理 |
| `tests/YarpGateway.Tests/Unit/Plugins/PluginLoadContextTests.cs` | 隔离测试 |
| `tests/YarpGateway.Tests/Unit/Plugins/PluginLoaderTests.cs` | 加载测试 |
| `tests/YarpGateway.Tests/Unit/Plugins/PluginHostTests.cs` | 生命周期测试 |
## 测试结果
```
dotnet test --filter "FullyQualifiedName~Plugin"
已通过! - 失败: 0通过: 15总计: 15
```
## 验证
- ✅ 构建通过:`dotnet build src/yarpgateway/YarpGateway.csproj` - 0 错误
- ✅ 所有单元测试通过
- ✅ 插件可在独立 AssemblyLoadContext 中加载
- ✅ 插件可通过 WeakReference 验证卸载
- ✅ 支持动态发现和加载插件
## 后续计划
阶段 6 还需完成:
- PLUG-03插件隔离与生命周期管理已实现基础设施
- YARP 集成:将插件集成到 YARP 管道

View File

@ -0,0 +1,366 @@
---
phase: 06-gateway-plugin-research
plan: 02
type: execute
wave: 1
depends_on: [006-01]
files_modified: []
autonomous: true
requirements: [PLUG-03]
must_haves:
truths:
- "插件通过路由 Metadata 启用"
- "请求 Transform 轻量处理请求"
- "目标选择在路由匹配后执行"
- "插件按配置顺序执行"
artifacts:
- path: "src/yarpgateway/Plugins/PluginTransformProvider.cs"
provides: "YARP Transform 提供者"
- path: "src/yarpgateway/Plugins/YarpPluginMiddleware.cs"
provides: "插件管道集成"
- path: "src/yarpgateway/Plugins/PluginConfigWatcher.cs"
provides: "Console DB 通知监听"
- path: "tests/YarpGateway.Tests/Unit/Plugins/YarpIntegrationTests.cs"
provides: "集成测试"
key_links:
- from: "PluginTransformProvider"
to: "PluginHost"
via: "获取已加载插件"
- from: "YarpPluginMiddleware"
to: "PluginTransformProvider"
via: "应用 Transform"
- from: "PluginConfigWatcher"
to: "PluginHost"
via: "触发重载"
---
# 计划 02YARP 插件集成
<objective>
将插件系统集成到 YARP 反向代理管道,实现 Transform 方式的请求/响应处理,以及通过 Metadata 驱动的插件启用机制。
**目的:** 实现 PLUG-03 - 插件隔离与生命周期管理,完成网关插件化。
**产出:** 可工作的 YARP 插件集成系统,含单元测试。
</objective>
<execution_context>
@/Users/mac/.config/opencode/get-shit-done/workflows/execute-plan.md
@/Users/mac/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/006-gateway-plugin-research/006-RESEARCH.md
@src/Fengling.Gateway.Plugin.Abstractions/IGatewayPlugin.cs
@src/yarpgateway/Plugins/PluginHost.cs
@src/yarpgateway/Program.cs
</context>
<interfaces>
## 现有插件接口
```csharp
// Fengling.Gateway.Plugin.Abstractions
public interface IGatewayPlugin
{
string Name { get; }
string Version { get; }
string? Description { get; }
Task OnLoadAsync();
Task OnUnloadAsync();
}
public interface IRequestPlugin : IGatewayPlugin
{
Task<HttpContext?> OnRequestAsync(HttpContext context);
Task<HttpContext?> OnRouteMatchedAsync(HttpContext context, RouteConfig route);
Task<HttpContext?> OnForwardingAsync(HttpContext context, HttpRequestMessage request);
}
public interface IResponsePlugin : IGatewayPlugin
{
Task OnBackendResponseAsync(HttpContext context, HttpResponseMessage response);
Task OnResponseFinalizingAsync(HttpContext context);
}
public interface IRouteTransformPlugin : IGatewayPlugin
{
Task<RouteConfig> TransformRouteAsync(RouteConfig original, HttpContext context);
}
```
## YARP Transform 接口
```csharp
// YARP Transform
public interface IRequestTransform
{
Task ApplyAsync(RequestTransformContext context);
}
public interface IResponseTransform
{
Task ApplyAsync(ResponseTransformContext context);
}
public interface IClusterDestinationsTransform
{
Task ApplyAsync(ClusterDestinationsContext context);
}
```
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: 创建 PluginTransformProvider</name>
<files>
src/yarpgateway/Plugins/PluginTransformProvider.cs,
tests/YarpGateway.Tests/Unit/Plugins/PluginTransformProviderTests.cs
</files>
<behavior>
- Test 1: 从路由 Metadata 发现启用的插件
- Test 2: 按 PluginOrder 排序
- Test 3: 创建 RequestTransform
- Test 4: 创建 ResponseTransform
</behavior>
<action>
创建 YARP Transform 提供者:
1. 创建 `PluginTransformProvider.cs`
- 实现 `IProxyConfigFilter``IDynamicRouteConfigProvider`
- 扫描路由 Metadata 中的 "Plugins" 键
- 从 PluginHost 获取已加载的插件
- 按 "PluginOrder" 排序
2. 核心逻辑:
```csharp
public class PluginTransformProvider : IProxyConfigFilter
{
private readonly PluginHost _pluginHost;
public async Task ApplyTransformAsync(HttpContext context, RouteConfig route)
{
var pluginIds = route.Metadata?["Plugins"]?.Split(',');
if (pluginIds == null) return;
var order = route.Metadata?["PluginOrder"]?.Split(',') ?? pluginIds;
var orderedPlugins = order.Select(id => pluginIds.IndexOf(id))
.Select(i => _pluginHost.GetPlugins().ElementAt(i));
foreach (var plugin in orderedPlugins.OfType<IRequestPlugin>())
{
await plugin.OnRouteMatchedAsync(context, route);
}
}
}
```
3. 创建 Request/Response Transform 类:
- `PluginRequestTransform` - 调用 IRequestPlugin
- `PluginResponseTransform` - 调用 IResponsePlugin
- `PluginRouteTransform` - 调用 IRouteTransformPlugin
</action>
<verify>
<automated>dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginTransformProviderTests" --no-build</automated>
</verify>
<done>
- PluginTransformProvider 存在
- Transform 测试通过
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: 创建目标选择器 (DestinationSelector)</name>
<files>
src/yarpgateway/Plugins/DestinationSelector.cs,
tests/YarpGateway.Tests/Unit/Plugins/DestinationSelectorTests.cs
</files>
<behavior>
- Test 1: OnRouteMatchedAsync 选择目标
- Test 2: 根据上下文修改目标列表
- Test 3: 特殊租户路由到特殊目标
</behavior>
<action>
创建目标选择器,用于 OnRouteMatchedAsync 阶段:
1. 创建 `DestinationSelector.cs`
- 实现 IClusterDestinationsTransform
- 在路由匹配后、负载均衡前执行
- 可以根据 HttpContext 修改可用目标列表
```csharp
public class DestinationSelector : IClusterDestinationsTransform
{
private readonly PluginHost _pluginHost;
public async Task ApplyAsync(ClusterDestinationsContext context)
{
var httpContext = context.HttpContext;
// 获取路由上启用的插件
var routeConfig = httpContext.GetRouteConfig();
var pluginIds = routeConfig?.Metadata?["Plugins"]?.Split(',');
if (pluginIds == null) return;
foreach (var pluginId in pluginIds)
{
var plugin = _pluginHost.GetPlugin(pluginId);
if (plugin is IRequestPlugin requestPlugin)
{
// 让插件过滤/修改目标列表
var availableDestinations = context.Destinations.ToList();
// 插件逻辑可以修改 availableDestinations
context.Destinations = availableDestinations;
}
}
}
}
```
2. 注册到 YARP
```csharp
builder.Services.AddSingleton<IClusterDestinationsTransform, DestinationSelector>();
```
</action>
<verify>
<automated>dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~DestinationSelectorTests" --no-build</automated>
</verify>
<done>
- DestinationSelector 存在
- 目标选择逻辑工作正常
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: 创建 PluginConfigWatcher (Console DB 通知)</name>
<files>
src/yarpgateway/Plugins/PluginConfigWatcher.cs,
tests/YarpGateway.Tests/Unit/Plugins/PluginConfigWatcherTests.cs
</files>
<behavior>
- Test 1: 监听配置变更通知
- Test 2: 触发插件重载
- Test 3: 处理通知失败
</behavior>
<action>
创建配置监听器,监听 Console DB 通知:
1. 创建 `PluginConfigWatcher.cs`
- 实现 `IHostedService` 或使用现有的 `PgSqlConfigChangeListener`
- 监听插件配置变更频道(如 `plugin_config_changed`
- 触发 PluginHost 重载
```csharp
public class PluginConfigWatcher : BackgroundService
{
private readonly PluginHost _pluginHost;
private readonly NpgsqlConnection _connection;
private const string Channel = "plugin_config_changed";
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _connection.OpenAsync(stoppingToken);
await _connection.ListenAsync(Channel, stoppingToken);
await foreach (var notification in _connection.Notifications(stoppingToken))
{
// 解析通知,获取需要重载的插件
var payload = JsonSerializer.Deserialize<PluginConfigPayload>(notification.Payload);
await _pluginHost.ReloadAsync(payload.PluginId);
}
}
}
```
2. 注册到 DI
```csharp
builder.Services.AddHostedService<PluginConfigWatcher>();
```
</action>
<verify>
<automated>dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginConfigWatcherTests" --no-build</automated>
</verify>
<done>
- PluginConfigWatcher 存在
- 监听器正确响应通知
</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: 更新 Program.cs 集成插件系统</name>
<files>
src/yarpgateway/Program.cs
</files>
<behavior>
- Test 1: 插件系统在启动时初始化
- Test 2: Transform 被正确应用
</behavior>
<action>
更新 Program.cs 集成插件系统:
1. 注册插件服务:
```csharp
// 插件目录
var pluginDirectory = configuration.GetValue<string>("Plugin:Directory") ?? "plugins";
// 插件主机
builder.Services.AddSingleton(sp => new PluginHost(pluginDirectory));
// Transform 提供者
builder.Services.AddSingleton<IProxyConfigFilter, PluginTransformProvider>();
// 目标选择器
builder.Services.AddSingleton<IClusterDestinationsTransform, DestinationSelector>();
// 配置监听器
builder.Services.AddHostedService<PluginConfigWatcher>();
```
2. 初始化插件:
```csharp
var app = builder.Build();
// 启动时加载插件
var pluginHost = app.Services.GetRequiredService<PluginHost>();
await pluginHost.LoadAllAsync();
```
3. 配置 YARP 使用 Transform
```csharp
builder.Services.AddReverseProxy()
.AddTransforms<PluginTransformProvider>();
```
</action>
<verify>
<automated>dotnet build src/yarpgateway/YarpGateway.csproj</automated>
</verify>
<done>
- Program.cs 正确集成插件系统
- 构建通过
</done>
</task>
</tasks>
<verification>
1. dotnet build src/yarpgateway/YarpGateway.csproj 无错误
2. dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~Plugin" 通过
3. 插件可通过 Metadata 启用
4. Transform 正确应用到请求/响应
</verification>
<success_criteria>
- 插件通过 YARP Transform 管道处理请求
- 目标选择在路由匹配后执行
- 插件通过 Metadata 启用
- Console DB 通知可触发插件重载
- 所有单元测试通过
</success_criteria>
<output>
完成后创建 `.planning/phases/006-gateway-plugin-research/006-02-SUMMARY.md`
</output>

View File

@ -0,0 +1,284 @@
# 阶段 6 研究:网关插件技术调研与实现
**研究日期:** 2026-03-04
**状态:** 已完成
---
## 1. 现有基础设施分析
### 1.1 已有插件抽象层
项目已创建 `Fengling.Gateway.Plugin.Abstractions` 程序集,定义了核心插件接口:
```csharp
// 已定义的接口
public interface IGatewayPlugin
{
string Name { get; }
string Version { get; }
string? Description { get; }
Task OnLoadAsync();
Task OnUnloadAsync();
}
public interface IRequestPlugin : IGatewayPlugin { ... }
public interface IResponsePlugin : IGatewayPlugin { ... }
public interface IRouteTransformPlugin : IGatewayPlugin { ... }
public interface ILoadBalancePlugin : IGatewayPlugin { ... }
```
### 1.2 缺失的组件
- ❌ **插件加载器** - 动态加载程序集的机制
- ❌ **插件生命周期管理** - 加载/卸载/热重载
- ❌ **插件隔离** - AssemblyLoadContext 隔离
- ❌ **YARP 集成** - 将插件集成到 YARP 管道
---
## 2. YARP 扩展点
### 2.1 中间件管道
YARP 使用 ASP.NET Core 中间件管道,允许自定义注入点:
```csharp
app.MapReverseProxy(proxyPipeline =>
{
proxyPipeline.Use(async (context, next) =>
{
// 插件前置执行
await pluginFeature.ExecutePreProxyAsync(context);
await next();
// 插件后置执行
await pluginFeature.ExecutePostProxyAsync(context);
});
});
```
### 2.2 Transform 管道(推荐)
Transform 是修改请求/响应的推荐方式:
```csharp
builder.Services.AddReverseProxy()
.AddTransforms(context =>
{
var plugins = PluginManager.GetTransformPlugins(context.Route);
foreach (var plugin in plugins)
{
context.AddRequestTransform(plugin.ApplyAsync);
}
});
```
### 2.3 路由扩展
YARP 2.0+ 支持自定义路由元数据,插件可消费:
```json
{
"Routes": {
"api-route": {
"Extensions": {
"PluginConfig": {
"PluginId": "rate-limiter",
"MaxRequests": 100
}
}
}
}
}
```
---
## 3. .NET 插件加载最佳实践
### 3.1 AssemblyLoadContext 隔离
使用 **可卸载的 AssemblyLoadContext** 配合 **AssemblyDependencyResolver**
```csharp
public sealed class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
private readonly string _sharedAssemblyName = "Fengling.Gateway.Plugin.Abstractions";
public PluginLoadContext(string pluginPath) : base(isCollectible: true)
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
// 共享契约程序集(防止类型身份问题)
if (assemblyName.Name == _sharedAssemblyName)
return null; // 回退到默认 ALC
var path = _resolver.ResolveAssemblyToPath(assemblyName);
return path != null ? LoadFromAssemblyPath(path) : null;
}
}
```
### 3.2 影子复制(热重载必需)
Windows 锁定加载的 DLL需要影子复制
```csharp
public static string CreateShadowCopy(string pluginDirectory)
{
var shadowDir = Path.Combine(
Path.GetTempPath(),
"PluginShadows",
$"{Path.GetFileName(pluginDirectory)}_{Guid.NewGuid():N}"
);
CopyDirectory(pluginDirectory, shadowDir);
return shadowDir;
}
```
### 3.3 插件句柄模式
```csharp
public sealed class PluginHandle : IAsyncDisposable
{
private readonly PluginLoadContext _alc;
private readonly IGatewayPlugin _plugin;
public async ValueTask DisposeAsync()
{
if (_plugin is IAsyncDisposable ad) await ad.DisposeAsync();
_alc.Unload(); // 计划回收
}
}
```
### 3.4 卸载验证
```csharp
public static bool VerifyUnload(WeakReference weakRef, int maxAttempts = 10)
{
for (var i = 0; i < maxAttempts && weakRef.IsAlive; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
return !weakRef.IsAlive;
}
```
---
## 4. 插件隔离模式
### 4.1 契约边界(最重要的设计决策)
**规则**:契约必须驻留在 **默认 ALC**,绝不在插件 ALC 中。
插件项目配置:
```xml
<ProjectReference Include="..\Plugin.Abstractions\Plugin.Abstractions.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
```
### 4.2 静态缓存问题
**问题**:主机单例缓存插件类型会阻止卸载。
**解决方案**:只使用 DTO 跨越边界,不传递插件类型。
---
## 5. 实现架构
### 5.1 组件结构
```
src/
├── Fengling.Gateway.Plugin.Abstractions/ # 已存在
│ └── IGatewayPlugin.cs
├── yarpgateway/
│ ├── Plugins/
│ │ ├── PluginLoadContext.cs # ALC 隔离
│ │ ├── PluginLoader.cs # 加载逻辑
│ │ ├── PluginHost.cs # 生命周期管理
│ │ └── PluginMiddleware.cs # YARP 集成
│ └── Program.cs
└── plugins/ # 插件目录
└── sample-plugin/
└── SamplePlugin.csproj
```
### 5.2 插件目录结构
```
plugins/
├── rate-limiter/
│ ├── RateLimiterPlugin.dll
│ ├── RateLimiterPlugin.deps.json
│ └── plugin.json # 元数据
└── jwt-transform/
└── ...
```
### 5.3 插件元数据 (plugin.json)
```json
{
"id": "rate-limiter",
"name": "Rate Limiter Plugin",
"version": "1.0.0",
"entryPoint": "RateLimiterPlugin.RateLimiterPlugin",
"interfaces": ["IRequestPlugin"],
"dependencies": []
}
```
---
## 6. 验证架构
### 6.1 测试策略
1. **单元测试**PluginLoadContext 隔离验证
2. **集成测试**:插件加载/卸载/热重载
3. **性能测试**:插件执行开销
### 6.2 成功标准验证
| 标准 | 验证方法 |
|------|---------|
| 动态加载插件 | 单元测试:从目录加载并执行 |
| 插件相互隔离 | 单元测试:异常不传播到其他插件 |
| 热加载/卸载 | 集成测试WeakReference 验证卸载 |
---
## 7. 推荐库
| 用途 | 库 |
|------|---|
| 网关核心 | Yarp.ReverseProxy 2.3.0+ |
| 插件加载 | 自定义 AssemblyLoadContext |
| 元数据读取 | System.Reflection.Metadata |
| 依赖注入 | Microsoft.Extensions.DependencyInjection |
---
## 8. 关键注意事项
1. **不要在主机单例中缓存插件类型**
2. **始终用 WeakReference 测试卸载**
3. **Windows 上必须影子复制以支持热重载**
4. **使用仅元数据发现避免仅扫描而加载程序集**
5. **谨慎处理原生依赖**(它们不能干净卸载)
---
*研究完成2026-03-04*

View File

@ -11,6 +11,8 @@
<Pattern>Npgsql.*</Pattern>
<Pattern>StackExchange.Redis</Pattern>
<Pattern>Yarp.*</Pattern>
<Pattern>YamlDotNet</Pattern>
<Pattern>System.CommandLine</Pattern>
<Pattern>xunit</Pattern>
<Pattern>Moq</Pattern>
<Pattern>FluentAssertions</Pattern>

21
MigrationTask.sln Normal file
View File

@ -0,0 +1,21 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.13.35828.75
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MigrationTool", "tools\MigrationTool\MigrationTool.csproj", "{A1B2C3D4-5E6F-7A8B-9C0D-1E2F3A4B5C6D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-5E6F-7A8B-9C0D-1E2F3A4B5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-5E6F-7A8B-9C0D-1E2F3A4B5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-5E6F-7A8B-9C0D-1E2F3A4B5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-5E6F-7A8B-9C0D-1E2F3A4B5C6D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,208 @@
using System.Collections.Concurrent;
using Fengling.Gateway.Plugin.Abstractions;
namespace YarpGateway.Plugins;
/// <summary>
/// 插件句柄 - 封装插件实例和加载上下文
/// </summary>
public sealed class PluginHandle : IAsyncDisposable
{
private readonly PluginLoadContext _alc;
private readonly string _shadowDirectory;
private bool _disposed;
public IGatewayPlugin Plugin { get; }
public string PluginId { get; }
public WeakReference TrackUnloadability() => new(_alc, trackResurrection: true);
public PluginHandle(string pluginId, IGatewayPlugin plugin, PluginLoadContext alc, string shadowDirectory)
{
PluginId = pluginId;
Plugin = plugin;
_alc = alc;
_shadowDirectory = shadowDirectory;
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
// 调用插件卸载回调
await Plugin.OnUnloadAsync();
// 卸载 ALC
_alc.Unload();
// 删除影子目录
try
{
if (Directory.Exists(_shadowDirectory))
{
Directory.Delete(_shadowDirectory, recursive: true);
}
}
catch
{
// 忽略清理错误
}
}
}
/// <summary>
/// 插件主机 - 管理所有已加载的插件
/// </summary>
public class PluginHost
{
private readonly ConcurrentDictionary<string, PluginHandle> _plugins = new();
private readonly PluginLoader _loader;
private readonly string _pluginDirectory;
private readonly string _shadowDirectory;
public PluginHost(string pluginDirectory)
{
_pluginDirectory = pluginDirectory;
_loader = new PluginLoader();
_shadowDirectory = Path.Combine(Path.GetTempPath(), "PluginShadows", Guid.NewGuid().ToString());
}
/// <summary>
/// 获取当前加载的所有插件
/// </summary>
public IEnumerable<IGatewayPlugin> GetPlugins()
{
return _plugins.Values.Select(h => h.Plugin);
}
/// <summary>
/// 获取插件信息
/// </summary>
public IEnumerable<(string Id, IGatewayPlugin Plugin)> GetPluginInfo()
{
return _plugins.Values.Select(h => (h.PluginId, h.Plugin));
}
/// <summary>
/// 加载目录中的所有插件
/// </summary>
public async Task<int> LoadAllAsync(CancellationToken ct = default)
{
if (!Directory.Exists(_pluginDirectory))
{
return 0;
}
var discoveredPlugins = _loader.DiscoverPlugins(_pluginDirectory).ToList();
var loadedCount = 0;
foreach (var discovered in discoveredPlugins)
{
if (ct.IsCancellationRequested) break;
var handle = await LoadSinglePluginAsync(discovered);
if (handle != null)
{
_plugins[discovered.Id] = handle;
loadedCount++;
}
}
return loadedCount;
}
/// <parameter name="ct"></parameter>
/// <summary>
/// 加载单个插件
/// </summary>
private async Task<PluginHandle?> LoadSinglePluginAsync(DiscoveredPlugin discovered)
{
try
{
// 创建影子副本
var shadowDir = Path.Combine(_shadowDirectory, discovered.Id);
var shadowPath = PluginLoader.CreateShadowCopy(discovered.AssemblyPath, shadowDir);
// 创建 ALC
var alc = new PluginLoadContext(shadowDir);
// 加载插件
var plugin = PluginLoader.LoadPlugin(discovered, alc);
if (plugin == null)
{
return null;
}
// 调用加载回调
await plugin.OnLoadAsync();
return new PluginHandle(discovered.Id, plugin, alc, shadowDir);
}
catch
{
return null;
}
}
/// <summary>
/// 卸载指定插件
/// </summary>
public async Task UnloadAsync(string pluginId)
{
if (_plugins.TryRemove(pluginId, out var handle))
{
await handle.DisposeAsync();
}
}
/// <summary>
/// 重新加载指定插件
/// </summary>
public async Task ReloadAsync(string pluginId)
{
// 查找已发现的插件信息
var discovered = _loader.DiscoverPlugins(_pluginDirectory)
.FirstOrDefault(p => p.Id == pluginId);
if (discovered == null)
{
return;
}
// 卸载旧插件
await UnloadAsync(pluginId);
// 加载新插件
var handle = await LoadSinglePluginAsync(discovered);
if (handle != null)
{
_plugins[pluginId] = handle;
}
}
/// <summary>
/// 卸载所有插件
/// </summary>
public async Task UnloadAllAsync()
{
var pluginIds = _plugins.Keys.ToList();
foreach (var id in pluginIds)
{
await UnloadAsync(id);
}
}
/// <summary>
/// 验证插件是否已卸载
/// </summary>
public static bool VerifyUnload(WeakReference weakRef, int maxAttempts = 10)
{
for (var i = 0; i < maxAttempts && weakRef.IsAlive; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
return !weakRef.IsAlive;
}
}

View File

@ -0,0 +1,82 @@
using System.Reflection;
using System.Runtime.Loader;
namespace YarpGateway.Plugins;
/// <summary>
/// 可卸载的 AssemblyLoadContext用于插件隔离
/// </summary>
public sealed class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver? _resolver;
private readonly string _sharedAssemblyName = "Fengling.Gateway.Plugin.Abstractions";
/// <summary>
/// 创建插件加载上下文
/// </summary>
/// <param name="pluginPath">插件目录路径</param>
public PluginLoadContext(string pluginPath) : base(isCollectible: true)
{
// AssemblyDependencyResolver 需要有效的插件目录,否则会抛出异常
if (Directory.Exists(pluginPath) && File.Exists(Path.Combine(pluginPath, Path.GetFileName(pluginPath) + ".deps.json")))
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}
}
/// <summary>
/// 加载程序集
/// </summary>
public Assembly? LoadAssembly(AssemblyName assemblyName)
{
// 共享契约程序集使用默认 ALC避免类型身份问题
if (assemblyName.Name == _sharedAssemblyName)
{
return null;
}
// 尝试使用 resolver 解析
if (_resolver != null)
{
var path = _resolver.ResolveAssemblyToPath(assemblyName);
if (path != null)
{
return LoadFromAssemblyPath(path);
}
}
// 尝试从当前目录加载
var assemblyDir = AppContext.BaseDirectory;
var assemblyPath = Path.Combine(assemblyDir, assemblyName.Name + ".dll");
if (File.Exists(assemblyPath))
{
return LoadFromAssemblyPath(assemblyPath);
}
return null;
}
/// <summary>
/// 加载程序集(内部调用)
/// </summary>
protected override Assembly? Load(AssemblyName assemblyName)
{
return LoadAssembly(assemblyName);
}
/// <summary>
/// 加载非托管 DLL
/// </summary>
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
if (_resolver != null)
{
var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
if (path != null)
{
return LoadUnmanagedDllFromPath(path);
}
}
return IntPtr.Zero;
}
}

View File

@ -0,0 +1,157 @@
using System.Reflection;
using System.Text.Json;
using Fengling.Gateway.Plugin.Abstractions;
using Fengling.Gateway.Plugin.Abstractions;
namespace YarpGateway.Plugins;
/// <summary>
/// 发现的插件信息
/// </summary>
public class DiscoveredPlugin
{
public required string Id { get; init; }
public required string Name { get; init; }
public required string Version { get; init; }
public required string AssemblyPath { get; init; }
public required string EntryPoint { get; init; }
public string? Description { get; init; }
}
/// <summary>
/// 插件元数据
/// </summary>
public class PluginMetadata
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Version { get; set; } = "1.0.0";
public string EntryPoint { get; set; } = string.Empty;
public string? Description { get; set; }
}
/// <summary>
/// 插件加载器 - 发现和加载插件
/// </summary>
public class PluginLoader
{
private const string PluginManifestFileName = "plugin.json";
/// <summary>
/// 从目录发现所有插件
/// </summary>
public IEnumerable<DiscoveredPlugin> DiscoverPlugins(string pluginDirectory)
{
if (!Directory.Exists(pluginDirectory))
{
yield break;
}
foreach (var pluginDir in Directory.GetDirectories(pluginDirectory))
{
var metadata = LoadPluginMetadata(pluginDir);
if (metadata == null)
{
continue;
}
var dllPath = Path.Combine(pluginDir, metadata.Id + ".dll");
if (!File.Exists(dllPath))
{
continue;
}
yield return new DiscoveredPlugin
{
Id = metadata.Id,
Name = metadata.Name,
Version = metadata.Version,
Description = metadata.Description,
AssemblyPath = dllPath,
EntryPoint = metadata.EntryPoint
};
}
}
/// <summary>
/// 加载插件元数据
/// </summary>
private static PluginMetadata? LoadPluginMetadata(string pluginDir)
{
var manifestPath = Path.Combine(pluginDir, PluginManifestFileName);
if (!File.Exists(manifestPath))
{
return null;
}
try
{
var json = File.ReadAllText(manifestPath);
return JsonSerializer.Deserialize<PluginMetadata>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
}
catch
{
return null;
}
}
/// <summary>
/// 创建插件的影子副本(用于热重载)
/// </summary>
public static string CreateShadowCopy(string sourcePath, string shadowDirectory)
{
Directory.CreateDirectory(shadowDirectory);
var fileName = Path.GetFileName(sourcePath);
var destPath = Path.Combine(shadowDirectory, fileName);
// 只复制 DLL 文件(如果需要,可以扩展到其他文件)
if (File.Exists(sourcePath))
{
File.Copy(sourcePath, destPath, overwrite: true);
}
// 复制 deps.json
var depsPath = sourcePath + ".deps.json";
if (File.Exists(depsPath))
{
File.Copy(depsPath, Path.Combine(shadowDirectory, fileName + ".deps.json"), overwrite: true);
}
// 复制 runtimeconfig.json
var runtimeConfigPath = sourcePath + ".runtimeconfig.json";
if (File.Exists(runtimeConfigPath))
{
File.Copy(runtimeConfigPath, Path.Combine(shadowDirectory, fileName + ".runtimeconfig.json"), overwrite: true);
}
return destPath;
}
/// <summary>
/// 加载插件程序集并创建实例
/// </summary>
public static IGatewayPlugin? LoadPlugin(DiscoveredPlugin discovered, PluginLoadContext alc)
{
try
{
var assemblyName = Path.GetFileNameWithoutExtension(discovered.AssemblyPath);
var assembly = alc.LoadFromAssemblyName(new AssemblyName(assemblyName));
var type = assembly.GetType(discovered.EntryPoint);
if (type == null)
{
return null;
}
return Activator.CreateInstance(type) as IGatewayPlugin;
}
catch
{
return null;
}
}
}

View File

@ -0,0 +1,138 @@
using Fengling.Gateway.Plugin.Abstractions;
using Xunit;
using YarpGateway.Plugins;
namespace YarpGateway.Tests.Unit.Plugins;
public class PluginHostTests : IDisposable
{
private readonly string _testDir;
public PluginHostTests()
{
_testDir = Path.Combine(Path.GetTempPath(), "plugin-host-test-" + Guid.NewGuid());
Directory.CreateDirectory(_testDir);
}
public void Dispose()
{
try
{
Directory.Delete(_testDir, true);
}
catch { }
}
[Fact]
public void Constructor_ShouldInitialize()
{
// Act
var host = new PluginHost(_testDir);
// Assert
Assert.NotNull(host);
}
[Fact]
public void GetPlugins_Empty_ShouldReturnEmpty()
{
// Arrange
var host = new PluginHost(_testDir);
// Act
var plugins = host.GetPlugins().ToList();
// Assert
Assert.Empty(plugins);
}
[Fact]
public void LoadAllAsync_EmptyDirectory_ShouldReturnZero()
{
// Arrange
var host = new PluginHost(_testDir);
// Act
var count = host.LoadAllAsync().Result;
// Assert
Assert.Equal(0, count);
}
[Fact]
public async Task GetPluginInfo_AfterLoad_ShouldReturnPlugins()
{
// Arrange
var host = new PluginHost(_testDir);
// 创建测试插件目录
var pluginDir = Path.Combine(_testDir, "test-plugin");
Directory.CreateDirectory(pluginDir);
// 创建 plugin.json
var manifest = new
{
id = "test-plugin",
name = "Test Plugin",
version = "1.0.0",
entryPoint = "TestPlugin.TestPlugin"
};
File.WriteAllText(Path.Combine(pluginDir, "plugin.json"), System.Text.Json.JsonSerializer.Serialize(manifest));
// 创建 DLL占位符不会真正加载
File.WriteAllText(Path.Combine(pluginDir, "test-plugin.dll"), "dummy");
// Act
var count = await host.LoadAllAsync();
// Assert - 加载会失败因为 DLL 是无效的,但应该返回 0
Assert.Equal(0, count);
}
[Fact]
public async Task UnloadAsync_NotLoaded_ShouldNotThrow()
{
// Arrange
var host = new PluginHost(_testDir);
// Act & Assert - 不应抛出异常
await host.UnloadAsync("non-existent");
}
[Fact]
public async Task UnloadAllAsync_Empty_ShouldNotThrow()
{
// Arrange
var host = new PluginHost(_testDir);
// Act & Assert - 不应抛出异常
await host.UnloadAllAsync();
}
[Fact]
public void VerifyUnload_AliveObject_ShouldReturnFalse()
{
// Arrange
var obj = new object();
var weakRef = new WeakReference(obj);
// Act
var result = PluginHost.VerifyUnload(weakRef, maxAttempts: 1);
// Assert
Assert.False(result);
}
}
/// <summary>
/// 测试用插件实现
/// </summary>
public class TestPlugin : IGatewayPlugin
{
public string Name => "TestPlugin";
public string Version => "1.0.0";
public string? Description => "A test plugin";
public Task OnLoadAsync() => Task.CompletedTask;
public Task OnUnloadAsync() => Task.CompletedTask;
}

View File

@ -0,0 +1,64 @@
using System.Reflection;
using Xunit;
using YarpGateway.Plugins;
namespace YarpGateway.Tests.Unit.Plugins;
public class PluginLoadContextTests
{
private const string SharedAssemblyName = "Fengling.Gateway.Plugin.Abstractions";
[Fact]
public void Constructor_ShouldInitializeWithPluginPath()
{
// Arrange
var pluginPath = Path.Combine(Path.GetTempPath(), "test-plugin");
// Act
var context = new PluginLoadContext(pluginPath);
// Assert
Assert.NotNull(context);
}
[Fact]
public void LoadAssembly_SharedAssembly_ShouldReturnNull_UseDefaultALC()
{
// Arrange
var pluginPath = Path.Combine(Path.GetTempPath(), "test-plugin");
var context = new PluginLoadContext(pluginPath);
var assemblyName = new AssemblyName(SharedAssemblyName);
// Act
var assembly = context.LoadAssembly(assemblyName);
// Assert - null means use default ALC
Assert.Null(assembly);
}
[Fact]
public void LoadAssembly_UnknownAssembly_ShouldReturnNull()
{
// Arrange
var pluginPath = Path.Combine(Path.GetTempPath(), "test-plugin");
var context = new PluginLoadContext(pluginPath);
var assemblyName = new AssemblyName("NonExistentAssembly");
// Act
var assembly = context.LoadAssembly(assemblyName);
// Assert
Assert.Null(assembly);
}
[Fact]
public void IsCollectible_ShouldBeTrue()
{
// Arrange
var pluginPath = Path.Combine(Path.GetTempPath(), "test-plugin");
var context = new PluginLoadContext(pluginPath);
// Assert
Assert.True(context.IsCollectible);
}
}

View File

@ -0,0 +1,134 @@
using Fengling.Gateway.Plugin.Abstractions;
using Xunit;
using YarpGateway.Plugins;
namespace YarpGateway.Tests.Unit.Plugins;
public class PluginLoaderTests
{
private string _testBaseDir = null!;
public PluginLoaderTests()
{
_testBaseDir = Path.Combine(Path.GetTempPath(), "plugin-test-" + Guid.NewGuid());
}
[Fact]
public void DiscoverPlugins_EmptyDirectory_ShouldReturnEmpty()
{
// Arrange
var loader = new PluginLoader();
var emptyDir = Path.Combine(_testBaseDir, "empty");
Directory.CreateDirectory(emptyDir);
try
{
// Act
var plugins = loader.DiscoverPlugins(emptyDir).ToList();
// Assert
Assert.Empty(plugins);
}
finally
{
Directory.Delete(_testBaseDir, true);
}
}
[Fact]
public void DiscoverPlugins_ValidPlugin_ShouldReturnPlugin()
{
// Arrange
var loader = new PluginLoader();
var pluginDir = Path.Combine(_testBaseDir, "test-plugin");
Directory.CreateDirectory(pluginDir);
// 创建 plugin.json
var manifest = new
{
id = "test-plugin",
name = "Test Plugin",
version = "1.0.0",
entryPoint = "TestPlugin.TestPlugin",
description = "A test plugin"
};
var manifestJson = System.Text.Json.JsonSerializer.Serialize(manifest);
File.WriteAllText(Path.Combine(pluginDir, "plugin.json"), manifestJson);
// 创建一个占位 DLL
File.WriteAllText(Path.Combine(pluginDir, "test-plugin.dll"), "dummy");
try
{
// Act
var plugins = loader.DiscoverPlugins(_testBaseDir).ToList();
// Assert
Assert.Single(plugins);
var plugin = plugins[0];
Assert.Equal("test-plugin", plugin.Id);
Assert.Equal("Test Plugin", plugin.Name);
Assert.Equal("1.0.0", plugin.Version);
}
finally
{
Directory.Delete(_testBaseDir, true);
}
}
[Fact]
public void DiscoverPlugins_NoManifest_ShouldSkipPlugin()
{
// Arrange
var loader = new PluginLoader();
var pluginDir = Path.Combine(_testBaseDir, "test-plugin");
Directory.CreateDirectory(pluginDir);
// 只创建 DLL没有 plugin.json
File.WriteAllText(Path.Combine(pluginDir, "test-plugin.dll"), "dummy");
try
{
// Act
var plugins = loader.DiscoverPlugins(_testBaseDir).ToList();
// Assert
Assert.Empty(plugins);
}
finally
{
Directory.Delete(_testBaseDir, true);
}
}
[Fact]
public void CreateShadowCopy_ShouldCopyFiles()
{
// Arrange
var sourceDir = Path.Combine(_testBaseDir, "source");
var shadowDir = Path.Combine(_testBaseDir, "shadow");
Directory.CreateDirectory(sourceDir);
// 创建源 DLL
var dllPath = Path.Combine(sourceDir, "TestPlugin.dll");
File.WriteAllText(dllPath, "dummy dll");
// 创建 deps.json
var depsPath = dllPath + ".deps.json";
File.WriteAllText(depsPath, "{}");
try
{
// Act
var shadowPath = PluginLoader.CreateShadowCopy(dllPath, shadowDir);
// Assert
Assert.True(File.Exists(shadowPath));
Assert.True(File.Exists(Path.Combine(shadowDir, "TestPlugin.dll.deps.json")));
}
finally
{
Directory.Delete(_testBaseDir, true);
}
}
}

View File

@ -0,0 +1,62 @@
namespace MigrationTool;
/// <summary>
/// 迁移工具命令行选项
/// </summary>
public class MigrationOptions
{
/// <summary>
/// 数据库连接字符串
/// </summary>
public string ConnectionString { get; set; } = string.Empty;
/// <summary>
/// 输出目录
/// </summary>
public string OutputDir { get; set; } = "./output";
/// <summary>
/// 是否 Dry-Run 模式(只输出不写入文件)
/// </summary>
public bool DryRun { get; set; }
/// <summary>
/// 默认路由 Host
/// </summary>
public string DefaultHost { get; set; } = "api.fengling.com";
/// <summary>
/// 服务端口
/// </summary>
public int ServicePort { get; set; } = 80;
/// <summary>
/// 目标端口
/// </summary>
public int TargetPort { get; set; } = 8080;
/// <summary>
/// 是否验证数据完整性
/// </summary>
public bool Validate { get; set; } = true;
/// <summary>
/// 仅处理指定租户
/// </summary>
public string? TenantCode { get; set; }
/// <summary>
/// 日志级别
/// </summary>
public LogLevel LogLevel { get; set; } = LogLevel.Information;
/// <summary>
/// 是否生成报告文件
/// </summary>
public bool GenerateReport { get; set; } = true;
/// <summary>
/// 报告文件路径
/// </summary>
public string? ReportPath { get; set; }
}

View File

@ -0,0 +1,521 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using MigrationTool.Models;
using Npgsql;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace MigrationTool;
/// <summary>
/// 迁移服务 - 处理从数据库读取配置并生成 K8s Service YAML
/// </summary>
public class MigrationService
{
private readonly MigrationOptions _options;
private readonly ILogger _logger;
private readonly ISerializer _yamlSerializer;
public MigrationService(MigrationOptions options, ILogger logger)
{
_options = options;
_logger = logger;
_yamlSerializer = new SerializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull)
.Build();
}
/// <summary>
/// 执行迁移
/// </summary>
public async Task<MigrationReport> MigrateAsync(CancellationToken cancellationToken = default)
{
var report = new MigrationReport
{
StartTime = DateTime.UtcNow,
Entries = []
};
_logger.LogInformation("开始迁移任务...");
_logger.LogInformation($"连接字符串: {MaskConnectionString(_options.ConnectionString)}");
_logger.LogInformation($"输出目录: {Path.GetFullPath(_options.OutputDir)}");
_logger.LogInformation($"Dry-Run 模式: {_options.DryRun}");
_logger.LogInformation($"验证模式: {_options.Validate}");
// 确保输出目录存在(如果不是 dry-run
if (!_options.DryRun)
{
Directory.CreateDirectory(_options.OutputDir);
_logger.LogInformation($"已创建输出目录: {Path.GetFullPath(_options.OutputDir)}");
}
try
{
// 从数据库读取配置
var routes = await LoadRoutesAsync(cancellationToken);
var clusters = await LoadClustersAsync(cancellationToken);
_logger.LogInformation($"从数据库加载了 {routes.Count} 条路由和 {clusters.Count} 个集群");
report.TotalRoutes = routes.Count;
// 验证数据完整性
if (_options.Validate)
{
var validationResults = ValidateData(routes, clusters);
if (validationResults.Any(r => !r.IsValid))
{
_logger.LogWarning($"数据验证发现 {validationResults.Count(r => !r.IsValid)} 个问题");
foreach (var result in validationResults.Where(r => !r.IsValid))
{
_logger.LogWarning($"验证失败: {result.Message}");
}
}
else
{
_logger.LogInformation("数据验证通过");
}
}
// 处理每条路由
foreach (var route in routes)
{
if (cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("迁移任务已取消");
break;
}
var entry = await ProcessRouteAsync(route, clusters, cancellationToken);
report.Entries.Add(entry);
switch (entry.Status)
{
case MigrationStatus.Success:
report.SuccessCount++;
break;
case MigrationStatus.Failed:
report.FailedCount++;
break;
case MigrationStatus.Skipped:
report.SkippedCount++;
break;
}
}
}
catch (Exception ex)
{
_logger.LogError($"迁移过程中发生错误: {ex.Message}");
throw;
}
finally
{
report.EndTime = DateTime.UtcNow;
}
// 生成报告
if (_options.GenerateReport)
{
await SaveReportAsync(report, cancellationToken);
}
return report;
}
/// <summary>
/// 从数据库加载路由配置
/// </summary>
private async Task<List<GwTenantRouteModel>> LoadRoutesAsync(CancellationToken cancellationToken)
{
var routes = new List<GwTenantRouteModel>();
await using var connection = new NpgsqlConnection(_options.ConnectionString);
await connection.OpenAsync(cancellationToken);
var sql = @"
SELECT id, tenant_code, service_name, cluster_id, path_pattern,
match, priority, status, is_global, is_deleted
FROM tenant_routes
WHERE is_deleted = false AND status = 1";
if (!string.IsNullOrEmpty(_options.TenantCode))
{
sql += " AND tenant_code = @tenantCode";
}
sql += " ORDER BY tenant_code, service_name";
await using var command = new NpgsqlCommand(sql, connection);
if (!string.IsNullOrEmpty(_options.TenantCode))
{
command.Parameters.AddWithValue("@tenantCode", _options.TenantCode);
}
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var route = new GwTenantRouteModel
{
Id = reader.GetInt64(0),
TenantCode = reader.GetString(1),
ServiceName = reader.GetString(2),
ClusterId = reader.GetString(3),
PathPattern = reader.GetString(4),
MatchJson = reader.IsDBNull(5) ? null : reader.GetString(5),
Priority = reader.GetInt32(6),
Status = reader.GetInt32(7),
IsGlobal = reader.GetBoolean(8),
IsDeleted = reader.GetBoolean(9)
};
routes.Add(route);
}
return routes;
}
/// <summary>
/// 从数据库加载集群配置
/// </summary>
private async Task<List<GwClusterModel>> LoadClustersAsync(CancellationToken cancellationToken)
{
var clusters = new List<GwClusterModel>();
await using var connection = new NpgsqlConnection(_options.ConnectionString);
await connection.OpenAsync(cancellationToken);
const string sql = @"
SELECT id, cluster_id, name, description, destinations, status, is_deleted
FROM clusters
WHERE is_deleted = false AND status = 1";
await using var command = new NpgsqlCommand(sql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var cluster = new GwClusterModel
{
Id = reader.GetString(0),
ClusterId = reader.GetString(1),
Name = reader.GetString(2),
Description = reader.IsDBNull(3) ? null : reader.GetString(3),
DestinationsJson = reader.IsDBNull(4) ? null : reader.GetString(4),
Status = reader.GetInt32(5),
IsDeleted = reader.GetBoolean(6)
};
clusters.Add(cluster);
}
return clusters;
}
/// <summary>
/// 处理单条路由
/// </summary>
private async Task<MigrationEntry> ProcessRouteAsync(
GwTenantRouteModel route,
List<GwClusterModel> clusters,
CancellationToken cancellationToken)
{
var entry = new MigrationEntry
{
ServiceName = route.ServiceName,
TenantCode = route.TenantCode,
ClusterId = route.ClusterId,
Status = MigrationStatus.Success
};
_logger.LogDebug($"处理路由: {route.ServiceName} (租户: {route.TenantCode})");
try
{
// 查找对应的集群
var cluster = clusters.FirstOrDefault(c => c.ClusterId == route.ClusterId);
if (cluster == null)
{
_logger.LogWarning($"路由 {route.ServiceName} 引用的集群 {route.ClusterId} 不存在,跳过");
entry.Status = MigrationStatus.Skipped;
entry.ErrorMessage = $"集群 {route.ClusterId} 不存在";
return entry;
}
// 获取目标端点
var destinations = cluster.GetDestinations();
var destination = destinations.FirstOrDefault(d => d.Status == 1)
?? destinations.FirstOrDefault();
if (destination == null)
{
_logger.LogWarning($"集群 {cluster.ClusterId} 没有可用的目标端点");
}
// 生成 Service YAML
var serviceYaml = GenerateServiceYaml(route, cluster, destination);
// 确定输出文件名
var fileName = $"{route.TenantCode}-{route.ServiceName}.yaml".ToLowerInvariant();
var outputPath = Path.Combine(_options.OutputDir, fileName);
entry.OutputPath = outputPath;
if (_options.DryRun)
{
_logger.LogInformation($"[DRY-RUN] 将生成: {fileName}");
_logger.LogDebug($"YAML 内容:\n{serviceYaml}");
}
else
{
await File.WriteAllTextAsync(outputPath, serviceYaml, cancellationToken);
_logger.LogInformation($"已生成: {outputPath}");
}
}
catch (Exception ex)
{
_logger.LogError($"处理路由 {route.ServiceName} 时出错: {ex.Message}");
entry.Status = MigrationStatus.Failed;
entry.ErrorMessage = ex.Message;
}
return entry;
}
/// <summary>
/// 生成 K8s Service YAML
/// </summary>
private string GenerateServiceYaml(
GwTenantRouteModel route,
GwClusterModel cluster,
GwDestinationModel? destination)
{
var serviceName = $"{route.TenantCode}-{route.ServiceName}".ToLowerInvariant();
var path = route.GetPath();
var host = route.GetHost() ?? _options.DefaultHost;
var destinationId = destination?.DestinationId ?? "default";
var model = new K8sServiceModel
{
Metadata = new K8sMetadata
{
Name = serviceName,
Labels = new Dictionary<string, string>
{
["app-router-host"] = host,
["app-router-name"] = route.ServiceName,
["app-router-prefix"] = path,
["app-cluster-name"] = cluster.ClusterId,
["app-cluster-destination"] = destinationId,
["app-tenant"] = route.TenantCode,
["app-managed-by"] = "migration-tool"
},
Annotations = new Dictionary<string, string>
{
["migration-tool/fengling.gateway/route-id"] = route.Id.ToString(),
["migration-tool/fengling.gateway/cluster-id"] = cluster.Id,
["migration-tool/fengling.gateway/priority"] = route.Priority.ToString(),
["migration-tool/timestamp"] = DateTime.UtcNow.ToString("O")
}
},
Spec = new K8sSpec
{
Type = "ClusterIP",
Selector = new Dictionary<string, string>
{
["app"] = route.ServiceName.ToLowerInvariant()
},
Ports =
[
new K8sPort
{
Port = _options.ServicePort,
TargetPort = _options.TargetPort,
Name = "http",
Protocol = "TCP"
}
]
}
};
var yaml = _yamlSerializer.Serialize(model);
// 添加文档分隔符
return $"---\n{yaml}";
}
/// <summary>
/// 验证数据完整性
/// </summary>
private List<ValidationResult> ValidateData(
List<GwTenantRouteModel> routes,
List<GwClusterModel> clusters)
{
var results = new List<ValidationResult>();
var clusterIds = clusters.Select(c => c.ClusterId).ToHashSet();
foreach (var route in routes)
{
// 检查必填字段
if (string.IsNullOrWhiteSpace(route.ServiceName))
{
results.Add(new ValidationResult
{
IsValid = false,
Entity = $"Route[{route.Id}]",
Message = "ServiceName 不能为空"
});
}
if (string.IsNullOrWhiteSpace(route.TenantCode))
{
results.Add(new ValidationResult
{
IsValid = false,
Entity = $"Route[{route.Id}]",
Message = "TenantCode 不能为空"
});
}
if (string.IsNullOrWhiteSpace(route.ClusterId))
{
results.Add(new ValidationResult
{
IsValid = false,
Entity = $"Route[{route.Id}]",
Message = "ClusterId 不能为空"
});
}
// 检查集群引用
if (!string.IsNullOrWhiteSpace(route.ClusterId) && !clusterIds.Contains(route.ClusterId))
{
results.Add(new ValidationResult
{
IsValid = false,
Entity = $"Route[{route.Id}]",
Message = $"引用的集群 '{route.ClusterId}' 不存在"
});
}
// 检查路径
var path = route.GetPath();
if (string.IsNullOrWhiteSpace(path))
{
results.Add(new ValidationResult
{
IsValid = false,
Entity = $"Route[{route.Id}]",
Message = "Path 不能为空"
});
}
}
// 检查集群
foreach (var cluster in clusters)
{
if (string.IsNullOrWhiteSpace(cluster.ClusterId))
{
results.Add(new ValidationResult
{
IsValid = false,
Entity = $"Cluster[{cluster.Id}]",
Message = "ClusterId 不能为空"
});
}
var destinations = cluster.GetDestinations();
if (destinations.Count == 0)
{
results.Add(new ValidationResult
{
IsValid = false,
Entity = $"Cluster[{cluster.ClusterId}]",
Message = "没有配置目标端点"
});
}
foreach (var dest in destinations)
{
if (string.IsNullOrWhiteSpace(dest.DestinationId))
{
results.Add(new ValidationResult
{
IsValid = false,
Entity = $"Cluster[{cluster.ClusterId}]/Destination",
Message = "DestinationId 不能为空"
});
}
if (string.IsNullOrWhiteSpace(dest.Address))
{
results.Add(new ValidationResult
{
IsValid = false,
Entity = $"Cluster[{cluster.ClusterId}]/Destination[{dest.DestinationId}]",
Message = "Address 不能为空"
});
}
}
}
return results;
}
/// <summary>
/// 保存迁移报告
/// </summary>
private async Task SaveReportAsync(MigrationReport report, CancellationToken cancellationToken)
{
var reportPath = _options.ReportPath ??
Path.Combine(_options.OutputDir, $"migration-report-{DateTime.UtcNow:yyyyMMdd-HHmmss}.json");
var options = new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
var json = JsonSerializer.Serialize(report, options);
if (_options.DryRun)
{
_logger.LogInformation($"[DRY-RUN] 将生成报告: {reportPath}");
_logger.LogDebug($"报告内容:\n{json}");
}
else
{
await File.WriteAllTextAsync(reportPath, json, cancellationToken);
_logger.LogInformation($"已生成报告: {reportPath}");
}
}
/// <summary>
/// 掩盖连接字符串中的敏感信息
/// </summary>
private static string MaskConnectionString(string connectionString)
{
if (string.IsNullOrEmpty(connectionString))
return "[empty]";
try
{
var builder = new NpgsqlConnectionStringBuilder(connectionString);
if (!string.IsNullOrEmpty(builder.Password))
{
builder.Password = "***";
}
return builder.ToString();
}
catch
{
return "[invalid connection string]";
}
}
}
/// <summary>
/// 验证结果
/// </summary>
public class ValidationResult
{
public bool IsValid { get; set; }
public string Entity { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
}

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>MigrationTool</AssemblyName>
<RootNamespace>MigrationTool</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql" Version="9.0.3" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,212 @@
using System.Text.Json;
namespace MigrationTool.Models;
/// <summary>
/// 网关租户路由数据库模型
/// </summary>
public class GwTenantRouteModel
{
public long Id { get; set; }
public string TenantCode { get; set; } = string.Empty;
public string ServiceName { get; set; } = string.Empty;
public string ClusterId { get; set; } = string.Empty;
public string PathPattern { get; set; } = string.Empty;
public string? MatchJson { get; set; }
public int Priority { get; set; }
public int Status { get; set; }
public bool IsGlobal { get; set; }
public bool IsDeleted { get; set; }
/// <summary>
/// 解析 Match JSON 获取路径
/// </summary>
public string GetPath()
{
if (string.IsNullOrEmpty(MatchJson))
return PathPattern;
try
{
var match = JsonSerializer.Deserialize<RouteMatchJson>(MatchJson);
return match?.Path ?? PathPattern;
}
catch
{
return PathPattern;
}
}
/// <summary>
/// 解析 Match JSON 获取 Host
/// </summary>
public string? GetHost()
{
if (string.IsNullOrEmpty(MatchJson))
return null;
try
{
var match = JsonSerializer.Deserialize<RouteMatchJson>(MatchJson);
return match?.Hosts?.FirstOrDefault();
}
catch
{
return null;
}
}
}
/// <summary>
/// 路由匹配 JSON 结构
/// </summary>
public class RouteMatchJson
{
public string? Path { get; set; }
public List<string>? Methods { get; set; }
public List<string>? Hosts { get; set; }
}
/// <summary>
/// 网关集群数据库模型
/// </summary>
public class GwClusterModel
{
public string Id { get; set; } = string.Empty;
public string ClusterId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string? DestinationsJson { get; set; }
public int Status { get; set; }
public bool IsDeleted { get; set; }
/// <summary>
/// 解析 Destinations JSON
/// </summary>
public List<GwDestinationModel> GetDestinations()
{
if (string.IsNullOrEmpty(DestinationsJson))
return [];
try
{
var destinations = JsonSerializer.Deserialize<List<GwDestinationModel>>(DestinationsJson);
return destinations ?? [];
}
catch
{
return [];
}
}
}
/// <summary>
/// 目标端点模型
/// </summary>
public class GwDestinationModel
{
public string DestinationId { get; set; } = string.Empty;
public string Address { get; set; } = string.Empty;
public string? Health { get; set; }
public int Weight { get; set; } = 1;
public int HealthStatus { get; set; } = 1;
public int Status { get; set; } = 1;
}
/// <summary>
/// 迁移结果报告
/// </summary>
public class MigrationReport
{
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public int TotalRoutes { get; set; }
public int SuccessCount { get; set; }
public int FailedCount { get; set; }
public int SkippedCount { get; set; }
public List<MigrationEntry> Entries { get; set; } = [];
public TimeSpan Duration => EndTime - StartTime;
public void PrintSummary()
{
Console.WriteLine();
Console.WriteLine("=".PadRight(60, '='));
Console.WriteLine("迁移报告");
Console.WriteLine("=".PadRight(60, '='));
Console.WriteLine($"开始时间: {StartTime:yyyy-MM-dd HH:mm:ss}");
Console.WriteLine($"结束时间: {EndTime:yyyy-MM-dd HH:mm:ss}");
Console.WriteLine($"总耗时: {Duration.TotalSeconds:F2} 秒");
Console.WriteLine("-".PadRight(60, '-'));
Console.WriteLine($"总路由数: {TotalRoutes}");
Console.WriteLine($"成功: {SuccessCount}");
Console.WriteLine($"失败: {FailedCount}");
Console.WriteLine($"跳过: {SkippedCount}");
Console.WriteLine("=".PadRight(60, '='));
if (FailedCount > 0)
{
Console.WriteLine();
Console.WriteLine("失败详情:");
foreach (var entry in Entries.Where(e => e.Status == MigrationStatus.Failed))
{
Console.WriteLine($" - {entry.ServiceName}: {entry.ErrorMessage}");
}
}
}
}
/// <summary>
/// 迁移条目
/// </summary>
public class MigrationEntry
{
public string ServiceName { get; set; } = string.Empty;
public string TenantCode { get; set; } = string.Empty;
public string ClusterId { get; set; } = string.Empty;
public string OutputPath { get; set; } = string.Empty;
public MigrationStatus Status { get; set; }
public string? ErrorMessage { get; set; }
}
/// <summary>
/// 迁移状态
/// </summary>
public enum MigrationStatus
{
Success,
Failed,
Skipped
}
/// <summary>
/// K8s Service YAML 模型
/// </summary>
public class K8sServiceModel
{
public string ApiVersion { get; set; } = "v1";
public string Kind { get; set; } = "Service";
public K8sMetadata Metadata { get; set; } = new();
public K8sSpec Spec { get; set; } = new();
}
public class K8sMetadata
{
public string Name { get; set; } = string.Empty;
public Dictionary<string, string> Labels { get; set; } = new();
public Dictionary<string, string>? Annotations { get; set; }
}
public class K8sSpec
{
public string Type { get; set; } = "ClusterIP";
public Dictionary<string, string> Selector { get; set; } = new();
public List<K8sPort> Ports { get; set; } = [];
}
public class K8sPort
{
public int Port { get; set; }
public int TargetPort { get; set; }
public string? Name { get; set; }
public string? Protocol { get; set; }
}

View File

@ -0,0 +1,318 @@
using System.Text;
using MigrationTool;
using MigrationTool.Models;
// 设置控制台输出编码
Console.OutputEncoding = Encoding.UTF8;
PrintBanner();
// 解析命令行参数
var options = ParseArguments(args);
if (options == null)
{
PrintHelp();
return 1;
}
PrintOptions(options);
// 确认执行
if (!options.DryRun)
{
Console.WriteLine();
Console.Write("确认开始迁移? (y/N): ");
var confirm = Console.ReadLine()?.Trim().ToLowerInvariant();
if (confirm != "y" && confirm != "yes")
{
Console.WriteLine("已取消");
return 0;
}
}
Console.WriteLine();
// 创建日志记录器
var logger = new ConsoleLogger(options.LogLevel);
// 执行迁移
try
{
var service = new MigrationService(options, logger);
var report = await service.MigrateAsync();
// 打印报告
report.PrintSummary();
return report.FailedCount > 0 ? 1 : 0;
}
catch (Exception ex)
{
logger.LogError($"迁移失败: {ex.Message}");
logger.LogDebug(ex.StackTrace ?? "");
return 2;
}
/// <summary>
/// 解析命令行参数
/// </summary>
static MigrationOptions? ParseArguments(string[] args)
{
var options = new MigrationOptions
{
ConnectionString = GetDefaultConnectionString()
};
for (int i = 0; i < args.Length; i++)
{
var arg = args[i];
switch (arg.ToLowerInvariant())
{
case "--help":
case "-h":
case "-?":
return null;
case "--connection-string":
case "-c":
if (i + 1 < args.Length)
options.ConnectionString = args[++i];
break;
case "--output-dir":
case "-o":
if (i + 1 < args.Length)
options.OutputDir = args[++i];
break;
case "--dry-run":
case "-d":
options.DryRun = true;
break;
case "--default-host":
if (i + 1 < args.Length)
options.DefaultHost = args[++i];
break;
case "--service-port":
if (i + 1 < args.Length && int.TryParse(args[++i], out var svcPort))
options.ServicePort = svcPort;
break;
case "--target-port":
if (i + 1 < args.Length && int.TryParse(args[++i], out var tgtPort))
options.TargetPort = tgtPort;
break;
case "--no-validate":
options.Validate = false;
break;
case "--tenant":
case "-t":
if (i + 1 < args.Length)
options.TenantCode = args[++i];
break;
case "--log-level":
case "-l":
if (i + 1 < args.Length && Enum.TryParse<LogLevel>(args[++i], true, out var level))
options.LogLevel = level;
break;
case "--no-report":
options.GenerateReport = false;
break;
case "--report-path":
if (i + 1 < args.Length)
options.ReportPath = args[++i];
break;
default:
Console.WriteLine($"未知参数: {arg}");
break;
}
}
return options;
}
/// <summary>
/// 获取默认连接字符串(从环境变量)
/// </summary>
static string GetDefaultConnectionString()
{
var envConnectionString = Environment.GetEnvironmentVariable("GATEWAY_CONNECTION_STRING");
if (!string.IsNullOrEmpty(envConnectionString))
{
return envConnectionString;
}
return "Host=localhost;Database=fengling_gateway;Username=postgres;Password=postgres";
}
/// <summary>
/// 打印 Banner
/// </summary>
static void PrintBanner()
{
Console.WriteLine();
Console.WriteLine(@" __ __ _ _ _ _ _ _ ");
Console.WriteLine(@" | \/ (_) | ___ _ __ | |_(_)_ __ __ _| |_ ___ | |_ ");
Console.WriteLine(@" | |\/| | | |/ _ \ '_ \| __| | '_ \ / _` | __/ _ \ | __|");
Console.WriteLine(@" | | | | | | __/ | | | |_| | | | | (_| | || __/ | |_ ");
Console.WriteLine(@" |_| |_|_|_|\___|_| |_|\__|_|_| |_|\__,_|\__\___| \__|");
Console.WriteLine(@" ");
Console.WriteLine(@" Fengling Gateway Migration Tool v1.0.0");
Console.WriteLine();
}
/// <summary>
/// 打印帮助信息
/// </summary>
static void PrintHelp()
{
Console.WriteLine("用法: MigrationTool [选项]");
Console.WriteLine();
Console.WriteLine("选项:");
Console.WriteLine(" -h, --help 显示帮助信息");
Console.WriteLine(" -c, --connection-string 数据库连接字符串 (默认: 从 GATEWAY_CONNECTION_STRING 环境变量读取)");
Console.WriteLine(" -o, --output-dir YAML 文件输出目录 (默认: ./output)");
Console.WriteLine(" -d, --dry-run 干运行模式,只输出不写入文件");
Console.WriteLine(" --default-host 默认路由 Host (默认: api.fengling.com)");
Console.WriteLine(" --service-port Service 端口 (默认: 80)");
Console.WriteLine(" --target-port 目标端口 (默认: 8080)");
Console.WriteLine(" --no-validate 跳过数据验证");
Console.WriteLine(" -t, --tenant 仅处理指定租户");
Console.WriteLine(" -l, --log-level 日志级别 (Trace/Debug/Information/Warning/Error)");
Console.WriteLine(" --no-report 不生成报告文件");
Console.WriteLine(" --report-path 指定报告文件路径");
Console.WriteLine();
Console.WriteLine("示例:");
Console.WriteLine(" MigrationTool --dry-run");
Console.WriteLine(" MigrationTool -c \"Host=db;Database=gateway;Username=postgres;Password=secret\" -o ./yaml");
Console.WriteLine(" MigrationTool --tenant tenant1 --dry-run");
Console.WriteLine();
}
/// <summary>
/// 打印选项
/// </summary>
static void PrintOptions(MigrationOptions options)
{
Console.WriteLine("配置选项:");
Console.WriteLine($" 连接字符串: {MaskConnectionString(options.ConnectionString)}");
Console.WriteLine($" 输出目录: {Path.GetFullPath(options.OutputDir)}");
Console.WriteLine($" Dry-Run: {(options.DryRun ? "" : "")}");
Console.WriteLine($" 默认 Host: {options.DefaultHost}");
Console.WriteLine($" Service 端口: {options.ServicePort}");
Console.WriteLine($" Target 端口: {options.TargetPort}");
Console.WriteLine($" 验证数据: {(options.Validate ? "" : "")}");
Console.WriteLine($" 日志级别: {options.LogLevel}");
if (!string.IsNullOrEmpty(options.TenantCode))
{
Console.WriteLine($" 指定租户: {options.TenantCode}");
}
}
/// <summary>
/// 掩盖连接字符串中的敏感信息
/// </summary>
static string MaskConnectionString(string connectionString)
{
if (string.IsNullOrEmpty(connectionString))
return "[empty]";
try
{
var builder = new Npgsql.NpgsqlConnectionStringBuilder(connectionString);
if (!string.IsNullOrEmpty(builder.Password))
{
builder.Password = "***";
}
return builder.ToString();
}
catch
{
return "[invalid]";
}
}
/// <summary>
/// 简单的控制台日志记录器
/// </summary>
public class ConsoleLogger : ILogger
{
private readonly LogLevel _minLevel;
public ConsoleLogger(LogLevel minLevel)
{
_minLevel = minLevel;
}
public void LogTrace(string message)
{
if (_minLevel <= LogLevel.Trace)
WriteLog("TRC", ConsoleColor.Gray, message);
}
public void LogDebug(string message)
{
if (_minLevel <= LogLevel.Debug)
WriteLog("DBG", ConsoleColor.DarkGray, message);
}
public void LogInformation(string message)
{
if (_minLevel <= LogLevel.Information)
WriteLog("INF", ConsoleColor.White, message);
}
public void LogWarning(string message)
{
if (_minLevel <= LogLevel.Warning)
WriteLog("WRN", ConsoleColor.Yellow, message);
}
public void LogError(string message)
{
if (_minLevel <= LogLevel.Error)
WriteLog("ERR", ConsoleColor.Red, message);
}
private static void WriteLog(string level, ConsoleColor color, string message)
{
var timestamp = DateTime.Now.ToString("HH:mm:ss");
var originalColor = Console.ForegroundColor;
Console.ForegroundColor = color;
Console.WriteLine($"[{timestamp}] [{level}] {message}");
Console.ForegroundColor = originalColor;
}
}
/// <summary>
/// 日志记录器接口
/// </summary>
public interface ILogger
{
void LogTrace(string message);
void LogDebug(string message);
void LogInformation(string message);
void LogWarning(string message);
void LogError(string message);
}
/// <summary>
/// 日志级别
/// </summary>
public enum LogLevel
{
Trace,
Debug,
Information,
Warning,
Error
}

View File

@ -0,0 +1,23 @@
#### 网关配置的新想法
路由/集群/目标 等配置还是通过数据库变更通知网关进行重新加载的方式触发变更
k8s 的service中需要有固定的label来约定产生新的配置
以下是范例
service-label
- app-router-host = https://hostname #代表网关域名地址
- app-router-name = member # 代表路由名
- app-router-prefix = /member # 代表路由前缀
- app-cluster-name = member #代表集群名(id)
- app-cluster-destination = default # 代表标准服务目标 如果不是default 比如 1668 则代表企业编号独有的目标
详细请求说明:
比如一个请求进来了 请求路径是 {host}/member/api/v1/memberinfo/{id} header:{authorization: bearer xxx}
-> 根据 host+ prefix匹配到 member路由 -> 进入到对应的cluster ->
中间件进行解析或者是yarp-transform能做到最好使用yarp-transform来处理 或者如果我集成了openiddict是否可以拿到jwt这部分的信息
-> 解析出 customer/或者是租户id都行 只要能表示租户的都算 -> 找到对应的租户就进对应的目标服务 找不到就进 标准服务目标
配置生效详细说明:
1.在console项目中监听k8s 的服务 如果发现有新的app-router-name 相关信息就要产生待执行配置 用户明确确认后 生成数据库配置 在此之前只能存在于内存/缓存中
2.同样的监听服务 发现app-cluster-name 检查是否有新的app-cluster-name 如果有新的 同样产生待执行配置 后续于路由一致
3.同样监听服务 发现app-cluster-destination 检查对应的cluster是否存在这个destination 如果不存在 同样产生待执行配置 后续与上述一致
4.用户确认是否执行这些配置 用户加载到界面后可调整配置同意后产生持久化数据到数据库点击立即生效才下发到yarp网关 网关重新根据新的配置加载到最新配置后生效