Compare commits
17 Commits
4839366227
...
52eba07097
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52eba07097 | ||
|
|
8bdc24f374 | ||
|
|
cc11c7258f | ||
|
|
449fe3a385 | ||
|
|
0c08620565 | ||
|
|
28941fc0ef | ||
|
|
6b2d480692 | ||
|
|
42b8c9cca5 | ||
|
|
3994a95177 | ||
|
|
ee8b73ce7f | ||
|
|
5bce01796a | ||
|
|
c333ccecb2 | ||
|
|
ee6bb763b9 | ||
|
|
27ea1d1c21 | ||
|
|
8f7e8d3a71 | ||
|
|
b420ca1f1b | ||
|
|
da4f03502a |
88
.planning/PROJECT.md
Normal file
88
.planning/PROJECT.md
Normal file
@ -0,0 +1,88 @@
|
||||
# Fengling Gateway
|
||||
|
||||
## 这是什么
|
||||
|
||||
基于 YARP (Yet Another Reverse Proxy) 的 API 网关,用于风灵微服务生态系统。支持多租户路由、动态配置和分布式负载均衡,将请求路由到下游服务(auth-service、member-service、activity、platform、risk-control 等)。
|
||||
|
||||
## 核心价值
|
||||
|
||||
可靠、可扩展的 API 网关,将流量分发到后端微服务,支持零停机配置更新。
|
||||
|
||||
## 需求
|
||||
|
||||
### 已验证(现有功能)
|
||||
|
||||
- ✓ 基于 URL 路径的多租户路由 — 已有
|
||||
- ✓ JWT Token 解析和租户声明提取 — 已有
|
||||
- ✓ PostgreSQL 动态路由配置 — 已有
|
||||
- ✓ Kubernetes 服务发现集成 — 已有
|
||||
- ✓ 加权轮询负载均衡 — 已有
|
||||
- ✓ 通过 PostgreSQL NOTIFY 实现配置热重载 — 已有
|
||||
|
||||
### 进行中
|
||||
|
||||
- [ ] 实现 console 驱动的配置管理(配置在 fengling-console 变更,网关监听并重载)
|
||||
- [ ] 通过 PostgreSQL NOTIFY 广播支持多网关实例部署
|
||||
- [ ] 将 K8s 健康检查从网关移除(委托给 console)
|
||||
|
||||
### 范围外
|
||||
|
||||
- [直接配置网关的 UI] — 由 fengling-console 负责
|
||||
- [网关中的 K8s 服务健康检查] — 委托给 console
|
||||
- [认证/授权逻辑] — 由 auth-service 负责
|
||||
|
||||
## 背景
|
||||
|
||||
**生态系统结构:**
|
||||
```
|
||||
fengling-gateway/ # 当前项目 - API 网关 (YARP)
|
||||
↓ 路由流量到:
|
||||
fengling-console/ # 中央管理控制台 - 配置、租户管理
|
||||
fengling-console-web/ # 控制台 Web UI
|
||||
fengling-auth-service/ # 认证服务
|
||||
fengling-member-service/ # 会员服务
|
||||
fengling-activity/ # 活动服务
|
||||
fengling-platform/ # 平台服务
|
||||
fengling-risk-control/ # 风控服务
|
||||
fengling-service-discovery/# 服务发现
|
||||
```
|
||||
|
||||
**架构决策(新):**
|
||||
- 网关配置由 fengling-console 管理,网关不直接配置
|
||||
- Console 发布配置变更 → 网关订阅并重载
|
||||
- 多实例支持通过 PostgreSQL NOTIFY/LISTEN 实现(更轻量,无需 Redis)
|
||||
- Console 负责所有 K8s 服务健康监控
|
||||
- 网关只处理请求路由
|
||||
|
||||
**当前问题(来自 CONCERNS.md):**
|
||||
- 硬编码凭据(安全风险)
|
||||
- JWT Token 未验证(安全风险)
|
||||
- API 端点无认证(安全风险)
|
||||
- 负载均衡锁竞争
|
||||
- 缺少单元测试
|
||||
|
||||
**Console 集成现状:**
|
||||
- fengling-console 已实现 GatewayController 和 GatewayService
|
||||
- Console 拥有 GatewayDbContext,可直接管理网关配置数据
|
||||
- Console 的 ReloadGatewayAsync() 目前为空实现,未实现广播机制
|
||||
- 网关已有 PgSqlConfigChangeListener 使用 NOTIFY/LISTEN,可复用
|
||||
|
||||
## 约束
|
||||
|
||||
- **多实例**:网关必须支持同时运行多个实例
|
||||
- **热重载**:配置变更无需重启
|
||||
- **技术栈**:.NET 10.0, YARP, PostgreSQL
|
||||
- **部署**:Docker + Kubernetes
|
||||
|
||||
## 关键决策
|
||||
|
||||
| 决策 | 理由 | 结果 |
|
||||
|------|------|------|
|
||||
| Console 驱动配置 | 集中管理,单一事实来源 | ✓ 良好 |
|
||||
| PostgreSQL NOTIFY 广播 | 轻量方案,无需额外依赖 | ✓ 良好 |
|
||||
| K8s 健康委托给 console | 降低网关复杂度,console 是运维中心 | ✓ 良好 |
|
||||
| 保持 YARP 为核心 | 微软维护,支持良好 | ✓ 良好 |
|
||||
|
||||
---
|
||||
|
||||
*最后更新:2026-03-02 初始化后*
|
||||
102
.planning/REQUIREMENTS.md
Normal file
102
.planning/REQUIREMENTS.md
Normal file
@ -0,0 +1,102 @@
|
||||
# 需求文档:Fengling Gateway
|
||||
|
||||
**定义日期:** 2026-03-02
|
||||
**核心价值:** 可靠、可扩展的 API 网关,将流量分发到后端微服务,支持零停机配置更新。
|
||||
|
||||
## v1 需求
|
||||
|
||||
初始发布版本的需求。每个需求对应一个 Roadmap 阶段。
|
||||
|
||||
### 配置管理
|
||||
|
||||
- [x] **CFG-01**:网关监听来自 fengling-console 的配置变更事件(PostgreSQL NOTIFY)
|
||||
- [x] **CFG-02**:收到通知后网关无需重启即可重载配置
|
||||
- [x] **CFG-03**:多实例网关通过 PostgreSQL NOTIFY 广播接收配置更新
|
||||
|
||||
### 多实例支持
|
||||
|
||||
- [x] **INST-01**:多个网关实例可以同时运行
|
||||
- [x] **INST-02**:配置变更通过 NOTIFY 广播传播到所有实例
|
||||
- [x] **INST-03**:使用 PostgreSQL LISTEN 订阅配置变更频道
|
||||
|
||||
### K8s 健康委托
|
||||
|
||||
- [x] **K8S-01**:从网关注销 K8s 健康监控
|
||||
- [x] **K8S-02**:网关将服务健康检查委托给 console
|
||||
|
||||
- [ ] **K8S-01**:从网关注销 K8s 健康监控
|
||||
- [ ] **K8S-02**:网关将服务健康检查委托给 console
|
||||
|
||||
### 安全修复
|
||||
|
||||
- [ ] **SEC-01**:移除源代码中的硬编码凭据
|
||||
- [ ] **SEC-02**:实现正确的 JWT Token 验证
|
||||
- [ ] **SEC-03**:为网关管理 API 端点添加认证
|
||||
|
||||
### 性能优化
|
||||
|
||||
- [ ] **PERF-01**:优化负载均衡锁竞争
|
||||
- [ ] **PERF-02**:实现增量路由缓存更新
|
||||
|
||||
## v2 需求
|
||||
|
||||
延期到未来版本。已记录但不在当前 Roadmap 中。
|
||||
|
||||
### 可观测性
|
||||
|
||||
- **OBS-01**:分布式追踪集成
|
||||
- **OBS-02**:网关性能自定义指标
|
||||
|
||||
### 测试
|
||||
|
||||
- **TEST-01**:RouteCache 单元测试
|
||||
- **TEST-02**:JwtTransformMiddleware 单元测试
|
||||
- **TEST-03**:负载均衡策略单元测试
|
||||
|
||||
## 范围外
|
||||
|
||||
| 功能 | 原因 |
|
||||
|------|------|
|
||||
| 直接配置网关的 UI | 由 fengling-console 处理 |
|
||||
| 网关中的 K8s 服务健康检查 | 委托给 console |
|
||||
| 网关中的认证逻辑 | 由 auth-service 处理 |
|
||||
| 网关中的授权逻辑 | 由下游服务处理 |
|
||||
|
||||
## 可追溯性
|
||||
|
||||
哪些阶段覆盖哪些需求。Roadmap 创建时更新。
|
||||
|
||||
| 需求 | 阶段 | 状态 |
|
||||
|------|------|------|
|
||||
| CFG-01 | 阶段 1 | ✅ 已完成 |
|
||||
| CFG-02 | 阶段 1 | ✅ 已完成 |
|
||||
| CFG-03 | 阶段 1 | ✅ 已完成 |
|
||||
| INST-01 | 阶段 1 | ✅ 已完成 |
|
||||
| INST-02 | 阶段 1 | ✅ 已完成 |
|
||||
| INST-03 | 阶段 1 | ✅ 已完成 |
|
||||
QH|| K8S-01 | 阶段 2 | ✅ 已完成 |
|
||||
BH|| K8S-02 | 阶段 2 | ✅ 已完成 |
|
||||
| K8S-02 | 阶段 2 | 待处理 |
|
||||
| SEC-01 | 阶段 3 | 待处理 |
|
||||
| SEC-02 | 阶段 3 | 待处理 |
|
||||
| SEC-03 | 阶段 3 | 待处理 |
|
||||
| PERF-01 | 阶段 4 | 待处理 |
|
||||
| PERF-02 | 阶段 4 | 待处理 |
|
||||
|
||||
**覆盖率:**
|
||||
- v1 需求:共 12 项
|
||||
- 已映射到阶段:12 项
|
||||
- 未映射:0 ✓
|
||||
- 已完成:8 项(阶段 1 + 阶段 2)
|
||||
- 待处理:4 项
|
||||
- 待处理:6 项
|
||||
|
||||
---
|
||||
|
||||
**阶段 1 完成说明:**
|
||||
- 现有 `PgSqlConfigChangeListener.cs` 已实现所有监听需求
|
||||
- 监听频道:`gateway_config_changed`
|
||||
- 包含断线重连和回退轮询机制
|
||||
|
||||
*需求定义:2026-03-02*
|
||||
*最后更新:2026-03-02 阶段1完成后*
|
||||
198
.planning/ROADMAP.md
Normal file
198
.planning/ROADMAP.md
Normal file
@ -0,0 +1,198 @@
|
||||
#MY|# Roadmap:Fengling Gateway
|
||||
|
||||
**创建日期:** 2026-03-02
|
||||
**核心价值:** 可靠、可扩展的 API 网关,将流量分发到后端微服务,支持零停机配置更新。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 1:配置变更监听与多实例支持 ✅ 已完成
|
||||
|
||||
**目标:** 实现网关对配置变更的监听机制,支持多实例部署。
|
||||
|
||||
> **注意:** 此阶段只实现 YARP 网关部分的监听代码。Console 端的广播通知由 fengling-console 项目负责。
|
||||
|
||||
**需求:**
|
||||
- [x] CFG-01:网关监听来自 fengling-console 的配置变更事件(PostgreSQL NOTIFY)
|
||||
- [x] CFG-02:收到通知后网关无需重启即可重载配置
|
||||
- [x] CFG-03:多实例网关通过 PostgreSQL NOTIFY 广播接收配置更新
|
||||
- [x] INST-01:多个网关实例可以同时运行
|
||||
- [x] INST-02:配置变更通过 NOTIFY 广播传播到所有实例
|
||||
- [x] INST-03:使用 PostgreSQL LISTEN 订阅配置变更频道
|
||||
|
||||
**成功标准:**
|
||||
- [x] 网关使用 LISTEN 订阅配置变更频道(如 `gateway_config_changed`)
|
||||
- [x] 收到 NOTIFY 后触发配置重载,无需重启
|
||||
- [x] 多个网关实例通过数据库 NOTIFY 保持同步
|
||||
- [x] 广播事件在 5 秒内到达所有实例
|
||||
|
||||
**实现文件:**
|
||||
- `src/yarpgateway/Services/PgSqlConfigChangeListener.cs`
|
||||
|
||||
---
|
||||
|
||||
## 阶段 2:K8s 健康检查委托 ✅ 已完成
|
||||
|
||||
**目标:** 将 K8s 服务健康监控从网关移除,委托给 fengling-console。
|
||||
|
||||
**需求:**
|
||||
- [x] K8S-01:从网关注销 K8s 健康监控
|
||||
- [x] K8S-02:网关将服务健康检查委托给 console
|
||||
|
||||
**成功标准:**
|
||||
- [x] KubernetesPendingSyncService 已从网关移除
|
||||
- [x] PendingServicesController 已从网关移除
|
||||
- [x] 网关只执行请求路由,不做健康监控
|
||||
|
||||
**已删除的文件:**
|
||||
- `Services/KubernetesPendingSyncService.cs`
|
||||
- `Controllers/PendingServicesController.cs`
|
||||
- `Models/GwPendingServiceDiscovery.cs`
|
||||
|
||||
**已修改的文件:**
|
||||
- `Program.cs` - 移除服务注册和 using 语句
|
||||
- `Data/GatewayDbContext.cs` - 移除 DbSet 和模型配置
|
||||
|
||||
---
|
||||
|
||||
## 阶段 3:安全加固
|
||||
|
||||
**目标:** 修复关键安全漏洞。
|
||||
|
||||
**需求:**
|
||||
- [ ] SEC-01:移除源代码中的硬编码凭据
|
||||
- [ ] SEC-02:实现正确的 JWT Token 验证
|
||||
- [ ] SEC-03:为网关管理 API 端点添加认证
|
||||
|
||||
**成功标准:**
|
||||
1. 源代码中无硬编码密码/密钥
|
||||
2. JWT Token 经过验证(签名、过期时间、颁发者、受众)
|
||||
3. 所有 /api/gateway/* 端点需要认证
|
||||
|
||||
---
|
||||
|
||||
## 阶段 4:性能优化
|
||||
|
||||
**目标:** 优化高负载下的网关性能。
|
||||
|
||||
**需求:**
|
||||
- [ ] PERF-01:优化负载均衡锁竞争
|
||||
- [ ] PERF-02:实现增量路由缓存更新
|
||||
|
||||
**成功标准:**
|
||||
1. 负载均衡不需要每个请求都获取 Redis 锁
|
||||
2. 路由缓存更新是增量式的,而非全量重载
|
||||
3. 网关处理能力提升 10 倍
|
||||
|
||||
---
|
||||
|
||||
## 阶段 5:可观测性与测试
|
||||
|
||||
**目标:** 添加可观测性和测试覆盖。
|
||||
|
||||
**需求:**
|
||||
- [ ] OBS-01:分布式追踪集成
|
||||
- [ ] OBS-02:网关性能自定义指标
|
||||
- [ ] TEST-01:RouteCache 单元测试
|
||||
- [ ] TEST-02:JwtTransformMiddleware 单元测试
|
||||
- [ ] TEST-03:负载均衡策略单元测试
|
||||
|
||||
**成功标准:**
|
||||
1. 分布式追踪包含网关跨度
|
||||
2. 导出关键指标(请求数、延迟、错误率)
|
||||
3. 核心组件测试覆盖率 >80%
|
||||
|
||||
---
|
||||
|
||||
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 通知监听
|
||||
|
||||
**目标:** 实现网关插件化支持。
|
||||
|
||||
**需求:**
|
||||
- [x] PLUG-01:网关插件化架构设计
|
||||
- [x] PLUG-02:插件加载机制
|
||||
- [ ] PLUG-03:插件隔离与生命周期管理
|
||||
|
||||
**成功标准:**
|
||||
- [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 | ✅ 进行中 |
|
||||
|
||||
## 阶段 7:网关配置重构规划
|
||||
|
||||
**目标:** 分析网关配置新想法的可行性,重新规划网关配置架构。
|
||||
|
||||
**需求:**
|
||||
- [ ] REPL-01:分析"网关配置的新想法.md"中的方案
|
||||
- [ ] REPL-02:识别与现有需求的冲突点
|
||||
- [ ] REPL-03:制定新的网关配置架构
|
||||
|
||||
**成功标准:**
|
||||
1. 完成网关配置新想法的可行性分析
|
||||
2. 提出与现有阶段兼容的配置方案
|
||||
3. 更新 roadmap 以反映新的配置架构
|
||||
|
||||
---
|
||||
|
||||
## 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*
|
||||
128
.planning/STATE.md
Normal file
128
.planning/STATE.md
Normal file
@ -0,0 +1,128 @@
|
||||
#VR|# 状态:Fengling Gateway
|
||||
|
||||
**最后更新:** 2026-03-04
|
||||
|
||||
---
|
||||
|
||||
## 项目引用
|
||||
|
||||
参考:.planning/PROJECT.md(更新于 2026-03-02)
|
||||
|
||||
**核心价值:** 可靠、可扩展的 API 网关,将流量分发到后端微服务,支持零停机配置更新。
|
||||
|
||||
**当前重点:** 阶段 6:网关插件技术调研与实现
|
||||
|
||||
---
|
||||
|
||||
## 项目状态
|
||||
|
||||
| 项目 | 状态 |
|
||||
|------|------|
|
||||
| PROJECT.md | ✓ 已初始化 |
|
||||
| config.json | ✓ 已创建 |
|
||||
| 需求文档 | ✓ 已定义(18 个需求) |
|
||||
| Roadmap | ✓ 已创建(6 个阶段) |
|
||||
| 研究 | 未开始(自动模式跳过) |
|
||||
|
||||
---
|
||||
|
||||
## 阶段状态
|
||||
|
||||
| 阶段 | 名称 | 状态 | 计划数 | 进度 |
|
||||
|------|------|------|--------|------|
|
||||
| 1 | 配置变更监听与多实例支持 | ✅ 已完成 | 0 | 100% |
|
||||
| 2 | K8s 健康检查委托 | ✅ 已完成 | 0 | 100% |
|
||||
| 3 | 安全加固 | 未规划 | 0 | 0% |
|
||||
| 4 | 性能优化 | 未规划 | 0 | 0% |
|
||||
| 5 | 可观测性与测试 | 未规划 | 0 | 0% |
|
||||
#NH|QJ|| 6 | 网关插件技术调研与实现 | ✅ 进行中 | 2 | 50% |
|
||||
#PW|| 7 | 网关配置重构规划 | 待规划 | 0 | 0% |
|
||||
|
||||
---
|
||||
|
||||
## 累积上下文
|
||||
|
||||
### 初始化
|
||||
|
||||
- **2026-03-02:** 通过 /gsd-new-project --auto 初始化项目
|
||||
- 现有代码库的重构项目(已存在 ARCHITECTURE.md、CONCERNS.md、STACK.md)
|
||||
- 用户提供背景:网关架构讨论,重点是 console 驱动的配置管理
|
||||
|
||||
### 关键决策
|
||||
|
||||
| 决策 | 日期 | 备注 |
|
||||
|------|------|------|
|
||||
| Console 驱动配置 | 2026-03-02 | 配置在 fengling-console 变更,网关监听 |
|
||||
| PostgreSQL NOTIFY 广播 | 2026-03-02 | 使用 PostgreSQL NOTIFY/LISTEN,更轻量 |
|
||||
| K8s 健康委托 | 2026-03-02 | Console 处理 K8s 健康,非网关 |
|
||||
|
||||
### 阶段 1 分析结论
|
||||
|
||||
- **2026-03-02:** 分析现有代码 `PgSqlConfigChangeListener.cs`
|
||||
- 结论:现有实现已完整满足阶段 1 所有需求
|
||||
- 监听频道:`gateway_config_changed`
|
||||
- 包含:断线重连、回退轮询(5分钟)
|
||||
|
||||
### Console 集成现状
|
||||
|
||||
- Console 已实现 GatewayController 和 GatewayService
|
||||
- Console 拥有 GatewayDbContext,可直接管理网关配置
|
||||
- ReloadGatewayAsync() 为空实现,需要在 fengling-console 中实现 NOTIFY 发送
|
||||
|
||||
SK|### 阶段 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/) |
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
- 自动模式:跳过研究,工作流偏好设置为 yolo
|
||||
- 配置变更应提交到 git(commit_docs: true)
|
||||
- gsd-tools.cjs 不可用 - 项目结构手动创建
|
||||
|
||||
---
|
||||
|
||||
*最后更新:2026-03-04 - 完成阶段 6 计划 006-01:插件加载基础设施(PLUG-01, PLUG-02)*
|
||||
13
.planning/config.json
Normal file
13
.planning/config.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"mode": "yolo",
|
||||
"depth": "standard",
|
||||
"parallelization": true,
|
||||
"commit_docs": true,
|
||||
"model_profile": "balanced",
|
||||
"workflow": {
|
||||
"research": false,
|
||||
"plan_check": false,
|
||||
"verifier": false,
|
||||
"auto_advance": true
|
||||
}
|
||||
}
|
||||
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*
|
||||
71
.planning/phases/2-k8s-health-delegation/PLAN.md
Normal file
71
.planning/phases/2-k8s-health-delegation/PLAN.md
Normal file
@ -0,0 +1,71 @@
|
||||
# 阶段 2 计划:K8s 健康检查委托
|
||||
|
||||
## 目标
|
||||
|
||||
将 K8s 服务健康监控从网关移除,委托给 fengling-console。网关只专注于请求路由。
|
||||
|
||||
## 需要移除的代码
|
||||
|
||||
### 1. 后台服务
|
||||
- **文件**: `src/yarpgateway/Services/KubernetesPendingSyncService.cs`
|
||||
- **操作**: 删除文件
|
||||
- **影响**: 停止每 30 秒同步 K8s 服务
|
||||
|
||||
### 2. 服务注册
|
||||
- **文件**: `src/yarpgateway/Program.cs`
|
||||
- **行号**: ~118
|
||||
- **代码**: `builder.Services.AddHostedService<KubernetesPendingSyncService>();`
|
||||
- **操作**: 删除该行
|
||||
|
||||
### 3. API 控制器
|
||||
- **文件**: `src/yarpgateway/Controllers/PendingServicesController.cs`
|
||||
- **操作**: 删除文件
|
||||
- **影响**: 移除 `/api/gateway/pending-services/*` API
|
||||
|
||||
### 4. 数据模型
|
||||
- **文件**: `src/yarpgateway/Models/GwPendingServiceDiscovery.cs`
|
||||
- **操作**: 删除文件
|
||||
- **影响**: 移除待处理服务发现实体
|
||||
|
||||
### 5. DbContext
|
||||
- **文件**: `src/yarpgateway/Data/GatewayDbContext.cs`
|
||||
- **操作**: 移除 `DbSet<GwPendingServiceDiscovery>` 属性
|
||||
|
||||
### 6. 迁移文件(可选)
|
||||
- **文件**: `src/yarpgateway/Migrations/20260222134342_AddPendingServiceDiscovery.cs`
|
||||
- **操作**: 保留(数据库已有该表)或删除(如果重新创建数据库)
|
||||
|
||||
## 不需要移除
|
||||
|
||||
| 组件 | 理由 |
|
||||
|------|------|
|
||||
| DatabaseClusterConfigProvider | YARP 集群配置仍然需要 |
|
||||
| PgSqlConfigChangeListener | 配置监听仍然需要 |
|
||||
| 现有健康检查 | YARP 内置被动健康检查 |
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. 移除 PendingServicesController.cs
|
||||
2. 移除 KubernetesPendingSyncService.cs
|
||||
3. 移除 GwPendingServiceDiscovery.cs
|
||||
4. 更新 GatewayDbContext.cs
|
||||
5. 更新 Program.cs
|
||||
6. 更新 ROADMAP.md 标记为完成
|
||||
7. 提交代码
|
||||
|
||||
## 风险
|
||||
|
||||
- **数据丢失**: 如果数据库已有 `PendingServiceDiscoveries` 表,删除代码后数据仍然存在但无法访问
|
||||
- **API 变更**: 移除 `/api/gateway/pending-services/*` 端点,需要通知 console 团队
|
||||
|
||||
## 验证
|
||||
|
||||
完成后验证:
|
||||
- `dotnet build` 成功
|
||||
- 无 `KubernetesPendingSyncService` 引用
|
||||
- 无 `PendingServicesController` 引用
|
||||
- 无 `GwPendingServiceDiscovery` 引用
|
||||
|
||||
---
|
||||
|
||||
*计划创建: 2026-03-02*
|
||||
39
.planning/quick/001-upgrade-platform/001-PLAN.md
Normal file
39
.planning/quick/001-upgrade-platform/001-PLAN.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Plan: 升级 Fengling.Platform 包到最新
|
||||
|
||||
## 任务描述
|
||||
升级 fengling-gateway 项目中的 Fengling.Platform.Infrastructure 包到最新版本,并修复现有编译警告。
|
||||
|
||||
## 变更分析
|
||||
|
||||
### fengling-platform 新版本主要变更
|
||||
1. **主键类型变更**:从 `long Id` 改为 `string Id`(Guid)
|
||||
2. **新增 GwCluster 聚合根**:包含内嵌 Destinations 列表
|
||||
3. **GwTenantRoute 扩展**:新增 Match (GwRouteMatch)、Transforms 等字段
|
||||
4. **移除GwServiceInstance**:作为 GwCluster 的内嵌值对象 GwDestination
|
||||
5. **新增值对象**:GwRouteMatch、GwTransform、GwLoadBalancingPolicy、GwHealthCheckConfig、GwSessionAffinityConfig
|
||||
|
||||
### 当前编译警告
|
||||
1. **CS0108**: GatewayDbContext.Tenants 隐藏继承成员 PlatformDbContext.Tenants
|
||||
2. **NU1506**: 重复 PackageVersion 定义
|
||||
3. **NU1507**: 配置了多个包源
|
||||
|
||||
## 任务列表
|
||||
|
||||
### Task 1: 分析并修复 CS0108 警告
|
||||
- **文件**: src/yarpgateway/Data/GatewayDbContext.cs
|
||||
- **操作**: 将 `Tenants` 属性添加 `new` 关键字,或使用不同的名称避免隐藏
|
||||
- **验证**: dotnet build 无 CS0108 警告
|
||||
|
||||
### Task 2: 修复 NU1506 重复包版本警告
|
||||
- **文件**: Directory.Packages.props 或 YarpGateway.csproj
|
||||
- **操作**: 检查并移除重复的 PackageVersion 定义
|
||||
- **验证**: dotnet restore 无 NU1506 警告
|
||||
|
||||
### Task 3: 配置包源映射解决 NU1507
|
||||
- **文件**: NuGet.Config 或 Directory.Build.props
|
||||
- **操作**: 添加包源映射配置
|
||||
- **验证**: dotnet restore 无 NU1507 警告
|
||||
|
||||
## 验证
|
||||
- dotnet build 成功,0 错误
|
||||
- 无 CS0108、NU1506、NU1507 警告
|
||||
41
.planning/quick/001-upgrade-platform/001-SUMMARY.md
Normal file
41
.planning/quick/001-upgrade-platform/001-SUMMARY.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Quick Task 001 Summary: 升级 Fengling.Platform 包并修复编译警告
|
||||
|
||||
## 任务概述
|
||||
升级 fengling-gateway 项目中的 Fengling.Platform.Infrastructure 包引用,并修复编译警告。
|
||||
|
||||
## 变更内容
|
||||
|
||||
### 1. 修复 CS0108 警告
|
||||
**文件**: `src/yarpgateway/Data/GatewayDbContext.cs`
|
||||
**问题**: `GatewayDbContext.Tenants` 隐藏了继承的成员 `PlatformDbContext.Tenants`
|
||||
**修复**: 添加 `new` 关键字明确表示新定义
|
||||
```csharp
|
||||
// 修复前
|
||||
public DbSet<GwTenant> Tenants => Set<GwTenant>();
|
||||
// 修复后
|
||||
public new DbSet<GwTenant> Tenants => Set<GwTenant>();
|
||||
```
|
||||
|
||||
### 2. 修复 NU1506 重复 PackageVersion
|
||||
**文件**: `src/yarpgateway/Directory.Packages.props`
|
||||
**问题**: 重复定义了 Microsoft.AspNetCore.Authentication.JwtBearer、Microsoft.EntityFrameworkCore.Design、Microsoft.EntityFrameworkCore
|
||||
**修复**: 移除重复的 PackageVersion 定义
|
||||
|
||||
### 3. 修复 NU1507 多个包源警告
|
||||
**文件**: 根目录创建 `NuGet.Config`
|
||||
**问题**: 配置了多个包源(gitea、nuget.org),需要包源映射
|
||||
**修复**: 添加 packageSourceMapping 配置到 NuGet.Config,并删除子目录中的重复配置
|
||||
|
||||
## 构建结果
|
||||
- ✅ 0 错误
|
||||
- ✅ CS0108 警告已修复
|
||||
- ✅ NU1506 警告已修复
|
||||
- ✅ NU1507 警告已修复
|
||||
- ⚠️ MSB3277 警告(EntityFrameworkCore.Relational 版本冲突)- 来自测试项目依赖,不影响构建
|
||||
|
||||
## 相关文件
|
||||
- `src/yarpgateway/Data/GatewayDbContext.cs` - 添加 new 关键字
|
||||
- `src/yarpgateway/Directory.Packages.props` - 移除重复包版本
|
||||
- `src/yarpgateway/Directory.Build.props` - 添加包源映射
|
||||
- `tests/Directory.Build.props` - 添加包源映射
|
||||
- `NuGet.Config` - 新建包源映射配置
|
||||
25
Directory.Build.props
Normal file
25
Directory.Build.props
Normal file
@ -0,0 +1,25 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageSourceMapping Include="nuget.org">
|
||||
<Pattern>Microsoft.*</Pattern>
|
||||
<Pattern>Serilog.*</Pattern>
|
||||
<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>
|
||||
<Pattern>Microsoft.NET.Test.Sdk</Pattern>
|
||||
</PackageSourceMapping>
|
||||
<PackageSourceMapping Include="gitea">
|
||||
<Pattern>Fengling.*</Pattern>
|
||||
</PackageSourceMapping>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
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
|
||||
19
NuGet.Config
Normal file
19
NuGet.Config
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="Microsoft.*" />
|
||||
<package pattern="Serilog.*" />
|
||||
<package pattern="Npgsql.*" />
|
||||
<package pattern="StackExchange.Redis" />
|
||||
<package pattern="Yarp.*" />
|
||||
<package pattern="xunit" />
|
||||
<package pattern="Moq" />
|
||||
<package pattern="FluentAssertions" />
|
||||
<package pattern="Microsoft.NET.Test.Sdk" />
|
||||
</packageSource>
|
||||
<packageSource key="gitea">
|
||||
<package pattern="Fengling.*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
@ -4,10 +4,8 @@
|
||||
<File Path="Dockerfile" />
|
||||
</Folder>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/YarpGateway.csproj" />
|
||||
<Project Path="src/yarpgateway/YarpGateway.csproj" />
|
||||
<Project Path="src/Fengling.Gateway.Plugin.Abstractions/Fengling.Gateway.Plugin.Abstractions.csproj" />
|
||||
</Folder>
|
||||
<Project Path="src/YarpGateway.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/YarpGateway.Tests/YarpGateway.Tests.csproj" />
|
||||
|
||||
@ -1,490 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using YarpGateway.Data;
|
||||
using YarpGateway.Config;
|
||||
using YarpGateway.Models;
|
||||
using YarpGateway.Services;
|
||||
|
||||
namespace YarpGateway.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/gateway")]
|
||||
[Authorize] // 要求所有管理 API 都需要认证
|
||||
public class GatewayConfigController : ControllerBase
|
||||
{
|
||||
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
|
||||
private readonly DatabaseRouteConfigProvider _routeProvider;
|
||||
private readonly DatabaseClusterConfigProvider _clusterProvider;
|
||||
private readonly IRouteCache _routeCache;
|
||||
|
||||
public GatewayConfigController(
|
||||
IDbContextFactory<GatewayDbContext> dbContextFactory,
|
||||
DatabaseRouteConfigProvider routeProvider,
|
||||
DatabaseClusterConfigProvider clusterProvider,
|
||||
IRouteCache routeCache)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_routeProvider = routeProvider;
|
||||
_clusterProvider = clusterProvider;
|
||||
_routeCache = routeCache;
|
||||
}
|
||||
|
||||
#region Tenants
|
||||
|
||||
[HttpGet("tenants")]
|
||||
public async Task<IActionResult> GetTenants([FromQuery] int page = 1, [FromQuery] int pageSize = 10, [FromQuery] string? keyword = null)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var query = db.Tenants.Where(t => !t.IsDeleted);
|
||||
|
||||
if (!string.IsNullOrEmpty(keyword))
|
||||
{
|
||||
query = query.Where(t => t.TenantCode.Contains(keyword) || t.TenantName.Contains(keyword));
|
||||
}
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(t => t.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(t => new
|
||||
{
|
||||
t.Id,
|
||||
t.TenantCode,
|
||||
t.TenantName,
|
||||
t.Status,
|
||||
RouteCount = db.TenantRoutes.Count(r => r.TenantCode == t.TenantCode && !r.IsDeleted),
|
||||
t.Version,
|
||||
t.CreatedTime,
|
||||
t.UpdatedTime
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { items, total, page, pageSize, totalPages = (int)Math.Ceiling(total / (double)pageSize) });
|
||||
}
|
||||
|
||||
[HttpGet("tenants/{id}")]
|
||||
public async Task<IActionResult> GetTenant(long id)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var tenant = await db.Tenants.FindAsync(id);
|
||||
if (tenant == null) return NotFound();
|
||||
return Ok(tenant);
|
||||
}
|
||||
|
||||
[HttpPost("tenants")]
|
||||
public async Task<IActionResult> CreateTenant([FromBody] CreateTenantDto dto)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var existing = await db.Tenants.FirstOrDefaultAsync(t => t.TenantCode == dto.TenantCode);
|
||||
if (existing != null) return BadRequest($"Tenant code {dto.TenantCode} already exists");
|
||||
|
||||
var tenant = new GwTenant
|
||||
{
|
||||
Id = GenerateId(),
|
||||
TenantCode = dto.TenantCode,
|
||||
TenantName = dto.TenantName,
|
||||
Status = 1,
|
||||
Version = 1
|
||||
};
|
||||
await db.Tenants.AddAsync(tenant);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(tenant);
|
||||
}
|
||||
|
||||
[HttpPut("tenants/{id}")]
|
||||
public async Task<IActionResult> UpdateTenant(long id, [FromBody] UpdateTenantDto dto)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var tenant = await db.Tenants.FindAsync(id);
|
||||
if (tenant == null) return NotFound();
|
||||
|
||||
if (!string.IsNullOrEmpty(dto.TenantName)) tenant.TenantName = dto.TenantName;
|
||||
if (dto.Status != null) tenant.Status = dto.Status.Value;
|
||||
|
||||
tenant.Version++;
|
||||
tenant.UpdatedTime = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(tenant);
|
||||
}
|
||||
|
||||
[HttpDelete("tenants/{id}")]
|
||||
public async Task<IActionResult> DeleteTenant(long id)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var tenant = await db.Tenants.FindAsync(id);
|
||||
if (tenant == null) return NotFound();
|
||||
|
||||
tenant.IsDeleted = true;
|
||||
tenant.UpdatedTime = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Routes
|
||||
|
||||
[HttpGet("routes")]
|
||||
public async Task<IActionResult> GetRoutes([FromQuery] int page = 1, [FromQuery] int pageSize = 10, [FromQuery] string? tenantCode = null, [FromQuery] bool? isGlobal = null)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var query = db.TenantRoutes.Where(r => !r.IsDeleted);
|
||||
|
||||
if (!string.IsNullOrEmpty(tenantCode))
|
||||
query = query.Where(r => r.TenantCode == tenantCode);
|
||||
if (isGlobal != null)
|
||||
query = query.Where(r => r.IsGlobal == isGlobal.Value);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderBy(r => r.Priority)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { items, total, page, pageSize, totalPages = (int)Math.Ceiling(total / (double)pageSize) });
|
||||
}
|
||||
|
||||
[HttpGet("routes/global")]
|
||||
public async Task<IActionResult> GetGlobalRoutes()
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var routes = await db.TenantRoutes.Where(r => r.IsGlobal && !r.IsDeleted).ToListAsync();
|
||||
return Ok(routes);
|
||||
}
|
||||
|
||||
[HttpGet("routes/tenant/{tenantCode}")]
|
||||
public async Task<IActionResult> GetTenantRoutes(string tenantCode)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var routes = await db.TenantRoutes.Where(r => r.TenantCode == tenantCode && !r.IsDeleted).ToListAsync();
|
||||
return Ok(routes);
|
||||
}
|
||||
|
||||
[HttpGet("routes/{id}")]
|
||||
public async Task<IActionResult> GetRoute(long id)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var route = await db.TenantRoutes.FindAsync(id);
|
||||
if (route == null) return NotFound();
|
||||
return Ok(route);
|
||||
}
|
||||
|
||||
[HttpPost("routes")]
|
||||
public async Task<IActionResult> CreateRoute([FromBody] CreateRouteDto dto)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
|
||||
if ((dto.IsGlobal != true) && !string.IsNullOrEmpty(dto.TenantCode))
|
||||
{
|
||||
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.TenantCode == dto.TenantCode);
|
||||
if (tenant == null) return BadRequest($"Tenant {dto.TenantCode} not found");
|
||||
}
|
||||
|
||||
var route = new GwTenantRoute
|
||||
{
|
||||
Id = GenerateId(),
|
||||
TenantCode = dto.TenantCode ?? string.Empty,
|
||||
ServiceName = dto.ServiceName,
|
||||
ClusterId = dto.ClusterId,
|
||||
PathPattern = dto.PathPattern,
|
||||
Priority = dto.Priority ?? 10,
|
||||
Status = 1,
|
||||
IsGlobal = dto.IsGlobal ?? false,
|
||||
Version = 1,
|
||||
CreatedTime = DateTime.UtcNow
|
||||
};
|
||||
await db.TenantRoutes.AddAsync(route);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await _routeCache.ReloadAsync();
|
||||
|
||||
return Ok(route);
|
||||
}
|
||||
|
||||
[HttpPut("routes/{id}")]
|
||||
public async Task<IActionResult> UpdateRoute(long id, [FromBody] CreateRouteDto dto)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var route = await db.TenantRoutes.FindAsync(id);
|
||||
if (route == null) return NotFound();
|
||||
|
||||
route.ServiceName = dto.ServiceName;
|
||||
route.ClusterId = dto.ClusterId;
|
||||
route.PathPattern = dto.PathPattern;
|
||||
if (dto.Priority != null) route.Priority = dto.Priority.Value;
|
||||
route.Version++;
|
||||
route.UpdatedTime = DateTime.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await _routeCache.ReloadAsync();
|
||||
|
||||
return Ok(route);
|
||||
}
|
||||
|
||||
[HttpDelete("routes/{id}")]
|
||||
public async Task<IActionResult> DeleteRoute(long id)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var route = await db.TenantRoutes.FindAsync(id);
|
||||
if (route == null) return NotFound();
|
||||
|
||||
route.IsDeleted = true;
|
||||
route.UpdatedTime = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await _routeCache.ReloadAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Clusters
|
||||
|
||||
[HttpGet("clusters")]
|
||||
public async Task<IActionResult> GetClusters()
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var clusters = await db.ServiceInstances
|
||||
.Where(i => !i.IsDeleted)
|
||||
.GroupBy(i => i.ClusterId)
|
||||
.Select(g => new
|
||||
{
|
||||
ClusterId = g.Key,
|
||||
ClusterName = g.Key,
|
||||
InstanceCount = g.Count(),
|
||||
HealthyInstanceCount = g.Count(i => i.Health == 1),
|
||||
Instances = g.ToList()
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(clusters);
|
||||
}
|
||||
|
||||
[HttpGet("clusters/{clusterId}")]
|
||||
public async Task<IActionResult> GetCluster(string clusterId)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var instances = await db.ServiceInstances.Where(i => i.ClusterId == clusterId && !i.IsDeleted).ToListAsync();
|
||||
if (!instances.Any()) return NotFound();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
ClusterName = clusterId,
|
||||
InstanceCount = instances.Count,
|
||||
HealthyInstanceCount = instances.Count(i => i.Health == 1),
|
||||
Instances = instances
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("clusters")]
|
||||
public async Task<IActionResult> CreateCluster([FromBody] CreateClusterDto dto)
|
||||
{
|
||||
return Ok(new { message = "Cluster created", clusterId = dto.ClusterId });
|
||||
}
|
||||
|
||||
[HttpDelete("clusters/{clusterId}")]
|
||||
public async Task<IActionResult> DeleteCluster(string clusterId)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var instances = await db.ServiceInstances.Where(i => i.ClusterId == clusterId).ToListAsync();
|
||||
foreach (var instance in instances)
|
||||
{
|
||||
instance.IsDeleted = true;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
await _clusterProvider.ReloadAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Instances
|
||||
|
||||
[HttpGet("clusters/{clusterId}/instances")]
|
||||
public async Task<IActionResult> GetInstances(string clusterId)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var instances = await db.ServiceInstances.Where(i => i.ClusterId == clusterId && !i.IsDeleted).ToListAsync();
|
||||
return Ok(instances);
|
||||
}
|
||||
|
||||
[HttpGet("instances/{id}")]
|
||||
public async Task<IActionResult> GetInstance(long id)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var instance = await db.ServiceInstances.FindAsync(id);
|
||||
if (instance == null) return NotFound();
|
||||
return Ok(instance);
|
||||
}
|
||||
|
||||
[HttpPost("clusters/{clusterId}/instances")]
|
||||
public async Task<IActionResult> CreateInstance(string clusterId, [FromBody] CreateInstanceDto dto)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var existing = await db.ServiceInstances.FirstOrDefaultAsync(i => i.ClusterId == clusterId && i.DestinationId == dto.DestinationId);
|
||||
if (existing != null) return BadRequest($"Instance {dto.DestinationId} already exists");
|
||||
|
||||
var instance = new GwServiceInstance
|
||||
{
|
||||
Id = GenerateId(),
|
||||
ClusterId = clusterId,
|
||||
DestinationId = dto.DestinationId,
|
||||
Address = dto.Address,
|
||||
Weight = dto.Weight ?? 1,
|
||||
Health = dto.IsHealthy == true ? 1 : 0,
|
||||
Status = 1,
|
||||
Version = 1,
|
||||
CreatedTime = DateTime.UtcNow
|
||||
};
|
||||
await db.ServiceInstances.AddAsync(instance);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await _clusterProvider.ReloadAsync();
|
||||
|
||||
return Ok(instance);
|
||||
}
|
||||
|
||||
[HttpDelete("instances/{id}")]
|
||||
public async Task<IActionResult> DeleteInstance(long id)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var instance = await db.ServiceInstances.FindAsync(id);
|
||||
if (instance == null) return NotFound();
|
||||
|
||||
instance.IsDeleted = true;
|
||||
instance.UpdatedTime = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await _clusterProvider.ReloadAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Config & Stats
|
||||
|
||||
[HttpPost("config/reload")]
|
||||
public async Task<IActionResult> ReloadConfig()
|
||||
{
|
||||
await _routeCache.ReloadAsync();
|
||||
await _routeProvider.ReloadAsync();
|
||||
await _clusterProvider.ReloadAsync();
|
||||
return Ok(new { message = "Config reloaded successfully", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
[HttpGet("config/status")]
|
||||
public async Task<IActionResult> GetConfigStatus()
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var routeCount = await db.TenantRoutes.CountAsync(r => r.Status == 1 && !r.IsDeleted);
|
||||
var instanceCount = await db.ServiceInstances.CountAsync(i => i.Status == 1 && !i.IsDeleted);
|
||||
var healthyCount = await db.ServiceInstances.CountAsync(i => i.Health == 1 && !i.IsDeleted);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
routeCount,
|
||||
clusterCount = await db.ServiceInstances.Where(i => !i.IsDeleted).GroupBy(i => i.ClusterId).CountAsync(),
|
||||
instanceCount,
|
||||
healthyInstanceCount = healthyCount,
|
||||
lastReloadTime = DateTime.UtcNow,
|
||||
isListening = true,
|
||||
listenerStatus = "Active"
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("config/versions")]
|
||||
public async Task<IActionResult> GetVersionInfo()
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var routeVersion = await db.TenantRoutes.OrderByDescending(r => r.Version).Select(r => r.Version).FirstOrDefaultAsync();
|
||||
var clusterVersion = await db.ServiceInstances.OrderByDescending(i => i.Version).Select(i => i.Version).FirstOrDefaultAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
routeVersion,
|
||||
clusterVersion,
|
||||
routeVersionUpdatedAt = DateTime.UtcNow,
|
||||
clusterVersionUpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("stats/overview")]
|
||||
public async Task<IActionResult> GetOverviewStats()
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var totalTenants = await db.Tenants.CountAsync(t => !t.IsDeleted);
|
||||
var activeTenants = await db.Tenants.CountAsync(t => !t.IsDeleted && t.Status == 1);
|
||||
var totalRoutes = await db.TenantRoutes.CountAsync(r => r.Status == 1 && !r.IsDeleted);
|
||||
var totalInstances = await db.ServiceInstances.CountAsync(i => i.Status == 1 && !i.IsDeleted);
|
||||
var healthyInstances = await db.ServiceInstances.CountAsync(i => i.Health == 1 && !i.IsDeleted);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
totalTenants,
|
||||
activeTenants,
|
||||
totalRoutes,
|
||||
totalClusters = await db.ServiceInstances.Where(i => !i.IsDeleted).GroupBy(i => i.ClusterId).CountAsync(),
|
||||
totalInstances,
|
||||
healthyInstances,
|
||||
lastUpdated = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DTOs
|
||||
|
||||
public class CreateTenantDto
|
||||
{
|
||||
public string TenantCode { get; set; } = string.Empty;
|
||||
public string TenantName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class UpdateTenantDto
|
||||
{
|
||||
public string? TenantName { get; set; }
|
||||
public int? Status { get; set; }
|
||||
}
|
||||
|
||||
public class CreateRouteDto
|
||||
{
|
||||
public string? TenantCode { get; set; }
|
||||
public string ServiceName { get; set; } = string.Empty;
|
||||
public string ClusterId { get; set; } = string.Empty;
|
||||
public string PathPattern { get; set; } = string.Empty;
|
||||
public int? Priority { get; set; }
|
||||
public bool? IsGlobal { get; set; }
|
||||
}
|
||||
|
||||
public class CreateClusterDto
|
||||
{
|
||||
public string ClusterId { get; set; } = string.Empty;
|
||||
public string ClusterName { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string? LoadBalancingPolicy { get; set; }
|
||||
}
|
||||
|
||||
public class CreateInstanceDto
|
||||
{
|
||||
public string DestinationId { get; set; } = string.Empty;
|
||||
public string Address { get; set; } = string.Empty;
|
||||
public int? Weight { get; set; }
|
||||
public bool? IsHealthy { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private long GenerateId()
|
||||
{
|
||||
return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
}
|
||||
}
|
||||
@ -1,211 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using YarpGateway.Data;
|
||||
using YarpGateway.Models;
|
||||
|
||||
namespace YarpGateway.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/gateway/pending-services")]
|
||||
[Authorize] // 要求所有管理 API 都需要认证
|
||||
public class PendingServicesController : ControllerBase
|
||||
{
|
||||
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
|
||||
private readonly ILogger<PendingServicesController> _logger;
|
||||
|
||||
public PendingServicesController(
|
||||
IDbContextFactory<GatewayDbContext> dbContextFactory,
|
||||
ILogger<PendingServicesController> logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetPendingServices(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 10,
|
||||
[FromQuery] int? status = null)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var query = db.PendingServiceDiscoveries.Where(p => !p.IsDeleted);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(p => p.Status == status.Value);
|
||||
}
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(p => p.DiscoveredAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(p => new
|
||||
{
|
||||
p.Id,
|
||||
p.K8sServiceName,
|
||||
p.K8sNamespace,
|
||||
p.K8sClusterIP,
|
||||
DiscoveredPorts = System.Text.Json.JsonSerializer.Deserialize<List<int>>(p.DiscoveredPorts) ?? new List<int>(),
|
||||
Labels = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(p.Labels) ?? new Dictionary<string, string>(),
|
||||
p.PodCount,
|
||||
Status = (PendingServiceStatus)p.Status,
|
||||
p.AssignedClusterId,
|
||||
p.AssignedBy,
|
||||
p.AssignedAt,
|
||||
p.DiscoveredAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { items, total, page, pageSize });
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> GetPendingService(long id)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
var service = await db.PendingServiceDiscoveries.FindAsync(id);
|
||||
|
||||
if (service == null || service.IsDeleted)
|
||||
{
|
||||
return NotFound(new { message = "Pending service not found" });
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
service.Id,
|
||||
service.K8sServiceName,
|
||||
service.K8sNamespace,
|
||||
service.K8sClusterIP,
|
||||
DiscoveredPorts = System.Text.Json.JsonSerializer.Deserialize<List<int>>(service.DiscoveredPorts) ?? new List<int>(),
|
||||
Labels = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(service.Labels) ?? new Dictionary<string, string>(),
|
||||
service.PodCount,
|
||||
Status = (PendingServiceStatus)service.Status,
|
||||
service.AssignedClusterId,
|
||||
service.AssignedBy,
|
||||
service.AssignedAt,
|
||||
service.DiscoveredAt
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("{id}/assign")]
|
||||
public async Task<IActionResult> AssignService(long id, [FromBody] AssignServiceRequest request)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var pendingService = await db.PendingServiceDiscoveries.FindAsync(id);
|
||||
if (pendingService == null || pendingService.IsDeleted)
|
||||
{
|
||||
return NotFound(new { message = "Pending service not found" });
|
||||
}
|
||||
|
||||
if (pendingService.Status != (int)PendingServiceStatus.Pending)
|
||||
{
|
||||
return BadRequest(new { message = $"Service is already {((PendingServiceStatus)pendingService.Status)}, cannot assign" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(request.ClusterId))
|
||||
{
|
||||
return BadRequest(new { message = "ClusterId is required" });
|
||||
}
|
||||
|
||||
var existingCluster = await db.ServiceInstances
|
||||
.AnyAsync(i => i.ClusterId == request.ClusterId && !i.IsDeleted);
|
||||
|
||||
if (!existingCluster)
|
||||
{
|
||||
return BadRequest(new { message = $"Cluster '{request.ClusterId}' does not exist. Please create the cluster first." });
|
||||
}
|
||||
|
||||
var discoveredPorts = System.Text.Json.JsonSerializer.Deserialize<List<int>>(pendingService.DiscoveredPorts) ?? new List<int>();
|
||||
var primaryPort = discoveredPorts.FirstOrDefault() > 0 ? discoveredPorts.First() : 80;
|
||||
|
||||
var instanceNumber = await db.ServiceInstances
|
||||
.CountAsync(i => i.ClusterId == request.ClusterId && !i.IsDeleted);
|
||||
|
||||
var newInstance = new GwServiceInstance
|
||||
{
|
||||
ClusterId = request.ClusterId,
|
||||
DestinationId = $"{pendingService.K8sServiceName}-{instanceNumber + 1}",
|
||||
Address = $"http://{pendingService.K8sClusterIP}:{primaryPort}",
|
||||
Health = 1,
|
||||
Weight = 100,
|
||||
Status = 1,
|
||||
CreatedTime = DateTime.UtcNow,
|
||||
Version = 1
|
||||
};
|
||||
|
||||
db.ServiceInstances.Add(newInstance);
|
||||
|
||||
pendingService.Status = (int)PendingServiceStatus.Approved;
|
||||
pendingService.AssignedClusterId = request.ClusterId;
|
||||
pendingService.AssignedBy = "admin";
|
||||
pendingService.AssignedAt = DateTime.UtcNow;
|
||||
pendingService.Version++;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Service {ServiceName} assigned to cluster {ClusterId} by admin",
|
||||
pendingService.K8sServiceName, request.ClusterId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
message = $"Service '{pendingService.K8sServiceName}' assigned to cluster '{request.ClusterId}'",
|
||||
instanceId = newInstance.Id
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("{id}/reject")]
|
||||
public async Task<IActionResult> RejectService(long id)
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var pendingService = await db.PendingServiceDiscoveries.FindAsync(id);
|
||||
if (pendingService == null || pendingService.IsDeleted)
|
||||
{
|
||||
return NotFound(new { message = "Pending service not found" });
|
||||
}
|
||||
|
||||
if (pendingService.Status != (int)PendingServiceStatus.Pending)
|
||||
{
|
||||
return BadRequest(new { message = $"Service is already {((PendingServiceStatus)pendingService.Status)}, cannot reject" });
|
||||
}
|
||||
|
||||
pendingService.Status = (int)PendingServiceStatus.Rejected;
|
||||
pendingService.AssignedBy = "admin";
|
||||
pendingService.AssignedAt = DateTime.UtcNow;
|
||||
pendingService.Version++;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Service {ServiceName} rejected by admin", pendingService.K8sServiceName);
|
||||
|
||||
return Ok(new { success = true, message = $"Service '{pendingService.K8sServiceName}' rejected" });
|
||||
}
|
||||
|
||||
[HttpGet("clusters")]
|
||||
public async Task<IActionResult> GetClusters()
|
||||
{
|
||||
await using var db = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var clusters = await db.ServiceInstances
|
||||
.Where(i => !i.IsDeleted)
|
||||
.GroupBy(i => i.ClusterId)
|
||||
.Select(g => new
|
||||
{
|
||||
ClusterId = g.Key,
|
||||
InstanceCount = g.Count(),
|
||||
HealthyCount = g.Count(i => i.Health == 1)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(clusters);
|
||||
}
|
||||
}
|
||||
|
||||
public class AssignServiceRequest
|
||||
{
|
||||
public string ClusterId { get; set; } = string.Empty;
|
||||
}
|
||||
@ -1,141 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using YarpGateway.Config;
|
||||
using YarpGateway.Models;
|
||||
|
||||
namespace YarpGateway.Data;
|
||||
|
||||
public class GatewayDbContext : DbContext
|
||||
{
|
||||
public GatewayDbContext(DbContextOptions<GatewayDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<GwTenant> Tenants => Set<GwTenant>();
|
||||
public DbSet<GwTenantRoute> TenantRoutes => Set<GwTenantRoute>();
|
||||
public DbSet<GwServiceInstance> ServiceInstances => Set<GwServiceInstance>();
|
||||
public DbSet<GwPendingServiceDiscovery> PendingServiceDiscoveries => Set<GwPendingServiceDiscovery>();
|
||||
|
||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||
{
|
||||
DetectConfigChanges();
|
||||
var result = base.SaveChanges(acceptAllChangesOnSuccess);
|
||||
if (_configChangeDetected)
|
||||
{
|
||||
NotifyConfigChangedSync();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
|
||||
{
|
||||
DetectConfigChanges();
|
||||
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
||||
if (_configChangeDetected)
|
||||
{
|
||||
await NotifyConfigChangedAsync(cancellationToken);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private bool _configChangeDetected;
|
||||
|
||||
private void DetectConfigChanges()
|
||||
{
|
||||
var entries = ChangeTracker.Entries()
|
||||
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
|
||||
.Where(e => e.Entity is GwTenantRoute or GwServiceInstance or GwTenant);
|
||||
|
||||
_configChangeDetected = entries.Any();
|
||||
}
|
||||
|
||||
private bool IsRelationalDatabase()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Database.IsRelational();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyConfigChangedSync()
|
||||
{
|
||||
if (!IsRelationalDatabase()) return;
|
||||
|
||||
var connectionString = Database.GetConnectionString();
|
||||
if (string.IsNullOrEmpty(connectionString)) return;
|
||||
|
||||
using var connection = new NpgsqlConnection(connectionString);
|
||||
connection.Open();
|
||||
using var cmd = new NpgsqlCommand($"NOTIFY {ConfigNotifyChannel.GatewayConfigChanged}", connection);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private async Task NotifyConfigChangedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsRelationalDatabase()) return;
|
||||
|
||||
var connectionString = Database.GetConnectionString();
|
||||
if (string.IsNullOrEmpty(connectionString)) return;
|
||||
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand($"NOTIFY {ConfigNotifyChannel.GatewayConfigChanged}", connection);
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<GwTenant>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.TenantCode).HasMaxLength(50).IsRequired();
|
||||
entity.Property(e => e.TenantName).HasMaxLength(100).IsRequired();
|
||||
entity.HasIndex(e => e.TenantCode).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<GwTenantRoute>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.TenantCode).HasMaxLength(50);
|
||||
entity.Property(e => e.ServiceName).HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.ClusterId).HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.PathPattern).HasMaxLength(200).IsRequired();
|
||||
entity.HasIndex(e => e.TenantCode);
|
||||
entity.HasIndex(e => e.ServiceName);
|
||||
entity.HasIndex(e => e.ClusterId);
|
||||
entity.HasIndex(e => new { e.ServiceName, e.IsGlobal, e.Status });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<GwServiceInstance>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.ClusterId).HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.DestinationId).HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.Address).HasMaxLength(200).IsRequired();
|
||||
entity.HasIndex(e => new { e.ClusterId, e.DestinationId }).IsUnique();
|
||||
entity.HasIndex(e => e.Health);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<GwPendingServiceDiscovery>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.K8sServiceName).HasMaxLength(255).IsRequired();
|
||||
entity.Property(e => e.K8sNamespace).HasMaxLength(255).IsRequired();
|
||||
entity.Property(e => e.K8sClusterIP).HasMaxLength(50);
|
||||
entity.Property(e => e.DiscoveredPorts).HasMaxLength(500);
|
||||
entity.Property(e => e.Labels).HasMaxLength(2000);
|
||||
entity.Property(e => e.AssignedClusterId).HasMaxLength(100);
|
||||
entity.Property(e => e.AssignedBy).HasMaxLength(100);
|
||||
entity.HasIndex(e => new { e.K8sServiceName, e.K8sNamespace, e.IsDeleted }).IsUnique();
|
||||
entity.HasIndex(e => e.Status);
|
||||
entity.HasIndex(e => e.DiscoveredAt);
|
||||
});
|
||||
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@ -16,7 +16,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Yarp.ReverseProxy" />
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
namespace YarpGateway.Models;
|
||||
|
||||
public class GwPendingServiceDiscovery
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string K8sServiceName { get; set; } = string.Empty;
|
||||
public string K8sNamespace { get; set; } = string.Empty;
|
||||
public string? K8sClusterIP { get; set; }
|
||||
public string DiscoveredPorts { get; set; } = "[]";
|
||||
public string Labels { get; set; } = "{}";
|
||||
public int PodCount { get; set; } = 0;
|
||||
public int Status { get; set; } = 0;
|
||||
public string? AssignedClusterId { get; set; }
|
||||
public string? AssignedBy { get; set; }
|
||||
public DateTime? AssignedAt { get; set; }
|
||||
public DateTime DiscoveredAt { get; set; } = DateTime.UtcNow;
|
||||
public bool IsDeleted { get; set; } = false;
|
||||
public int Version { get; set; } = 0;
|
||||
}
|
||||
|
||||
public enum PendingServiceStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Approved = 1,
|
||||
Rejected = 2,
|
||||
K8sServiceNotFound = 3
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
namespace YarpGateway.Models;
|
||||
|
||||
public class GwServiceInstance
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string ClusterId { get; set; } = string.Empty;
|
||||
public string DestinationId { get; set; } = string.Empty;
|
||||
public string Address { get; set; } = string.Empty;
|
||||
public int Health { get; set; } = 1;
|
||||
public int Weight { get; set; } = 1;
|
||||
public int Status { get; set; } = 1;
|
||||
public long? CreatedBy { get; set; }
|
||||
public DateTime CreatedTime { get; set; } = DateTime.UtcNow;
|
||||
public long? UpdatedBy { get; set; }
|
||||
public DateTime? UpdatedTime { get; set; }
|
||||
public bool IsDeleted { get; set; } = false;
|
||||
public int Version { get; set; } = 0;
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
namespace YarpGateway.Models;
|
||||
|
||||
public class GwTenant
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string TenantCode { get; set; } = string.Empty;
|
||||
public string TenantName { get; set; } = string.Empty;
|
||||
public int Status { get; set; } = 1;
|
||||
public long? CreatedBy { get; set; }
|
||||
public DateTime CreatedTime { get; set; } = DateTime.UtcNow;
|
||||
public long? UpdatedBy { get; set; }
|
||||
public DateTime? UpdatedTime { get; set; }
|
||||
public bool IsDeleted { get; set; } = false;
|
||||
public int Version { get; set; } = 0;
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
namespace YarpGateway.Models;
|
||||
|
||||
public class GwTenantRoute
|
||||
{
|
||||
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 int Priority { get; set; } = 0;
|
||||
public int Status { get; set; } = 1;
|
||||
public bool IsGlobal { get; set; } = false;
|
||||
public long? CreatedBy { get; set; }
|
||||
public DateTime CreatedTime { get; set; } = DateTime.UtcNow;
|
||||
public long? UpdatedBy { get; set; }
|
||||
public DateTime? UpdatedTime { get; set; }
|
||||
public bool IsDeleted { get; set; } = false;
|
||||
public int Version { get; set; } = 0;
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="gitea" value="https://gitea.shtao1.cn/api/packages/fengling/nuget/index.json" />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
@ -1,161 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using YarpGateway.Data;
|
||||
using YarpGateway.Models;
|
||||
|
||||
namespace YarpGateway.Services;
|
||||
|
||||
public class KubernetesPendingSyncService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<KubernetesPendingSyncService> _logger;
|
||||
private readonly TimeSpan _syncInterval = TimeSpan.FromSeconds(30);
|
||||
private readonly TimeSpan _staleThreshold = TimeSpan.FromHours(24);
|
||||
|
||||
public KubernetesPendingSyncService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<KubernetesPendingSyncService> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Starting K8s pending service sync background task");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SyncPendingServicesAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during K8s pending service sync");
|
||||
}
|
||||
|
||||
await Task.Delay(_syncInterval, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SyncPendingServicesAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var providers = scope.ServiceProvider.GetServices<Fengling.ServiceDiscovery.IServiceDiscoveryProvider>();
|
||||
var k8sProvider = providers.FirstOrDefault(p => p.ProviderName == "Kubernetes");
|
||||
|
||||
if (k8sProvider == null)
|
||||
{
|
||||
_logger.LogWarning("No Kubernetes service discovery provider found");
|
||||
return;
|
||||
}
|
||||
|
||||
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<GatewayDbContext>>();
|
||||
|
||||
var discoveredServices = await k8sProvider.GetServicesAsync(ct);
|
||||
|
||||
await using var db = await dbContextFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var existingPending = await db.PendingServiceDiscoveries
|
||||
.Where(p => !p.IsDeleted && p.Status == (int)PendingServiceStatus.Pending)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var existingDict = existingPending
|
||||
.ToDictionary(p => $"{p.K8sServiceName}|{p.K8sNamespace}");
|
||||
|
||||
var discoveredSet = discoveredServices
|
||||
.Select(s => $"{s.Name}|{s.Namespace}")
|
||||
.ToHashSet();
|
||||
|
||||
var addedCount = 0;
|
||||
var updatedCount = 0;
|
||||
var cleanedCount = 0;
|
||||
|
||||
foreach (var item in existingDict)
|
||||
{
|
||||
var key = item.Key;
|
||||
|
||||
if (!discoveredSet.Contains(key))
|
||||
{
|
||||
var pending = item.Value;
|
||||
|
||||
if (DateTime.UtcNow - pending.DiscoveredAt > _staleThreshold)
|
||||
{
|
||||
pending.IsDeleted = true;
|
||||
pending.Version++;
|
||||
cleanedCount++;
|
||||
_logger.LogInformation("Cleaned up stale pending service {ServiceName} in namespace {Namespace}",
|
||||
pending.K8sServiceName, pending.K8sNamespace);
|
||||
}
|
||||
else
|
||||
{
|
||||
pending.Status = (int)PendingServiceStatus.K8sServiceNotFound;
|
||||
pending.Version++;
|
||||
_logger.LogInformation("Pending service {ServiceName} in namespace {Namespace} not found in K8s, marked as not found",
|
||||
pending.K8sServiceName, pending.K8sNamespace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (discoveredServices.Count > 0)
|
||||
{
|
||||
var discoveredDict = discoveredServices.ToDictionary(
|
||||
s => $"{s.Name}|{s.Namespace}",
|
||||
s => s);
|
||||
|
||||
foreach (var item in discoveredDict)
|
||||
{
|
||||
var key = item.Key;
|
||||
var service = item.Value;
|
||||
|
||||
if (existingDict.TryGetValue(key, out var existing))
|
||||
{
|
||||
if (existing.Status == (int)PendingServiceStatus.K8sServiceNotFound)
|
||||
{
|
||||
existing.Status = (int)PendingServiceStatus.Pending;
|
||||
existing.Version++;
|
||||
updatedCount++;
|
||||
}
|
||||
|
||||
var portsJson = JsonSerializer.Serialize(service.Ports);
|
||||
var labelsJson = JsonSerializer.Serialize(service.Labels);
|
||||
|
||||
if (existing.DiscoveredPorts != portsJson || existing.Labels != labelsJson)
|
||||
{
|
||||
existing.DiscoveredPorts = portsJson;
|
||||
existing.Labels = labelsJson;
|
||||
existing.K8sClusterIP = service.ClusterIP;
|
||||
existing.PodCount = service.Ports.Count;
|
||||
existing.Version++;
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var newPending = new GwPendingServiceDiscovery
|
||||
{
|
||||
K8sServiceName = service.Name,
|
||||
K8sNamespace = service.Namespace,
|
||||
K8sClusterIP = service.ClusterIP,
|
||||
DiscoveredPorts = JsonSerializer.Serialize(service.Ports),
|
||||
Labels = JsonSerializer.Serialize(service.Labels),
|
||||
PodCount = service.Ports.Count,
|
||||
Status = (int)PendingServiceStatus.Pending,
|
||||
DiscoveredAt = DateTime.UtcNow,
|
||||
Version = 1
|
||||
};
|
||||
db.PendingServiceDiscoveries.Add(newPending);
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (addedCount > 0 || updatedCount > 0 || cleanedCount > 0)
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
_logger.LogInformation("K8s sync completed: {Added} new, {Updated} updated, {Cleaned} cleaned",
|
||||
addedCount, updatedCount, cleanedCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@ using Yarp.ReverseProxy.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Collections.Concurrent;
|
||||
using YarpGateway.Data;
|
||||
using YarpGateway.Models;
|
||||
using Fengling.Platform.Domain.AggregatesModel.GatewayAggregate;
|
||||
|
||||
namespace YarpGateway.Config;
|
||||
|
||||
@ -47,45 +47,35 @@ public class DatabaseClusterConfigProvider
|
||||
{
|
||||
await using var dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var instances = await dbContext.ServiceInstances
|
||||
.Where(i => i.Status == 1 && !i.IsDeleted)
|
||||
.GroupBy(i => i.ClusterId)
|
||||
var clusters = await dbContext.GwClusters
|
||||
.Where(c => c.Status == 1 && !c.IsDeleted)
|
||||
.Include(c => c.Destinations)
|
||||
.ToListAsync();
|
||||
|
||||
var newClusters = new ConcurrentDictionary<string, ClusterConfig>();
|
||||
|
||||
foreach (var group in instances)
|
||||
foreach (var cluster in clusters)
|
||||
{
|
||||
var destinations = new Dictionary<string, DestinationConfig>();
|
||||
foreach (var instance in group)
|
||||
foreach (var dest in cluster.Destinations.Where(d => d.Status == 1))
|
||||
{
|
||||
destinations[instance.DestinationId] = new DestinationConfig
|
||||
destinations[dest.DestinationId] = new DestinationConfig
|
||||
{
|
||||
Address = instance.Address,
|
||||
Address = dest.Address,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["Weight"] = instance.Weight.ToString()
|
||||
["Weight"] = dest.Weight.ToString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var config = new ClusterConfig
|
||||
{
|
||||
ClusterId = group.Key,
|
||||
ClusterId = cluster.ClusterId,
|
||||
Destinations = destinations,
|
||||
LoadBalancingPolicy = "DistributedWeightedRoundRobin",
|
||||
HealthCheck = new HealthCheckConfig
|
||||
{
|
||||
Active = new ActiveHealthCheckConfig
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromSeconds(30),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Path = "/health"
|
||||
}
|
||||
}
|
||||
LoadBalancingPolicy = cluster.LoadBalancingPolicy.ToString(),
|
||||
};
|
||||
newClusters[group.Key] = config;
|
||||
newClusters[cluster.ClusterId] = config;
|
||||
}
|
||||
|
||||
_clusters.Clear();
|
||||
@ -2,7 +2,7 @@ using System.Collections.Concurrent;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
using YarpGateway.Data;
|
||||
using YarpGateway.Models;
|
||||
using Fengling.Platform.Domain.AggregatesModel.GatewayAggregate;
|
||||
|
||||
namespace YarpGateway.Config;
|
||||
|
||||
@ -51,7 +51,7 @@ public class DatabaseRouteConfigProvider
|
||||
await using var dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var routes = await dbContext
|
||||
.TenantRoutes.Where(r => r.Status == 1 && !r.IsDeleted)
|
||||
.GwTenantRoutes.Where(r => r.Status == 1 && !r.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
var newRoutes = new ConcurrentDictionary<string, RouteConfig>();
|
||||
@ -62,7 +62,7 @@ public class DatabaseRouteConfigProvider
|
||||
{
|
||||
RouteId = route.Id.ToString(),
|
||||
ClusterId = route.ClusterId,
|
||||
Match = new RouteMatch { Path = route.PathPattern },
|
||||
Match = new RouteMatch { Path = route.Match?.Path ?? string.Empty },
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["TenantCode"] = route.TenantCode,
|
||||
91
src/yarpgateway/Data/GatewayDbContext.cs
Normal file
91
src/yarpgateway/Data/GatewayDbContext.cs
Normal file
@ -0,0 +1,91 @@
|
||||
using Fengling.Platform.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using YarpGateway.Config;
|
||||
using Fengling.Platform.Domain.AggregatesModel.GatewayAggregate;
|
||||
using Fengling.Platform.Domain.AggregatesModel.TenantAggregate;
|
||||
|
||||
namespace YarpGateway.Data;
|
||||
|
||||
public class GatewayDbContext : PlatformDbContext
|
||||
{
|
||||
// DbSet 别名,兼容旧代码
|
||||
public DbSet<GwTenantRoute> TenantRoutes => GwTenantRoutes;
|
||||
public DbSet<GwCluster> ServiceInstances => GwClusters;
|
||||
|
||||
public GatewayDbContext(DbContextOptions<GatewayDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||
{
|
||||
DetectConfigChanges();
|
||||
var result = base.SaveChanges(acceptAllChangesOnSuccess);
|
||||
if (_configChangeDetected)
|
||||
{
|
||||
NotifyConfigChangedSync();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
|
||||
{
|
||||
DetectConfigChanges();
|
||||
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
||||
if (_configChangeDetected)
|
||||
{
|
||||
await NotifyConfigChangedAsync(cancellationToken);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private bool _configChangeDetected;
|
||||
|
||||
private void DetectConfigChanges()
|
||||
{
|
||||
var entries = ChangeTracker.Entries()
|
||||
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
|
||||
.Where(e => e.Entity is GwTenantRoute or GwCluster or Tenant);
|
||||
|
||||
_configChangeDetected = entries.Any();
|
||||
}
|
||||
|
||||
private bool IsRelationalDatabase()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Database.IsRelational();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyConfigChangedSync()
|
||||
{
|
||||
if (!IsRelationalDatabase()) return;
|
||||
|
||||
var connectionString = Database.GetConnectionString();
|
||||
if (string.IsNullOrEmpty(connectionString)) return;
|
||||
|
||||
using var connection = new NpgsqlConnection(connectionString);
|
||||
connection.Open();
|
||||
using var cmd = new NpgsqlCommand($"NOTIFY {ConfigNotifyChannel.GatewayConfigChanged}", connection);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private async Task NotifyConfigChangedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsRelationalDatabase()) return;
|
||||
|
||||
var connectionString = Database.GetConnectionString();
|
||||
if (string.IsNullOrEmpty(connectionString)) return;
|
||||
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand($"NOTIFY {ConfigNotifyChannel.GatewayConfigChanged}", connection);
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
23
src/yarpgateway/Directory.Build.props
Normal file
23
src/yarpgateway/Directory.Build.props
Normal file
@ -0,0 +1,23 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageSourceMapping Include="nuget.org">
|
||||
<Pattern>Microsoft.*</Pattern>
|
||||
<Pattern>Serilog.*</Pattern>
|
||||
<Pattern>Npgsql.*</Pattern>
|
||||
<Pattern>StackExchange.Redis</Pattern>
|
||||
<Pattern>Yarp.*</Pattern>
|
||||
<Pattern>xunit</Pattern>
|
||||
<Pattern>Moq</Pattern>
|
||||
<Pattern>FluentAssertions</Pattern>
|
||||
<Pattern>Microsoft.NET.Test.Sdk</Pattern>
|
||||
</PackageSourceMapping>
|
||||
<PackageSourceMapping Include="gitea">
|
||||
<Pattern>Fengling.*</Pattern>
|
||||
</PackageSourceMapping>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@ -4,29 +4,19 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Fengling ServiceDiscovery Packages (from Gitea) -->
|
||||
<PackageVersion Include="Fengling.ServiceDiscovery.Core" Version="1.0.0" />
|
||||
<PackageVersion Include="Fengling.ServiceDiscovery.Kubernetes" Version="1.0.0" />
|
||||
<PackageVersion Include="Fengling.ServiceDiscovery.Static" Version="1.0.0" />
|
||||
|
||||
<!-- Microsoft Packages (aligned with fengling-console) -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
|
||||
<PackageVersion Include="Fengling.Platform.Infrastructure" Version="1.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
|
||||
|
||||
<!-- Database -->
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
|
||||
<!-- Serilog -->
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
|
||||
<!-- Others -->
|
||||
<PackageVersion Include="StackExchange.Redis" Version="2.8.31" />
|
||||
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,8 +11,6 @@ using YarpGateway.LoadBalancing;
|
||||
using YarpGateway.Middleware;
|
||||
using YarpGateway.Services;
|
||||
using StackExchange.Redis;
|
||||
using Fengling.ServiceDiscovery.Extensions;
|
||||
using Fengling.ServiceDiscovery.Kubernetes.Extensions;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@ -105,19 +103,7 @@ builder.Services.AddSingleton<IProxyConfigProvider>(sp => sp.GetRequiredService<
|
||||
|
||||
builder.Services.AddHostedService<PgSqlConfigChangeListener>();
|
||||
|
||||
// 添加 Kubernetes 服务发现
|
||||
var useInClusterConfig = builder.Configuration.GetValue<bool>("ServiceDiscovery:UseInClusterConfig", true);
|
||||
builder.Services.AddKubernetesServiceDiscovery(options =>
|
||||
{
|
||||
options.LabelSelector = "app.kubernetes.io/managed-by=yarp";
|
||||
options.UseInClusterConfig = useInClusterConfig;
|
||||
});
|
||||
|
||||
builder.Services.AddServiceDiscovery();
|
||||
|
||||
builder.Services.AddHostedService<KubernetesPendingSyncService>();
|
||||
|
||||
// CORS 配置 - 修复 AllowAnyOrigin 与 AllowCredentials 不兼容问题
|
||||
// CORS 配置
|
||||
var corsSettings = builder.Configuration.GetSection("Cors");
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
@ -181,4 +167,4 @@ catch (Exception ex)
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
}
|
||||
@ -142,12 +142,12 @@ public class PgSqlConfigChangeListener : BackgroundService
|
||||
await using var scope = _serviceProvider.CreateAsyncScope();
|
||||
await using var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
|
||||
var currentRouteVersion = await db.TenantRoutes
|
||||
var currentRouteVersion = await db.GwTenantRoutes
|
||||
.OrderByDescending(r => r.Version)
|
||||
.Select(r => r.Version)
|
||||
.FirstOrDefaultAsync(stoppingToken);
|
||||
|
||||
var currentClusterVersion = await db.ServiceInstances
|
||||
var currentClusterVersion = await db.GwClusters
|
||||
.OrderByDescending(i => i.Version)
|
||||
.Select(i => i.Version)
|
||||
.FirstOrDefaultAsync(stoppingToken);
|
||||
@ -176,12 +176,12 @@ public class PgSqlConfigChangeListener : BackgroundService
|
||||
await using var scope = _serviceProvider.CreateAsyncScope();
|
||||
await using var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
|
||||
_lastRouteVersion = await db.TenantRoutes
|
||||
_lastRouteVersion = await db.GwTenantRoutes
|
||||
.OrderByDescending(r => r.Version)
|
||||
.Select(r => r.Version)
|
||||
.FirstOrDefaultAsync(stoppingToken);
|
||||
|
||||
_lastClusterVersion = await db.ServiceInstances
|
||||
_lastClusterVersion = await db.GwClusters
|
||||
.OrderByDescending(i => i.Version)
|
||||
.Select(i => i.Version)
|
||||
.FirstOrDefaultAsync(stoppingToken);
|
||||
@ -1,5 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using YarpGateway.Models;
|
||||
using Fengling.Platform.Domain.AggregatesModel.GatewayAggregate;
|
||||
using YarpGateway.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@ -8,7 +8,7 @@ namespace YarpGateway.Services;
|
||||
|
||||
public class RouteInfo
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string ClusterId { get; set; } = string.Empty;
|
||||
public string PathPattern { get; set; } = string.Empty;
|
||||
public int Priority { get; set; }
|
||||
@ -95,7 +95,7 @@ public class RouteCache : IRouteCache
|
||||
{
|
||||
using var db = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var routes = await db.TenantRoutes
|
||||
var routes = await db.GwTenantRoutes
|
||||
.Where(r => r.Status == 1 && !r.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
@ -108,11 +108,13 @@ public class RouteCache : IRouteCache
|
||||
|
||||
foreach (var route in routes)
|
||||
{
|
||||
var pathPattern = route.Match?.Path ?? string.Empty;
|
||||
|
||||
var routeInfo = new RouteInfo
|
||||
{
|
||||
Id = route.Id,
|
||||
ClusterId = route.ClusterId,
|
||||
PathPattern = route.PathPattern,
|
||||
PathPattern = pathPattern,
|
||||
Priority = route.Priority,
|
||||
IsGlobal = route.IsGlobal
|
||||
};
|
||||
@ -120,13 +122,13 @@ public class RouteCache : IRouteCache
|
||||
if (route.IsGlobal)
|
||||
{
|
||||
_globalRoutes[route.ServiceName] = routeInfo;
|
||||
_pathRoutes[route.PathPattern] = routeInfo;
|
||||
_pathRoutes[pathPattern] = routeInfo;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(route.TenantCode))
|
||||
{
|
||||
_tenantRoutes.GetOrAdd(route.TenantCode, _ => new())
|
||||
[route.ServiceName] = routeInfo;
|
||||
_pathRoutes[route.PathPattern] = routeInfo;
|
||||
_pathRoutes[pathPattern] = routeInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Fengling.Platform.Infrastructure" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<IncludeAssets>runtime; build; native; contentfiles, analyzers; buildtransitive</IncludeAssets>
|
||||
@ -21,11 +22,10 @@
|
||||
<PackageReference Include="Yarp.ReverseProxy" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Fengling.ServiceDiscovery.Core" />
|
||||
<PackageReference Include="Fengling.ServiceDiscovery.Kubernetes" />
|
||||
<PackageReference Include="Fengling.ServiceDiscovery.Static" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Fengling.Gateway.Plugin.Abstractions\Fengling.Gateway.Plugin.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\.dockerignore">
|
||||
@ -4,4 +4,20 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageSourceMapping Include="nuget.org">
|
||||
<Pattern>Microsoft.*</Pattern>
|
||||
<Pattern>Serilog.*</Pattern>
|
||||
<Pattern>Npgsql.*</Pattern>
|
||||
<Pattern>StackExchange.Redis</Pattern>
|
||||
<Pattern>Yarp.*</Pattern>
|
||||
<Pattern>xunit</Pattern>
|
||||
<Pattern>Moq</Pattern>
|
||||
<Pattern>FluentAssertions</Pattern>
|
||||
<Pattern>Microsoft.NET.Test.Sdk</Pattern>
|
||||
</PackageSourceMapping>
|
||||
<PackageSourceMapping Include="gitea">
|
||||
<Pattern>Fengling.*</Pattern>
|
||||
</PackageSourceMapping>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@ -87,7 +87,7 @@ public class TenantRoutingMiddlewareTests
|
||||
// Arrange
|
||||
var routeInfo = new RouteInfo
|
||||
{
|
||||
Id = 1,
|
||||
Id = "1",
|
||||
ClusterId = "cluster-user-service",
|
||||
PathPattern = "/api/user-service/**",
|
||||
Priority = 1,
|
||||
@ -147,7 +147,7 @@ public class TenantRoutingMiddlewareTests
|
||||
// Arrange
|
||||
var routeInfo = new RouteInfo
|
||||
{
|
||||
Id = 1,
|
||||
Id = "1",
|
||||
ClusterId = "cluster-1",
|
||||
IsGlobal = false
|
||||
};
|
||||
@ -173,7 +173,7 @@ public class TenantRoutingMiddlewareTests
|
||||
// Arrange
|
||||
var routeInfo = new RouteInfo
|
||||
{
|
||||
Id = 1,
|
||||
Id = "1",
|
||||
ClusterId = "cluster-1",
|
||||
IsGlobal = false
|
||||
};
|
||||
@ -224,7 +224,7 @@ public class TenantRoutingMiddlewareTests
|
||||
// Arrange
|
||||
var routeInfo = new RouteInfo
|
||||
{
|
||||
Id = 1,
|
||||
Id = "1",
|
||||
ClusterId = "cluster-1",
|
||||
IsGlobal = false
|
||||
};
|
||||
@ -249,7 +249,7 @@ public class TenantRoutingMiddlewareTests
|
||||
// Arrange
|
||||
var routeInfo = new RouteInfo
|
||||
{
|
||||
Id = 1,
|
||||
Id = "1",
|
||||
ClusterId = "global-cluster",
|
||||
IsGlobal = true
|
||||
};
|
||||
@ -292,7 +292,7 @@ public class TenantRoutingMiddlewareTests
|
||||
|
||||
var tenantRoute = new RouteInfo
|
||||
{
|
||||
Id = 1,
|
||||
Id = "1",
|
||||
ClusterId = "tenant-specific-cluster",
|
||||
IsGlobal = false
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@ using Moq;
|
||||
using Xunit;
|
||||
using FluentAssertions;
|
||||
using YarpGateway.Data;
|
||||
using YarpGateway.Models;
|
||||
using Fengling.Platform.Domain.AggregatesModel.GatewayAggregate;
|
||||
using YarpGateway.Services;
|
||||
|
||||
namespace YarpGateway.Tests.Unit.Services;
|
||||
@ -53,11 +53,11 @@ public class RouteCacheTests
|
||||
{
|
||||
new GwTenantRoute
|
||||
{
|
||||
Id = 1,
|
||||
Id = Guid.CreateVersion7().ToString("N"),
|
||||
TenantCode = "",
|
||||
ServiceName = "user-service",
|
||||
ClusterId = "cluster-user",
|
||||
PathPattern = "/api/user/**",
|
||||
Match = new GwRouteMatch { Path = "/api/user/**" },
|
||||
Priority = 1,
|
||||
Status = 1,
|
||||
IsGlobal = true,
|
||||
@ -65,11 +65,11 @@ public class RouteCacheTests
|
||||
},
|
||||
new GwTenantRoute
|
||||
{
|
||||
Id = 2,
|
||||
Id = Guid.CreateVersion7().ToString("N"),
|
||||
TenantCode = "",
|
||||
ServiceName = "order-service",
|
||||
ClusterId = "cluster-order",
|
||||
PathPattern = "/api/order/**",
|
||||
Match = new GwRouteMatch { Path = "/api/order/**" },
|
||||
Priority = 1,
|
||||
Status = 1,
|
||||
IsGlobal = true,
|
||||
@ -97,11 +97,11 @@ public class RouteCacheTests
|
||||
{
|
||||
new GwTenantRoute
|
||||
{
|
||||
Id = 1,
|
||||
Id = Guid.CreateVersion7().ToString("N"),
|
||||
TenantCode = "tenant-1",
|
||||
ServiceName = "user-service",
|
||||
ClusterId = "cluster-tenant-user",
|
||||
PathPattern = "/api/user/**",
|
||||
Match = new GwRouteMatch { Path = "/api/user/**" },
|
||||
Priority = 1,
|
||||
Status = 1,
|
||||
IsGlobal = false,
|
||||
@ -129,11 +129,11 @@ public class RouteCacheTests
|
||||
{
|
||||
new GwTenantRoute
|
||||
{
|
||||
Id = 1,
|
||||
Id = Guid.CreateVersion7().ToString("N"),
|
||||
TenantCode = "tenant-1",
|
||||
ServiceName = "user-service",
|
||||
ClusterId = "tenant-cluster",
|
||||
PathPattern = "/api/user/**",
|
||||
Match = new GwRouteMatch { Path = "/api/user/**" },
|
||||
Priority = 1,
|
||||
Status = 1,
|
||||
IsGlobal = false,
|
||||
@ -141,11 +141,11 @@ public class RouteCacheTests
|
||||
},
|
||||
new GwTenantRoute
|
||||
{
|
||||
Id = 2,
|
||||
Id = Guid.CreateVersion7().ToString("N"),
|
||||
TenantCode = "",
|
||||
ServiceName = "user-service",
|
||||
ClusterId = "global-cluster",
|
||||
PathPattern = "/api/user/**",
|
||||
Match = new GwRouteMatch { Path = "/api/user/**" },
|
||||
Priority = 1,
|
||||
Status = 1,
|
||||
IsGlobal = true,
|
||||
@ -173,11 +173,11 @@ public class RouteCacheTests
|
||||
{
|
||||
new GwTenantRoute
|
||||
{
|
||||
Id = 1,
|
||||
Id = Guid.CreateVersion7().ToString("N"),
|
||||
TenantCode = "",
|
||||
ServiceName = "user-service",
|
||||
ClusterId = "global-cluster",
|
||||
PathPattern = "/api/user/**",
|
||||
Match = new GwRouteMatch { Path = "/api/user/**" },
|
||||
Priority = 1,
|
||||
Status = 1,
|
||||
IsGlobal = true,
|
||||
@ -222,11 +222,11 @@ public class RouteCacheTests
|
||||
{
|
||||
new GwTenantRoute
|
||||
{
|
||||
Id = 1,
|
||||
Id = Guid.CreateVersion7().ToString("N"),
|
||||
TenantCode = "",
|
||||
ServiceName = "user-service",
|
||||
ClusterId = "cluster-user",
|
||||
PathPattern = "/api/user/**",
|
||||
Match = new GwRouteMatch { Path = "/api/user/**" },
|
||||
Priority = 1,
|
||||
Status = 1,
|
||||
IsGlobal = true,
|
||||
@ -271,11 +271,11 @@ public class RouteCacheTests
|
||||
{
|
||||
new GwTenantRoute
|
||||
{
|
||||
Id = 1,
|
||||
Id = Guid.CreateVersion7().ToString("N"),
|
||||
TenantCode = "",
|
||||
ServiceName = "old-service",
|
||||
ClusterId = "old-cluster",
|
||||
PathPattern = "/api/old/**",
|
||||
Match = new GwRouteMatch { Path = "/api/old/**" },
|
||||
Priority = 1,
|
||||
Status = 1,
|
||||
IsGlobal = true,
|
||||
@ -294,11 +294,11 @@ public class RouteCacheTests
|
||||
context.TenantRoutes.RemoveRange(context.TenantRoutes);
|
||||
context.TenantRoutes.Add(new GwTenantRoute
|
||||
{
|
||||
Id = 2,
|
||||
Id = Guid.CreateVersion7().ToString("N"),
|
||||
TenantCode = "",
|
||||
ServiceName = "new-service",
|
||||
ClusterId = "new-cluster",
|
||||
PathPattern = "/api/new/**",
|
||||
Match = new GwRouteMatch { Path = "/api/new/**" },
|
||||
Priority = 1,
|
||||
Status = 1,
|
||||
IsGlobal = true,
|
||||
@ -322,11 +322,11 @@ public class RouteCacheTests
|
||||
{
|
||||
new GwTenantRoute
|
||||
{
|
||||
Id = 1,
|
||||
Id = Guid.CreateVersion7().ToString("N"),
|
||||
TenantCode = "",
|
||||
ServiceName = "active-service",
|
||||
ClusterId = "cluster-1",
|
||||
PathPattern = "/api/active/**",
|
||||
Match = new GwRouteMatch { Path = "/api/active/**" },
|
||||
Priority = 1,
|
||||
Status = 1,
|
||||
IsGlobal = true,
|
||||
@ -334,11 +334,11 @@ public class RouteCacheTests
|
||||
},
|
||||
new GwTenantRoute
|
||||
{
|
||||
Id = 2,
|
||||
Id = Guid.CreateVersion7().ToString("N"),
|
||||
TenantCode = "",
|
||||
ServiceName = "deleted-service",
|
||||
ClusterId = "cluster-2",
|
||||
PathPattern = "/api/deleted/**",
|
||||
Match = new GwRouteMatch { Path = "/api/deleted/**" },
|
||||
Priority = 1,
|
||||
Status = 1,
|
||||
IsGlobal = true,
|
||||
@ -363,11 +363,11 @@ public class RouteCacheTests
|
||||
{
|
||||
new GwTenantRoute
|
||||
{
|
||||
Id = 1,
|
||||
Id = Guid.CreateVersion7().ToString("N"),
|
||||
TenantCode = "",
|
||||
ServiceName = "active-service",
|
||||
ClusterId = "cluster-1",
|
||||
PathPattern = "/api/active/**",
|
||||
Match = new GwRouteMatch { Path = "/api/active/**" },
|
||||
Priority = 1,
|
||||
Status = 1,
|
||||
IsGlobal = true,
|
||||
@ -375,11 +375,11 @@ public class RouteCacheTests
|
||||
},
|
||||
new GwTenantRoute
|
||||
{
|
||||
Id = 2,
|
||||
Id = Guid.CreateVersion7().ToString("N"),
|
||||
TenantCode = "",
|
||||
ServiceName = "inactive-service",
|
||||
ClusterId = "cluster-2",
|
||||
PathPattern = "/api/inactive/**",
|
||||
Match = new GwRouteMatch { Path = "/api/inactive/**" },
|
||||
Priority = 1,
|
||||
Status = 0, // Inactive
|
||||
IsGlobal = true,
|
||||
@ -404,11 +404,11 @@ public class RouteCacheTests
|
||||
{
|
||||
new GwTenantRoute
|
||||
{
|
||||
Id = 1,
|
||||
Id = Guid.CreateVersion7().ToString("N"),
|
||||
TenantCode = "",
|
||||
ServiceName = "user-service",
|
||||
ClusterId = "cluster-user",
|
||||
PathPattern = "/api/user/**",
|
||||
Match = new GwRouteMatch { Path = "/api/user/**" },
|
||||
Priority = 1,
|
||||
Status = 1,
|
||||
IsGlobal = true,
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/YarpGateway.csproj" />
|
||||
<ProjectReference Include="../../src/yarpgateway/YarpGateway.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
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