Compare commits
No commits in common. "52eba0709742d296346dccda25d656e7c8cb0275" and "4839366227001b6a52a525b32aedb17c4f13be36" have entirely different histories.
52eba07097
...
4839366227
@ -1,88 +0,0 @@
|
|||||||
# 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 初始化后*
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
# 需求文档: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完成后*
|
|
||||||
@ -1,198 +0,0 @@
|
|||||||
#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*
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
#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)*
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"mode": "yolo",
|
|
||||||
"depth": "standard",
|
|
||||||
"parallelization": true,
|
|
||||||
"commit_docs": true,
|
|
||||||
"model_profile": "balanced",
|
|
||||||
"workflow": {
|
|
||||||
"research": false,
|
|
||||||
"plan_check": false,
|
|
||||||
"verifier": false,
|
|
||||||
"auto_advance": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,290 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
# 计划 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 管道
|
|
||||||
@ -1,366 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@ -1,284 +0,0 @@
|
|||||||
# 阶段 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*
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
# 阶段 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*
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
# 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 警告
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
# 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` - 新建包源映射配置
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
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
19
NuGet.Config
@ -1,19 +0,0 @@
|
|||||||
<?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,8 +4,10 @@
|
|||||||
<File Path="Dockerfile" />
|
<File Path="Dockerfile" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
<Project Path="src/yarpgateway/YarpGateway.csproj" />
|
<Project Path="src/YarpGateway.csproj" />
|
||||||
<Project Path="src/Fengling.Gateway.Plugin.Abstractions/Fengling.Gateway.Plugin.Abstractions.csproj" />
|
<Project Path="src/Fengling.Gateway.Plugin.Abstractions/Fengling.Gateway.Plugin.Abstractions.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Project Path="src/YarpGateway.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/YarpGateway.Tests/YarpGateway.Tests.csproj" />
|
<Project Path="tests/YarpGateway.Tests/YarpGateway.Tests.csproj" />
|
||||||
|
|||||||
@ -2,7 +2,7 @@ using Yarp.ReverseProxy.Configuration;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using YarpGateway.Data;
|
using YarpGateway.Data;
|
||||||
using Fengling.Platform.Domain.AggregatesModel.GatewayAggregate;
|
using YarpGateway.Models;
|
||||||
|
|
||||||
namespace YarpGateway.Config;
|
namespace YarpGateway.Config;
|
||||||
|
|
||||||
@ -47,35 +47,45 @@ public class DatabaseClusterConfigProvider
|
|||||||
{
|
{
|
||||||
await using var dbContext = _dbContextFactory.CreateDbContext();
|
await using var dbContext = _dbContextFactory.CreateDbContext();
|
||||||
|
|
||||||
var clusters = await dbContext.GwClusters
|
var instances = await dbContext.ServiceInstances
|
||||||
.Where(c => c.Status == 1 && !c.IsDeleted)
|
.Where(i => i.Status == 1 && !i.IsDeleted)
|
||||||
.Include(c => c.Destinations)
|
.GroupBy(i => i.ClusterId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var newClusters = new ConcurrentDictionary<string, ClusterConfig>();
|
var newClusters = new ConcurrentDictionary<string, ClusterConfig>();
|
||||||
|
|
||||||
foreach (var cluster in clusters)
|
foreach (var group in instances)
|
||||||
{
|
{
|
||||||
var destinations = new Dictionary<string, DestinationConfig>();
|
var destinations = new Dictionary<string, DestinationConfig>();
|
||||||
foreach (var dest in cluster.Destinations.Where(d => d.Status == 1))
|
foreach (var instance in group)
|
||||||
{
|
{
|
||||||
destinations[dest.DestinationId] = new DestinationConfig
|
destinations[instance.DestinationId] = new DestinationConfig
|
||||||
{
|
{
|
||||||
Address = dest.Address,
|
Address = instance.Address,
|
||||||
Metadata = new Dictionary<string, string>
|
Metadata = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["Weight"] = dest.Weight.ToString()
|
["Weight"] = instance.Weight.ToString()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
var config = new ClusterConfig
|
var config = new ClusterConfig
|
||||||
{
|
{
|
||||||
ClusterId = cluster.ClusterId,
|
ClusterId = group.Key,
|
||||||
Destinations = destinations,
|
Destinations = destinations,
|
||||||
LoadBalancingPolicy = cluster.LoadBalancingPolicy.ToString(),
|
LoadBalancingPolicy = "DistributedWeightedRoundRobin",
|
||||||
|
HealthCheck = new HealthCheckConfig
|
||||||
|
{
|
||||||
|
Active = new ActiveHealthCheckConfig
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Interval = TimeSpan.FromSeconds(30),
|
||||||
|
Timeout = TimeSpan.FromSeconds(5),
|
||||||
|
Path = "/health"
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
newClusters[cluster.ClusterId] = config;
|
newClusters[group.Key] = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
_clusters.Clear();
|
_clusters.Clear();
|
||||||
@ -2,7 +2,7 @@ using System.Collections.Concurrent;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Yarp.ReverseProxy.Configuration;
|
using Yarp.ReverseProxy.Configuration;
|
||||||
using YarpGateway.Data;
|
using YarpGateway.Data;
|
||||||
using Fengling.Platform.Domain.AggregatesModel.GatewayAggregate;
|
using YarpGateway.Models;
|
||||||
|
|
||||||
namespace YarpGateway.Config;
|
namespace YarpGateway.Config;
|
||||||
|
|
||||||
@ -51,7 +51,7 @@ public class DatabaseRouteConfigProvider
|
|||||||
await using var dbContext = _dbContextFactory.CreateDbContext();
|
await using var dbContext = _dbContextFactory.CreateDbContext();
|
||||||
|
|
||||||
var routes = await dbContext
|
var routes = await dbContext
|
||||||
.GwTenantRoutes.Where(r => r.Status == 1 && !r.IsDeleted)
|
.TenantRoutes.Where(r => r.Status == 1 && !r.IsDeleted)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var newRoutes = new ConcurrentDictionary<string, RouteConfig>();
|
var newRoutes = new ConcurrentDictionary<string, RouteConfig>();
|
||||||
@ -62,7 +62,7 @@ public class DatabaseRouteConfigProvider
|
|||||||
{
|
{
|
||||||
RouteId = route.Id.ToString(),
|
RouteId = route.Id.ToString(),
|
||||||
ClusterId = route.ClusterId,
|
ClusterId = route.ClusterId,
|
||||||
Match = new RouteMatch { Path = route.Match?.Path ?? string.Empty },
|
Match = new RouteMatch { Path = route.PathPattern },
|
||||||
Metadata = new Dictionary<string, string>
|
Metadata = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["TenantCode"] = route.TenantCode,
|
["TenantCode"] = route.TenantCode,
|
||||||
490
src/Controllers/GatewayConfigController.cs
Normal file
490
src/Controllers/GatewayConfigController.cs
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
211
src/Controllers/PendingServicesController.cs
Normal file
211
src/Controllers/PendingServicesController.cs
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
141
src/Data/GatewayDbContext.cs
Normal file
141
src/Data/GatewayDbContext.cs
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/Directory.Build.props
Normal file
7
src/Directory.Build.props
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@ -4,17 +4,27 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Fengling ServiceDiscovery Packages (from Gitea) -->
|
<!-- Fengling ServiceDiscovery Packages (from Gitea) -->
|
||||||
<PackageVersion Include="Fengling.Platform.Infrastructure" Version="1.0.12" />
|
<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="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="10.0.0" />
|
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="10.0.0" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
|
||||||
|
|
||||||
<!-- Database -->
|
<!-- Database -->
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||||
|
|
||||||
<!-- Serilog -->
|
<!-- Serilog -->
|
||||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
|
|
||||||
<!-- Others -->
|
<!-- Others -->
|
||||||
<PackageVersion Include="StackExchange.Redis" Version="2.8.31" />
|
<PackageVersion Include="StackExchange.Redis" Version="2.8.31" />
|
||||||
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
|
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||||
@ -16,7 +16,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.1.0" />
|
<PackageReference Include="Yarp.ReverseProxy" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
27
src/Models/GwPendingServiceDiscovery.cs
Normal file
27
src/Models/GwPendingServiceDiscovery.cs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
18
src/Models/GwServiceInstance.cs
Normal file
18
src/Models/GwServiceInstance.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
15
src/Models/GwTenant.cs
Normal file
15
src/Models/GwTenant.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
19
src/Models/GwTenantRoute.cs
Normal file
19
src/Models/GwTenantRoute.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
8
src/NuGet.Config
Normal file
8
src/NuGet.Config
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?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>
|
||||||
@ -11,6 +11,8 @@ using YarpGateway.LoadBalancing;
|
|||||||
using YarpGateway.Middleware;
|
using YarpGateway.Middleware;
|
||||||
using YarpGateway.Services;
|
using YarpGateway.Services;
|
||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
|
using Fengling.ServiceDiscovery.Extensions;
|
||||||
|
using Fengling.ServiceDiscovery.Kubernetes.Extensions;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@ -103,7 +105,19 @@ builder.Services.AddSingleton<IProxyConfigProvider>(sp => sp.GetRequiredService<
|
|||||||
|
|
||||||
builder.Services.AddHostedService<PgSqlConfigChangeListener>();
|
builder.Services.AddHostedService<PgSqlConfigChangeListener>();
|
||||||
|
|
||||||
// CORS 配置
|
// 添加 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 不兼容问题
|
||||||
var corsSettings = builder.Configuration.GetSection("Cors");
|
var corsSettings = builder.Configuration.GetSection("Cors");
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
161
src/Services/KubernetesPendingSyncService.cs
Normal file
161
src/Services/KubernetesPendingSyncService.cs
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -142,12 +142,12 @@ public class PgSqlConfigChangeListener : BackgroundService
|
|||||||
await using var scope = _serviceProvider.CreateAsyncScope();
|
await using var scope = _serviceProvider.CreateAsyncScope();
|
||||||
await using var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
await using var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||||
|
|
||||||
var currentRouteVersion = await db.GwTenantRoutes
|
var currentRouteVersion = await db.TenantRoutes
|
||||||
.OrderByDescending(r => r.Version)
|
.OrderByDescending(r => r.Version)
|
||||||
.Select(r => r.Version)
|
.Select(r => r.Version)
|
||||||
.FirstOrDefaultAsync(stoppingToken);
|
.FirstOrDefaultAsync(stoppingToken);
|
||||||
|
|
||||||
var currentClusterVersion = await db.GwClusters
|
var currentClusterVersion = await db.ServiceInstances
|
||||||
.OrderByDescending(i => i.Version)
|
.OrderByDescending(i => i.Version)
|
||||||
.Select(i => i.Version)
|
.Select(i => i.Version)
|
||||||
.FirstOrDefaultAsync(stoppingToken);
|
.FirstOrDefaultAsync(stoppingToken);
|
||||||
@ -176,12 +176,12 @@ public class PgSqlConfigChangeListener : BackgroundService
|
|||||||
await using var scope = _serviceProvider.CreateAsyncScope();
|
await using var scope = _serviceProvider.CreateAsyncScope();
|
||||||
await using var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
await using var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||||
|
|
||||||
_lastRouteVersion = await db.GwTenantRoutes
|
_lastRouteVersion = await db.TenantRoutes
|
||||||
.OrderByDescending(r => r.Version)
|
.OrderByDescending(r => r.Version)
|
||||||
.Select(r => r.Version)
|
.Select(r => r.Version)
|
||||||
.FirstOrDefaultAsync(stoppingToken);
|
.FirstOrDefaultAsync(stoppingToken);
|
||||||
|
|
||||||
_lastClusterVersion = await db.GwClusters
|
_lastClusterVersion = await db.ServiceInstances
|
||||||
.OrderByDescending(i => i.Version)
|
.OrderByDescending(i => i.Version)
|
||||||
.Select(i => i.Version)
|
.Select(i => i.Version)
|
||||||
.FirstOrDefaultAsync(stoppingToken);
|
.FirstOrDefaultAsync(stoppingToken);
|
||||||
@ -1,5 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using Fengling.Platform.Domain.AggregatesModel.GatewayAggregate;
|
using YarpGateway.Models;
|
||||||
using YarpGateway.Data;
|
using YarpGateway.Data;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@ -8,7 +8,7 @@ namespace YarpGateway.Services;
|
|||||||
|
|
||||||
public class RouteInfo
|
public class RouteInfo
|
||||||
{
|
{
|
||||||
public string Id { get; set; } = string.Empty;
|
public long Id { get; set; }
|
||||||
public string ClusterId { get; set; } = string.Empty;
|
public string ClusterId { get; set; } = string.Empty;
|
||||||
public string PathPattern { get; set; } = string.Empty;
|
public string PathPattern { get; set; } = string.Empty;
|
||||||
public int Priority { get; set; }
|
public int Priority { get; set; }
|
||||||
@ -95,7 +95,7 @@ public class RouteCache : IRouteCache
|
|||||||
{
|
{
|
||||||
using var db = _dbContextFactory.CreateDbContext();
|
using var db = _dbContextFactory.CreateDbContext();
|
||||||
|
|
||||||
var routes = await db.GwTenantRoutes
|
var routes = await db.TenantRoutes
|
||||||
.Where(r => r.Status == 1 && !r.IsDeleted)
|
.Where(r => r.Status == 1 && !r.IsDeleted)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@ -108,13 +108,11 @@ public class RouteCache : IRouteCache
|
|||||||
|
|
||||||
foreach (var route in routes)
|
foreach (var route in routes)
|
||||||
{
|
{
|
||||||
var pathPattern = route.Match?.Path ?? string.Empty;
|
|
||||||
|
|
||||||
var routeInfo = new RouteInfo
|
var routeInfo = new RouteInfo
|
||||||
{
|
{
|
||||||
Id = route.Id,
|
Id = route.Id,
|
||||||
ClusterId = route.ClusterId,
|
ClusterId = route.ClusterId,
|
||||||
PathPattern = pathPattern,
|
PathPattern = route.PathPattern,
|
||||||
Priority = route.Priority,
|
Priority = route.Priority,
|
||||||
IsGlobal = route.IsGlobal
|
IsGlobal = route.IsGlobal
|
||||||
};
|
};
|
||||||
@ -122,13 +120,13 @@ public class RouteCache : IRouteCache
|
|||||||
if (route.IsGlobal)
|
if (route.IsGlobal)
|
||||||
{
|
{
|
||||||
_globalRoutes[route.ServiceName] = routeInfo;
|
_globalRoutes[route.ServiceName] = routeInfo;
|
||||||
_pathRoutes[pathPattern] = routeInfo;
|
_pathRoutes[route.PathPattern] = routeInfo;
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrEmpty(route.TenantCode))
|
else if (!string.IsNullOrEmpty(route.TenantCode))
|
||||||
{
|
{
|
||||||
_tenantRoutes.GetOrAdd(route.TenantCode, _ => new())
|
_tenantRoutes.GetOrAdd(route.TenantCode, _ => new())
|
||||||
[route.ServiceName] = routeInfo;
|
[route.ServiceName] = routeInfo;
|
||||||
_pathRoutes[pathPattern] = routeInfo;
|
_pathRoutes[route.PathPattern] = routeInfo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -8,7 +8,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Fengling.Platform.Infrastructure" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles, analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles, analyzers; buildtransitive</IncludeAssets>
|
||||||
@ -22,9 +21,10 @@
|
|||||||
<PackageReference Include="Yarp.ReverseProxy" />
|
<PackageReference Include="Yarp.ReverseProxy" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Fengling.Gateway.Plugin.Abstractions\Fengling.Gateway.Plugin.Abstractions.csproj" />
|
<PackageReference Include="Fengling.ServiceDiscovery.Core" />
|
||||||
|
<PackageReference Include="Fengling.ServiceDiscovery.Kubernetes" />
|
||||||
|
<PackageReference Include="Fengling.ServiceDiscovery.Static" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@ -1,91 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,208 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,157 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,20 +4,4 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</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>
|
</Project>
|
||||||
|
|||||||
@ -87,7 +87,7 @@ public class TenantRoutingMiddlewareTests
|
|||||||
// Arrange
|
// Arrange
|
||||||
var routeInfo = new RouteInfo
|
var routeInfo = new RouteInfo
|
||||||
{
|
{
|
||||||
Id = "1",
|
Id = 1,
|
||||||
ClusterId = "cluster-user-service",
|
ClusterId = "cluster-user-service",
|
||||||
PathPattern = "/api/user-service/**",
|
PathPattern = "/api/user-service/**",
|
||||||
Priority = 1,
|
Priority = 1,
|
||||||
@ -147,7 +147,7 @@ public class TenantRoutingMiddlewareTests
|
|||||||
// Arrange
|
// Arrange
|
||||||
var routeInfo = new RouteInfo
|
var routeInfo = new RouteInfo
|
||||||
{
|
{
|
||||||
Id = "1",
|
Id = 1,
|
||||||
ClusterId = "cluster-1",
|
ClusterId = "cluster-1",
|
||||||
IsGlobal = false
|
IsGlobal = false
|
||||||
};
|
};
|
||||||
@ -173,7 +173,7 @@ public class TenantRoutingMiddlewareTests
|
|||||||
// Arrange
|
// Arrange
|
||||||
var routeInfo = new RouteInfo
|
var routeInfo = new RouteInfo
|
||||||
{
|
{
|
||||||
Id = "1",
|
Id = 1,
|
||||||
ClusterId = "cluster-1",
|
ClusterId = "cluster-1",
|
||||||
IsGlobal = false
|
IsGlobal = false
|
||||||
};
|
};
|
||||||
@ -224,7 +224,7 @@ public class TenantRoutingMiddlewareTests
|
|||||||
// Arrange
|
// Arrange
|
||||||
var routeInfo = new RouteInfo
|
var routeInfo = new RouteInfo
|
||||||
{
|
{
|
||||||
Id = "1",
|
Id = 1,
|
||||||
ClusterId = "cluster-1",
|
ClusterId = "cluster-1",
|
||||||
IsGlobal = false
|
IsGlobal = false
|
||||||
};
|
};
|
||||||
@ -249,7 +249,7 @@ public class TenantRoutingMiddlewareTests
|
|||||||
// Arrange
|
// Arrange
|
||||||
var routeInfo = new RouteInfo
|
var routeInfo = new RouteInfo
|
||||||
{
|
{
|
||||||
Id = "1",
|
Id = 1,
|
||||||
ClusterId = "global-cluster",
|
ClusterId = "global-cluster",
|
||||||
IsGlobal = true
|
IsGlobal = true
|
||||||
};
|
};
|
||||||
@ -292,7 +292,7 @@ public class TenantRoutingMiddlewareTests
|
|||||||
|
|
||||||
var tenantRoute = new RouteInfo
|
var tenantRoute = new RouteInfo
|
||||||
{
|
{
|
||||||
Id = "1",
|
Id = 1,
|
||||||
ClusterId = "tenant-specific-cluster",
|
ClusterId = "tenant-specific-cluster",
|
||||||
IsGlobal = false
|
IsGlobal = false
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,138 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
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 Xunit;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using YarpGateway.Data;
|
using YarpGateway.Data;
|
||||||
using Fengling.Platform.Domain.AggregatesModel.GatewayAggregate;
|
using YarpGateway.Models;
|
||||||
using YarpGateway.Services;
|
using YarpGateway.Services;
|
||||||
|
|
||||||
namespace YarpGateway.Tests.Unit.Services;
|
namespace YarpGateway.Tests.Unit.Services;
|
||||||
@ -53,11 +53,11 @@ public class RouteCacheTests
|
|||||||
{
|
{
|
||||||
new GwTenantRoute
|
new GwTenantRoute
|
||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7().ToString("N"),
|
Id = 1,
|
||||||
TenantCode = "",
|
TenantCode = "",
|
||||||
ServiceName = "user-service",
|
ServiceName = "user-service",
|
||||||
ClusterId = "cluster-user",
|
ClusterId = "cluster-user",
|
||||||
Match = new GwRouteMatch { Path = "/api/user/**" },
|
PathPattern = "/api/user/**",
|
||||||
Priority = 1,
|
Priority = 1,
|
||||||
Status = 1,
|
Status = 1,
|
||||||
IsGlobal = true,
|
IsGlobal = true,
|
||||||
@ -65,11 +65,11 @@ public class RouteCacheTests
|
|||||||
},
|
},
|
||||||
new GwTenantRoute
|
new GwTenantRoute
|
||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7().ToString("N"),
|
Id = 2,
|
||||||
TenantCode = "",
|
TenantCode = "",
|
||||||
ServiceName = "order-service",
|
ServiceName = "order-service",
|
||||||
ClusterId = "cluster-order",
|
ClusterId = "cluster-order",
|
||||||
Match = new GwRouteMatch { Path = "/api/order/**" },
|
PathPattern = "/api/order/**",
|
||||||
Priority = 1,
|
Priority = 1,
|
||||||
Status = 1,
|
Status = 1,
|
||||||
IsGlobal = true,
|
IsGlobal = true,
|
||||||
@ -97,11 +97,11 @@ public class RouteCacheTests
|
|||||||
{
|
{
|
||||||
new GwTenantRoute
|
new GwTenantRoute
|
||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7().ToString("N"),
|
Id = 1,
|
||||||
TenantCode = "tenant-1",
|
TenantCode = "tenant-1",
|
||||||
ServiceName = "user-service",
|
ServiceName = "user-service",
|
||||||
ClusterId = "cluster-tenant-user",
|
ClusterId = "cluster-tenant-user",
|
||||||
Match = new GwRouteMatch { Path = "/api/user/**" },
|
PathPattern = "/api/user/**",
|
||||||
Priority = 1,
|
Priority = 1,
|
||||||
Status = 1,
|
Status = 1,
|
||||||
IsGlobal = false,
|
IsGlobal = false,
|
||||||
@ -129,11 +129,11 @@ public class RouteCacheTests
|
|||||||
{
|
{
|
||||||
new GwTenantRoute
|
new GwTenantRoute
|
||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7().ToString("N"),
|
Id = 1,
|
||||||
TenantCode = "tenant-1",
|
TenantCode = "tenant-1",
|
||||||
ServiceName = "user-service",
|
ServiceName = "user-service",
|
||||||
ClusterId = "tenant-cluster",
|
ClusterId = "tenant-cluster",
|
||||||
Match = new GwRouteMatch { Path = "/api/user/**" },
|
PathPattern = "/api/user/**",
|
||||||
Priority = 1,
|
Priority = 1,
|
||||||
Status = 1,
|
Status = 1,
|
||||||
IsGlobal = false,
|
IsGlobal = false,
|
||||||
@ -141,11 +141,11 @@ public class RouteCacheTests
|
|||||||
},
|
},
|
||||||
new GwTenantRoute
|
new GwTenantRoute
|
||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7().ToString("N"),
|
Id = 2,
|
||||||
TenantCode = "",
|
TenantCode = "",
|
||||||
ServiceName = "user-service",
|
ServiceName = "user-service",
|
||||||
ClusterId = "global-cluster",
|
ClusterId = "global-cluster",
|
||||||
Match = new GwRouteMatch { Path = "/api/user/**" },
|
PathPattern = "/api/user/**",
|
||||||
Priority = 1,
|
Priority = 1,
|
||||||
Status = 1,
|
Status = 1,
|
||||||
IsGlobal = true,
|
IsGlobal = true,
|
||||||
@ -173,11 +173,11 @@ public class RouteCacheTests
|
|||||||
{
|
{
|
||||||
new GwTenantRoute
|
new GwTenantRoute
|
||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7().ToString("N"),
|
Id = 1,
|
||||||
TenantCode = "",
|
TenantCode = "",
|
||||||
ServiceName = "user-service",
|
ServiceName = "user-service",
|
||||||
ClusterId = "global-cluster",
|
ClusterId = "global-cluster",
|
||||||
Match = new GwRouteMatch { Path = "/api/user/**" },
|
PathPattern = "/api/user/**",
|
||||||
Priority = 1,
|
Priority = 1,
|
||||||
Status = 1,
|
Status = 1,
|
||||||
IsGlobal = true,
|
IsGlobal = true,
|
||||||
@ -222,11 +222,11 @@ public class RouteCacheTests
|
|||||||
{
|
{
|
||||||
new GwTenantRoute
|
new GwTenantRoute
|
||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7().ToString("N"),
|
Id = 1,
|
||||||
TenantCode = "",
|
TenantCode = "",
|
||||||
ServiceName = "user-service",
|
ServiceName = "user-service",
|
||||||
ClusterId = "cluster-user",
|
ClusterId = "cluster-user",
|
||||||
Match = new GwRouteMatch { Path = "/api/user/**" },
|
PathPattern = "/api/user/**",
|
||||||
Priority = 1,
|
Priority = 1,
|
||||||
Status = 1,
|
Status = 1,
|
||||||
IsGlobal = true,
|
IsGlobal = true,
|
||||||
@ -271,11 +271,11 @@ public class RouteCacheTests
|
|||||||
{
|
{
|
||||||
new GwTenantRoute
|
new GwTenantRoute
|
||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7().ToString("N"),
|
Id = 1,
|
||||||
TenantCode = "",
|
TenantCode = "",
|
||||||
ServiceName = "old-service",
|
ServiceName = "old-service",
|
||||||
ClusterId = "old-cluster",
|
ClusterId = "old-cluster",
|
||||||
Match = new GwRouteMatch { Path = "/api/old/**" },
|
PathPattern = "/api/old/**",
|
||||||
Priority = 1,
|
Priority = 1,
|
||||||
Status = 1,
|
Status = 1,
|
||||||
IsGlobal = true,
|
IsGlobal = true,
|
||||||
@ -294,11 +294,11 @@ public class RouteCacheTests
|
|||||||
context.TenantRoutes.RemoveRange(context.TenantRoutes);
|
context.TenantRoutes.RemoveRange(context.TenantRoutes);
|
||||||
context.TenantRoutes.Add(new GwTenantRoute
|
context.TenantRoutes.Add(new GwTenantRoute
|
||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7().ToString("N"),
|
Id = 2,
|
||||||
TenantCode = "",
|
TenantCode = "",
|
||||||
ServiceName = "new-service",
|
ServiceName = "new-service",
|
||||||
ClusterId = "new-cluster",
|
ClusterId = "new-cluster",
|
||||||
Match = new GwRouteMatch { Path = "/api/new/**" },
|
PathPattern = "/api/new/**",
|
||||||
Priority = 1,
|
Priority = 1,
|
||||||
Status = 1,
|
Status = 1,
|
||||||
IsGlobal = true,
|
IsGlobal = true,
|
||||||
@ -322,11 +322,11 @@ public class RouteCacheTests
|
|||||||
{
|
{
|
||||||
new GwTenantRoute
|
new GwTenantRoute
|
||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7().ToString("N"),
|
Id = 1,
|
||||||
TenantCode = "",
|
TenantCode = "",
|
||||||
ServiceName = "active-service",
|
ServiceName = "active-service",
|
||||||
ClusterId = "cluster-1",
|
ClusterId = "cluster-1",
|
||||||
Match = new GwRouteMatch { Path = "/api/active/**" },
|
PathPattern = "/api/active/**",
|
||||||
Priority = 1,
|
Priority = 1,
|
||||||
Status = 1,
|
Status = 1,
|
||||||
IsGlobal = true,
|
IsGlobal = true,
|
||||||
@ -334,11 +334,11 @@ public class RouteCacheTests
|
|||||||
},
|
},
|
||||||
new GwTenantRoute
|
new GwTenantRoute
|
||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7().ToString("N"),
|
Id = 2,
|
||||||
TenantCode = "",
|
TenantCode = "",
|
||||||
ServiceName = "deleted-service",
|
ServiceName = "deleted-service",
|
||||||
ClusterId = "cluster-2",
|
ClusterId = "cluster-2",
|
||||||
Match = new GwRouteMatch { Path = "/api/deleted/**" },
|
PathPattern = "/api/deleted/**",
|
||||||
Priority = 1,
|
Priority = 1,
|
||||||
Status = 1,
|
Status = 1,
|
||||||
IsGlobal = true,
|
IsGlobal = true,
|
||||||
@ -363,11 +363,11 @@ public class RouteCacheTests
|
|||||||
{
|
{
|
||||||
new GwTenantRoute
|
new GwTenantRoute
|
||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7().ToString("N"),
|
Id = 1,
|
||||||
TenantCode = "",
|
TenantCode = "",
|
||||||
ServiceName = "active-service",
|
ServiceName = "active-service",
|
||||||
ClusterId = "cluster-1",
|
ClusterId = "cluster-1",
|
||||||
Match = new GwRouteMatch { Path = "/api/active/**" },
|
PathPattern = "/api/active/**",
|
||||||
Priority = 1,
|
Priority = 1,
|
||||||
Status = 1,
|
Status = 1,
|
||||||
IsGlobal = true,
|
IsGlobal = true,
|
||||||
@ -375,11 +375,11 @@ public class RouteCacheTests
|
|||||||
},
|
},
|
||||||
new GwTenantRoute
|
new GwTenantRoute
|
||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7().ToString("N"),
|
Id = 2,
|
||||||
TenantCode = "",
|
TenantCode = "",
|
||||||
ServiceName = "inactive-service",
|
ServiceName = "inactive-service",
|
||||||
ClusterId = "cluster-2",
|
ClusterId = "cluster-2",
|
||||||
Match = new GwRouteMatch { Path = "/api/inactive/**" },
|
PathPattern = "/api/inactive/**",
|
||||||
Priority = 1,
|
Priority = 1,
|
||||||
Status = 0, // Inactive
|
Status = 0, // Inactive
|
||||||
IsGlobal = true,
|
IsGlobal = true,
|
||||||
@ -404,11 +404,11 @@ public class RouteCacheTests
|
|||||||
{
|
{
|
||||||
new GwTenantRoute
|
new GwTenantRoute
|
||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7().ToString("N"),
|
Id = 1,
|
||||||
TenantCode = "",
|
TenantCode = "",
|
||||||
ServiceName = "user-service",
|
ServiceName = "user-service",
|
||||||
ClusterId = "cluster-user",
|
ClusterId = "cluster-user",
|
||||||
Match = new GwRouteMatch { Path = "/api/user/**" },
|
PathPattern = "/api/user/**",
|
||||||
Priority = 1,
|
Priority = 1,
|
||||||
Status = 1,
|
Status = 1,
|
||||||
IsGlobal = true,
|
IsGlobal = true,
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../src/yarpgateway/YarpGateway.csproj" />
|
<ProjectReference Include="../../src/YarpGateway.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
@ -1,521 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,212 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
@ -1,318 +0,0 @@
|
|||||||
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
23
网关配置的新想法.md
@ -1,23 +0,0 @@
|
|||||||
#### 网关配置的新想法
|
|
||||||
|
|
||||||
路由/集群/目标 等配置还是通过数据库变更通知网关进行重新加载的方式触发变更
|
|
||||||
|
|
||||||
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