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/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]
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
+{
+}