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:
parent
8bdc24f374
commit
52eba07097
@ -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*
|
||||
|
||||
@ -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)*
|
||||
|
||||
290
.planning/phases/006-gateway-plugin-research/006-01-PLAN.md
Normal file
290
.planning/phases/006-gateway-plugin-research/006-01-PLAN.md
Normal 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>
|
||||
@ -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 管道
|
||||
366
.planning/phases/006-gateway-plugin-research/006-02-PLAN.md
Normal file
366
.planning/phases/006-gateway-plugin-research/006-02-PLAN.md
Normal 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: "触发重载"
|
||||
---
|
||||
|
||||
# 计划 02:YARP 插件集成
|
||||
|
||||
<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>
|
||||
284
.planning/phases/006-gateway-plugin-research/006-RESEARCH.md
Normal file
284
.planning/phases/006-gateway-plugin-research/006-RESEARCH.md
Normal 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*
|
||||
@ -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
21
MigrationTask.sln
Normal 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
|
||||
208
src/yarpgateway/Plugins/PluginHost.cs
Normal file
208
src/yarpgateway/Plugins/PluginHost.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
82
src/yarpgateway/Plugins/PluginLoadContext.cs
Normal file
82
src/yarpgateway/Plugins/PluginLoadContext.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
157
src/yarpgateway/Plugins/PluginLoader.cs
Normal file
157
src/yarpgateway/Plugins/PluginLoader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
138
tests/YarpGateway.Tests/Unit/Plugins/PluginHostTests.cs
Normal file
138
tests/YarpGateway.Tests/Unit/Plugins/PluginHostTests.cs
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
134
tests/YarpGateway.Tests/Unit/Plugins/PluginLoaderTests.cs
Normal file
134
tests/YarpGateway.Tests/Unit/Plugins/PluginLoaderTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
62
tools/MigrationTool/MigrationOptions.cs
Normal file
62
tools/MigrationTool/MigrationOptions.cs
Normal 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; }
|
||||
}
|
||||
521
tools/MigrationTool/MigrationService.cs
Normal file
521
tools/MigrationTool/MigrationService.cs
Normal 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;
|
||||
}
|
||||
17
tools/MigrationTool/MigrationTool.csproj
Normal file
17
tools/MigrationTool/MigrationTool.csproj
Normal 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>
|
||||
212
tools/MigrationTool/Models/GatewayConfigModel.cs
Normal file
212
tools/MigrationTool/Models/GatewayConfigModel.cs
Normal 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; }
|
||||
}
|
||||
318
tools/MigrationTool/Program.cs
Normal file
318
tools/MigrationTool/Program.cs
Normal 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
|
||||
}
|
||||
23
网关配置的新想法.md
Normal file
23
网关配置的新想法.md
Normal 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网关 网关重新根据新的配置加载到最新配置后生效
|
||||
Loading…
Reference in New Issue
Block a user