From bce6aaa33fe71d68d0a8c9927d48e8882bad1bd0 Mon Sep 17 00:00:00 2001 From: movingsam Date: Sun, 1 Mar 2026 16:38:10 +0800 Subject: [PATCH] =?UTF-8?q?chore(docker):=20=E4=BC=98=E5=8C=96=20Docker=20?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E7=BC=93=E5=AD=98=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 actions/cache 缓存 Docker 构建层,加快构建速度 - 统一缓存路径改为 /tmp/.buildx-cache 以简化管理 - 更新构建缓存的来源和目标目录配置 - 确保构建结束时正确移动缓存文件,避免缓存丢失 docs(gateway): 新增网关插件系统技术方案文档 - 详细描述插件系统的设计目标和核心功能 - 规划基于 Razor Pages 的 Web UI 管理界面 - 设计插件类型接口及生命周期管理方案 --- .gitea/workflows/docker.yml | 18 +- .planning/docs/gateway-plugin-system.md | 638 ++++++++++++++++++++++++ 2 files changed, 650 insertions(+), 6 deletions(-) create mode 100644 .planning/docs/gateway-plugin-system.md diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 8771701..1f133cf 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -16,8 +16,13 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- - name: Extract version id: version @@ -56,11 +61,12 @@ jobs: labels: ${{ steps.meta.outputs.labels }} build-args: | BUILD_VERSION=${{ steps.version.outputs.version }} - cache-from: type=local,src=/data/.buildx-cache - cache-to: type=local,dest=/data/.buildx-cache-new,mode=max + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max - name: Move cache + if: always() run: | - rm -rf /data/.buildx-cache - mv /data/.buildx-cache-new /data/.buildx-cache + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache diff --git a/.planning/docs/gateway-plugin-system.md b/.planning/docs/gateway-plugin-system.md new file mode 100644 index 0000000..b610b53 --- /dev/null +++ b/.planning/docs/gateway-plugin-system.md @@ -0,0 +1,638 @@ +# 网关插件系统技术方案 + +## 一、概述 + +本文档描述 YARP 网关的插件系统规划,包括 Web UI 管理界面和动态编译加载两大核心功能。 + +--- + +## 二、Web UI 管理界面 + +### 2.1 技术选型 + +| 项目 | 选择 | 理由 | +|------|------|------| +| 框架 | Razor Pages | 嵌入主应用,单项目部署 | +| 路由 | `/gateway/ui` | 参考 SwaggerUI 风格 | +| 编辑器 | Monaco Editor | VS Code 同款,体验一致 | + +### 2.2 功能模块 + +``` +/gateway/ui +├── 路由管理 (Routes) +│ ├── 列表/搜索 +│ ├── 创建/编辑/删除 +│ └── 路由规则配置 +├── 集群管理 (Clusters) +│ ├── 上下游服务列表 +│ ├── 实例管理 +│ └── 健康状态 +├── 插件管理 (Plugins) +│ ├── 已加载插件列表 +│ ├── 上传 DLL +│ └── 在线编写 C# 代码 +└── 监控统计 + ├── QPS/延迟 + └── 流量图表 +``` + +--- + +## 三、插件系统架构 + +### 3.1 插件类型定义 + +```csharp +namespace Fengling.Gateway.Plugin.Abstractions +{ + /// + /// 插件基础接口 + /// + public interface IGatewayPlugin + { + string Name { get; } + string Version { get; } + string? Description { get; } + + Task OnLoadAsync(); + Task OnUnloadAsync(); + } + + /// + /// 请求处理插件 + /// + public interface IRequestPlugin : IGatewayPlugin + { + /// 请求到达网关前 + Task OnRequestAsync(HttpContext context); + + /// 路由决策后 + Task OnRouteMatchedAsync(HttpContext context, RouteConfig route); + + /// 转发到后端前 + Task OnForwardingAsync(HttpContext context, HttpRequestMessage request); + } + + /// + /// 响应处理插件 + /// + public interface IResponsePlugin : IGatewayPlugin + { + /// 后端响应后 + Task OnBackendResponseAsync(HttpContext context, HttpResponseMessage response); + + /// 返回客户端前 + Task OnResponseFinalizingAsync(HttpContext context); + } + + /// + /// 路由转换插件 + /// + public interface IRouteTransformPlugin : IGatewayPlugin + { + Task TransformRouteAsync(RouteConfig original, HttpContext context); + } + + /// + /// 负载均衡插件 + /// + public interface ILoadBalancePlugin : IGatewayPlugin + { + Task SelectDestinationAsync( + IReadOnlyList destinations, + HttpContext context); + } +} +``` + +### 3.2 插件阶段枚举 + +```csharp +public enum PipelineStage +{ + None = 0, + OnRequest = 1, // 请求到达网关前 + OnRoute = 2, // 路由决策时 + OnRequestBackend = 3, // 转发到后端前 + OnResponseBackend = 4, // 后端响应后 + OnResponse = 5 // 返回给客户端前 +} +``` + +--- + +## 四、核心模块设计 + +### 4.1 依赖管理(A方案) + +#### 简单场景:直接使用网关已有程序集 + +```csharp +// API 暴露网关程序集 +[ApiController] +public class AssembliesController : ControllerBase +{ + [HttpGet("available")] + public List GetAvailableAssemblies() + { + return AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) + .Select(a => new AssemblyInfo + { + Name = a.GetName().Name, + Version = a.GetName().Version?.ToString(), + Location = a.Location + }) + .OrderBy(a => a.Name) + .ToList(); + } +} +``` + +#### 复杂场景:上传 ZIP 包 + +```csharp +public class PluginUploadService +{ + private readonly IObjectStorage _storage; + private readonly PluginDbContext _db; + private readonly string _localPluginPath = "/app/plugins"; + + public async Task UploadPluginAsync(IFormFile zipFile, string pluginName) + { + // 1. 上传到对象存储 + var storageKey = $"plugins/{Guid.NewGuid()}/{zipFile.FileName}"; + await _storage.UploadAsync(zipFile.OpenReadStream(), storageKey); + + // 2. 保存到数据库 + var plugin = new PluginPackage + { + Id = Guid.NewGuid(), + Name = pluginName, + StorageKey = storageKey, + UploadedAt = DateTime.UtcNow, + Status = PluginStatus.Pending + }; + + await _db.PluginPackages.AddAsync(plugin); + await _db.SaveChangesAsync(); + + return plugin; + } + + public async Task ExtractAndLoadAsync(Guid pluginId) + { + var plugin = await _db.PluginPackages.FindAsync(pluginId); + + // 3. 下载到本地 + var localDir = Path.Combine(_localPluginPath, plugin.Id.ToString()); + Directory.CreateDirectory(localDir); + + await _storage.DownloadAsync(plugin.StorageKey, localDir + ".zip"); + + // 4. 解压 + ZipFile.ExtractToDirectory(localDir + ".zip", localDir, overwriteFiles: true); + File.Delete(localDir + ".zip"); + + // 5. 加载插件 + await _pluginManager.LoadFromDirectoryAsync(localDir); + } +} +``` + +#### ZIP 上传验证 + +```csharp +public class PluginValidationService +{ + public async Task ValidateAsync(Stream zipStream) + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + await ExtractZipAsync(zipStream, tempDir); + + try + { + var dlls = Directory.GetFiles(tempDir, "*.dll"); + var result = new PluginValidationResult(); + + foreach (var dll in dlls) + { + var dllResult = await ValidateDllAsync(dll); + result.Assemblies.Add(dllResult); + } + + var validPlugins = result.Assemblies + .Where(a => a.IsValidPlugin) + .ToList(); + + if (validPlugins.Count == 0) + { + result.IsValid = false; + result.ErrorMessage = "未找到实现 IGatewayPlugin 接口的类"; + } + else + { + result.IsValid = true; + result.ValidPluginTypes = validPlugins + .SelectMany(a => a.PluginTypes) + .ToList(); + } + + return result; + } + finally + { + Directory.Delete(tempDir, true); + } + } + + private async Task ValidateDllAsync(string dllPath) + { + var result = new DllValidationResult { DllName = Path.GetFileName(dllPath) }; + + try + { + var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(dllPath); + + var pluginTypes = assembly.GetTypes() + .Where(t => typeof(IGatewayPlugin).IsAssignableFrom(t)) + .Where(t => !t.IsAbstract && !t.IsInterface) + .ToList(); + + if (pluginTypes.Count > 0) + { + result.IsValidPlugin = true; + result.PluginTypes = pluginTypes.Select(t => new PluginTypeInfo + { + TypeName = t.FullName, + ImplementedInterfaces = t.GetInterfaces().Select(i => i.Name).ToList() + }).ToList(); + } + } + catch (Exception ex) + { + result.IsValid = false; + result.ErrorMessage = ex.Message; + } + + return result; + } +} +``` + +--- + +### 4.2 插件间通信(B方案) + +采用**方案 3:强类型 + 弱类型混合** + +#### 插件上下文 + +```csharp +public class GatewayContext +{ + // 预定义常用字段(强类型) + public string? UserId { get; set; } + public string? TenantId { get; set; } + public UserTier Tier { get; set; } + public bool IsAuthenticated { get; set; } + public DateTime RequestTime { get; set; } + + // 扩展数据(弱类型) + public PluginDataBag Data { get; } = new(); +} + +public class PluginDataBag +{ + private readonly Dictionary _data = new(); + + public T? Get(string key) => _data.TryGetValue(key, out var v) ? (T)v : default; + public void Set(string key, T value) => _data[key] = value!; +} +``` + +#### 使用示例 + +```csharp +// 插件 A: 认证 +public class AuthPlugin : IRequestPlugin +{ + public async Task OnRequestAsync(HttpContext context) + { + var userId = ValidateToken(context); + + if (userId != null) + { + context.Items["CurrentUserId"] = userId; + context.Items["IsAuthenticated"] = true; + } + + return context; + } +} + +// 插件 B: 审计 +public class AuditPlugin : IRequestPlugin +{ + public async Task OnRequestAsync(HttpContext context) + { + if (context.Items.TryGetValue("CurrentUserId", out var userId)) + { + await _logger.LogAsync($"User {userId} accessed {context.Request.Path}"); + } + + return context; + } +} +``` + +--- + +### 4.3 在线代码编辑器(C方案) + +采用 **Monaco Editor + 前端模拟补全** + +> **补充说明**:如需更完整的 C# IntelliSense(如真实代码分析、跳转到定义),可使用 Microsoft 官方的 **roslyn-language-server**(VS Code C# 扩展背后使用的语言服务器)。 +> +> **部署方式**: +> ```bash +> # 安装 +> dotnet tool install -g Microsoft.CodeAnalysis.LanguageServer +> +> # 启动服务 +> roslyn-languageserver --port 5000 +> ``` +> +> 前端通过 WebSocket 连接该服务获取完整的语言特性支持。但目前阶段前端模拟补全已足够使用。 + + + +```html + + +
+ + +``` + +#### 编辑器模板 + +```csharp +// 生成的代码模板 +$@" +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Fengling.Gateway.Plugin.Abstractions; + +public class {pluginName} : IRequestPlugin +{{ + public string Name => ""{pluginName}""; + public string Version => ""1.0.0""; + + public async Task OnRequestAsync(HttpContext ctx) + {{ + // 编写你的逻辑 + {userCode} + }} +}} +"; +``` + +--- + +### 4.4 插件生命周期管理 + +```csharp +public class PluginManager +{ + private readonly Dictionary _plugins = new(); + private readonly RoslynPluginCompiler _compiler = new(); + + public async Task LoadPluginAsync(byte[] assemblyBytes, string pluginName) + { + // 1. 隔离加载程序集 + var context = new PluginLoadContext(pluginName); + var assembly = context.LoadFromStream(new MemoryStream(assemblyBytes)); + + // 2. 查找插件入口类型 + var pluginType = assembly.GetTypes() + .FirstOrDefault(t => typeof(IGatewayPlugin).IsAssignableFrom(t)); + + if (pluginType == null) + { + context.Unload(); + throw new InvalidOperationException("No IGatewayPlugin implementation found"); + } + + // 3. 创建实例 + var plugin = (IGatewayPlugin)Activator.CreateInstance(pluginType)!; + await plugin.OnLoadAsync(); + + // 4. 保存实例 + var instance = new PluginInstance + { + Name = pluginName, + Assembly = assembly, + Context = context, + Plugin = plugin, + LoadedAt = DateTime.UtcNow + }; + + _plugins[pluginName] = instance; + return instance; + } + + public async Task UnloadPluginAsync(string pluginName) + { + if (!_plugins.TryGetValue(pluginName, out var instance)) + return; + + await instance.Plugin.OnUnloadAsync(); + instance.Context.Unload(); + _plugins.Remove(pluginName); + } +} + +public class PluginLoadContext : AssemblyLoadContext +{ + public PluginLoadContext(string name) : base(name, isCollectible: true) { } + + protected override Assembly? Load(AssemblyName assemblyName) + { + return null; + } +} +``` + +--- + +### 4.5 插件编译服务 + +```csharp +public class RoslynPluginCompiler +{ + private readonly IEnumerable _defaultReferences; + + public RoslynPluginCompiler() + { + _defaultReferences = GetDefaultReferences(); + } + + public CompileResult Compile(string sourceCode, string pluginName, IEnumerable extraAssemblies) + { + var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode); + + var references = _defaultReferences.Concat( + extraAssemblies.Select(a => MetadataReference.CreateFromFile(a)) + ); + + var compilation = CSharpCompilation.Create( + assemblyName: $"Plugin_{pluginName}_{Guid.NewGuid():N}", + syntaxTrees: new[] { syntaxTree }, + references: references, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + .WithAllowUnsafe(false) + .WithOptimizationLevel(OptimizationLevel.Release)); + + using var ms = new MemoryStream(); + var emitResult = compilation.Emit(ms); + + if (!emitResult.Success) + { + return CompileResult.Fail(emitResult.Diagnostics); + } + + ms.Seek(0, SeekOrigin.Begin); + return CompileResult.Success(ms.ToArray()); + } + + private IEnumerable GetDefaultReferences() + { + return AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) + .Select(a => MetadataReference.CreateFromFile(a.Location)); + } +} +``` + +--- + +## 五、数据库模型 + +```csharp +public class PluginPackage +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Version { get; set; } = "1.0.0"; + public string? Description { get; set; } + public string StorageKey { get; set; } = string.Empty; + public PluginStatus Status { get; set; } + public DateTime UploadedAt { get; set; } + public DateTime? LoadedAt { get; set; } +} + +public class PluginPipeline +{ + public Guid Id { get; set; } + public Guid PluginId { get; set; } + public PipelineStage Stage { get; set; } + public int Order { get; set; } + public bool IsEnabled { get; set; } +} + +public enum PluginStatus +{ + Pending = 0, + Validated = 1, + Loaded = 2, + Failed = 3, + Disabled = 4 +} +``` + +--- + +## 六、实施计划 + +| 阶段 | 任务 | 优先级 | +|------|------|--------| +| Phase 1 | 集成 YARP 到现有项目 | 高 | +| Phase 2 | 插件基础接口定义 | 高 | +| Phase 3 | 插件编译 + 加载框架 | 高 | +| Phase 4 | 嵌入式 Razor UI | 中 | +| Phase 5 | Monaco Editor 集成 | 中 | +| Phase 6 | ZIP 上传验证功能 | 中 | +| Phase 7 | 测试与优化 | 低 | + +--- + +## 七、NuGet 依赖 + +```xml + + + + + + + + +``` + +--- + +## 八、总结 + +本方案实现了: + +1. **Web UI 管理**:类 SwaggerUI 风格的可视化界面 +2. **动态编译**:Roslyn 在线编译 C# 代码 +3. **插件加载**:独立 AssemblyLoadContext,支持热卸载 +4. **灵活扩展**:支持简单场景(使用已有程序集)和复杂场景(上传 ZIP) +5. **流程控制**:插件可分配到 5 个不同阶段执行 + +--- + +*文档版本: 1.0* +*最后更新: 2026-03-01*