From 52eba0709742d296346dccda25d656e7c8cb0275 Mon Sep 17 00:00:00 2001 From: movingsam Date: Sun, 8 Mar 2026 00:35:04 +0800 Subject: [PATCH] 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 --- .planning/ROADMAP.md | 98 +++- .planning/STATE.md | 91 ++- .../006-01-PLAN.md | 290 ++++++++++ .../006-01-SUMMARY.md | 57 ++ .../006-02-PLAN.md | 366 ++++++++++++ .../006-RESEARCH.md | 284 ++++++++++ Directory.Build.props | 2 + MigrationTask.sln | 21 + src/yarpgateway/Plugins/PluginHost.cs | 208 +++++++ src/yarpgateway/Plugins/PluginLoadContext.cs | 82 +++ src/yarpgateway/Plugins/PluginLoader.cs | 157 ++++++ .../Unit/Plugins/PluginHostTests.cs | 138 +++++ .../Unit/Plugins/PluginLoadContextTests.cs | 64 +++ .../Unit/Plugins/PluginLoaderTests.cs | 134 +++++ tools/MigrationTool/MigrationOptions.cs | 62 +++ tools/MigrationTool/MigrationService.cs | 521 ++++++++++++++++++ tools/MigrationTool/MigrationTool.csproj | 17 + .../Models/GatewayConfigModel.cs | 212 +++++++ tools/MigrationTool/Program.cs | 318 +++++++++++ 网关配置的新想法.md | 23 + 20 files changed, 3098 insertions(+), 47 deletions(-) create mode 100644 .planning/phases/006-gateway-plugin-research/006-01-PLAN.md create mode 100644 .planning/phases/006-gateway-plugin-research/006-01-SUMMARY.md create mode 100644 .planning/phases/006-gateway-plugin-research/006-02-PLAN.md create mode 100644 .planning/phases/006-gateway-plugin-research/006-RESEARCH.md create mode 100644 MigrationTask.sln create mode 100644 src/yarpgateway/Plugins/PluginHost.cs create mode 100644 src/yarpgateway/Plugins/PluginLoadContext.cs create mode 100644 src/yarpgateway/Plugins/PluginLoader.cs create mode 100644 tests/YarpGateway.Tests/Unit/Plugins/PluginHostTests.cs create mode 100644 tests/YarpGateway.Tests/Unit/Plugins/PluginLoadContextTests.cs create mode 100644 tests/YarpGateway.Tests/Unit/Plugins/PluginLoaderTests.cs create mode 100644 tools/MigrationTool/MigrationOptions.cs create mode 100644 tools/MigrationTool/MigrationService.cs create mode 100644 tools/MigrationTool/MigrationTool.csproj create mode 100644 tools/MigrationTool/Models/GatewayConfigModel.cs create mode 100644 tools/MigrationTool/Program.cs create mode 100644 网关配置的新想法.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 3d5a64d..46b51ce 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1,4 +1,4 @@ -# Roadmap:Fengling Gateway +#MY|# Roadmap:Fengling 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-03:YARP 插件集成 + +**成功标准:** +- [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* diff --git a/.planning/STATE.md b/.planning/STATE.md index 12ae3d0..5c861c8 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,6 +1,6 @@ -# 状态:Fengling Gateway +#VR|# 状态:Fengling Gateway -**最后更新:** 2026-03-02 +**最后更新:** 2026-03-04 --- @@ -10,7 +10,7 @@ **核心价值:** 可靠、可扩展的 API 网关,将流量分发到后端微服务,支持零停机配置更新。 -**当前重点:** 阶段 2:K8s 健康检查委托 +**当前重点:** 阶段 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|- 配置变更应提交到 git(commit_docs: true) -#KB|- gsd-tools.cjs 不可用 - 项目结构手动创建 -#KB| -#KB|--- -#KB| -#KB|*最后更新:2026-03-04 - 完成快速任务 001: 升级 Fengling.Platform 包并修复编译警告* + +## 备注 + +- 自动模式:跳过研究,工作流偏好设置为 yolo +- 配置变更应提交到 git(commit_docs: true) +- gsd-tools.cjs 不可用 - 项目结构手动创建 + +--- + +*最后更新:2026-03-04 - 完成阶段 6 计划 006-01:插件加载基础设施(PLUG-01, PLUG-02)* diff --git a/.planning/phases/006-gateway-plugin-research/006-01-PLAN.md b/.planning/phases/006-gateway-plugin-research/006-01-PLAN.md new file mode 100644 index 0000000..f67b984 --- /dev/null +++ b/.planning/phases/006-gateway-plugin-research/006-01-PLAN.md @@ -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:插件加载基础设施 + + +实现插件动态加载基础设施,包括 AssemblyLoadContext 隔离、插件发现和生命周期管理。 + +**目的:** 为网关提供安全的插件加载机制,支持隔离和热重载。 +**产出:** 可工作的插件加载系统,含单元测试。 + + + +@/Users/mac/.config/opencode/get-shit-done/workflows/execute-plan.md +@/Users/mac/.config/opencode/get-shit-done/templates/summary.md + + + +@.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 + + + + +```csharp +// Fengling.Gateway.Plugin.Abstractions +public interface IGatewayPlugin +{ + string Name { get; } + string Version { get; } + string? Description { get; } + Task OnLoadAsync(); + Task OnUnloadAsync(); +} +``` + + + + + + Task 1: 创建 PluginLoadContext 隔离机制 + + src/yarpgateway/Plugins/PluginLoadContext.cs, + tests/YarpGateway.Tests/Unit/Plugins/PluginLoadContextTests.cs + + + - Test 1: 加载插件程序集到独立 ALC + - Test 2: 共享契约程序集使用默认 ALC + - Test 3: 卸载 ALC 后内存被回收 + + +创建可卸载的 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; + } +} +``` + +**注意**:先写测试,确保卸载验证通过。 + + + dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginLoadContextTests" --no-build + + + - PluginLoadContext 类存在 + - 测试验证隔离和卸载 + - 构建通过 + + + + + Task 2: 创建 PluginLoader 发现和加载逻辑 + + src/yarpgateway/Plugins/PluginLoader.cs, + src/yarpgateway/Plugins/DiscoveredPlugin.cs, + tests/YarpGateway.Tests/Unit/Plugins/PluginLoaderTests.cs + + + - Test 1: 从目录发现插件程序集 + - Test 2: 加载插件并返回 IGatewayPlugin 实例 + - Test 3: 处理无效插件(返回 null 或异常) + + +创建插件发现和加载器: + +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 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` 方法,将插件复制到临时目录。 + + + dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginLoaderTests" --no-build + + + - PluginLoader 类存在 + - 可发现和加载插件 + - 测试通过 + + + + + Task 3: 创建 PluginHost 生命周期管理 + + src/yarpgateway/Plugins/PluginHost.cs, + src/yarpgateway/Plugins/PluginHandle.cs, + tests/YarpGateway.Tests/Unit/Plugins/PluginHostTests.cs + + + - Test 1: LoadAllAsync 加载目录下所有插件 + - Test 2: UnloadAsync 卸载指定插件 + - Test 3: GetPlugins 返回当前加载的插件 + - Test 4: 插件卸载后 WeakReference 显示已回收 + + +创建插件生命周期管理器: + +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 _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(); + } + } +} +``` + + + dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginHostTests" --no-build + + + - PluginHost 和 PluginHandle 类存在 + - 可加载/卸载插件 + - 卸载验证测试通过 + + + + + + +1. dotnet build src/yarpgateway/YarpGateway.csproj 无错误 +2. dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginLoad" 通过 +3. 插件加载/卸载功能可用 + + + +- 插件可在独立 AssemblyLoadContext 中加载 +- 插件可通过 WeakReference 验证卸载 +- 所有单元测试通过 + + + +完成后创建 `.planning/phases/006-gateway-plugin-research/006-01-SUMMARY.md` + \ No newline at end of file diff --git a/.planning/phases/006-gateway-plugin-research/006-01-SUMMARY.md b/.planning/phases/006-gateway-plugin-research/006-01-SUMMARY.md new file mode 100644 index 0000000..72d7f40 --- /dev/null +++ b/.planning/phases/006-gateway-plugin-research/006-01-SUMMARY.md @@ -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 管道 diff --git a/.planning/phases/006-gateway-plugin-research/006-02-PLAN.md b/.planning/phases/006-gateway-plugin-research/006-02-PLAN.md new file mode 100644 index 0000000..8be44c8 --- /dev/null +++ b/.planning/phases/006-gateway-plugin-research/006-02-PLAN.md @@ -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: "触发重载" +--- + +# 计划 02:YARP 插件集成 + + +将插件系统集成到 YARP 反向代理管道,实现 Transform 方式的请求/响应处理,以及通过 Metadata 驱动的插件启用机制。 + +**目的:** 实现 PLUG-03 - 插件隔离与生命周期管理,完成网关插件化。 +**产出:** 可工作的 YARP 插件集成系统,含单元测试。 + + + +@/Users/mac/.config/opencode/get-shit-done/workflows/execute-plan.md +@/Users/mac/.config/opencode/get-shit-done/templates/summary.md + + + +@.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 + + + +## 现有插件接口 + +```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 OnRequestAsync(HttpContext context); + Task OnRouteMatchedAsync(HttpContext context, RouteConfig route); + Task OnForwardingAsync(HttpContext context, HttpRequestMessage request); +} + +public interface IResponsePlugin : IGatewayPlugin +{ + Task OnBackendResponseAsync(HttpContext context, HttpResponseMessage response); + Task OnResponseFinalizingAsync(HttpContext context); +} + +public interface IRouteTransformPlugin : IGatewayPlugin +{ + Task 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); +} +``` + + + + + + Task 1: 创建 PluginTransformProvider + + src/yarpgateway/Plugins/PluginTransformProvider.cs, + tests/YarpGateway.Tests/Unit/Plugins/PluginTransformProviderTests.cs + + + - Test 1: 从路由 Metadata 发现启用的插件 + - Test 2: 按 PluginOrder 排序 + - Test 3: 创建 RequestTransform + - Test 4: 创建 ResponseTransform + + +创建 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()) + { + await plugin.OnRouteMatchedAsync(context, route); + } + } +} +``` + +3. 创建 Request/Response Transform 类: + - `PluginRequestTransform` - 调用 IRequestPlugin + - `PluginResponseTransform` - 调用 IResponsePlugin + - `PluginRouteTransform` - 调用 IRouteTransformPlugin + + + dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginTransformProviderTests" --no-build + + + - PluginTransformProvider 存在 + - Transform 测试通过 + + + + + Task 2: 创建目标选择器 (DestinationSelector) + + src/yarpgateway/Plugins/DestinationSelector.cs, + tests/YarpGateway.Tests/Unit/Plugins/DestinationSelectorTests.cs + + + - Test 1: OnRouteMatchedAsync 选择目标 + - Test 2: 根据上下文修改目标列表 + - Test 3: 特殊租户路由到特殊目标 + + +创建目标选择器,用于 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(); +``` + + + dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~DestinationSelectorTests" --no-build + + + - DestinationSelector 存在 + - 目标选择逻辑工作正常 + + + + + Task 3: 创建 PluginConfigWatcher (Console DB 通知) + + src/yarpgateway/Plugins/PluginConfigWatcher.cs, + tests/YarpGateway.Tests/Unit/Plugins/PluginConfigWatcherTests.cs + + + - Test 1: 监听配置变更通知 + - Test 2: 触发插件重载 + - Test 3: 处理通知失败 + + +创建配置监听器,监听 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(notification.Payload); + await _pluginHost.ReloadAsync(payload.PluginId); + } + } +} +``` + +2. 注册到 DI: +```csharp +builder.Services.AddHostedService(); +``` + + + dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginConfigWatcherTests" --no-build + + + - PluginConfigWatcher 存在 + - 监听器正确响应通知 + + + + + Task 4: 更新 Program.cs 集成插件系统 + + src/yarpgateway/Program.cs + + + - Test 1: 插件系统在启动时初始化 + - Test 2: Transform 被正确应用 + + +更新 Program.cs 集成插件系统: + +1. 注册插件服务: +```csharp +// 插件目录 +var pluginDirectory = configuration.GetValue("Plugin:Directory") ?? "plugins"; + +// 插件主机 +builder.Services.AddSingleton(sp => new PluginHost(pluginDirectory)); + +// Transform 提供者 +builder.Services.AddSingleton(); + +// 目标选择器 +builder.Services.AddSingleton(); + +// 配置监听器 +builder.Services.AddHostedService(); +``` + +2. 初始化插件: +```csharp +var app = builder.Build(); + +// 启动时加载插件 +var pluginHost = app.Services.GetRequiredService(); +await pluginHost.LoadAllAsync(); +``` + +3. 配置 YARP 使用 Transform: +```csharp +builder.Services.AddReverseProxy() + .AddTransforms(); +``` + + + dotnet build src/yarpgateway/YarpGateway.csproj + + + - Program.cs 正确集成插件系统 + - 构建通过 + + + + + + +1. dotnet build src/yarpgateway/YarpGateway.csproj 无错误 +2. dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~Plugin" 通过 +3. 插件可通过 Metadata 启用 +4. Transform 正确应用到请求/响应 + + + +- 插件通过 YARP Transform 管道处理请求 +- 目标选择在路由匹配后执行 +- 插件通过 Metadata 启用 +- Console DB 通知可触发插件重载 +- 所有单元测试通过 + + + +完成后创建 `.planning/phases/006-gateway-plugin-research/006-02-SUMMARY.md` + diff --git a/.planning/phases/006-gateway-plugin-research/006-RESEARCH.md b/.planning/phases/006-gateway-plugin-research/006-RESEARCH.md new file mode 100644 index 0000000..15e7afd --- /dev/null +++ b/.planning/phases/006-gateway-plugin-research/006-RESEARCH.md @@ -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 + + false + runtime + +``` + +### 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* \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 2358532..ff592fa 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,6 +11,8 @@ Npgsql.* StackExchange.Redis Yarp.* + YamlDotNet + System.CommandLine xunit Moq FluentAssertions diff --git a/MigrationTask.sln b/MigrationTask.sln new file mode 100644 index 0000000..17e1130 --- /dev/null +++ b/MigrationTask.sln @@ -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 diff --git a/src/yarpgateway/Plugins/PluginHost.cs b/src/yarpgateway/Plugins/PluginHost.cs new file mode 100644 index 0000000..741fa4f --- /dev/null +++ b/src/yarpgateway/Plugins/PluginHost.cs @@ -0,0 +1,208 @@ +using System.Collections.Concurrent; +using Fengling.Gateway.Plugin.Abstractions; + +namespace YarpGateway.Plugins; + +/// +/// 插件句柄 - 封装插件实例和加载上下文 +/// +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 + { + // 忽略清理错误 + } + } +} + +/// +/// 插件主机 - 管理所有已加载的插件 +/// +public class PluginHost +{ + private readonly ConcurrentDictionary _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()); + } + + /// + /// 获取当前加载的所有插件 + /// + public IEnumerable GetPlugins() + { + return _plugins.Values.Select(h => h.Plugin); + } + + /// + /// 获取插件信息 + /// + public IEnumerable<(string Id, IGatewayPlugin Plugin)> GetPluginInfo() + { + return _plugins.Values.Select(h => (h.PluginId, h.Plugin)); + } + + /// + /// 加载目录中的所有插件 + /// + public async Task 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; + } + + /// + /// + /// 加载单个插件 + /// + private async Task 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; + } + } + + /// + /// 卸载指定插件 + /// + public async Task UnloadAsync(string pluginId) + { + if (_plugins.TryRemove(pluginId, out var handle)) + { + await handle.DisposeAsync(); + } + } + + /// + /// 重新加载指定插件 + /// + 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; + } + } + + /// + /// 卸载所有插件 + /// + public async Task UnloadAllAsync() + { + var pluginIds = _plugins.Keys.ToList(); + foreach (var id in pluginIds) + { + await UnloadAsync(id); + } + } + + /// + /// 验证插件是否已卸载 + /// + 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; + } +} diff --git a/src/yarpgateway/Plugins/PluginLoadContext.cs b/src/yarpgateway/Plugins/PluginLoadContext.cs new file mode 100644 index 0000000..9ba1ca3 --- /dev/null +++ b/src/yarpgateway/Plugins/PluginLoadContext.cs @@ -0,0 +1,82 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace YarpGateway.Plugins; + +/// +/// 可卸载的 AssemblyLoadContext,用于插件隔离 +/// +public sealed class PluginLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver? _resolver; + private readonly string _sharedAssemblyName = "Fengling.Gateway.Plugin.Abstractions"; + + /// + /// 创建插件加载上下文 + /// + /// 插件目录路径 + 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); + } + } + + /// + /// 加载程序集 + /// + 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; + } + + /// + /// 加载程序集(内部调用) + /// + protected override Assembly? Load(AssemblyName assemblyName) + { + return LoadAssembly(assemblyName); + } + + /// + /// 加载非托管 DLL + /// + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) + { + if (_resolver != null) + { + var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + if (path != null) + { + return LoadUnmanagedDllFromPath(path); + } + } + return IntPtr.Zero; + } +} diff --git a/src/yarpgateway/Plugins/PluginLoader.cs b/src/yarpgateway/Plugins/PluginLoader.cs new file mode 100644 index 0000000..a6d192a --- /dev/null +++ b/src/yarpgateway/Plugins/PluginLoader.cs @@ -0,0 +1,157 @@ +using System.Reflection; +using System.Text.Json; +using Fengling.Gateway.Plugin.Abstractions; +using Fengling.Gateway.Plugin.Abstractions; + +namespace YarpGateway.Plugins; + +/// +/// 发现的插件信息 +/// +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; } +} + +/// +/// 插件元数据 +/// +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; } +} + +/// +/// 插件加载器 - 发现和加载插件 +/// +public class PluginLoader +{ + private const string PluginManifestFileName = "plugin.json"; + + /// + /// 从目录发现所有插件 + /// + public IEnumerable 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 + }; + } + } + + /// + /// 加载插件元数据 + /// + 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(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + } + catch + { + return null; + } + } + + /// + /// 创建插件的影子副本(用于热重载) + /// + 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; + } + + /// + /// 加载插件程序集并创建实例 + /// + 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; + } + } +} diff --git a/tests/YarpGateway.Tests/Unit/Plugins/PluginHostTests.cs b/tests/YarpGateway.Tests/Unit/Plugins/PluginHostTests.cs new file mode 100644 index 0000000..ace3960 --- /dev/null +++ b/tests/YarpGateway.Tests/Unit/Plugins/PluginHostTests.cs @@ -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); + } +} + +/// +/// 测试用插件实现 +/// +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; +} diff --git a/tests/YarpGateway.Tests/Unit/Plugins/PluginLoadContextTests.cs b/tests/YarpGateway.Tests/Unit/Plugins/PluginLoadContextTests.cs new file mode 100644 index 0000000..068625b --- /dev/null +++ b/tests/YarpGateway.Tests/Unit/Plugins/PluginLoadContextTests.cs @@ -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); + } +} diff --git a/tests/YarpGateway.Tests/Unit/Plugins/PluginLoaderTests.cs b/tests/YarpGateway.Tests/Unit/Plugins/PluginLoaderTests.cs new file mode 100644 index 0000000..4c7aa5b --- /dev/null +++ b/tests/YarpGateway.Tests/Unit/Plugins/PluginLoaderTests.cs @@ -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); + } + } +} diff --git a/tools/MigrationTool/MigrationOptions.cs b/tools/MigrationTool/MigrationOptions.cs new file mode 100644 index 0000000..d25f01a --- /dev/null +++ b/tools/MigrationTool/MigrationOptions.cs @@ -0,0 +1,62 @@ +namespace MigrationTool; + +/// +/// 迁移工具命令行选项 +/// +public class MigrationOptions +{ + /// + /// 数据库连接字符串 + /// + public string ConnectionString { get; set; } = string.Empty; + + /// + /// 输出目录 + /// + public string OutputDir { get; set; } = "./output"; + + /// + /// 是否 Dry-Run 模式(只输出不写入文件) + /// + public bool DryRun { get; set; } + + /// + /// 默认路由 Host + /// + public string DefaultHost { get; set; } = "api.fengling.com"; + + /// + /// 服务端口 + /// + public int ServicePort { get; set; } = 80; + + /// + /// 目标端口 + /// + public int TargetPort { get; set; } = 8080; + + /// + /// 是否验证数据完整性 + /// + public bool Validate { get; set; } = true; + + /// + /// 仅处理指定租户 + /// + public string? TenantCode { get; set; } + + /// + /// 日志级别 + /// + public LogLevel LogLevel { get; set; } = LogLevel.Information; + + /// + /// 是否生成报告文件 + /// + public bool GenerateReport { get; set; } = true; + + /// + /// 报告文件路径 + /// + public string? ReportPath { get; set; } +} diff --git a/tools/MigrationTool/MigrationService.cs b/tools/MigrationTool/MigrationService.cs new file mode 100644 index 0000000..9fcbef9 --- /dev/null +++ b/tools/MigrationTool/MigrationService.cs @@ -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; + +/// +/// 迁移服务 - 处理从数据库读取配置并生成 K8s Service YAML +/// +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(); + } + + /// + /// 执行迁移 + /// + public async Task 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; + } + + /// + /// 从数据库加载路由配置 + /// + private async Task> LoadRoutesAsync(CancellationToken cancellationToken) + { + var routes = new List(); + + 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; + } + + /// + /// 从数据库加载集群配置 + /// + private async Task> LoadClustersAsync(CancellationToken cancellationToken) + { + var clusters = new List(); + + 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; + } + + /// + /// 处理单条路由 + /// + private async Task ProcessRouteAsync( + GwTenantRouteModel route, + List 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; + } + + /// + /// 生成 K8s Service YAML + /// + 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 + { + ["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 + { + ["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 + { + ["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}"; + } + + /// + /// 验证数据完整性 + /// + private List ValidateData( + List routes, + List clusters) + { + var results = new List(); + 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; + } + + /// + /// 保存迁移报告 + /// + 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}"); + } + } + + /// + /// 掩盖连接字符串中的敏感信息 + /// + 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]"; + } + } +} + +/// +/// 验证结果 +/// +public class ValidationResult +{ + public bool IsValid { get; set; } + public string Entity { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; +} diff --git a/tools/MigrationTool/MigrationTool.csproj b/tools/MigrationTool/MigrationTool.csproj new file mode 100644 index 0000000..d6a3c17 --- /dev/null +++ b/tools/MigrationTool/MigrationTool.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + enable + enable + MigrationTool + MigrationTool + + + + + + + + diff --git a/tools/MigrationTool/Models/GatewayConfigModel.cs b/tools/MigrationTool/Models/GatewayConfigModel.cs new file mode 100644 index 0000000..186a245 --- /dev/null +++ b/tools/MigrationTool/Models/GatewayConfigModel.cs @@ -0,0 +1,212 @@ +using System.Text.Json; + +namespace MigrationTool.Models; + +/// +/// 网关租户路由数据库模型 +/// +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; } + + /// + /// 解析 Match JSON 获取路径 + /// + public string GetPath() + { + if (string.IsNullOrEmpty(MatchJson)) + return PathPattern; + + try + { + var match = JsonSerializer.Deserialize(MatchJson); + return match?.Path ?? PathPattern; + } + catch + { + return PathPattern; + } + } + + /// + /// 解析 Match JSON 获取 Host + /// + public string? GetHost() + { + if (string.IsNullOrEmpty(MatchJson)) + return null; + + try + { + var match = JsonSerializer.Deserialize(MatchJson); + return match?.Hosts?.FirstOrDefault(); + } + catch + { + return null; + } + } +} + +/// +/// 路由匹配 JSON 结构 +/// +public class RouteMatchJson +{ + public string? Path { get; set; } + public List? Methods { get; set; } + public List? Hosts { get; set; } +} + +/// +/// 网关集群数据库模型 +/// +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; } + + /// + /// 解析 Destinations JSON + /// + public List GetDestinations() + { + if (string.IsNullOrEmpty(DestinationsJson)) + return []; + + try + { + var destinations = JsonSerializer.Deserialize>(DestinationsJson); + return destinations ?? []; + } + catch + { + return []; + } + } +} + +/// +/// 目标端点模型 +/// +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; +} + +/// +/// 迁移结果报告 +/// +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 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}"); + } + } + } +} + +/// +/// 迁移条目 +/// +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; } +} + +/// +/// 迁移状态 +/// +public enum MigrationStatus +{ + Success, + Failed, + Skipped +} + +/// +/// K8s Service YAML 模型 +/// +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 Labels { get; set; } = new(); + public Dictionary? Annotations { get; set; } +} + +public class K8sSpec +{ + public string Type { get; set; } = "ClusterIP"; + public Dictionary Selector { get; set; } = new(); + public List 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; } +} diff --git a/tools/MigrationTool/Program.cs b/tools/MigrationTool/Program.cs new file mode 100644 index 0000000..a7224ee --- /dev/null +++ b/tools/MigrationTool/Program.cs @@ -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; +} + +/// +/// 解析命令行参数 +/// +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(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; +} + +/// +/// 获取默认连接字符串(从环境变量) +/// +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"; +} + +/// +/// 打印 Banner +/// +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(); +} + +/// +/// 打印帮助信息 +/// +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(); +} + +/// +/// 打印选项 +/// +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}"); + } +} + +/// +/// 掩盖连接字符串中的敏感信息 +/// +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]"; + } +} + +/// +/// 简单的控制台日志记录器 +/// +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; + } +} + +/// +/// 日志记录器接口 +/// +public interface ILogger +{ + void LogTrace(string message); + void LogDebug(string message); + void LogInformation(string message); + void LogWarning(string message); + void LogError(string message); +} + +/// +/// 日志级别 +/// +public enum LogLevel +{ + Trace, + Debug, + Information, + Warning, + Error +} diff --git a/网关配置的新想法.md b/网关配置的新想法.md new file mode 100644 index 0000000..e2f1660 --- /dev/null +++ b/网关配置的新想法.md @@ -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网关 网关重新根据新的配置加载到最新配置后生效 \ No newline at end of file