From 46864c926205ee09090203f717e5a71ab25293db Mon Sep 17 00:00:00 2001 From: sharworange Date: Thu, 29 Jan 2026 23:18:18 +0800 Subject: [PATCH 01/11] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E9=87=8D=E5=86=99?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Logger.cs | 18 +++++ PluginLoadContext.cs | 62 ++++++++++++++++ Program.cs | 166 +++++++++++++++++++++++++++++++++---------- appsettings.json | 25 +++++-- sharwapi.Core.csproj | 4 ++ 5 files changed, 233 insertions(+), 42 deletions(-) create mode 100644 Logger.cs create mode 100644 PluginLoadContext.cs diff --git a/Logger.cs b/Logger.cs new file mode 100644 index 0000000..53dd757 --- /dev/null +++ b/Logger.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Configuration; +using Serilog; + +namespace sharwapi.Core; + +public static class Logger +{ + /// + /// 初始化全局 Serilog 配置 + /// + /// 应用程序配置对象 + public static void Initialize(IConfiguration configuration) + { + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .CreateLogger(); + } +} diff --git a/PluginLoadContext.cs b/PluginLoadContext.cs new file mode 100644 index 0000000..c7098f8 --- /dev/null +++ b/PluginLoadContext.cs @@ -0,0 +1,62 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace sharwapi.Core +{ + // 自定义程序集加载上下文,用于实现插件的隔离加载。 + // 每个插件在独立的 AssemblyLoadContext 实例中运行,防止依赖冲突。 + public class PluginLoadContext : AssemblyLoadContext + { + // 依赖解析器,用于根据 .deps.json 文件解析依赖程序集的路径。 + private readonly AssemblyDependencyResolver _resolver; + + // 初始化 PluginLoadContext 的新实例。 + // pluginPath: 插件主程序集文件的完整路径。 + public PluginLoadContext(string pluginPath) : base(isCollectible: true) + { + // 初始化 AssemblyDependencyResolver,用于解析插件及其依赖项的路径。 + _resolver = new AssemblyDependencyResolver(pluginPath); + } + + // 重写 Load 方法以自定义程序集加载逻辑。 + protected override Assembly? Load(AssemblyName assemblyName) + { + // 确保 sharwapi.Contracts.Core 程序集不被当前上下文加载。 + // 这保证了宿主应用程序和插件使用相同的 IApiPlugin 接口类型, + // 避免因类型加载上下文不同而导致的类型转换异常。 + if (assemblyName.Name == "sharwapi.Contracts.Core") + { + // 返回 null,委托默认加载上下文(DefaultContext)加载该程序集。 + return null; + } + + // 尝试使用依赖解析器将程序集名称解析为文件路径。 + string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); + + if (assemblyPath != null) + { + // 如果解析成功,从指定路径加载程序集。 + return LoadFromAssemblyPath(assemblyPath); + } + + // 如果无法解析路径,返回 null。 + return null; + } + + // 重写 LoadUnmanagedDll 方法以自定义非托管库加载逻辑。 + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) + { + // 尝试解析非托管库的路径。 + string? libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + + if (libraryPath != null) + { + // 如果解析成功,从指定路径加载非托管库。 + return LoadUnmanagedDllFromPath(libraryPath); + } + + // 如果无法解析路径,返回零指针。 + return IntPtr.Zero; + } + } +} diff --git a/Program.cs b/Program.cs index e0bd1b7..1b7dbf4 100644 --- a/Program.cs +++ b/Program.cs @@ -1,66 +1,132 @@ using Microsoft.AspNetCore.Diagnostics; using sharwapi.Contracts.Core; +using sharwapi.Core; using System.Reflection; using Microsoft.OpenApi.Models; +using Serilog; +using Serilog.Extensions.Logging; -// ڼ¼ʱʱuptime +// 用于记录服务启动时的运行时长(uptime) var startTime = DateTime.UtcNow; -// WebApplicationBuilderӦڣ +// 构建配置以初始化 Serilog +var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .Build(); + +// 初始化全局 Logger +sharwapi.Core.Logger.Initialize(configuration); +Log.Information("Starting web host"); + +// 创建 WebApplicationBuilder(应用与服务配置入口) var builder = WebApplication.CreateBuilder(args); +// 将 Serilog 挂载到 Host,接管系统日志 +builder.Host.UseSerilog(); + builder.Services.Configure(opts => { opts.ShutdownTimeout = TimeSpan.FromSeconds(30); }); -// ׶δһʱ LoggerFactoryڼ¼ؽ׶ε־ -using var bootstrapLoggerFactory = LoggerFactory.Create(b => -{ - b.AddConsole(); -}); -var pluginLoaderLogger = bootstrapLoggerFactory.CreateLogger("PluginLoader"); +// 创建用于插件加载器的 Logger (使用 SerilogLoggerFactory 桥接) +var pluginLoaderLogger = new SerilogLoggerFactory(Log.Logger).CreateLogger("PluginLoader"); -// жȡ API Ϣ汾δʹĬֵ +// 从配置中读取 API 信息(名称与版本),若未配置则使用默认值 var apiName = builder.Configuration.GetValue("ApiInfo:Name") ?? "CoreAPI"; var apiVersion = builder.Configuration.GetValue("ApiInfo:Version") ?? "0.0.0"; -// λĿ¼ Plugins Ŀ¼IJDLL +// 加载位于运行目录下 Plugins 子目录的插件(DLL) var plugins = LoadPlugins(builder.Configuration, pluginLoaderLogger); -// ע뵽 DI Ϊʵֿɴлȡ˼ +// 将插件集合注入到 DI 容器(作为单例),插件实现可从容器中获取此集合 builder.Services.AddSingleton(plugins); -// ÿ DI עԼķ +// 让每个插件向 DI 容器注册它们自己的服务 pluginLoaderLogger.LogInformation("Registering plugin services..."); foreach (var plugin in plugins) { - plugin.RegisterServices(builder.Services, builder.Configuration); + // 实现配置隔离:为每个插件加载独立的配置文件 + // 路径格式:config/{PluginName}.json + var configPath = Path.Combine(AppContext.BaseDirectory, "config", $"{plugin.Name}.json"); + + // 检查当前插件的配置文件是否存在于指定路径 + if (!File.Exists(configPath)) + { + // 尝试从插件实例中获取默认配置对象 + var defaultConfig = plugin.DefaultConfig; + + // 如果插件提供了非空的默认配置对象 + if (defaultConfig != null) + { + try + { + // 获取配置文件所在的目录路径 + var configDir = Path.GetDirectoryName(configPath); + + // 检查目录路径是否有效且目录是否存在,若不存在则创建 + if (!string.IsNullOrEmpty(configDir) && !Directory.Exists(configDir)) + { + // 创建配置文件的父目录 + Directory.CreateDirectory(configDir); + } + + // 配置 JSON 序列化选项,设置为缩进格式以提高可读性 + var jsonOptions = new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true + }; + + // 将默认配置对象序列化为 JSON 字符串 + var jsonString = System.Text.Json.JsonSerializer.Serialize(defaultConfig, jsonOptions); + + // 将序列化后的 JSON 字符串写入到配置文件路径 + File.WriteAllText(configPath, jsonString); + + // 记录日志,表明已成功生成默认配置文件 + pluginLoaderLogger.LogInformation("Generated default configuration for plugin {PluginName} at {ConfigPath}", plugin.Name, configPath); + } + catch (Exception ex) + { + // 捕获并记录在生成默认配置文件过程中发生的任何异常 + pluginLoaderLogger.LogError(ex, "Failed to generate default configuration for plugin {PluginName}", plugin.Name); + } + } + } + + // 构建插件专用的 Configuration 对象 + var pluginConfig = new ConfigurationBuilder() + .AddJsonFile(configPath, optional: true, reloadOnChange: true) + .Build(); + + // 将独立的配置对象传递给插件的服务注册方法 + plugin.RegisterServices(builder.Services, pluginConfig); } -// ӻ OpenAPI/Swagger ֧ +// 添加基本的 OpenAPI/Swagger 支持 builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => { - // Swagger ĵϢ + // 基本的 Swagger 文档信息 options.SwaggerDoc("v1", new OpenApiInfo { Title = apiName, Version = apiVersion, - Description = "һɲ̬ API" + Description = "一个由插件动态构建的 API" }); - // Զ ApiKey ȫ壨ͷ X-Api-Tokenʹܱ· + // 添加自定义的 ApiKey 安全定义(头部 X-Api-Token)供插件使用受保护路由 options.AddSecurityDefinition("ApiKeyAuth", new OpenApiSecurityScheme { Name = "X-Api-Token", In = ParameterLocation.Header, Type = SecuritySchemeType.ApiKey, - Description = "ڷܱ·ɵ API " + Description = "用于访问受保护路由的 API 令牌" }); - // Ӧõȫ֣˴ָضΧ + // 将定义应用到全局(此处不指定特定范围) options.AddSecurityRequirement(new OpenApiSecurityRequirement { { @@ -77,10 +143,10 @@ }); }); -// WebApplication ʵʱעᣩ +// 构建 WebApplication 实例(此时服务已注册) var app = builder.Build(); -// ȫ쳣δ쳣ͳһ JSON Ӧ +// 全局异常处理:捕获未处理异常并返回统一的 JSON 响应 app.UseExceptionHandler(exceptionHandlerApp => { exceptionHandlerApp.Run(async context => @@ -89,14 +155,14 @@ context.Response.ContentType = "application/json"; var exceptionDetails = context.Features.Get(); var exception = exceptionDetails?.Error; - // ¼쳣־а· + // 记录异常(在日志中包含发生路径) app.Logger.LogError(exception, "Unhandled exception caught by global handler at {Path}", exceptionDetails?.Path); var response = new { StatusCode = context.Response.StatusCode, Message = "An unexpected internal server error has occurred.", - // ڿзϸϢй¶ڲ쳣Ϣ + // 在开发环境中返回详细信息,否则不泄露内部异常消息 Details = app.Environment.IsDevelopment() ? exception?.Message : null, Path = exceptionDetails?.Path }; @@ -104,7 +170,7 @@ }); }); -// ڿ Swagger UIڵ鿴ĵ +// 在开发环境中启用 Swagger UI,便于调试与查看文档 if (app.Environment.IsDevelopment()) { app.Logger.LogInformation("Enabling Swagger UI (Development Mode)..."); @@ -112,37 +178,58 @@ app.UseSwaggerUI(options => { options.SwaggerEndpoint("/swagger/v1/swagger.json", $"{apiName} {apiVersion}"); - options.RoutePrefix = "swagger"; // /swagger Ϊ UI ·ǰ׺ + options.RoutePrefix = "swagger"; // 以 /swagger 作为 UI 的路由前缀 }); } -// ÿлܵעм +// 让每个插件有机会在请求管道中注册中间件 app.Logger.LogInformation("Configuring plugin middleware..."); foreach (var plugin in plugins) { plugin.Configure(app); } -// ÿע·˵ +// 让每个插件注册它们自身的路由与端点 app.Logger.LogInformation("Registering plugin routes..."); foreach (var plugin in plugins) { - plugin.RegisterRoutes(app, app.Configuration); + // 检查是否启用自动路由前缀 + IEndpointRouteBuilder routeBuilder = app; + if (plugin.UseAutoRoutePrefix) + { + // 如果启用,创建一个带前缀的路由组 + // 前缀格式:/{plugin.Name} + routeBuilder = app.MapGroup($"/{plugin.Name}"); + } + + // 将(可能是分组的)路由构建器传递给插件 + plugin.RegisterRoutes(routeBuilder, app.Configuration); app.Logger.LogInformation("Loaded Plugin: {PluginName} v{PluginVersion}", plugin.Name, plugin.Version); } -// ·ʾ API ơ汾ʱ +// 根路径:显示 API 名称、版本与已运行时长 app.MapGet("/", () => { var uptime = DateTime.UtcNow - startTime; return new { apiName, version = apiVersion, runningTime = uptime, message = "Core API running." }; }); -// -app.Run(); +// 启动并监听请求 +try +{ + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Host terminated unexpectedly"); +} +finally +{ + Log.CloseAndFlush(); +} -// Plugins Ŀ¼ʵ IApiPlugin Ͳʵ -List LoadPlugins(IConfiguration configuration, ILogger logger) +// 从 Plugins 目录加载实现了 IApiPlugin 的类型并返回其实例集合 +List LoadPlugins(IConfiguration configuration, Microsoft.Extensions.Logging.ILogger logger) { var loadedPlugins = new List(); string pluginsPath = Path.Combine(AppContext.BaseDirectory, "Plugins"); @@ -156,7 +243,7 @@ List LoadPlugins(IConfiguration configuration, ILogger logger) } catch (Exception ex) { - // ޷Ŀ¼ʱ¼󲢷ؿղб + // 无法创建目录时记录错误并返回空插件列表 logger.LogError(ex, "Failed to create plugins directory at {PluginsPath}", pluginsPath); return loadedPlugins; } @@ -167,8 +254,11 @@ List LoadPlugins(IConfiguration configuration, ILogger logger) { try { - // Լ򵥷ʽز򼯲ʵ IApiPlugin - var assembly = Assembly.LoadFrom(dllPath); + // 使用自定义的 PluginLoadContext 加载插件程序集,实现隔离 + // 每个插件使用单独的 LoadContext,确保依赖隔离 + var loadContext = new PluginLoadContext(dllPath); + var assembly = loadContext.LoadFromAssemblyPath(dllPath); + var pluginTypes = assembly.GetTypes() .Where(t => typeof(IApiPlugin).IsAssignableFrom(t) && !t.IsInterface); @@ -182,9 +272,9 @@ List LoadPlugins(IConfiguration configuration, ILogger logger) } catch (Exception ex) { - // ʧʱ¼󲢼 + // 插件加载失败时记录错误并继续加载其他插件 logger.LogError(ex, "Error loading plugin from {DllPath}", dllPath); } } return loadedPlugins; -} \ No newline at end of file +} diff --git a/appsettings.json b/appsettings.json index 5bb98af..8b09287 100644 --- a/appsettings.json +++ b/appsettings.json @@ -1,9 +1,26 @@ { - "Logging": { - "LogLevel": { + "Serilog": { + "MinimumLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } + "Override": { + "Microsoft": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { "Name": "Console" }, + { + "Name": "File", + "Args": { + "path": "logs/log-.txt", + "rollingInterval": "Day", + "retainedFileCountLimit": 30, + "fileSizeLimitBytes": 10485760, + "rollOnFileSizeLimit": true + } + } + ], + "Enrich": [ "FromLogContext" ] }, "Urls": "http://localhost:5000", "ApiInfo": { diff --git a/sharwapi.Core.csproj b/sharwapi.Core.csproj index ba5ea54..3a928ed 100644 --- a/sharwapi.Core.csproj +++ b/sharwapi.Core.csproj @@ -9,6 +9,10 @@ + + + + From 274a58b18ec4bdef8c11e5f3080aa1c6677bd721 Mon Sep 17 00:00:00 2001 From: sharworange Date: Sat, 14 Feb 2026 23:55:36 +0800 Subject: [PATCH 02/11] =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E9=87=8D=E5=86=99=20&&=20=E6=9B=B4=E6=8D=A2?= =?UTF-8?q?=E6=A1=86=E6=9E=B6=E5=88=B0.NET=2010=20&&=20=E6=B7=BB=E5=8A=A0G?= =?UTF-8?q?ithub=20CI/CD=E6=9E=84=E5=BB=BAworkflow=20&&=20=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=BB=8Enuget=E6=BA=90=E8=8E=B7=E5=8F=96Cont?= =?UTF-8?q?racts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dotnet.yml | 45 +++++++++++++++++++ .gitignore | 4 +- Program.cs | 24 +++++++++- Properties/PublishProfiles/Portable.pubxml | 13 ++++++ Properties/PublishProfiles/linux-arm64.pubxml | 14 ++++++ Properties/PublishProfiles/linux-x64.pubxml | 14 ++++++ Properties/PublishProfiles/macos-arm64.pubxml | 13 ++++++ Properties/PublishProfiles/macos-x64.pubxml | 13 ++++++ Properties/PublishProfiles/win-arm64.pubxml | 14 ++++++ Properties/PublishProfiles/win-x64.pubxml | 14 ++++++ Properties/PublishProfiles/win-x86.pubxml | 14 ++++++ README.md | 4 +- README_CN.md | 4 +- appsettings.json | 2 + nuget.config | 15 +++++++ sharwapi.Core.csproj | 4 +- 16 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/dotnet.yml create mode 100644 Properties/PublishProfiles/Portable.pubxml create mode 100644 Properties/PublishProfiles/linux-arm64.pubxml create mode 100644 Properties/PublishProfiles/linux-x64.pubxml create mode 100644 Properties/PublishProfiles/macos-arm64.pubxml create mode 100644 Properties/PublishProfiles/macos-x64.pubxml create mode 100644 Properties/PublishProfiles/win-arm64.pubxml create mode 100644 Properties/PublishProfiles/win-x64.pubxml create mode 100644 Properties/PublishProfiles/win-x86.pubxml create mode 100644 nuget.config diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..bd3fe8e --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,45 @@ +name: .NET Core Build + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + include: + - rid: linux-x64 + profile: linux-x64 + - rid: linux-arm64 + profile: linux-arm64 + - rid: win-x64 + profile: win-x64 + - rid: win-x86 + profile: win-x86 + - rid: win-arm64 + profile: win-arm64 + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore dependencies + run: dotnet restore sharwapi.Core.csproj + + - name: Publish + run: dotnet publish sharwapi.Core.csproj /p:PublishProfile=Properties/PublishProfiles/${{ matrix.profile }}.pubxml --configuration Release --output ./publish/${{ matrix.rid }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: sharwapi-core-${{ matrix.rid }} + path: ./publish/${{ matrix.rid }} diff --git a/.gitignore b/.gitignore index 95274ae..9be1989 100644 --- a/.gitignore +++ b/.gitignore @@ -430,4 +430,6 @@ FodyWeavers.xsd # Custom -appsettings.Development.json \ No newline at end of file +appsettings.Development.json + +!Properties/PublishProfiles/*.pubxml \ No newline at end of file diff --git a/Program.cs b/Program.cs index 1b7dbf4..2aaa2a8 100644 --- a/Program.cs +++ b/Program.cs @@ -197,9 +197,29 @@ IEndpointRouteBuilder routeBuilder = app; if (plugin.UseAutoRoutePrefix) { + // 默认使用插件名称作为前缀 + string routePrefix = plugin.Name; + + // 尝试从配置中读取重写值 (配置节: RouteOverride:插件名) + var overrideRoute = app.Configuration.GetValue($"RouteOverride:{plugin.Name}"); + if (!string.IsNullOrEmpty(overrideRoute)) + { + // 验证重写值只包含字母和数字 + if (System.Text.RegularExpressions.Regex.IsMatch(overrideRoute, "^[a-zA-Z0-9]+$")) + { + routePrefix = overrideRoute; + app.Logger.LogInformation("Route prefix for plugin '{PluginName}' overridden to '{RoutePrefix}'", plugin.Name, routePrefix); + } + else + { + // 无效重写值,回退到插件名作为路由前缀 + app.Logger.LogWarning("Invalid route override '{OverrideRoute}' for plugin '{PluginName}'. Only alphanumeric characters (A-Z, a-z, 0-9) are allowed. Falling back to default.", overrideRoute, plugin.Name); + } + } + // 如果启用,创建一个带前缀的路由组 - // 前缀格式:/{plugin.Name} - routeBuilder = app.MapGroup($"/{plugin.Name}"); + // 使用 TrimStart('/') 确保路径格式正确 + routeBuilder = app.MapGroup($"/{routePrefix.TrimStart('/')}"); } // 将(可能是分组的)路由构建器传递给插件 diff --git a/Properties/PublishProfiles/Portable.pubxml b/Properties/PublishProfiles/Portable.pubxml new file mode 100644 index 0000000..dad14ed --- /dev/null +++ b/Properties/PublishProfiles/Portable.pubxml @@ -0,0 +1,13 @@ + + + + + Release + AnyCPU + FileSystem + net10.0 + $(PublishRid) + false + false + + diff --git a/Properties/PublishProfiles/linux-arm64.pubxml b/Properties/PublishProfiles/linux-arm64.pubxml new file mode 100644 index 0000000..93f980d --- /dev/null +++ b/Properties/PublishProfiles/linux-arm64.pubxml @@ -0,0 +1,14 @@ + + + + + Release + AnyCPU + FileSystem + net10.0 + linux-arm64 + true + true + false + + diff --git a/Properties/PublishProfiles/linux-x64.pubxml b/Properties/PublishProfiles/linux-x64.pubxml new file mode 100644 index 0000000..4749c34 --- /dev/null +++ b/Properties/PublishProfiles/linux-x64.pubxml @@ -0,0 +1,14 @@ + + + + + Release + AnyCPU + FileSystem + net10.0 + linux-x64 + true + true + false + + diff --git a/Properties/PublishProfiles/macos-arm64.pubxml b/Properties/PublishProfiles/macos-arm64.pubxml new file mode 100644 index 0000000..cd70a77 --- /dev/null +++ b/Properties/PublishProfiles/macos-arm64.pubxml @@ -0,0 +1,13 @@ + + + + + Release + AnyCPU + FileSystem + net10.0 + osx-arm64 + true + false + + diff --git a/Properties/PublishProfiles/macos-x64.pubxml b/Properties/PublishProfiles/macos-x64.pubxml new file mode 100644 index 0000000..1cd935b --- /dev/null +++ b/Properties/PublishProfiles/macos-x64.pubxml @@ -0,0 +1,13 @@ + + + + + Release + AnyCPU + FileSystem + net10.0 + osx-x64 + true + false + + diff --git a/Properties/PublishProfiles/win-arm64.pubxml b/Properties/PublishProfiles/win-arm64.pubxml new file mode 100644 index 0000000..ca61ab4 --- /dev/null +++ b/Properties/PublishProfiles/win-arm64.pubxml @@ -0,0 +1,14 @@ + + + + + Release + AnyCPU + FileSystem + net10.0 + win-arm64 + true + true + false + + diff --git a/Properties/PublishProfiles/win-x64.pubxml b/Properties/PublishProfiles/win-x64.pubxml new file mode 100644 index 0000000..d50680c --- /dev/null +++ b/Properties/PublishProfiles/win-x64.pubxml @@ -0,0 +1,14 @@ + + + + + Release + AnyCPU + FileSystem + net10.0 + win-x64 + true + true + false + + diff --git a/Properties/PublishProfiles/win-x86.pubxml b/Properties/PublishProfiles/win-x86.pubxml new file mode 100644 index 0000000..996f599 --- /dev/null +++ b/Properties/PublishProfiles/win-x86.pubxml @@ -0,0 +1,14 @@ + + + + + Release + AnyCPU + FileSystem + net10.0 + win-x86 + true + true + false + + diff --git a/README.md b/README.md index 2e30f99..94727d1 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ Before you start, please ensure your device meets the following recommended requ - **OS**: Windows x64 / Linux x64 - **CPU**: 1 Core or higher -- **RAM**: 1GB or higher +- **RAM**: 512M or higher - **Disk**: 5GB available space -- **Runtime**: [ASP.NET Core 9 Runtime](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) +- **Runtime**: [ASP.NET Core 10 Runtime](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) You can download and run the software from [Github Releases](https://github.com/sharwapi/sharwapi.core/releases). diff --git a/README_CN.md b/README_CN.md index 089941e..0435a3c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -25,9 +25,9 @@ SharwAPI (又称Sharw's API) 是一款基于.NET开发的模块化API框架, - **系统**:Windows x64 / Linux x64 - **CPU**:1 核或更高 -- **内存**:1GB 或更高 +- **内存**:512M 或更高 - **硬盘**:5GB 可用空间 -- **运行时**:[ASP.NET Core 9 Runtime](https://dotnet.microsoft.com/zh-cn/download/dotnet/9.0) +- **运行时**:[ASP.NET Core 10 Runtime](https://dotnet.microsoft.com/zh-cn/download/dotnet/10.0) 你可以在 [Github Releases](https://github.com/sharwapi/sharwapi.core/releases) 中下载软件并运行。 diff --git a/appsettings.json b/appsettings.json index 8b09287..6ad7a7d 100644 --- a/appsettings.json +++ b/appsettings.json @@ -26,5 +26,7 @@ "ApiInfo": { "Name": "Sharw's API", "Version": "0.1.0" + }, + "RouteOverride": { } } diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..8f8f0af --- /dev/null +++ b/nuget.config @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sharwapi.Core.csproj b/sharwapi.Core.csproj index 3a928ed..4ccfb74 100644 --- a/sharwapi.Core.csproj +++ b/sharwapi.Core.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable 4cd6e719-cb4f-4dbe-8f8a-5aef3154b923 @@ -17,7 +17,7 @@ - + From 4be51bab64df1180150d7af98e1ff7da4ad1df63 Mon Sep 17 00:00:00 2001 From: sharworange Date: Sun, 15 Feb 2026 00:04:47 +0800 Subject: [PATCH 03/11] =?UTF-8?q?=E4=BF=AE=E6=94=B9README=E4=B8=AD?= =?UTF-8?q?=E5=85=B3=E4=BA=8E=E8=BF=90=E8=A1=8C=E6=97=B6=E7=9A=84=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- README_CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 94727d1..fc5ca3e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Before you start, please ensure your device meets the following recommended requ - **CPU**: 1 Core or higher - **RAM**: 512M or higher - **Disk**: 5GB available space -- **Runtime**: [ASP.NET Core 10 Runtime](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- **Runtime**: Programs downloaded from Releases do not require .NET Runtime installation (built-in) You can download and run the software from [Github Releases](https://github.com/sharwapi/sharwapi.core/releases). diff --git a/README_CN.md b/README_CN.md index 0435a3c..d00d7c2 100644 --- a/README_CN.md +++ b/README_CN.md @@ -27,7 +27,7 @@ SharwAPI (又称Sharw's API) 是一款基于.NET开发的模块化API框架, - **CPU**:1 核或更高 - **内存**:512M 或更高 - **硬盘**:5GB 可用空间 -- **运行时**:[ASP.NET Core 10 Runtime](https://dotnet.microsoft.com/zh-cn/download/dotnet/10.0) +- **运行时**:从 Releases 下载的程序无需安装 .NET Runtime(程序已内置) 你可以在 [Github Releases](https://github.com/sharwapi/sharwapi.core/releases) 中下载软件并运行。 From b5ab4d42d5d37b86673a4b4fd9d1099bce04c0a4 Mon Sep 17 00:00:00 2001 From: sharworange Date: Sun, 15 Feb 2026 17:25:11 +0800 Subject: [PATCH 04/11] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=9B=B8=E5=85=B3=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Program.cs | 83 ++++++++++++++++++++++++++++++++++++++++++++ nuget.config | 9 ----- sharwapi.Core.csproj | 3 +- 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/Program.cs b/Program.cs index 2aaa2a8..d08daff 100644 --- a/Program.cs +++ b/Program.cs @@ -5,6 +5,7 @@ using Microsoft.OpenApi.Models; using Serilog; using Serilog.Extensions.Logging; +using NuGet.Versioning; // 用于记录服务启动时的运行时长(uptime) var startTime = DateTime.UtcNow; @@ -40,6 +41,88 @@ // 加载位于运行目录下 Plugins 子目录的插件(DLL) var plugins = LoadPlugins(builder.Configuration, pluginLoaderLogger); +// --- 依赖检查逻辑 --- +pluginLoaderLogger.LogInformation("Checking plugin dependencies..."); +var loadedPluginsMap = plugins.ToDictionary(p => p.Name, p => p.Version); +var validPlugins = new List(); +foreach (var plugin in plugins) +{ + bool dependenciesMet = true; + foreach (var dependency in plugin.Dependencies) + { + string depName = dependency.Key; + string depRangeStr = dependency.Value ?? string.Empty; + + // 检查依赖插件是否存在 + if (!loadedPluginsMap.TryGetValue(depName, out var loadedVersionStr)) + { + pluginLoaderLogger.LogError("Plugin '{PluginName}' failed to load. Missing dependency: '{DepName}'.", plugin.Name, depName); + dependenciesMet = false; + break; + } + + // 解析当前加载的插件版本 + if (!NuGetVersion.TryParse(loadedVersionStr, out var loadedVersion)) + { + pluginLoaderLogger.LogError("Plugin '{PluginName}' depends on '{DepName}', but the loaded version '{LoadedVer}' of dependency '{DepName}' has an invalid format.", + plugin.Name, depName, loadedVersionStr, depName); + dependenciesMet = false; + break; + } + + // 解析依赖要求的版本范围 + // VersionRange.Parse 支持 "[1.0, 2.0)", "1.0" (即 >=1.0) 等标准写法 + bool isRangeValid = VersionRange.TryParse(depRangeStr, out var requiredRange); + + // 如果解析失败,尝试处理浮动版本 (例如 "1.*") + if (!isRangeValid && FloatRange.TryParse(depRangeStr, out var floatRange)) + { + // 检查浮动版本范围 + if (!floatRange.Satisfies(loadedVersion)) + { + pluginLoaderLogger.LogError("Plugin '{PluginName}' requires '{DepName}' version '{DepRange}' (Floating), but loaded version '{LoadedVer}' is incompatible.", + plugin.Name, depName, depRangeStr, loadedVersionStr); + dependenciesMet = false; + break; + } + } + else if (isRangeValid) + { + // 检查标准版本范围 + if (!requiredRange.Satisfies(loadedVersion)) + { + pluginLoaderLogger.LogError("Plugin '{PluginName}' requires '{DepName}' version '{DepRange}', but loaded version '{LoadedVer}' is incompatible.", + plugin.Name, depName, depRangeStr, loadedVersionStr); + dependenciesMet = false; + break; + } + } + else + { + // 无法解析的版本要求 + pluginLoaderLogger.LogError("Plugin '{PluginName}' has an invalid dependency version format for '{DepName}': '{DepRange}'.", + plugin.Name, depName, depRangeStr); + dependenciesMet = false; + dependenciesMet = false; + break; + } + } + + if (dependenciesMet) + { + validPlugins.Add(plugin); + } +} + +// 移除未能满足依赖的插件 +if (validPlugins.Count < plugins.Count) +{ + var removedCount = plugins.Count - validPlugins.Count; + pluginLoaderLogger.LogWarning("{Count} plugins were unloaded due to missing or incompatible dependencies.", removedCount); + plugins = validPlugins; +} +// -------------------- + // 将插件集合注入到 DI 容器(作为单例),插件实现可从容器中获取此集合 builder.Services.AddSingleton(plugins); diff --git a/nuget.config b/nuget.config index 8f8f0af..e6e85ba 100644 --- a/nuget.config +++ b/nuget.config @@ -3,13 +3,4 @@ - - - - - - - - - \ No newline at end of file diff --git a/sharwapi.Core.csproj b/sharwapi.Core.csproj index 4ccfb74..b7430c2 100644 --- a/sharwapi.Core.csproj +++ b/sharwapi.Core.csproj @@ -9,6 +9,7 @@ + @@ -17,7 +18,7 @@ - + From c647cb38c4ead28a1bcd1c89e48b93c6e5cccd32 Mon Sep 17 00:00:00 2001 From: sharworange Date: Sun, 15 Feb 2026 17:27:57 +0800 Subject: [PATCH 05/11] =?UTF-8?q?=E4=BF=AE=E6=94=B9Contracts=E5=BC=95?= =?UTF-8?q?=E7=94=A8nuget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sharwapi.Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sharwapi.Core.csproj b/sharwapi.Core.csproj index b7430c2..7f0ed20 100644 --- a/sharwapi.Core.csproj +++ b/sharwapi.Core.csproj @@ -18,7 +18,7 @@ - + From 305b3f2c55698abfbadff295b01e4c85a78f978f Mon Sep 17 00:00:00 2001 From: sharworange Date: Thu, 19 Feb 2026 19:48:54 +0800 Subject: [PATCH 06/11] =?UTF-8?q?=E6=B7=BB=E5=8A=A0ValidateDependency?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E7=94=A8=E4=BA=8E=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Program.cs | 31 +++++++++++++++++++++++++++---- sharwapi.Core.csproj | 1 + 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Program.cs b/Program.cs index d08daff..9c96f48 100644 --- a/Program.cs +++ b/Program.cs @@ -43,18 +43,23 @@ // --- 依赖检查逻辑 --- pluginLoaderLogger.LogInformation("Checking plugin dependencies..."); -var loadedPluginsMap = plugins.ToDictionary(p => p.Name, p => p.Version); +// 这里需要传递给插件的是所有候选插件的列表(版本), 用于插件自行判断 +var allCandidatePluginsMap = plugins.ToDictionary(p => p.Name, p => p.Version); var validPlugins = new List(); + foreach (var plugin in plugins) { bool dependenciesMet = true; + + // --- 第一阶段:声明式强依赖检查 --- foreach (var dependency in plugin.Dependencies) { string depName = dependency.Key; string depRangeStr = dependency.Value ?? string.Empty; // 检查依赖插件是否存在 - if (!loadedPluginsMap.TryGetValue(depName, out var loadedVersionStr)) + // 注意:这里检查的是 candidates 列表,因为所有插件都还没被正式确认加载 + if (!allCandidatePluginsMap.TryGetValue(depName, out var loadedVersionStr)) { pluginLoaderLogger.LogError("Plugin '{PluginName}' failed to load. Missing dependency: '{DepName}'.", plugin.Name, depName); dependenciesMet = false; @@ -89,7 +94,7 @@ else if (isRangeValid) { // 检查标准版本范围 - if (!requiredRange.Satisfies(loadedVersion)) + if (!requiredRange!.Satisfies(loadedVersion)) { pluginLoaderLogger.LogError("Plugin '{PluginName}' requires '{DepName}' version '{DepRange}', but loaded version '{LoadedVer}' is incompatible.", plugin.Name, depName, depRangeStr, loadedVersionStr); @@ -103,8 +108,26 @@ pluginLoaderLogger.LogError("Plugin '{PluginName}' has an invalid dependency version format for '{DepName}': '{DepRange}'.", plugin.Name, depName, depRangeStr); dependenciesMet = false; + break; + } + } + + // --- 第二阶段:自定义验证逻辑 (可选依赖/高级检查) --- + if (dependenciesMet) + { + try + { + // 调用插件的 ValidateDependency 方法 + if (!plugin.ValidateDependency(allCandidatePluginsMap)) + { + pluginLoaderLogger.LogWarning("Plugin '{PluginName}' rejected loading during validation check.", plugin.Name); + dependenciesMet = false; + } + } + catch (Exception ex) + { + pluginLoaderLogger.LogError(ex, "Plugin '{PluginName}' threw an exception during dependency validation.", plugin.Name); dependenciesMet = false; - break; } } diff --git a/sharwapi.Core.csproj b/sharwapi.Core.csproj index 7f0ed20..e7b5d58 100644 --- a/sharwapi.Core.csproj +++ b/sharwapi.Core.csproj @@ -19,6 +19,7 @@ + From 0984db3136a635a6dc162c0a14905778f5c8e07c Mon Sep 17 00:00:00 2001 From: sharworange Date: Thu, 19 Feb 2026 23:41:57 +0800 Subject: [PATCH 07/11] =?UTF-8?q?=E5=AF=B9=E4=B8=BB=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E6=A8=A1=E5=9D=97=E5=8C=96=E9=87=8D=E6=9E=84?= =?UTF-8?q?=EF=BC=8C=E4=B8=BA=E6=8F=92=E4=BB=B6=E7=83=AD=E9=87=8D=E8=BD=BD?= =?UTF-8?q?=EF=BC=8C=E5=BC=82=E6=AD=A5=E8=BF=90=E8=A1=8C=E7=AD=89=E9=AB=98?= =?UTF-8?q?=E7=BA=A7=E7=89=B9=E6=80=A7=E5=81=9A=E9=93=BA=E5=9E=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Modules/Configuration/AppConfiguration.cs | 52 +++ Modules/Configuration/SerilogSetup.cs | 49 +++ Modules/Hosting/ApplicationHost.cs | 127 ++++++ Modules/Hosting/HostingExtensions.cs | 33 ++ Modules/Logging/Logger.cs | 21 + Modules/Middleware/ExceptionHandling.cs | 39 ++ Modules/Middleware/MiddlewarePipeline.cs | 25 ++ .../PluginDependencyChecker.cs | 145 +++++++ Modules/PluginManagement/PluginLoadContext.cs | 68 +++ Modules/PluginManagement/PluginLoader.cs | 100 +++++ Modules/Routing/EndpointRegistration.cs | 51 +++ Modules/Routing/RoutePrefixResolver.cs | 52 +++ Modules/Services/ServiceRegistration.cs | 149 +++++++ Program.cs | 408 +----------------- sharwapi.Core.csproj | 4 +- 15 files changed, 916 insertions(+), 407 deletions(-) create mode 100644 Modules/Configuration/AppConfiguration.cs create mode 100644 Modules/Configuration/SerilogSetup.cs create mode 100644 Modules/Hosting/ApplicationHost.cs create mode 100644 Modules/Hosting/HostingExtensions.cs create mode 100644 Modules/Logging/Logger.cs create mode 100644 Modules/Middleware/ExceptionHandling.cs create mode 100644 Modules/Middleware/MiddlewarePipeline.cs create mode 100644 Modules/PluginManagement/PluginDependencyChecker.cs create mode 100644 Modules/PluginManagement/PluginLoadContext.cs create mode 100644 Modules/PluginManagement/PluginLoader.cs create mode 100644 Modules/Routing/EndpointRegistration.cs create mode 100644 Modules/Routing/RoutePrefixResolver.cs create mode 100644 Modules/Services/ServiceRegistration.cs diff --git a/Modules/Configuration/AppConfiguration.cs b/Modules/Configuration/AppConfiguration.cs new file mode 100644 index 0000000..daea91d --- /dev/null +++ b/Modules/Configuration/AppConfiguration.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Configuration; + +namespace sharwapi.Core.Modules.Configuration; + +/// +/// 应用程序配置构建器 +/// 负责构建和管理应用程序的配置信息 +/// +public static class AppConfiguration +{ + /// + /// 构建应用程序配置 + /// + /// 配置构建器实例 + public static IConfigurationBuilder Build() + { + return new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); + } + + /// + /// 获取 API 名称 + /// + /// 应用程序配置 + /// API 名称 + public static string GetApiName(IConfiguration configuration) + { + return configuration.GetValue("ApiInfo:Name") ?? "CoreAPI"; + } + + /// + /// 获取 API 版本 + /// + /// 应用程序配置 + /// API 版本 + public static string GetApiVersion(IConfiguration configuration) + { + return configuration.GetValue("ApiInfo:Version") ?? "0.0.0"; + } + + /// + /// 获取路由前缀重写配置值 + /// + /// 应用程序配置 + /// 插件名称 + /// 路由前缀重写值 + public static string? GetRouteOverride(IConfiguration configuration, string pluginName) + { + return configuration.GetValue($"RouteOverride:{pluginName}"); + } +} diff --git a/Modules/Configuration/SerilogSetup.cs b/Modules/Configuration/SerilogSetup.cs new file mode 100644 index 0000000..a82ae65 --- /dev/null +++ b/Modules/Configuration/SerilogSetup.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Configuration; +using Serilog; + +namespace sharwapi.Core.Modules.Configuration; + +/// +/// Serilog 日志配置 +/// 负责初始化和管理全局 Serilog 日志配置 +/// +public static class SerilogSetup +{ + /// + /// 初始化全局 Serilog 配置 + /// + /// 应用程序配置对象 + public static void Initialize(IConfiguration configuration) + { + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .CreateLogger(); + } + + /// + /// 配置日志输出到控制台 + /// + /// 日志配置构建器 + /// 日志配置构建器 + public static LoggerConfiguration AddConsole(this LoggerConfiguration loggerConfiguration) + { + return loggerConfiguration.WriteTo.Console(); + } + + /// + /// 配置日志输出到文件 + /// + /// 日志配置构建器 + /// 日志文件路径 + /// 日志配置构建器 + public static LoggerConfiguration AddFile(this LoggerConfiguration loggerConfiguration, string path) + { + return loggerConfiguration.WriteTo.File( + path: path, + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 30, + fileSizeLimitBytes: 10485760, + rollOnFileSizeLimit: true + ); + } +} diff --git a/Modules/Hosting/ApplicationHost.cs b/Modules/Hosting/ApplicationHost.cs new file mode 100644 index 0000000..62bb9db --- /dev/null +++ b/Modules/Hosting/ApplicationHost.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Extensions.Logging; +using sharwapi.Core.Modules.Configuration; +using sharwapi.Core.Modules.Hosting; +using sharwapi.Core.Modules.Logging; +using sharwapi.Core.Modules.Middleware; +using sharwapi.Core.Modules.PluginManagement; +using sharwapi.Core.Modules.Routing; +using sharwapi.Core.Modules.Services; +using sharwapi.Contracts.Core; + +namespace sharwapi.Core.Modules.Hosting; + +/// +/// 应用程序主机 +/// 负责组装和启动整个应用程序 +/// +public class ApplicationHost +{ + private readonly string[] _args; + private IConfiguration _configuration = null!; + private WebApplication _app = null!; + private DateTime _startTime; + private string _apiName = null!; + private string _apiVersion = null!; + + /// + /// 初始化应用程序主机 + /// + /// 命令行参数 + public ApplicationHost(string[] args) + { + _args = args; + _startTime = DateTime.UtcNow; + } + + /// + /// 构建应用程序 + /// + /// 应用程序主机实例 + public ApplicationHost Build() + { + // 1. 构建配置 + _configuration = AppConfiguration.Build().Build(); + + // 2. 初始化日志 + Logger.Initialize(_configuration); + Log.Information("Starting web host"); + + // 3. 创建 WebApplicationBuilder + var builder = WebApplication.CreateBuilder(_args); + + // 4. 配置主机 + builder.Host.UseSerilogLogging(); + builder.ConfigureHostOptions(); + + // 5. 获取 API 信息 + _apiName = AppConfiguration.GetApiName(_configuration); + _apiVersion = AppConfiguration.GetApiVersion(_configuration); + + // 6. 加载插件 + var pluginLogger = new SerilogLoggerFactory(Log.Logger).CreateLogger("PluginLoader"); + var pluginLoader = new PluginLoader(_configuration, pluginLogger); + var plugins = pluginLoader.LoadPlugins(); + + // 7. 检查依赖 + var dependencyChecker = new PluginDependencyChecker(pluginLogger); + plugins = dependencyChecker.CheckDependencies(plugins); + + // 8. 注册服务 + // 将插件集合注入到 DI 容器(作为单例),插件实现可从容器中获取此集合 + builder.Services.AddSingleton(plugins); + + var serviceRegistrar = new PluginServiceRegistrar(builder.Services, pluginLogger); + serviceRegistrar.AddSwaggerServices(_apiName, _apiVersion); + serviceRegistrar.RegisterPluginServices(plugins, _configuration); + + // 9. 构建应用 + _app = builder.Build(); + + // 10. 配置中间件 + ExceptionHandling.Configure(_app); + MiddlewarePipeline.Configure(_app, plugins); + + // 在开发环境中启用 Swagger UI + if (_app.Environment.IsDevelopment()) + { + _app.Logger.LogInformation("Enabling Swagger UI (Development Mode)..."); + _app.UseSwagger(); + _app.UseSwaggerUI(options => + { + options.SwaggerEndpoint("/swagger/v1/swagger.json", $"{_apiName} {_apiVersion}"); + options.RoutePrefix = "swagger"; + }); + } + + // 11. 注册路由 + EndpointRegistration.RegisterPluginRoutes(_app, plugins, _configuration); + EndpointRegistration.RegisterRootEndpoint(_app, _apiName, _apiVersion, _startTime); + + return this; + } + + /// + /// 运行应用程序 + /// + public void Run() + { + try + { + _app.Run(); + } + catch (Exception ex) + { + Log.Fatal(ex, "Host terminated unexpectedly"); + } + finally + { + Log.CloseAndFlush(); + } + } +} diff --git a/Modules/Hosting/HostingExtensions.cs b/Modules/Hosting/HostingExtensions.cs new file mode 100644 index 0000000..9c30337 --- /dev/null +++ b/Modules/Hosting/HostingExtensions.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Hosting; +using Serilog; + +namespace sharwapi.Core.Modules.Hosting; + +/// +/// 主机扩展方法 +/// +public static class HostingExtensions +{ + /// + /// 使用 Serilog 接管系统日志 + /// + /// 主机构建器 + /// 主机构建器 + public static IHostBuilder UseSerilogLogging(this IHostBuilder hostBuilder) + { + return hostBuilder.UseSerilog(); + } + + /// + /// 配置主机选项 + /// + /// Web 应用程序构建器 + /// 关闭超时时间(秒) + public static void ConfigureHostOptions(this WebApplicationBuilder builder, int shutdownTimeoutSeconds = 30) + { + builder.Services.Configure(opts => + { + opts.ShutdownTimeout = TimeSpan.FromSeconds(shutdownTimeoutSeconds); + }); + } +} diff --git a/Modules/Logging/Logger.cs b/Modules/Logging/Logger.cs new file mode 100644 index 0000000..5a74203 --- /dev/null +++ b/Modules/Logging/Logger.cs @@ -0,0 +1,21 @@ +using Serilog; + +namespace sharwapi.Core.Modules.Logging; + +/// +/// 日志服务 +/// 提供应用程序的日志记录功能 +/// +public static class Logger +{ + /// + /// 初始化全局 Serilog 配置 + /// + /// 应用程序配置对象 + public static void Initialize(Microsoft.Extensions.Configuration.IConfiguration configuration) + { + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .CreateLogger(); + } +} diff --git a/Modules/Middleware/ExceptionHandling.cs b/Modules/Middleware/ExceptionHandling.cs new file mode 100644 index 0000000..c0ae10b --- /dev/null +++ b/Modules/Middleware/ExceptionHandling.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Diagnostics; + +namespace sharwapi.Core.Modules.Middleware; + +/// +/// 全局异常处理中间件配置 +/// +public static class ExceptionHandling +{ + /// + /// 配置全局异常处理 + /// + /// Web 应用程序 + public static void Configure(WebApplication app) + { + app.UseExceptionHandler(exceptionHandlerApp => + { + exceptionHandlerApp.Run(async context => + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/json"; + var exceptionDetails = context.Features.Get(); + var exception = exceptionDetails?.Error; + + app.Logger.LogError(exception, "Unhandled exception caught by global handler at {Path}", exceptionDetails?.Path); + + var response = new + { + StatusCode = context.Response.StatusCode, + Message = "An unexpected internal server error has occurred.", + Details = app.Environment.IsDevelopment() ? exception?.Message : null, + Path = exceptionDetails?.Path + }; + + await context.Response.WriteAsJsonAsync(response); + }); + }); + } +} diff --git a/Modules/Middleware/MiddlewarePipeline.cs b/Modules/Middleware/MiddlewarePipeline.cs new file mode 100644 index 0000000..99c6918 --- /dev/null +++ b/Modules/Middleware/MiddlewarePipeline.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Builder; +using sharwapi.Contracts.Core; + +namespace sharwapi.Core.Modules.Middleware; + +/// +/// 插件中间件管道配置 +/// +public static class MiddlewarePipeline +{ + /// + /// 配置所有插件的中间件 + /// + /// Web 应用程序 + /// 已加载的插件列表 + public static void Configure(WebApplication app, List plugins) + { + app.Logger.LogInformation("Configuring plugin middleware..."); + + foreach (var plugin in plugins) + { + plugin.Configure(app); + } + } +} diff --git a/Modules/PluginManagement/PluginDependencyChecker.cs b/Modules/PluginManagement/PluginDependencyChecker.cs new file mode 100644 index 0000000..b27edee --- /dev/null +++ b/Modules/PluginManagement/PluginDependencyChecker.cs @@ -0,0 +1,145 @@ +using Microsoft.Extensions.Logging; +using NuGet.Versioning; +using sharwapi.Contracts.Core; + +namespace sharwapi.Core.Modules.PluginManagement; + +/// +/// 插件依赖检查器 +/// 负责检查插件的依赖关系是否满足 +/// +public class PluginDependencyChecker +{ + private readonly ILogger _logger; + + /// + /// 初始化依赖检查器 + /// + /// 日志记录器 + public PluginDependencyChecker(ILogger logger) + { + _logger = logger; + } + + /// + /// 检查所有插件的依赖关系 + /// + /// 所有候选插件列表 + /// 通过依赖检查的有效插件列表 + public List CheckDependencies(List plugins) + { + // 构建插件名称到版本号的映射,用于依赖检查 + var allCandidatePluginsMap = plugins.ToDictionary(p => p.Name, p => p.Version); + var validPlugins = new List(); + + // 遍历每个插件,检查其依赖是否满足 + foreach (var plugin in plugins) + { + if (CheckPluginDependencies(plugin, allCandidatePluginsMap)) + { + validPlugins.Add(plugin); + } + } + + // 如果有插件因依赖问题被移除,记录警告日志 + if (validPlugins.Count < plugins.Count) + { + var removedCount = plugins.Count - validPlugins.Count; + _logger.LogWarning("{Count} plugins were unloaded due to missing or incompatible dependencies.", removedCount); + } + + return validPlugins; + } + + /// + /// 检查单个插件的依赖关系 + /// + /// 要检查的插件 + /// 所有候选插件的映射 + /// 如果依赖满足返回 true,否则返回 false + private bool CheckPluginDependencies(IApiPlugin plugin, Dictionary allCandidatePluginsMap) + { + // 第一阶段:声明式强依赖检查 + foreach (var dependency in plugin.Dependencies) + { + if (!CheckSingleDependency(plugin, dependency, allCandidatePluginsMap)) + { + return false; + } + } + + // 第二阶段:自定义验证逻辑 + try + { + if (!plugin.ValidateDependency(allCandidatePluginsMap)) + { + _logger.LogWarning("Plugin '{PluginName}' rejected loading during validation check.", plugin.Name); + return false; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Plugin '{PluginName}' threw an exception during dependency validation.", plugin.Name); + return false; + } + + return true; + } + + /// + /// 检查单个依赖 + /// + private bool CheckSingleDependency(IApiPlugin plugin, KeyValuePair dependency, Dictionary allCandidatePluginsMap) + { + // 提取依赖名称和版本范围 + string depName = dependency.Key; + string depRangeStr = dependency.Value ?? string.Empty; + + // 检查依赖插件是否存在 + if (!allCandidatePluginsMap.TryGetValue(depName, out var loadedVersionStr)) + { + _logger.LogError("Plugin '{PluginName}' failed to load. Missing dependency: '{DepName}'.", plugin.Name, depName); + return false; + } + + // 解析已加载的插件版本号 + if (!NuGetVersion.TryParse(loadedVersionStr, out var loadedVersion)) + { + _logger.LogError("Plugin '{PluginName}' depends on '{DepName}', but the loaded version '{LoadedVer}' of dependency '{DepName}' has an invalid format.", + plugin.Name, depName, loadedVersionStr, depName); + return false; + } + + // 解析依赖要求的版本范围 + if (!VersionRange.TryParse(depRangeStr, out var requiredRange)) + { + // 尝试处理浮动版本(如 1.*) + if (FloatRange.TryParse(depRangeStr, out var floatRange)) + { + // 检查浮动版本是否满足 + if (!floatRange.Satisfies(loadedVersion)) + { + _logger.LogError("Plugin '{PluginName}' requires '{DepName}' version '{DepRange}' (Floating), but loaded version '{LoadedVer}' is incompatible.", + plugin.Name, depName, depRangeStr, loadedVersionStr); + return false; + } + } + else + { + // 无法解析的版本要求格式 + _logger.LogError("Plugin '{PluginName}' has an invalid dependency version format for '{DepName}': '{DepRange}'.", + plugin.Name, depName, depRangeStr); + return false; + } + } + else if (!requiredRange!.Satisfies(loadedVersion)) + { + // 标准版本范围检查 + _logger.LogError("Plugin '{PluginName}' requires '{DepName}' version '{DepRange}', but loaded version '{LoadedVer}' is incompatible.", + plugin.Name, depName, depRangeStr, loadedVersionStr); + return false; + } + + return true; + } +} diff --git a/Modules/PluginManagement/PluginLoadContext.cs b/Modules/PluginManagement/PluginLoadContext.cs new file mode 100644 index 0000000..d9966ef --- /dev/null +++ b/Modules/PluginManagement/PluginLoadContext.cs @@ -0,0 +1,68 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace sharwapi.Core.Modules.PluginManagement; + +/// +/// 自定义程序集加载上下文,用于实现插件的隔离加载。 +/// 每个插件在独立的 AssemblyLoadContext 实例中运行,防止依赖冲突。 +/// +public class PluginLoadContext : AssemblyLoadContext +{ + /// + /// 依赖解析器,用于根据 .deps.json 文件解析依赖程序集的路径。 + /// + private readonly AssemblyDependencyResolver _resolver; + + /// + /// 初始化 PluginLoadContext 的新实例。 + /// + /// 插件主程序集文件的完整路径。 + public PluginLoadContext(string pluginPath) : base(isCollectible: true) + { + _resolver = new AssemblyDependencyResolver(pluginPath); + } + + /// + /// 重写 Load 方法以自定义程序集加载逻辑。 + /// + /// 程序集名称。 + /// 加载的程序集实例,如果无法解析则返回 null。 + protected override Assembly? Load(AssemblyName assemblyName) + { + // 确保 sharwapi.Contracts.Core 程序集不被当前上下文加载。 + // 这保证了宿主应用程序和插件使用相同的 IApiPlugin 接口类型, + // 避免因类型加载上下文不同而导致的类型转换异常。 + if (assemblyName.Name == "sharwapi.Contracts.Core") + { + return null; + } + + // 尝试使用依赖解析器将程序集名称解析为文件路径。 + string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); + + if (assemblyPath != null) + { + return LoadFromAssemblyPath(assemblyPath); + } + + return null; + } + + /// + /// 重写 LoadUnmanagedDll 方法以自定义非托管库加载逻辑。 + /// + /// 非托管库名称。 + /// 加载的库指针,如果无法解析则返回 IntPtr.Zero。 + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) + { + string? libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + + if (libraryPath != null) + { + return LoadUnmanagedDllFromPath(libraryPath); + } + + return IntPtr.Zero; + } +} diff --git a/Modules/PluginManagement/PluginLoader.cs b/Modules/PluginManagement/PluginLoader.cs new file mode 100644 index 0000000..a0f37de --- /dev/null +++ b/Modules/PluginManagement/PluginLoader.cs @@ -0,0 +1,100 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using sharwapi.Contracts.Core; + +namespace sharwapi.Core.Modules.PluginManagement; + +/// +/// 插件加载器 +/// 负责从 Plugins 目录加载实现了 IApiPlugin 的程序集 +/// +public class PluginLoader +{ + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + /// + /// 初始化插件加载器 + /// + /// 应用程序配置 + /// 日志记录器 + public PluginLoader(IConfiguration configuration, ILogger logger) + { + _configuration = configuration; + _logger = logger; + } + + /// + /// 加载所有插件 + /// + /// 加载的插件列表 + public List LoadPlugins() + { + var loadedPlugins = new List(); + + // 获取插件目录路径 + string pluginsPath = Path.Combine(AppContext.BaseDirectory, "Plugins"); + + // 检查插件目录是否存在,如果不存在则创建 + if (!Directory.Exists(pluginsPath)) + { + try + { + Directory.CreateDirectory(pluginsPath); + _logger.LogInformation("Plugins directory did not exist and was created at {PluginsPath}", pluginsPath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create plugins directory at {PluginsPath}", pluginsPath); + } + return loadedPlugins; + } + + // 遍历 Plugins 目录下的所有 DLL 文件 + foreach (var dllPath in Directory.GetFiles(pluginsPath, "*.dll")) + { + try + { + // 尝试从每个 DLL 加载插件 + var plugin = LoadPluginFromPath(dllPath); + if (plugin != null) + { + loadedPlugins.Add(plugin); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading plugin from {DllPath}", dllPath); + } + } + + return loadedPlugins; + } + + /// + /// 从指定路径加载单个插件 + /// + /// DLL 文件路径 + /// 插件实例,如果加载失败则返回 null + private IApiPlugin? LoadPluginFromPath(string dllPath) + { + // 使用隔离加载上下文加载程序集,防止插件之间的依赖冲突 + var loadContext = new PluginLoadContext(dllPath); + var assembly = loadContext.LoadFromAssemblyPath(dllPath); + + // 从程序集中查找所有实现了 IApiPlugin 接口的类型 + var pluginTypes = assembly.GetTypes() + .Where(t => typeof(IApiPlugin).IsAssignableFrom(t) && !t.IsInterface); + + // 遍历找到的类型,尝试实例化第一个成功的类型 + foreach (var type in pluginTypes) + { + if (Activator.CreateInstance(type) is IApiPlugin plugin) + { + return plugin; + } + } + + return null; + } +} diff --git a/Modules/Routing/EndpointRegistration.cs b/Modules/Routing/EndpointRegistration.cs new file mode 100644 index 0000000..8151469 --- /dev/null +++ b/Modules/Routing/EndpointRegistration.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using sharwapi.Contracts.Core; + +namespace sharwapi.Core.Modules.Routing; + +/// +/// 端点注册 +/// 负责注册插件路由和根路径端点 +/// +public static class EndpointRegistration +{ + /// + /// 注册所有插件的路由 + /// + /// Web 应用程序 + /// 已加载的插件列表 + /// 应用程序配置 + public static void RegisterPluginRoutes(WebApplication app, List plugins, IConfiguration configuration) + { + app.Logger.LogInformation("Registering plugin routes..."); + + // 遍历所有已加载的插件,为每个插件注册路由 + foreach (var plugin in plugins) + { + // 解析插件的路由前缀(可能来自配置覆盖) + var routeBuilder = RoutePrefixResolver.Resolve(plugin, configuration, app); + + // 调用插件的路由注册方法 + plugin.RegisterRoutes(routeBuilder, configuration); + + app.Logger.LogInformation("Loaded Plugin: {PluginName} v{PluginVersion}", plugin.Name, plugin.Version); + } + } + + /// + /// 注册根路径端点 + /// + /// Web 应用程序 + /// API 名称 + /// API 版本 + /// 应用启动时间 + public static void RegisterRootEndpoint(WebApplication app, string apiName, string apiVersion, DateTime startTime) + { + app.MapGet("/", () => + { + var uptime = DateTime.UtcNow - startTime; + return new { apiName, version = apiVersion, runningTime = uptime, message = "Core API running." }; + }); + } +} diff --git a/Modules/Routing/RoutePrefixResolver.cs b/Modules/Routing/RoutePrefixResolver.cs new file mode 100644 index 0000000..0895141 --- /dev/null +++ b/Modules/Routing/RoutePrefixResolver.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using sharwapi.Contracts.Core; + +namespace sharwapi.Core.Modules.Routing; + +/// +/// 路由前缀解析器 +/// 负责解析插件的路由前缀 +/// +public static class RoutePrefixResolver +{ + /// + /// 解析插件的路由前缀 + /// 根据插件配置和应用程序配置确定路由前缀 + /// + /// 插件实例 + /// 应用程序配置 + /// Web 应用程序 + /// 路由构建器(可能是分组路由) + public static IEndpointRouteBuilder Resolve(IApiPlugin plugin, IConfiguration configuration, WebApplication app) + { + // 如果插件不启用自动路由前缀,直接返回 app + if (!plugin.UseAutoRoutePrefix) + { + return app; + } + + // 默认使用插件名称作为路由前缀 + string routePrefix = plugin.Name; + + // 检查是否存在路由前缀覆盖配置 + var overrideRoute = configuration.GetValue($"RouteOverride:{plugin.Name}"); + if (!string.IsNullOrEmpty(overrideRoute)) + { + // 验证覆盖值只包含字母和数字 + if (System.Text.RegularExpressions.Regex.IsMatch(overrideRoute, "^[a-zA-Z0-9]+$")) + { + routePrefix = overrideRoute; + app.Logger.LogInformation("Route prefix for plugin '{PluginName}' overridden to '{RoutePrefix}'", plugin.Name, routePrefix); + } + else + { + // 无效的覆盖值,回退到插件名称 + app.Logger.LogWarning("Invalid route override '{OverrideRoute}' for plugin '{PluginName}'. Only alphanumeric characters (A-Z, a-z, 0-9) are allowed. Falling back to default.", overrideRoute, plugin.Name); + } + } + + // 返回带有前缀的路由组 + return app.MapGroup($"/{routePrefix.TrimStart('/')}"); + } +} diff --git a/Modules/Services/ServiceRegistration.cs b/Modules/Services/ServiceRegistration.cs new file mode 100644 index 0000000..c007432 --- /dev/null +++ b/Modules/Services/ServiceRegistration.cs @@ -0,0 +1,149 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using sharwapi.Contracts.Core; + +namespace sharwapi.Core.Modules.Services; + +/// +/// 插件服务注册器 +/// 负责将插件的服务注册到 DI 容器 +/// +public class PluginServiceRegistrar +{ + private readonly IServiceCollection _services; + private readonly Microsoft.Extensions.Logging.ILogger _logger; + + /// + /// 初始化服务注册器 + /// + /// 服务集合 + /// 日志记录器 + public PluginServiceRegistrar(IServiceCollection services, Microsoft.Extensions.Logging.ILogger logger) + { + _services = services; + _logger = logger; + } + + /// + /// 注册所有插件的服务 + /// + /// 已加载的插件列表 + /// 应用程序配置 + public void RegisterPluginServices(List plugins, IConfiguration configuration) + { + _logger.LogInformation("Registering plugin services..."); + + foreach (var plugin in plugins) + { + RegisterPluginServices(plugin, configuration); + } + } + + /// + /// 注册单个插件的服务 + /// + private void RegisterPluginServices(IApiPlugin plugin, IConfiguration configuration) + { + // 构建插件配置文件的完整路径 + var configPath = Path.Combine(AppContext.BaseDirectory, "config", $"{plugin.Name}.json"); + + // 确保配置文件存在,如果不存在则从插件的默认配置生成 + EnsurePluginConfigFile(plugin, configPath); + + // 为插件构建独立的配置对象,支持热重载 + var pluginConfig = new ConfigurationBuilder() + .AddJsonFile(configPath, optional: true, reloadOnChange: true) + .Build(); + + // 调用插件的服务注册方法 + plugin.RegisterServices(_services, pluginConfig); + } + + /// + /// 确保插件配置文件存在 + /// 如果配置文件不存在,从插件的 DefaultConfig 生成默认配置 + /// + private void EnsurePluginConfigFile(IApiPlugin plugin, string configPath) + { + // 检查配置文件是否已存在 + if (!File.Exists(configPath)) + { + // 获取插件提供的默认配置 + var defaultConfig = plugin.DefaultConfig; + + if (defaultConfig != null) + { + try + { + // 确保配置目录存在 + var configDir = Path.GetDirectoryName(configPath); + + if (!string.IsNullOrEmpty(configDir) && !Directory.Exists(configDir)) + { + Directory.CreateDirectory(configDir); + } + + // 配置 JSON 序列化选项为缩进格式,便于阅读 + var jsonOptions = new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true + }; + + // 将默认配置序列化为 JSON 并写入文件 + var jsonString = System.Text.Json.JsonSerializer.Serialize(defaultConfig, jsonOptions); + File.WriteAllText(configPath, jsonString); + + _logger.LogInformation("Generated default configuration for plugin {PluginName} at {ConfigPath}", plugin.Name, configPath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to generate default configuration for plugin {PluginName}", plugin.Name); + } + } + } + } + + /// + /// 添加 Swagger/OpenAPI 服务 + /// + /// API 名称 + /// API 版本 + public void AddSwaggerServices(string apiName, string apiVersion) + { + _services.AddEndpointsApiExplorer(); + + _services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo + { + Title = apiName, + Version = apiVersion, + Description = "一个由插件动态构建的 API" + }); + + options.AddSecurityDefinition("ApiKeyAuth", new Microsoft.OpenApi.Models.OpenApiSecurityScheme + { + Name = "X-Api-Token", + In = Microsoft.OpenApi.Models.ParameterLocation.Header, + Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey, + Description = "用于访问受保护路由的 API 令牌" + }); + + options.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement + { + { + new Microsoft.OpenApi.Models.OpenApiSecurityScheme + { + Reference = new Microsoft.OpenApi.Models.OpenApiReference + { + Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme, + Id = "ApiKeyAuth" + } + }, + new List() + } + }); + }); + } +} diff --git a/Program.cs b/Program.cs index 9c96f48..d80c985 100644 --- a/Program.cs +++ b/Program.cs @@ -1,406 +1,4 @@ -using Microsoft.AspNetCore.Diagnostics; -using sharwapi.Contracts.Core; -using sharwapi.Core; -using System.Reflection; -using Microsoft.OpenApi.Models; -using Serilog; -using Serilog.Extensions.Logging; -using NuGet.Versioning; +using sharwapi.Core.Modules.Hosting; -// 用于记录服务启动时的运行时长(uptime) -var startTime = DateTime.UtcNow; - -// 构建配置以初始化 Serilog -var configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) - .Build(); - -// 初始化全局 Logger -sharwapi.Core.Logger.Initialize(configuration); -Log.Information("Starting web host"); - -// 创建 WebApplicationBuilder(应用与服务配置入口) -var builder = WebApplication.CreateBuilder(args); - -// 将 Serilog 挂载到 Host,接管系统日志 -builder.Host.UseSerilog(); - -builder.Services.Configure(opts => -{ - opts.ShutdownTimeout = TimeSpan.FromSeconds(30); -}); - -// 创建用于插件加载器的 Logger (使用 SerilogLoggerFactory 桥接) -var pluginLoaderLogger = new SerilogLoggerFactory(Log.Logger).CreateLogger("PluginLoader"); - -// 从配置中读取 API 信息(名称与版本),若未配置则使用默认值 -var apiName = builder.Configuration.GetValue("ApiInfo:Name") ?? "CoreAPI"; -var apiVersion = builder.Configuration.GetValue("ApiInfo:Version") ?? "0.0.0"; - -// 加载位于运行目录下 Plugins 子目录的插件(DLL) -var plugins = LoadPlugins(builder.Configuration, pluginLoaderLogger); - -// --- 依赖检查逻辑 --- -pluginLoaderLogger.LogInformation("Checking plugin dependencies..."); -// 这里需要传递给插件的是所有候选插件的列表(版本), 用于插件自行判断 -var allCandidatePluginsMap = plugins.ToDictionary(p => p.Name, p => p.Version); -var validPlugins = new List(); - -foreach (var plugin in plugins) -{ - bool dependenciesMet = true; - - // --- 第一阶段:声明式强依赖检查 --- - foreach (var dependency in plugin.Dependencies) - { - string depName = dependency.Key; - string depRangeStr = dependency.Value ?? string.Empty; - - // 检查依赖插件是否存在 - // 注意:这里检查的是 candidates 列表,因为所有插件都还没被正式确认加载 - if (!allCandidatePluginsMap.TryGetValue(depName, out var loadedVersionStr)) - { - pluginLoaderLogger.LogError("Plugin '{PluginName}' failed to load. Missing dependency: '{DepName}'.", plugin.Name, depName); - dependenciesMet = false; - break; - } - - // 解析当前加载的插件版本 - if (!NuGetVersion.TryParse(loadedVersionStr, out var loadedVersion)) - { - pluginLoaderLogger.LogError("Plugin '{PluginName}' depends on '{DepName}', but the loaded version '{LoadedVer}' of dependency '{DepName}' has an invalid format.", - plugin.Name, depName, loadedVersionStr, depName); - dependenciesMet = false; - break; - } - - // 解析依赖要求的版本范围 - // VersionRange.Parse 支持 "[1.0, 2.0)", "1.0" (即 >=1.0) 等标准写法 - bool isRangeValid = VersionRange.TryParse(depRangeStr, out var requiredRange); - - // 如果解析失败,尝试处理浮动版本 (例如 "1.*") - if (!isRangeValid && FloatRange.TryParse(depRangeStr, out var floatRange)) - { - // 检查浮动版本范围 - if (!floatRange.Satisfies(loadedVersion)) - { - pluginLoaderLogger.LogError("Plugin '{PluginName}' requires '{DepName}' version '{DepRange}' (Floating), but loaded version '{LoadedVer}' is incompatible.", - plugin.Name, depName, depRangeStr, loadedVersionStr); - dependenciesMet = false; - break; - } - } - else if (isRangeValid) - { - // 检查标准版本范围 - if (!requiredRange!.Satisfies(loadedVersion)) - { - pluginLoaderLogger.LogError("Plugin '{PluginName}' requires '{DepName}' version '{DepRange}', but loaded version '{LoadedVer}' is incompatible.", - plugin.Name, depName, depRangeStr, loadedVersionStr); - dependenciesMet = false; - break; - } - } - else - { - // 无法解析的版本要求 - pluginLoaderLogger.LogError("Plugin '{PluginName}' has an invalid dependency version format for '{DepName}': '{DepRange}'.", - plugin.Name, depName, depRangeStr); - dependenciesMet = false; - break; - } - } - - // --- 第二阶段:自定义验证逻辑 (可选依赖/高级检查) --- - if (dependenciesMet) - { - try - { - // 调用插件的 ValidateDependency 方法 - if (!plugin.ValidateDependency(allCandidatePluginsMap)) - { - pluginLoaderLogger.LogWarning("Plugin '{PluginName}' rejected loading during validation check.", plugin.Name); - dependenciesMet = false; - } - } - catch (Exception ex) - { - pluginLoaderLogger.LogError(ex, "Plugin '{PluginName}' threw an exception during dependency validation.", plugin.Name); - dependenciesMet = false; - } - } - - if (dependenciesMet) - { - validPlugins.Add(plugin); - } -} - -// 移除未能满足依赖的插件 -if (validPlugins.Count < plugins.Count) -{ - var removedCount = plugins.Count - validPlugins.Count; - pluginLoaderLogger.LogWarning("{Count} plugins were unloaded due to missing or incompatible dependencies.", removedCount); - plugins = validPlugins; -} -// -------------------- - -// 将插件集合注入到 DI 容器(作为单例),插件实现可从容器中获取此集合 -builder.Services.AddSingleton(plugins); - -// 让每个插件向 DI 容器注册它们自己的服务 -pluginLoaderLogger.LogInformation("Registering plugin services..."); -foreach (var plugin in plugins) -{ - // 实现配置隔离:为每个插件加载独立的配置文件 - // 路径格式:config/{PluginName}.json - var configPath = Path.Combine(AppContext.BaseDirectory, "config", $"{plugin.Name}.json"); - - // 检查当前插件的配置文件是否存在于指定路径 - if (!File.Exists(configPath)) - { - // 尝试从插件实例中获取默认配置对象 - var defaultConfig = plugin.DefaultConfig; - - // 如果插件提供了非空的默认配置对象 - if (defaultConfig != null) - { - try - { - // 获取配置文件所在的目录路径 - var configDir = Path.GetDirectoryName(configPath); - - // 检查目录路径是否有效且目录是否存在,若不存在则创建 - if (!string.IsNullOrEmpty(configDir) && !Directory.Exists(configDir)) - { - // 创建配置文件的父目录 - Directory.CreateDirectory(configDir); - } - - // 配置 JSON 序列化选项,设置为缩进格式以提高可读性 - var jsonOptions = new System.Text.Json.JsonSerializerOptions - { - WriteIndented = true - }; - - // 将默认配置对象序列化为 JSON 字符串 - var jsonString = System.Text.Json.JsonSerializer.Serialize(defaultConfig, jsonOptions); - - // 将序列化后的 JSON 字符串写入到配置文件路径 - File.WriteAllText(configPath, jsonString); - - // 记录日志,表明已成功生成默认配置文件 - pluginLoaderLogger.LogInformation("Generated default configuration for plugin {PluginName} at {ConfigPath}", plugin.Name, configPath); - } - catch (Exception ex) - { - // 捕获并记录在生成默认配置文件过程中发生的任何异常 - pluginLoaderLogger.LogError(ex, "Failed to generate default configuration for plugin {PluginName}", plugin.Name); - } - } - } - - // 构建插件专用的 Configuration 对象 - var pluginConfig = new ConfigurationBuilder() - .AddJsonFile(configPath, optional: true, reloadOnChange: true) - .Build(); - - // 将独立的配置对象传递给插件的服务注册方法 - plugin.RegisterServices(builder.Services, pluginConfig); -} - -// 添加基本的 OpenAPI/Swagger 支持 -builder.Services.AddEndpointsApiExplorer(); - -builder.Services.AddSwaggerGen(options => -{ - // 基本的 Swagger 文档信息 - options.SwaggerDoc("v1", new OpenApiInfo - { - Title = apiName, - Version = apiVersion, - Description = "一个由插件动态构建的 API" - }); - - // 添加自定义的 ApiKey 安全定义(头部 X-Api-Token)供插件使用受保护路由 - options.AddSecurityDefinition("ApiKeyAuth", new OpenApiSecurityScheme - { - Name = "X-Api-Token", - In = ParameterLocation.Header, - Type = SecuritySchemeType.ApiKey, - Description = "用于访问受保护路由的 API 令牌" - }); - - // 将定义应用到全局(此处不指定特定范围) - options.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "ApiKeyAuth" - } - }, - new List() - } - }); -}); - -// 构建 WebApplication 实例(此时服务已注册) -var app = builder.Build(); - -// 全局异常处理:捕获未处理异常并返回统一的 JSON 响应 -app.UseExceptionHandler(exceptionHandlerApp => -{ - exceptionHandlerApp.Run(async context => - { - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - context.Response.ContentType = "application/json"; - var exceptionDetails = context.Features.Get(); - var exception = exceptionDetails?.Error; - // 记录异常(在日志中包含发生路径) - app.Logger.LogError(exception, "Unhandled exception caught by global handler at {Path}", exceptionDetails?.Path); - var response = new - { - StatusCode = context.Response.StatusCode, - Message = "An unexpected internal server error has occurred.", - - // 在开发环境中返回详细信息,否则不泄露内部异常消息 - Details = app.Environment.IsDevelopment() ? exception?.Message : null, - Path = exceptionDetails?.Path - }; - await context.Response.WriteAsJsonAsync(response); - }); -}); - -// 在开发环境中启用 Swagger UI,便于调试与查看文档 -if (app.Environment.IsDevelopment()) -{ - app.Logger.LogInformation("Enabling Swagger UI (Development Mode)..."); - app.UseSwagger(); - app.UseSwaggerUI(options => - { - options.SwaggerEndpoint("/swagger/v1/swagger.json", $"{apiName} {apiVersion}"); - options.RoutePrefix = "swagger"; // 以 /swagger 作为 UI 的路由前缀 - }); -} - -// 让每个插件有机会在请求管道中注册中间件 -app.Logger.LogInformation("Configuring plugin middleware..."); -foreach (var plugin in plugins) -{ - plugin.Configure(app); -} - -// 让每个插件注册它们自身的路由与端点 -app.Logger.LogInformation("Registering plugin routes..."); -foreach (var plugin in plugins) -{ - // 检查是否启用自动路由前缀 - IEndpointRouteBuilder routeBuilder = app; - if (plugin.UseAutoRoutePrefix) - { - // 默认使用插件名称作为前缀 - string routePrefix = plugin.Name; - - // 尝试从配置中读取重写值 (配置节: RouteOverride:插件名) - var overrideRoute = app.Configuration.GetValue($"RouteOverride:{plugin.Name}"); - if (!string.IsNullOrEmpty(overrideRoute)) - { - // 验证重写值只包含字母和数字 - if (System.Text.RegularExpressions.Regex.IsMatch(overrideRoute, "^[a-zA-Z0-9]+$")) - { - routePrefix = overrideRoute; - app.Logger.LogInformation("Route prefix for plugin '{PluginName}' overridden to '{RoutePrefix}'", plugin.Name, routePrefix); - } - else - { - // 无效重写值,回退到插件名作为路由前缀 - app.Logger.LogWarning("Invalid route override '{OverrideRoute}' for plugin '{PluginName}'. Only alphanumeric characters (A-Z, a-z, 0-9) are allowed. Falling back to default.", overrideRoute, plugin.Name); - } - } - - // 如果启用,创建一个带前缀的路由组 - // 使用 TrimStart('/') 确保路径格式正确 - routeBuilder = app.MapGroup($"/{routePrefix.TrimStart('/')}"); - } - - // 将(可能是分组的)路由构建器传递给插件 - plugin.RegisterRoutes(routeBuilder, app.Configuration); - app.Logger.LogInformation("Loaded Plugin: {PluginName} v{PluginVersion}", plugin.Name, plugin.Version); -} - -// 根路径:显示 API 名称、版本与已运行时长 -app.MapGet("/", () => -{ - var uptime = DateTime.UtcNow - startTime; - return new { apiName, version = apiVersion, runningTime = uptime, message = "Core API running." }; -}); - -// 启动并监听请求 -try -{ - app.Run(); -} -catch (Exception ex) -{ - Log.Fatal(ex, "Host terminated unexpectedly"); -} -finally -{ - Log.CloseAndFlush(); -} - -// 从 Plugins 目录加载实现了 IApiPlugin 的类型并返回其实例集合 -List LoadPlugins(IConfiguration configuration, Microsoft.Extensions.Logging.ILogger logger) -{ - var loadedPlugins = new List(); - string pluginsPath = Path.Combine(AppContext.BaseDirectory, "Plugins"); - - if (!Directory.Exists(pluginsPath)) - { - try - { - Directory.CreateDirectory(pluginsPath); - logger.LogInformation("Plugins directory did not exist and was created at {PluginsPath}", pluginsPath); - } - catch (Exception ex) - { - // 无法创建目录时记录错误并返回空插件列表 - logger.LogError(ex, "Failed to create plugins directory at {PluginsPath}", pluginsPath); - return loadedPlugins; - } - return loadedPlugins; - } - - foreach (var dllPath in Directory.GetFiles(pluginsPath, "*.dll")) - { - try - { - // 使用自定义的 PluginLoadContext 加载插件程序集,实现隔离 - // 每个插件使用单独的 LoadContext,确保依赖隔离 - var loadContext = new PluginLoadContext(dllPath); - var assembly = loadContext.LoadFromAssemblyPath(dllPath); - - var pluginTypes = assembly.GetTypes() - .Where(t => typeof(IApiPlugin).IsAssignableFrom(t) && !t.IsInterface); - - foreach (var type in pluginTypes) - { - if (Activator.CreateInstance(type) is IApiPlugin plugin) - { - loadedPlugins.Add(plugin); - } - } - } - catch (Exception ex) - { - // 插件加载失败时记录错误并继续加载其他插件 - logger.LogError(ex, "Error loading plugin from {DllPath}", dllPath); - } - } - return loadedPlugins; -} +var applicationHost = new ApplicationHost(args); +applicationHost.Build().Run(); diff --git a/sharwapi.Core.csproj b/sharwapi.Core.csproj index e7b5d58..7116ce9 100644 --- a/sharwapi.Core.csproj +++ b/sharwapi.Core.csproj @@ -18,8 +18,8 @@ - - + + From 618dc62cb19d3141db190b75c116103cc8016d43 Mon Sep 17 00:00:00 2001 From: sharworange Date: Tue, 24 Feb 2026 22:41:36 +0800 Subject: [PATCH 08/11] =?UTF-8?q?=E5=B0=8F=E8=8C=83=E5=9B=B4=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=EF=BC=8C=E7=A1=AE=E4=BF=9D=E8=83=BD=E5=A4=9F=E9=95=BF?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E7=A8=B3=E5=AE=9A=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Logger.cs | 18 -- Modules/Configuration/AppConfiguration.cs | 2 +- Modules/Configuration/SerilogSetup.cs | 49 ---- Modules/Hosting/ApplicationHost.cs | 2 +- Modules/Logging/Logger.cs | 8 +- Modules/Middleware/ExceptionHandling.cs | 9 +- Modules/Middleware/MiddlewarePipeline.cs | 9 +- .../PluginDependencyChecker.cs | 242 ++++++++++++++---- Modules/Routing/EndpointRegistration.cs | 21 +- Modules/Services/ServiceRegistration.cs | 43 +++- PluginLoadContext.cs | 62 ----- appsettings.json | 19 +- 12 files changed, 286 insertions(+), 198 deletions(-) delete mode 100644 Logger.cs delete mode 100644 Modules/Configuration/SerilogSetup.cs delete mode 100644 PluginLoadContext.cs diff --git a/Logger.cs b/Logger.cs deleted file mode 100644 index 53dd757..0000000 --- a/Logger.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Serilog; - -namespace sharwapi.Core; - -public static class Logger -{ - /// - /// 初始化全局 Serilog 配置 - /// - /// 应用程序配置对象 - public static void Initialize(IConfiguration configuration) - { - Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(configuration) - .CreateLogger(); - } -} diff --git a/Modules/Configuration/AppConfiguration.cs b/Modules/Configuration/AppConfiguration.cs index daea91d..d42daa9 100644 --- a/Modules/Configuration/AppConfiguration.cs +++ b/Modules/Configuration/AppConfiguration.cs @@ -15,7 +15,7 @@ public static class AppConfiguration public static IConfigurationBuilder Build() { return new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) + .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); } diff --git a/Modules/Configuration/SerilogSetup.cs b/Modules/Configuration/SerilogSetup.cs deleted file mode 100644 index a82ae65..0000000 --- a/Modules/Configuration/SerilogSetup.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Serilog; - -namespace sharwapi.Core.Modules.Configuration; - -/// -/// Serilog 日志配置 -/// 负责初始化和管理全局 Serilog 日志配置 -/// -public static class SerilogSetup -{ - /// - /// 初始化全局 Serilog 配置 - /// - /// 应用程序配置对象 - public static void Initialize(IConfiguration configuration) - { - Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(configuration) - .CreateLogger(); - } - - /// - /// 配置日志输出到控制台 - /// - /// 日志配置构建器 - /// 日志配置构建器 - public static LoggerConfiguration AddConsole(this LoggerConfiguration loggerConfiguration) - { - return loggerConfiguration.WriteTo.Console(); - } - - /// - /// 配置日志输出到文件 - /// - /// 日志配置构建器 - /// 日志文件路径 - /// 日志配置构建器 - public static LoggerConfiguration AddFile(this LoggerConfiguration loggerConfiguration, string path) - { - return loggerConfiguration.WriteTo.File( - path: path, - rollingInterval: RollingInterval.Day, - retainedFileCountLimit: 30, - fileSizeLimitBytes: 10485760, - rollOnFileSizeLimit: true - ); - } -} diff --git a/Modules/Hosting/ApplicationHost.cs b/Modules/Hosting/ApplicationHost.cs index 62bb9db..25c2afa 100644 --- a/Modules/Hosting/ApplicationHost.cs +++ b/Modules/Hosting/ApplicationHost.cs @@ -115,7 +115,7 @@ public void Run() { _app.Run(); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { Log.Fatal(ex, "Host terminated unexpectedly"); } diff --git a/Modules/Logging/Logger.cs b/Modules/Logging/Logger.cs index 5a74203..dcd1514 100644 --- a/Modules/Logging/Logger.cs +++ b/Modules/Logging/Logger.cs @@ -1,18 +1,20 @@ +using Microsoft.Extensions.Configuration; using Serilog; namespace sharwapi.Core.Modules.Logging; /// /// 日志服务 -/// 提供应用程序的日志记录功能 +/// 提供应用程序的日志记录功能,通过 appsettings.json 中的 Serilog 节点进行配置。 /// public static class Logger { /// - /// 初始化全局 Serilog 配置 + /// 初始化全局 Serilog 配置。 + /// 日志输出目标(Console、File 等)均从 appsettings.json 的 Serilog 节点读取,无需硬编码。 /// /// 应用程序配置对象 - public static void Initialize(Microsoft.Extensions.Configuration.IConfiguration configuration) + public static void Initialize(IConfiguration configuration) { Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(configuration) diff --git a/Modules/Middleware/ExceptionHandling.cs b/Modules/Middleware/ExceptionHandling.cs index c0ae10b..acdc9f8 100644 --- a/Modules/Middleware/ExceptionHandling.cs +++ b/Modules/Middleware/ExceptionHandling.cs @@ -22,7 +22,14 @@ public static void Configure(WebApplication app) var exceptionDetails = context.Features.Get(); var exception = exceptionDetails?.Error; - app.Logger.LogError(exception, "Unhandled exception caught by global handler at {Path}", exceptionDetails?.Path); + if (exception != null) + { + app.Logger.LogError(exception, "Unhandled exception caught by global handler at {Path}", exceptionDetails?.Path); + } + else + { + app.Logger.LogError("Unhandled exception caught by global handler at {Path}, but exception details were unavailable.", exceptionDetails?.Path); + } var response = new { diff --git a/Modules/Middleware/MiddlewarePipeline.cs b/Modules/Middleware/MiddlewarePipeline.cs index 99c6918..d906aa4 100644 --- a/Modules/Middleware/MiddlewarePipeline.cs +++ b/Modules/Middleware/MiddlewarePipeline.cs @@ -19,7 +19,14 @@ public static void Configure(WebApplication app, List plugins) foreach (var plugin in plugins) { - plugin.Configure(app); + try + { + plugin.Configure(app); + } + catch (Exception ex) + { + app.Logger.LogError(ex, "Plugin '{PluginName}' threw an exception during Configure().", plugin.Name); + } } } } diff --git a/Modules/PluginManagement/PluginDependencyChecker.cs b/Modules/PluginManagement/PluginDependencyChecker.cs index b27edee..f2a50f3 100644 --- a/Modules/PluginManagement/PluginDependencyChecker.cs +++ b/Modules/PluginManagement/PluginDependencyChecker.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using Microsoft.Extensions.Logging; using NuGet.Versioning; using sharwapi.Contracts.Core; @@ -6,7 +7,7 @@ namespace sharwapi.Core.Modules.PluginManagement; /// /// 插件依赖检查器 -/// 负责检查插件的依赖关系是否满足 +/// 负责检查插件的依赖关系是否满足,并进行拓扑排序以确定加载顺序 /// public class PluginDependencyChecker { @@ -25,108 +26,217 @@ public PluginDependencyChecker(ILogger logger) /// 检查所有插件的依赖关系 /// /// 所有候选插件列表 - /// 通过依赖检查的有效插件列表 + /// 通过依赖检查的有效插件列表(已拓扑排序) public List CheckDependencies(List plugins) { - // 构建插件名称到版本号的映射,用于依赖检查 - var allCandidatePluginsMap = plugins.ToDictionary(p => p.Name, p => p.Version); - var validPlugins = new List(); + if (plugins.Count == 0) + return plugins; - // 遍历每个插件,检查其依赖是否满足 - foreach (var plugin in plugins) + // 步骤1:构建依赖图 + var dependencyGraph = BuildDependencyGraph(plugins); + + // 步骤2:检测循环依赖(使用 Kahn 算法) + var sortedPlugins = TopologicalSort(dependencyGraph, plugins); + + if (sortedPlugins == null) + { + // 循环依赖已被 Kahn 算法检测并记录,返回空列表 + return new List(); + } + + // 步骤3:阶段一 - 声明式强依赖检查(基于拓扑排序结果) + var stageOneValid = new Dictionary(); + foreach (var plugin in sortedPlugins) + { + if (CheckDeclarativeDependencies(plugin, dependencyGraph, stageOneValid)) + { + stageOneValid[plugin.Name] = plugin; + } + else + { + _logger.LogWarning("Plugin '{PluginName}' failed stage one (declarative) dependency check and was removed.", plugin.Name); + } + } + + // 步骤4:阶段二 - 自定义验证(只对阶段一通过的插件调用) + // 传入的是已通过阶段一的"有效候选",而非全部候选 + var stageTwoValid = new Dictionary(); + foreach (var plugin in stageOneValid.Values) { - if (CheckPluginDependencies(plugin, allCandidatePluginsMap)) + try + { + // 传入阶段一的有效插件列表 + if (!plugin.ValidateDependency(new ReadOnlyDictionary( + stageOneValid.ToDictionary(p => p.Key, p => p.Value.Version)))) + { + _logger.LogWarning("Plugin '{PluginName}' rejected loading during stage two (ValidateDependency) check.", plugin.Name); + } + else + { + stageTwoValid[plugin.Name] = plugin; + } + } + catch (Exception ex) { - validPlugins.Add(plugin); + _logger.LogError(ex, "Plugin '{PluginName}' threw an exception during stage two dependency validation.", plugin.Name); } } - // 如果有插件因依赖问题被移除,记录警告日志 - if (validPlugins.Count < plugins.Count) + // 步骤5:级联剔除 - 如果某个插件被剔除,依赖它的插件也需要重新检查 + var finalValid = ApplyCascadeRemoval(stageTwoValid, dependencyGraph); + + // 记录结果 + if (finalValid.Count < plugins.Count) { - var removedCount = plugins.Count - validPlugins.Count; + var removedCount = plugins.Count - finalValid.Count; _logger.LogWarning("{Count} plugins were unloaded due to missing or incompatible dependencies.", removedCount); } - return validPlugins; + // 返回拓扑排序后的有效插件列表 + return sortedPlugins.Where(p => finalValid.ContainsKey(p.Name)).ToList(); } /// - /// 检查单个插件的依赖关系 + /// 构建依赖图 /// - /// 要检查的插件 - /// 所有候选插件的映射 - /// 如果依赖满足返回 true,否则返回 false - private bool CheckPluginDependencies(IApiPlugin plugin, Dictionary allCandidatePluginsMap) + private Dictionary> BuildDependencyGraph(List plugins) { - // 第一阶段:声明式强依赖检查 - foreach (var dependency in plugin.Dependencies) + var graph = new Dictionary>(); + + foreach (var plugin in plugins) { - if (!CheckSingleDependency(plugin, dependency, allCandidatePluginsMap)) + if (!graph.ContainsKey(plugin.Name)) { - return false; + graph[plugin.Name] = new HashSet(); + } + + foreach (var dep in plugin.Dependencies) + { + graph[plugin.Name].Add(dep.Key); } } - // 第二阶段:自定义验证逻辑 - try + return graph; + } + + /// + /// 使用 Kahn 算法进行拓扑排序,同时检测循环依赖 + /// + private List? TopologicalSort(Dictionary> graph, List plugins) + { + var pluginMap = plugins.ToDictionary(p => p.Name); + + // 计算每个插件的入度(被依赖数) + var inDegree = new Dictionary(); + foreach (var plugin in plugins) + { + inDegree[plugin.Name] = 0; + } + + foreach (var plugin in plugins) { - if (!plugin.ValidateDependency(allCandidatePluginsMap)) + foreach (var dep in graph[plugin.Name]) { - _logger.LogWarning("Plugin '{PluginName}' rejected loading during validation check.", plugin.Name); - return false; + // 如果被依赖的插件存在于候选列表中,增加其入度 + if (inDegree.ContainsKey(dep)) + { + inDegree[dep]++; + } } } - catch (Exception ex) + + // 入度为0的插件可以首先加载(没有插件依赖它) + var queue = new Queue(inDegree.Where(kv => kv.Value == 0).Select(kv => kv.Key)); + var sorted = new List(); + + while (queue.Count > 0) { - _logger.LogError(ex, "Plugin '{PluginName}' threw an exception during dependency validation.", plugin.Name); - return false; + var current = queue.Dequeue(); + sorted.Add(current); + + // 找到所有依赖当前插件的插件 + foreach (var plugin in plugins) + { + if (graph[plugin.Name].Contains(current)) + { + inDegree[plugin.Name]--; + if (inDegree[plugin.Name] == 0) + { + queue.Enqueue(plugin.Name); + } + } + } + } + + // 如果排序后的数量不等于插件数量,说明存在循环依赖 + if (sorted.Count != plugins.Count) + { + // 找出循环依赖的插件 + var cyclicPlugins = inDegree.Where(kv => kv.Value > 0).Select(kv => kv.Key); + _logger.LogError("Circular dependency detected among plugins: {CyclicPlugins}. Loading aborted.", + string.Join(", ", cyclicPlugins)); + return null; } + // 按排序结果返回插件列表 + return sorted.Select(name => pluginMap[name]).ToList(); + } + + /// + /// 阶段一:声明式强依赖检查 + /// 只检查插件声明的 Dependencies 是否满足 + /// + private bool CheckDeclarativeDependencies(IApiPlugin plugin, + Dictionary> graph, + Dictionary validPlugins) + { + foreach (var dependency in plugin.Dependencies) + { + if (!CheckSingleDependency(plugin, dependency, validPlugins)) + { + return false; + } + } return true; } /// - /// 检查单个依赖 + /// 检查单个依赖(使用当前有效插件列表) /// - private bool CheckSingleDependency(IApiPlugin plugin, KeyValuePair dependency, Dictionary allCandidatePluginsMap) + private bool CheckSingleDependency(IApiPlugin plugin, KeyValuePair dependency, Dictionary validPlugins) { - // 提取依赖名称和版本范围 string depName = dependency.Key; string depRangeStr = dependency.Value ?? string.Empty; - // 检查依赖插件是否存在 - if (!allCandidatePluginsMap.TryGetValue(depName, out var loadedVersionStr)) + // 检查依赖插件是否在有效列表中 + if (!validPlugins.TryGetValue(depName, out var depPlugin)) { _logger.LogError("Plugin '{PluginName}' failed to load. Missing dependency: '{DepName}'.", plugin.Name, depName); return false; } - // 解析已加载的插件版本号 - if (!NuGetVersion.TryParse(loadedVersionStr, out var loadedVersion)) + // 解析版本 + if (!NuGetVersion.TryParse(depPlugin.Version, out var loadedVersion)) { - _logger.LogError("Plugin '{PluginName}' depends on '{DepName}', but the loaded version '{LoadedVer}' of dependency '{DepName}' has an invalid format.", - plugin.Name, depName, loadedVersionStr, depName); + _logger.LogError("Plugin '{PluginName}' depends on '{DepName}', but the loaded version '{LoadedVer}' has an invalid format.", + plugin.Name, depName, depPlugin.Version); return false; } - // 解析依赖要求的版本范围 + // 解析版本范围 if (!VersionRange.TryParse(depRangeStr, out var requiredRange)) { - // 尝试处理浮动版本(如 1.*) if (FloatRange.TryParse(depRangeStr, out var floatRange)) { - // 检查浮动版本是否满足 if (!floatRange.Satisfies(loadedVersion)) { _logger.LogError("Plugin '{PluginName}' requires '{DepName}' version '{DepRange}' (Floating), but loaded version '{LoadedVer}' is incompatible.", - plugin.Name, depName, depRangeStr, loadedVersionStr); + plugin.Name, depName, depRangeStr, loadedVersion); return false; } } else { - // 无法解析的版本要求格式 _logger.LogError("Plugin '{PluginName}' has an invalid dependency version format for '{DepName}': '{DepRange}'.", plugin.Name, depName, depRangeStr); return false; @@ -134,12 +244,52 @@ private bool CheckSingleDependency(IApiPlugin plugin, KeyValuePair + /// 级联剔除:当某个插件被剔除时,依赖它的插件也需要重新检查 + /// + private Dictionary ApplyCascadeRemoval(Dictionary validPlugins, + Dictionary> graph) + { + var result = new Dictionary(validPlugins); + bool changed; + + do + { + changed = false; + var toRemove = new List(); + + foreach (var plugin in result.Values) + { + // 检查这个插件依赖的所有插件是否都还在结果中 + foreach (var depName in graph[plugin.Name]) + { + if (!result.ContainsKey(depName)) + { + // 依赖缺失,需要剔除此插件 + toRemove.Add(plugin.Name); + _logger.LogWarning("Plugin '{PluginName}' is being removed due to missing dependency '{DepName}'.", + plugin.Name, depName); + break; + } + } + } + + foreach (var name in toRemove) + { + result.Remove(name); + changed = true; + } + + } while (changed); + + return result; + } } diff --git a/Modules/Routing/EndpointRegistration.cs b/Modules/Routing/EndpointRegistration.cs index 8151469..053545b 100644 --- a/Modules/Routing/EndpointRegistration.cs +++ b/Modules/Routing/EndpointRegistration.cs @@ -23,13 +23,20 @@ public static void RegisterPluginRoutes(WebApplication app, List plu // 遍历所有已加载的插件,为每个插件注册路由 foreach (var plugin in plugins) { - // 解析插件的路由前缀(可能来自配置覆盖) - var routeBuilder = RoutePrefixResolver.Resolve(plugin, configuration, app); - - // 调用插件的路由注册方法 - plugin.RegisterRoutes(routeBuilder, configuration); - - app.Logger.LogInformation("Loaded Plugin: {PluginName} v{PluginVersion}", plugin.Name, plugin.Version); + try + { + // 解析插件的路由前缀(可能来自配置覆盖) + var routeBuilder = RoutePrefixResolver.Resolve(plugin, configuration, app); + + // 调用插件的路由注册方法 + plugin.RegisterRoutes(routeBuilder, configuration); + + app.Logger.LogInformation("Loaded Plugin: {PluginName} v{PluginVersion}", plugin.Name, plugin.Version); + } + catch (Exception ex) + { + app.Logger.LogError(ex, "Plugin '{PluginName}' threw an exception during RegisterRoutes().", plugin.Name); + } } } diff --git a/Modules/Services/ServiceRegistration.cs b/Modules/Services/ServiceRegistration.cs index c007432..dbfc0a9 100644 --- a/Modules/Services/ServiceRegistration.cs +++ b/Modules/Services/ServiceRegistration.cs @@ -92,9 +92,14 @@ private void EnsurePluginConfigFile(IApiPlugin plugin, string configPath) // 将默认配置序列化为 JSON 并写入文件 var jsonString = System.Text.Json.JsonSerializer.Serialize(defaultConfig, jsonOptions); - File.WriteAllText(configPath, jsonString); - - _logger.LogInformation("Generated default configuration for plugin {PluginName} at {ConfigPath}", plugin.Name, configPath); + if (TryWriteConfigWithTimeout(configPath, jsonString, TimeSpan.FromSeconds(10))) + { + _logger.LogInformation("Generated default configuration for plugin {PluginName} at {ConfigPath}", plugin.Name, configPath); + } + else + { + _logger.LogError("Timed out while generating default configuration for plugin {PluginName} at {ConfigPath}", plugin.Name, configPath); + } } catch (Exception ex) { @@ -146,4 +151,36 @@ public void AddSwaggerServices(string apiName, string apiVersion) }); }); } + /// + /// 以超时保护写入配置文件,避免启动阶段因 I/O 卡顿而无限阻塞。 + /// + private static bool TryWriteConfigWithTimeout(string configPath, string jsonContent, TimeSpan timeout) + { + try + { + using var cts = new CancellationTokenSource(timeout); + var writeTask = WriteConfigAsync(configPath, jsonContent, cts.Token); + writeTask.GetAwaiter().GetResult(); + return true; + } + catch (OperationCanceledException) + { + return false; + } + } + + private static async Task WriteConfigAsync(string configPath, string jsonContent, CancellationToken cancellationToken) + { + await using var fileStream = new FileStream( + configPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + options: FileOptions.Asynchronous | FileOptions.WriteThrough); + + var bytes = System.Text.Encoding.UTF8.GetBytes(jsonContent); + await fileStream.WriteAsync(bytes, cancellationToken); + await fileStream.FlushAsync(cancellationToken); + } } diff --git a/PluginLoadContext.cs b/PluginLoadContext.cs deleted file mode 100644 index c7098f8..0000000 --- a/PluginLoadContext.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Reflection; -using System.Runtime.Loader; - -namespace sharwapi.Core -{ - // 自定义程序集加载上下文,用于实现插件的隔离加载。 - // 每个插件在独立的 AssemblyLoadContext 实例中运行,防止依赖冲突。 - public class PluginLoadContext : AssemblyLoadContext - { - // 依赖解析器,用于根据 .deps.json 文件解析依赖程序集的路径。 - private readonly AssemblyDependencyResolver _resolver; - - // 初始化 PluginLoadContext 的新实例。 - // pluginPath: 插件主程序集文件的完整路径。 - public PluginLoadContext(string pluginPath) : base(isCollectible: true) - { - // 初始化 AssemblyDependencyResolver,用于解析插件及其依赖项的路径。 - _resolver = new AssemblyDependencyResolver(pluginPath); - } - - // 重写 Load 方法以自定义程序集加载逻辑。 - protected override Assembly? Load(AssemblyName assemblyName) - { - // 确保 sharwapi.Contracts.Core 程序集不被当前上下文加载。 - // 这保证了宿主应用程序和插件使用相同的 IApiPlugin 接口类型, - // 避免因类型加载上下文不同而导致的类型转换异常。 - if (assemblyName.Name == "sharwapi.Contracts.Core") - { - // 返回 null,委托默认加载上下文(DefaultContext)加载该程序集。 - return null; - } - - // 尝试使用依赖解析器将程序集名称解析为文件路径。 - string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); - - if (assemblyPath != null) - { - // 如果解析成功,从指定路径加载程序集。 - return LoadFromAssemblyPath(assemblyPath); - } - - // 如果无法解析路径,返回 null。 - return null; - } - - // 重写 LoadUnmanagedDll 方法以自定义非托管库加载逻辑。 - protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) - { - // 尝试解析非托管库的路径。 - string? libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); - - if (libraryPath != null) - { - // 如果解析成功,从指定路径加载非托管库。 - return LoadUnmanagedDllFromPath(libraryPath); - } - - // 如果无法解析路径,返回零指针。 - return IntPtr.Zero; - } - } -} diff --git a/appsettings.json b/appsettings.json index 6ad7a7d..9e63e76 100644 --- a/appsettings.json +++ b/appsettings.json @@ -10,13 +10,20 @@ "WriteTo": [ { "Name": "Console" }, { - "Name": "File", + "Name": "Async", "Args": { - "path": "logs/log-.txt", - "rollingInterval": "Day", - "retainedFileCountLimit": 30, - "fileSizeLimitBytes": 10485760, - "rollOnFileSizeLimit": true + "configure": [ + { + "Name": "File", + "Args": { + "path": "logs/log-.txt", + "rollingInterval": "Day", + "retainedFileCountLimit": 30, + "fileSizeLimitBytes": 10485760, + "rollOnFileSizeLimit": true + } + } + ] } } ], From e5255594226934af1838470ed9ffd08e94a27a28 Mon Sep 17 00:00:00 2001 From: sharworange Date: Wed, 25 Feb 2026 01:09:28 +0800 Subject: [PATCH 09/11] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E7=AD=89=E7=BA=A7=20&&=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8nuget=E6=BA=90=E7=9A=84Contracts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appsettings.json | 2 +- sharwapi.Core.csproj | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/appsettings.json b/appsettings.json index 9e63e76..f7b5ccd 100644 --- a/appsettings.json +++ b/appsettings.json @@ -3,7 +3,7 @@ "MinimumLevel": { "Default": "Information", "Override": { - "Microsoft": "Warning", + "Microsoft.AspNetCore": "Information", "System": "Warning" } }, diff --git a/sharwapi.Core.csproj b/sharwapi.Core.csproj index 7116ce9..e7b5d58 100644 --- a/sharwapi.Core.csproj +++ b/sharwapi.Core.csproj @@ -18,8 +18,8 @@ - - + + From e84524aa5443bb62049785f901ef9c69b2dc0b6f Mon Sep 17 00:00:00 2001 From: sharworange Date: Thu, 26 Feb 2026 01:37:43 +0800 Subject: [PATCH 10/11] =?UTF-8?q?=E8=A7=A3=E5=86=B3RegisterRoutes=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E5=88=B0=E7=9A=84configuration=E4=B8=8D=E6=98=AF?= =?UTF-8?q?=E6=8F=92=E4=BB=B6configuration=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Modules/Routing/EndpointRegistration.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Modules/Routing/EndpointRegistration.cs b/Modules/Routing/EndpointRegistration.cs index 053545b..9f25f94 100644 --- a/Modules/Routing/EndpointRegistration.cs +++ b/Modules/Routing/EndpointRegistration.cs @@ -15,7 +15,7 @@ public static class EndpointRegistration /// /// Web 应用程序 /// 已加载的插件列表 - /// 应用程序配置 + /// 应用程序配置(用于解析路由前缀覆盖) public static void RegisterPluginRoutes(WebApplication app, List plugins, IConfiguration configuration) { app.Logger.LogInformation("Registering plugin routes..."); @@ -25,11 +25,17 @@ public static void RegisterPluginRoutes(WebApplication app, List plu { try { - // 解析插件的路由前缀(可能来自配置覆盖) + // 解析插件的路由前缀(可能来自配置覆盖,使用全局配置) var routeBuilder = RoutePrefixResolver.Resolve(plugin, configuration, app); - // 调用插件的路由注册方法 - plugin.RegisterRoutes(routeBuilder, configuration); + // 为插件构建专属配置(来自 config/{插件名}.json) + var configPath = Path.Combine(AppContext.BaseDirectory, "config", $"{plugin.Name}.json"); + var pluginConfig = new ConfigurationBuilder() + .AddJsonFile(configPath, optional: true, reloadOnChange: true) + .Build(); + + // 调用插件的路由注册方法,传入插件专属配置 + plugin.RegisterRoutes(routeBuilder, pluginConfig); app.Logger.LogInformation("Loaded Plugin: {PluginName} v{PluginVersion}", plugin.Name, plugin.Version); } From 30631d2e3763b7dbfe17ae04d5ab3c87286b297b Mon Sep 17 00:00:00 2001 From: sharworange Date: Thu, 26 Feb 2026 18:00:41 +0800 Subject: [PATCH 11/11] =?UTF-8?q?=E6=B7=BB=E5=8A=A0publish=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/publish.yml | 85 +++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..91a936f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,85 @@ +name: Publish + +on: + push: + tags: ["v*"] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - rid: linux-x64 + profile: linux-x64 + - rid: linux-arm64 + profile: linux-arm64 + - rid: win-x64 + profile: win-x64 + - rid: win-x86 + profile: win-x86 + - rid: win-arm64 + profile: win-arm64 + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore dependencies + run: dotnet restore sharwapi.Core.csproj + + - name: Publish + run: dotnet publish sharwapi.Core.csproj /p:PublishProfile=Properties/PublishProfiles/${{ matrix.profile }}.pubxml --configuration Release --output ./publish/${{ matrix.rid }} + + - name: Prepare package content + run: | + mkdir -p ./package/${{ matrix.rid }} + + if [ -f "./publish/${{ matrix.rid }}/sharwapi.Core" ]; then + cp "./publish/${{ matrix.rid }}/sharwapi.Core" "./package/${{ matrix.rid }}/" + fi + + if [ -f "./publish/${{ matrix.rid }}/sharwapi.Core.exe" ]; then + cp "./publish/${{ matrix.rid }}/sharwapi.Core.exe" "./package/${{ matrix.rid }}/" + fi + + if [ ! -f "./publish/${{ matrix.rid }}/appsettings.json" ]; then + echo "appsettings.json not found in publish output for ${{ matrix.rid }}" + exit 1 + fi + cp "./publish/${{ matrix.rid }}/appsettings.json" "./package/${{ matrix.rid }}/" + + - name: Archive artifact + run: tar -czf ${{ matrix.rid }}.tar.gz -C ./package/${{ matrix.rid }} . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.rid }} + path: ${{ matrix.rid }}.tar.gz + + release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: ./artifacts + merge-multiple: true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + files: ./artifacts/*.tar.gz + generate_release_notes: true