From bdb83cdc82e90df96dd0f3b4f1ebf682aece60f3 Mon Sep 17 00:00:00 2001 From: Blank Date: Wed, 29 Jul 2026 14:47:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor(logger):=20=E6=8B=86=E5=88=86=20Lo?= =?UTF-8?q?gHandler.Create=20=E7=A7=81=E6=9C=89=20helper=20=E9=99=8D?= =?UTF-8?q?=E4=BD=8E=E8=AE=A4=E7=9F=A5=E5=A4=8D=E6=9D=82=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 LogHandler.Create 的 Cognitive Complexity 由 34 降至 ≤5(Sonar S3776 修复)。 方法体只保留入口协调(参数校验 / Serilog 诊断调用 / 路径解析 / sink 装配调用 / 默认 logger 设置),其 余职责抽到 11 个 private static helper:ResolveLogPath / SelectOutputTemplates / ApplyLoki / BuildLokiLabels / ApplyLokiProperties / WriteLokiSink / BuildLokiCredentials / ApplyMongoDb / ApplyFile / ApplyConsole / ApplyMinimumLevel。public 签名与可观察行为零变更。 新增 GameFrameX.Foundation.Tests/Logger/LogHandlerCreateTests.cs 5 个 [Fact] 覆盖参数校验、 可用 logger 返回、configurationAction 回调、目录自动创建,使用独立 [Collection] 与 per-test 临时 目录隔离全局 Serilog / LogHelper 状态。dotnet build 0 错误,dotnet test 1802 通过 / 0 失败。 Linear: GFX-186 --- GameFrameX.Foundation.Logger/LogHandler.cs | 421 +++++++++++------- .../Logger/LogHandlerCreateTests.cs | 150 +++++++ 2 files changed, 420 insertions(+), 151 deletions(-) create mode 100644 GameFrameX.Foundation.Tests/Logger/LogHandlerCreateTests.cs diff --git a/GameFrameX.Foundation.Logger/LogHandler.cs b/GameFrameX.Foundation.Logger/LogHandler.cs index e7754bc..b42a128 100644 --- a/GameFrameX.Foundation.Logger/LogHandler.cs +++ b/GameFrameX.Foundation.Logger/LogHandler.cs @@ -26,7 +26,7 @@ // Gitee 仓库:https://gitee.com/GameFrameX // Gitee Repository: https://gitee.com/GameFrameX // CNB 仓库:https://cnb.cool/GameFrameX -// CNB Repository: https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX // 官方文档:https://gameframex.doc.alianblank.com/ // Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== @@ -172,184 +172,303 @@ public static ILogger Create(LogOptions logOptions, bool isDefault = true, Actio SerilogDiagnosis(); try { - // 文件名 - var logFileName = logOptions.LogFileName.IsNotNullOrEmptyOrWhiteSpace() ? logOptions.LogFileName : $"{logOptions.LogTagName ?? logOptions.LogType}_.log"; + var logPath = ResolveLogPath(logOptions, isDefault); - // 日志文件存储的路径,默认在应用程序运行目录下的子目录/logs - var logSavePath = logOptions.LogSavePath ?? "./logs/"; - if (!logSavePath.EndsWith(Path.DirectorySeparatorChar)) - { - logSavePath += Path.DirectorySeparatorChar; - } + var logger = CreateLoggerConfiguration(); + logger.Enrich.WithProperty("TagName", logOptions.LogTagName); + logger.Enrich.WithProperty("LogType", logOptions.LogType); - logSavePath += logOptions.LogType + Path.DirectorySeparatorChar; + ApplyLoki(logger, logOptions); + configurationAction?.Invoke(logger); - // 计算最终日志文件路径 - var logPath = Path.Combine(logSavePath, logFileName); - // 兼容可能的层级目录:始终创建文件所在的目录 - var logFolderPath = Path.GetDirectoryName(logPath) ?? logSavePath; - if (!Directory.Exists(logFolderPath)) - { - Directory.CreateDirectory(logFolderPath); - } + var templates = SelectOutputTemplates(logOptions); + ApplyMongoDb(logger, logOptions); + ApplyFile(logger, logOptions, logPath, templates.File); + ApplyMinimumLevel(logger, logOptions); + ApplyConsole(logger, logOptions, templates.Console); + var serilog = logger.CreateLogger(); if (isDefault) { - LogHelper.ShowOption("log configuration information", logOptions); + Log.Logger = serilog; + LogHelper.SetLogger(serilog); } - var logger = CreateLoggerConfiguration(); - logger.Enrich.WithProperty("TagName", logOptions.LogTagName); - logger.Enrich.WithProperty("LogType", logOptions.LogType); + return serilog; + } + catch (Exception e) + { + Log.Error(e, "配置日志系统过程中发生错误"); + throw; + } + } + + /// + /// 计算最终日志文件路径,必要时创建目录。 + /// + /// 日志配置选项 / Log configuration options + /// 是否为默认配置(用于触发展示调用) / Whether this is the default configuration + /// 最终日志文件路径 / Final log file path + private static string ResolveLogPath(LogOptions logOptions, bool isDefault) + { + // 文件名 + var logFileName = logOptions.LogFileName.IsNotNullOrEmptyOrWhiteSpace() ? logOptions.LogFileName : $"{logOptions.LogTagName ?? logOptions.LogType}_.log"; + + // 日志文件存储的路径,默认在应用程序运行目录下的子目录/logs + var logSavePath = logOptions.LogSavePath ?? "./logs/"; + if (!logSavePath.EndsWith(Path.DirectorySeparatorChar)) + { + logSavePath += Path.DirectorySeparatorChar; + } + + logSavePath += logOptions.LogType + Path.DirectorySeparatorChar; + + // 计算最终日志文件路径 + var logPath = Path.Combine(logSavePath, logFileName); + // 兼容可能的层级目录:始终创建文件所在的目录 + var logFolderPath = Path.GetDirectoryName(logPath) ?? logSavePath; + if (!Directory.Exists(logFolderPath)) + { + Directory.CreateDirectory(logFolderPath); + } + + if (isDefault) + { + LogHelper.ShowOption("log configuration information", logOptions); + } - if (logOptions.IsGrafanaLoki && logOptions.GrafanaLokiLabels != null) + return logPath; + } + + /// + /// 选择控制台与文件的输出模板(根据 LogTagName 是否非空切换)。 + /// + /// 日志配置选项 / Log configuration options + /// 控制台 / 文件输出模板二元组 / Console and file output template tuple + private static (string Console, string File) SelectOutputTemplates(LogOptions logOptions) + { + if (logOptions.LogTagName.IsNotNullOrEmptyOrWhiteSpace()) + { + return (ConsoleOutputTagNameTemplate, FileOutputTagNameTemplate); + } + + return (ConsoleOutputTemplate, FileOutputTemplate); + } + + /// + /// 装配 Grafana Loki sink(按需启用,含 label / property / 凭证 / 压缩)。 + /// + /// Logger 配置 / Logger configuration + /// 日志配置选项 / Log configuration options + private static void ApplyLoki(LoggerConfiguration logger, LogOptions logOptions) + { + if (!logOptions.IsGrafanaLoki || logOptions.GrafanaLokiLabels == null) + { + return; + } + + ApplyLokiProperties(logger, logOptions); + var labels = BuildLokiLabels(logOptions); + WriteLokiSink(logger, logOptions, labels); + } + + /// + /// 将 LogOptions.GrafanaLokiLabels 转换为 Serilog Loki 所需的标签数组。 + /// + /// 日志配置选项 / Log configuration options + /// Loki 标签数组 / Loki label array + private static LokiLabel[] BuildLokiLabels(LogOptions logOptions) + { + var grafanaLokiLabels = new List(logOptions.GrafanaLokiLabels.Count); + foreach (var kv in logOptions.GrafanaLokiLabels) + { + grafanaLokiLabels.Add(new LokiLabel { - var grafanaLokiLabels = new List(); - foreach (var kv in logOptions.GrafanaLokiLabels) - { - var lokiLabel = new LokiLabel - { - Key = kv.Key, - Value = kv.Value, - }; - grafanaLokiLabels.Add(lokiLabel); - } + Key = kv.Key, + Value = kv.Value, + }); + } - if (logOptions.GrafanaLokiProperty != null) - { - foreach (var property in logOptions.GrafanaLokiProperty) - { - if (string.IsNullOrWhiteSpace(property.Key)) - { - continue; - } - - if (string.IsNullOrWhiteSpace(property.Value)) - { - continue; - } - - logger.Enrich.WithProperty(property.Key, property.Value); - } - } + return grafanaLokiLabels.ToArray(); + } - LokiCredentials lokiCredentials = null; - if (!string.IsNullOrWhiteSpace(logOptions.GrafanaLokiUserName) && !string.IsNullOrWhiteSpace(logOptions.GrafanaLokiPassword)) - { - lokiCredentials = new LokiCredentials - { - Login = logOptions.GrafanaLokiUserName, - Password = logOptions.GrafanaLokiPassword, - }; - } + /// + /// 将 LogOptions.GrafanaLokiProperty 中的非空属性 Enrich 到日志事件。 + /// + /// Logger 配置 / Logger configuration + /// 日志配置选项 / Log configuration options + private static void ApplyLokiProperties(LoggerConfiguration logger, LogOptions logOptions) + { + if (logOptions.GrafanaLokiProperty == null) + { + return; + } - // 判断是否启用压缩 - if (logOptions.GrafanaLokiCompressionEnabled) - { - logger.WriteTo.GrafanaLoki( - logOptions.GrafanaLokiUrl, - labels: grafanaLokiLabels.ToArray(), - credentials: lokiCredentials, - batchSizeLimit: 1000, - period: TimeSpan.FromSeconds(2), - httpMessageHandler: new LokiGzipHandler(CompressionLevel.Optimal), - restrictedToMinimumLevel: LogEventLevel.Verbose); - } - else - { - logger.WriteTo.GrafanaLoki( - logOptions.GrafanaLokiUrl, - labels: grafanaLokiLabels.ToArray(), - credentials: lokiCredentials); - } + foreach (var property in logOptions.GrafanaLokiProperty) + { + if (string.IsNullOrWhiteSpace(property.Key)) + { + continue; } - configurationAction?.Invoke(logger); - var consoleOutputTemplate = ConsoleOutputTemplate; - var fileOutputTemplate = FileOutputTemplate; - if (logOptions.LogTagName.IsNotNullOrEmptyOrWhiteSpace()) + if (string.IsNullOrWhiteSpace(property.Value)) { - consoleOutputTemplate = ConsoleOutputTagNameTemplate; - fileOutputTemplate = FileOutputTagNameTemplate; + continue; } - if (logOptions.IsWriteToMongoDb) + logger.Enrich.WithProperty(property.Key, property.Value); + } + } + + /// + /// 写入 Grafana Loki sink;按压缩开关选择是否启用 Gzip 处理器。 + /// + /// Logger 配置 / Logger configuration + /// 日志配置选项 / Log configuration options + /// Loki 标签数组 / Loki label array + private static void WriteLokiSink(LoggerConfiguration logger, LogOptions logOptions, LokiLabel[] labels) + { + var credentials = BuildLokiCredentials(logOptions); + if (logOptions.GrafanaLokiCompressionEnabled) + { + logger.WriteTo.GrafanaLoki( + logOptions.GrafanaLokiUrl, + labels: labels, + credentials: credentials, + batchSizeLimit: 1000, + period: TimeSpan.FromSeconds(2), + httpMessageHandler: new LokiGzipHandler(CompressionLevel.Optimal), + restrictedToMinimumLevel: LogEventLevel.Verbose); + } + else + { + logger.WriteTo.GrafanaLoki( + logOptions.GrafanaLokiUrl, + labels: labels, + credentials: credentials); + } + } + + /// + /// 根据用户名/密码构造 LokiCredentials;任一为空返回 null。 + /// + /// 日志配置选项 / Log configuration options + /// Loki 凭证或 null / Loki credentials or null + private static LokiCredentials BuildLokiCredentials(LogOptions logOptions) + { + if (string.IsNullOrWhiteSpace(logOptions.GrafanaLokiUserName) || string.IsNullOrWhiteSpace(logOptions.GrafanaLokiPassword)) + { + return null; + } + + return new LokiCredentials + { + Login = logOptions.GrafanaLokiUserName, + Password = logOptions.GrafanaLokiPassword, + }; + } + + /// + /// 装配 MongoDB sink(按 IsWriteToMongoDb 开关)。 + /// + /// Logger 配置 / Logger configuration + /// 日志配置选项 / Log configuration options + private static void ApplyMongoDb(LoggerConfiguration logger, LogOptions logOptions) + { + if (!logOptions.IsWriteToMongoDb) + { + return; + } + + logger.WriteTo.MongoDBBson( + logOptions.MongoDbDatabaseUrl, + logOptions.LogSavePath, + cappedMaxSizeMb: logOptions.MongoDbCappedMaxSizeMb, + cappedMaxDocuments: logOptions.MongoDbCappedMaxDocuments, + rollingInterval: (Serilog.Sinks.MongoDB.RollingInterval)logOptions.RollingInterval, + restrictedToMinimumLevel: logOptions.LogEventLevel); + } + + /// + /// 装配文件 sink(按 IsWriteToFile 开关)。 + /// + /// Logger 配置 / Logger configuration + /// 日志配置选项 / Log configuration options + /// 日志文件路径 / Log file path + /// 文件输出模板 / File output template + private static void ApplyFile(LoggerConfiguration logger, LogOptions logOptions, string logPath, string fileOutputTemplate) + { + if (!logOptions.IsWriteToFile) + { + return; + } + + logger.WriteTo.File(logPath, + shared: true, + restrictedToMinimumLevel: logOptions.LogEventLevel, + outputTemplate: fileOutputTemplate, + rollingInterval: logOptions.RollingInterval, + rollOnFileSizeLimit: logOptions.FileSizeLimitBytes > 0, + fileSizeLimitBytes: logOptions.FileSizeLimitBytes); + } + + /// + /// 装配控制台 sink(按 IsConsole 开关)。 + /// + /// Logger 配置 / Logger configuration + /// 日志配置选项 / Log configuration options + /// 控制台输出模板 / Console output template + private static void ApplyConsole(LoggerConfiguration logger, LogOptions logOptions, string consoleOutputTemplate) + { + if (!logOptions.IsConsole) + { + return; + } + + logger.WriteTo.Console(outputTemplate: consoleOutputTemplate, + restrictedToMinimumLevel: logOptions.LogEventLevel, + theme: Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme.Literate); + } + + /// + /// 根据 LogOptions.LogEventLevel 设置最低日志级别。 + /// + /// Logger 配置 / Logger configuration + /// 日志配置选项 / Log configuration options + private static void ApplyMinimumLevel(LoggerConfiguration logger, LogOptions logOptions) + { + switch (logOptions.LogEventLevel) + { + case LogEventLevel.Verbose: { - logger.WriteTo.MongoDBBson( - logOptions.MongoDbDatabaseUrl, - logOptions.LogSavePath, - cappedMaxSizeMb: logOptions.MongoDbCappedMaxSizeMb, - cappedMaxDocuments: logOptions.MongoDbCappedMaxDocuments, - rollingInterval: (Serilog.Sinks.MongoDB.RollingInterval)logOptions.RollingInterval, - restrictedToMinimumLevel: logOptions.LogEventLevel); + logger.MinimumLevel.Verbose(); } - - if (logOptions.IsWriteToFile) + break; + case LogEventLevel.Debug: { - logger.WriteTo.File(logPath, - shared: true, - restrictedToMinimumLevel: logOptions.LogEventLevel, - outputTemplate: fileOutputTemplate, - rollingInterval: logOptions.RollingInterval, - rollOnFileSizeLimit: logOptions.FileSizeLimitBytes > 0, - fileSizeLimitBytes: logOptions.FileSizeLimitBytes - ); + logger.MinimumLevel.Debug(); } - - switch (logOptions.LogEventLevel) + break; + case LogEventLevel.Information: { - case LogEventLevel.Verbose: - { - logger.MinimumLevel.Verbose(); - } - break; - case LogEventLevel.Debug: - { - logger.MinimumLevel.Debug(); - } - break; - case LogEventLevel.Information: - { - logger.MinimumLevel.Information(); - } - break; - case LogEventLevel.Warning: - { - logger.MinimumLevel.Warning(); - } - break; - case LogEventLevel.Error: - { - logger.MinimumLevel.Error(); - } - break; - case LogEventLevel.Fatal: - { - logger.MinimumLevel.Fatal(); - } - break; + logger.MinimumLevel.Information(); } - - if (logOptions.IsConsole) + break; + case LogEventLevel.Warning: { - logger.WriteTo.Console(outputTemplate: consoleOutputTemplate, - restrictedToMinimumLevel: logOptions.LogEventLevel, - theme: Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme.Literate); + logger.MinimumLevel.Warning(); } - - var serilog = logger.CreateLogger(); - if (isDefault) + break; + case LogEventLevel.Error: { - Log.Logger = serilog; - LogHelper.SetLogger(serilog); + logger.MinimumLevel.Error(); } - - return serilog; - } - catch (Exception e) - { - Log.Error(e, "配置日志系统过程中发生错误"); - throw; + break; + case LogEventLevel.Fatal: + { + logger.MinimumLevel.Fatal(); + } + break; } } } diff --git a/GameFrameX.Foundation.Tests/Logger/LogHandlerCreateTests.cs b/GameFrameX.Foundation.Tests/Logger/LogHandlerCreateTests.cs new file mode 100644 index 0000000..edf4ba2 --- /dev/null +++ b/GameFrameX.Foundation.Tests/Logger/LogHandlerCreateTests.cs @@ -0,0 +1,150 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// +// 本项目采用 MIT 许可证与 Apache License 2.0 双许可证分发, +// This project is dual-licensed under the MIT License and Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using GameFrameX.Foundation.Logger; +using Serilog; +using Xunit; + +namespace GameFrameX.Foundation.Tests.Logger; + +[Collection(nameof(LogHandlerCreateTestsCollection))] +public sealed class LogHandlerCreateTests : IDisposable +{ + private readonly string _tempDirectory; + + public LogHandlerCreateTests() + { + // Use a unique temp directory per test so concurrent runs / file sinks don't collide. + _tempDirectory = Path.Combine(Path.GetTempPath(), "GameFrameX.Foundation.Tests", "LogHandlerCreate", Guid.NewGuid().ToString("N")); + } + + public void Dispose() + { + // All tests pass isDefault: false, so Log.Logger is never mutated. + // Clean up the per-test temp directory only. + try + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + } + catch + { + // Ignore cleanup failures — temp directory will be reaped by the OS. + } + + GC.SuppressFinalize(this); + } + + [Fact] + public void Create_WithNullLogOptions_ShouldThrowArgumentNullException() + { + Assert.Throws(() => LogHandler.Create(null, isDefault: false)); + } + + [Fact] + public void Create_WithEmptyLogType_ShouldThrowArgumentException() + { + var options = new LogOptions("logs") + { + LogType = "", + IsWriteToFile = false, + IsConsole = false, + }; + + Assert.Throws(() => LogHandler.Create(options, isDefault: false)); + } + + [Fact] + public void Create_WithMinimalOptions_ShouldReturnUsableLogger() + { + var options = new LogOptions("logs") + { + LogType = "gfx-186-app", + LogTagName = "gfx-186", + LogSavePath = _tempDirectory, + IsWriteToFile = false, + IsConsole = false, + }; + + var logger = LogHandler.Create(options, isDefault: false); + + Assert.NotNull(logger); + } + + [Fact] + public void Create_ShouldInvokeConfigurationAction() + { + var options = new LogOptions("logs") + { + LogType = "gfx-186-app", + LogTagName = "gfx-186", + LogSavePath = _tempDirectory, + IsWriteToFile = false, + IsConsole = false, + }; + + var captured = false; + var logger = LogHandler.Create(options, isDefault: false, configurationAction: _ => { captured = true; }); + + Assert.NotNull(logger); + Assert.True(captured, "configurationAction should be invoked exactly once."); + } + + [Fact] + public void Create_WithNonExistentSaveDirectory_ShouldCreateItAutomatically() + { + // Use a deeply nested directory that definitely does not exist beforehand. + var nested = Path.Combine(_tempDirectory, "nested", "logs"); + Assert.False(Directory.Exists(nested)); + + var options = new LogOptions("logs") + { + LogType = "gfx-186-app", + LogSavePath = nested, + IsWriteToFile = false, + IsConsole = false, + }; + + var logger = LogHandler.Create(options, isDefault: false); + + Assert.NotNull(logger); + Assert.True(Directory.Exists(nested), "ResolveLogPath should ensure the log folder exists."); + } +} + +[CollectionDefinition(nameof(LogHandlerCreateTestsCollection))] +public sealed class LogHandlerCreateTestsCollection +{ +} From 76a33fbd0aecbd3dddaf3b66ef212d5f7449dc19 Mon Sep 17 00:00:00 2001 From: Blank Date: Wed, 29 Jul 2026 16:51:16 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(tests):=20=E4=BF=AE=E5=A4=8D=20Resource?= =?UTF-8?q?ManagerTests=20=E7=BC=BA=E5=A4=B1=E6=96=AD=E8=A8=80=E7=9A=84=20?= =?UTF-8?q?Sonar=20S2699=20=E8=BF=9D=E8=A7=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linear: GFX-189 --- .../Localization/ResourceManagerTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/GameFrameX.Foundation.Tests/Localization/ResourceManagerTests.cs b/GameFrameX.Foundation.Tests/Localization/ResourceManagerTests.cs index 9fc31fb..fda6f28 100644 --- a/GameFrameX.Foundation.Tests/Localization/ResourceManagerTests.cs +++ b/GameFrameX.Foundation.Tests/Localization/ResourceManagerTests.cs @@ -200,8 +200,11 @@ public void Dispose_ShouldNotThrow() // Arrange var manager = new ResourceManager(); - // Act & Assert (should not throw) - manager.Dispose(); + // Act + var exception = Record.Exception(() => manager.Dispose()); + + // Assert (should not throw) + Assert.Null(exception); } [Fact]