diff --git a/GFramework.Core.Abstractions/logging/ILogAppender.cs b/GFramework.Core.Abstractions/logging/ILogAppender.cs new file mode 100644 index 00000000..a667d172 --- /dev/null +++ b/GFramework.Core.Abstractions/logging/ILogAppender.cs @@ -0,0 +1,18 @@ +namespace GFramework.Core.Abstractions.logging; + +/// +/// 日志输出器接口,负责将日志条目写入特定目标 +/// +public interface ILogAppender +{ + /// + /// 追加日志条目 + /// + /// 日志条目 + void Append(LogEntry entry); + + /// + /// 刷新缓冲区,确保所有日志已写入 + /// + void Flush(); +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/logging/ILogFilter.cs b/GFramework.Core.Abstractions/logging/ILogFilter.cs new file mode 100644 index 00000000..c5e8e5c5 --- /dev/null +++ b/GFramework.Core.Abstractions/logging/ILogFilter.cs @@ -0,0 +1,14 @@ +namespace GFramework.Core.Abstractions.logging; + +/// +/// 日志过滤器接口,用于决定是否应该记录某条日志 +/// +public interface ILogFilter +{ + /// + /// 判断是否应该记录该日志条目 + /// + /// 日志条目 + /// 如果应该记录返回 true,否则返回 false + bool ShouldLog(LogEntry entry); +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/logging/ILogFormatter.cs b/GFramework.Core.Abstractions/logging/ILogFormatter.cs new file mode 100644 index 00000000..00792e16 --- /dev/null +++ b/GFramework.Core.Abstractions/logging/ILogFormatter.cs @@ -0,0 +1,14 @@ +namespace GFramework.Core.Abstractions.logging; + +/// +/// 日志格式化器接口,用于将日志条目格式化为字符串 +/// +public interface ILogFormatter +{ + /// + /// 将日志条目格式化为字符串 + /// + /// 日志条目 + /// 格式化后的日志字符串 + string Format(LogEntry entry); +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/logging/ILogger.cs b/GFramework.Core.Abstractions/logging/ILogger.cs index 185d6041..72efefc2 100644 --- a/GFramework.Core.Abstractions/logging/ILogger.cs +++ b/GFramework.Core.Abstractions/logging/ILogger.cs @@ -309,4 +309,48 @@ public interface ILogger void Fatal(string msg, Exception t); #endregion + + #region Generic Log Methods + + /// + /// 使用指定的日志级别记录消息 + /// + /// 日志级别 + /// 要记录的消息字符串 + void Log(LogLevel level, string message); + + /// + /// 使用指定的日志级别根据格式和参数记录消息 + /// + /// 日志级别 + /// 格式字符串 + /// 参数 + void Log(LogLevel level, string format, object arg); + + /// + /// 使用指定的日志级别根据格式和参数记录消息 + /// + /// 日志级别 + /// 格式字符串 + /// 第一个参数 + /// 第二个参数 + void Log(LogLevel level, string format, object arg1, object arg2); + + /// + /// 使用指定的日志级别根据格式和参数数组记录消息 + /// + /// 日志级别 + /// 格式字符串 + /// 参数数组 + void Log(LogLevel level, string format, params object[] arguments); + + /// + /// 使用指定的日志级别记录消息和异常 + /// + /// 日志级别 + /// 伴随异常的消息 + /// 要记录的异常 + void Log(LogLevel level, string message, Exception exception); + + #endregion } \ No newline at end of file diff --git a/GFramework.Core.Abstractions/logging/IStructuredLogger.cs b/GFramework.Core.Abstractions/logging/IStructuredLogger.cs new file mode 100644 index 00000000..eee7092d --- /dev/null +++ b/GFramework.Core.Abstractions/logging/IStructuredLogger.cs @@ -0,0 +1,24 @@ +namespace GFramework.Core.Abstractions.logging; + +/// +/// 支持结构化日志的日志记录器接口 +/// +public interface IStructuredLogger : ILogger +{ + /// + /// 使用指定的日志级别记录消息和结构化属性 + /// + /// 日志级别 + /// 日志消息 + /// 结构化属性键值对 + void Log(LogLevel level, string message, params (string Key, object? Value)[] properties); + + /// + /// 使用指定的日志级别记录消息、异常和结构化属性 + /// + /// 日志级别 + /// 日志消息 + /// 异常对象 + /// 结构化属性键值对 + void Log(LogLevel level, string message, Exception? exception, params (string Key, object? Value)[] properties); +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/logging/LogContext.cs b/GFramework.Core.Abstractions/logging/LogContext.cs new file mode 100644 index 00000000..6effb1d4 --- /dev/null +++ b/GFramework.Core.Abstractions/logging/LogContext.cs @@ -0,0 +1,117 @@ +namespace GFramework.Core.Abstractions.logging; + +/// +/// 日志上下文,用于在异步流中传递结构化属性 +/// +public sealed class LogContext : IDisposable +{ + private static readonly AsyncLocal?> _context = new(); + private readonly bool _hadPreviousValue; + private readonly string _key; + private readonly object? _previousValue; + + private LogContext(string key, object? value) + { + _key = key; + + var current = _context.Value; + if (current?.TryGetValue(key, out var prev) == true) + { + _previousValue = prev; + _hadPreviousValue = true; + } + + EnsureContext(); + _context.Value![key] = value; + } + + /// + /// 获取当前上下文中的所有属性 + /// + public static IReadOnlyDictionary Current + { + get + { + var context = _context.Value; + return context ?? + (IReadOnlyDictionary)new Dictionary(StringComparer.Ordinal); + } + } + + /// + /// 释放上下文,恢复之前的值 + /// + public void Dispose() + { + var current = _context.Value; + if (current == null) return; + + if (_hadPreviousValue) + { + current[_key] = _previousValue; + } + else + { + current.Remove(_key); + if (current.Count == 0) + { + _context.Value = null; + } + } + } + + /// + /// 向当前上下文添加一个属性 + /// + /// 属性键 + /// 属性值 + /// 可释放的上下文对象,释放时会恢复之前的值 + public static IDisposable Push(string key, object? value) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Key cannot be null or whitespace.", nameof(key)); + + return new LogContext(key, value); + } + + /// + /// 向当前上下文添加多个属性 + /// + /// 属性键值对 + /// 可释放的上下文对象,释放时会恢复之前的值 + public static IDisposable PushProperties(params (string Key, object? Value)[] properties) + { + if (properties == null || properties.Length == 0) + throw new ArgumentException("Properties cannot be null or empty.", nameof(properties)); + + return new CompositeDisposable(properties.Select(p => Push(p.Key, p.Value)).ToArray()); + } + + /// + /// 清除当前上下文中的所有属性 + /// + public static void Clear() + { + _context.Value = null; + } + + private static void EnsureContext() + { + _context.Value ??= new Dictionary(StringComparer.Ordinal); + } + + /// + /// 组合多个可释放对象 + /// + private sealed class CompositeDisposable(IDisposable[] disposables) : IDisposable + { + public void Dispose() + { + // 按相反顺序释放 + for (int i = disposables.Length - 1; i >= 0; i--) + { + disposables[i].Dispose(); + } + } + } +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/logging/LogEntry.cs b/GFramework.Core.Abstractions/logging/LogEntry.cs new file mode 100644 index 00000000..740353e8 --- /dev/null +++ b/GFramework.Core.Abstractions/logging/LogEntry.cs @@ -0,0 +1,37 @@ +namespace GFramework.Core.Abstractions.logging; + +/// +/// 日志条目,包含完整的日志信息 +/// +public sealed record LogEntry( + DateTime Timestamp, + LogLevel Level, + string LoggerName, + string Message, + Exception? Exception, + IReadOnlyDictionary? Properties) +{ + /// + /// 获取合并了上下文属性的所有属性 + /// + /// 包含日志属性和上下文属性的字典 + public IReadOnlyDictionary GetAllProperties() + { + var contextProps = LogContext.Current; + + if (Properties == null || Properties.Count == 0) + return contextProps; + + if (contextProps.Count == 0) + return Properties; + + // 合并属性,日志属性优先 + var merged = new Dictionary(contextProps, StringComparer.Ordinal); + foreach (var prop in Properties) + { + merged[prop.Key] = prop.Value; + } + + return merged; + } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/logging/AsyncLogAppenderTests.cs b/GFramework.Core.Tests/logging/AsyncLogAppenderTests.cs new file mode 100644 index 00000000..eb5bd3df --- /dev/null +++ b/GFramework.Core.Tests/logging/AsyncLogAppenderTests.cs @@ -0,0 +1,219 @@ +using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging.appenders; +using NUnit.Framework; + +namespace GFramework.Core.Tests.logging; + +/// +/// 测试 AsyncLogAppender 的功能和行为 +/// +[TestFixture] +public class AsyncLogAppenderTests +{ + [Test] + public void Constructor_WithNullInnerAppender_ShouldThrowArgumentNullException() + { + Assert.Throws(() => new AsyncLogAppender(null!)); + } + + [Test] + public void Constructor_WithInvalidBufferSize_ShouldThrowArgumentException() + { + var innerAppender = new TestAppender(); + Assert.Throws(() => new AsyncLogAppender(innerAppender, bufferSize: 0)); + Assert.Throws(() => new AsyncLogAppender(innerAppender, bufferSize: -1)); + } + + [Test] + public void Append_ShouldNotBlock() + { + var innerAppender = new SlowAppender(delayMs: 100); + using var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 1000); + + var startTime = DateTime.UtcNow; + for (int i = 0; i < 10; i++) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null); + asyncAppender.Append(entry); + } + + var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds; + + // 异步写入应该非常快(< 100ms),不应该等待内部 Appender + Assert.That(elapsed, Is.LessThan(100)); + } + + [Test] + public void Append_ShouldEventuallyWriteToInnerAppender() + { + var innerAppender = new TestAppender(); + using (var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 1000)) + { + for (int i = 0; i < 10; i++) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null); + asyncAppender.Append(entry); + } + + asyncAppender.Flush(); + } + + Assert.That(innerAppender.Entries.Count, Is.EqualTo(10)); + } + + [Test] + public void Flush_ShouldWaitForAllEntriesToBeProcessed() + { + var innerAppender = new TestAppender(); + using var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 1000); + + for (int i = 0; i < 100; i++) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null); + asyncAppender.Append(entry); + } + + asyncAppender.Flush(); + + Assert.That(innerAppender.Entries.Count, Is.EqualTo(100)); + } + + [Test] + public void Dispose_ShouldProcessRemainingEntries() + { + var innerAppender = new TestAppender(); + using (var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 1000)) + { + for (int i = 0; i < 50; i++) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null); + asyncAppender.Append(entry); + } + } // Dispose 会等待所有日志处理完成 + + Assert.That(innerAppender.Entries.Count, Is.EqualTo(50)); + } + + [Test] + public void Append_AfterDispose_ShouldThrowObjectDisposedException() + { + var innerAppender = new TestAppender(); + var asyncAppender = new AsyncLogAppender(innerAppender); + asyncAppender.Dispose(); + + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test", null, null); + + Assert.Throws(() => asyncAppender.Append(entry)); + } + + [Test] + public void PendingCount_ShouldReflectQueuedEntries() + { + var innerAppender = new SlowAppender(delayMs: 50); + using var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 1000); + + for (int i = 0; i < 10; i++) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null); + asyncAppender.Append(entry); + } + + // 应该有一些待处理的条目 + Assert.That(asyncAppender.PendingCount, Is.GreaterThanOrEqualTo(0)); + } + + [Test] + public async Task Append_FromMultipleThreads_ShouldHandleConcurrency() + { + var innerAppender = new TestAppender(); + using var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 10000); + + var tasks = new Task[10]; + for (int t = 0; t < 10; t++) + { + int threadId = t; + tasks[t] = Task.Run(() => + { + for (int i = 0; i < 100; i++) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", + $"Thread {threadId} Message {i}", null, null); + asyncAppender.Append(entry); + } + }); + } + + await Task.WhenAll(tasks); + asyncAppender.Flush(); + + Assert.That(innerAppender.Entries.Count, Is.EqualTo(1000)); + } + + [Test] + public void Append_WhenInnerAppenderThrows_ShouldNotCrash() + { + var innerAppender = new ThrowingAppender(); + using var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 1000); + + // 即使内部 Appender 抛出异常,也不应该影响调用线程 + Assert.DoesNotThrow(() => + { + for (int i = 0; i < 10; i++) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null); + asyncAppender.Append(entry); + } + }); + + Thread.Sleep(100); // 等待后台处理 + } + + // 辅助测试类 + private class TestAppender : ILogAppender + { + public List Entries { get; } = new(); + + public void Append(LogEntry entry) + { + lock (Entries) + { + Entries.Add(entry); + } + } + + public void Flush() + { + } + } + + private class SlowAppender : ILogAppender + { + private readonly int _delayMs; + + public SlowAppender(int delayMs) + { + _delayMs = delayMs; + } + + public void Append(LogEntry entry) + { + Thread.Sleep(_delayMs); + } + + public void Flush() + { + } + } + + private class ThrowingAppender : ILogAppender + { + public void Append(LogEntry entry) + { + throw new InvalidOperationException("Test exception"); + } + + public void Flush() + { + } + } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/logging/CachedLoggerFactoryTests.cs b/GFramework.Core.Tests/logging/CachedLoggerFactoryTests.cs new file mode 100644 index 00000000..18a6bac5 --- /dev/null +++ b/GFramework.Core.Tests/logging/CachedLoggerFactoryTests.cs @@ -0,0 +1,95 @@ +using System.IO; +using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging; +using NUnit.Framework; + +namespace GFramework.Core.Tests.logging; + +/// +/// 测试 CachedLoggerFactory 的功能和行为 +/// +[TestFixture] +public class CachedLoggerFactoryTests +{ + [Test] + public void Constructor_WithNullInnerFactory_ShouldThrowArgumentNullException() + { + Assert.Throws(() => new CachedLoggerFactory(null!)); + } + + [Test] + public void GetLogger_WithSameNameAndLevel_ShouldReturnSameInstance() + { + var innerFactory = new ConsoleLoggerFactory(); + var cachedFactory = new CachedLoggerFactory(innerFactory); + + var logger1 = cachedFactory.GetLogger("TestLogger", LogLevel.Info); + var logger2 = cachedFactory.GetLogger("TestLogger", LogLevel.Info); + + Assert.That(logger1, Is.SameAs(logger2)); + } + + [Test] + public void GetLogger_WithDifferentNames_ShouldReturnDifferentInstances() + { + var innerFactory = new ConsoleLoggerFactory(); + var cachedFactory = new CachedLoggerFactory(innerFactory); + + var logger1 = cachedFactory.GetLogger("Logger1", LogLevel.Info); + var logger2 = cachedFactory.GetLogger("Logger2", LogLevel.Info); + + Assert.That(logger1, Is.Not.SameAs(logger2)); + } + + [Test] + public void GetLogger_WithDifferentLevels_ShouldReturnDifferentInstances() + { + var innerFactory = new ConsoleLoggerFactory(); + var cachedFactory = new CachedLoggerFactory(innerFactory); + + var logger1 = cachedFactory.GetLogger("TestLogger", LogLevel.Info); + var logger2 = cachedFactory.GetLogger("TestLogger", LogLevel.Debug); + + Assert.That(logger1, Is.Not.SameAs(logger2)); + } + + [Test] + public void GetLogger_MultipleCalls_ShouldOnlyCreateOnce() + { + var trackingFactory = new TrackingLoggerFactory(); + var cachedFactory = new CachedLoggerFactory(trackingFactory); + + cachedFactory.GetLogger("TestLogger", LogLevel.Info); + cachedFactory.GetLogger("TestLogger", LogLevel.Info); + cachedFactory.GetLogger("TestLogger", LogLevel.Info); + + Assert.That(trackingFactory.CreateCount, Is.EqualTo(1)); + } + + [Test] + public void GetLogger_WithMultipleNamesAndLevels_ShouldCacheCorrectly() + { + var trackingFactory = new TrackingLoggerFactory(); + var cachedFactory = new CachedLoggerFactory(trackingFactory); + + cachedFactory.GetLogger("Logger1", LogLevel.Info); + cachedFactory.GetLogger("Logger1", LogLevel.Debug); + cachedFactory.GetLogger("Logger2", LogLevel.Info); + cachedFactory.GetLogger("Logger1", LogLevel.Info); // 缓存命中 + cachedFactory.GetLogger("Logger2", LogLevel.Info); // 缓存命中 + + Assert.That(trackingFactory.CreateCount, Is.EqualTo(3)); + } + + // 辅助测试类 + private class TrackingLoggerFactory : ILoggerFactory + { + public int CreateCount { get; private set; } + + public ILogger GetLogger(string name, LogLevel minLevel = LogLevel.Info) + { + CreateCount++; + return new ConsoleLogger(name, minLevel, new StringWriter(), false); + } + } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/logging/CompositeFilterTests.cs b/GFramework.Core.Tests/logging/CompositeFilterTests.cs new file mode 100644 index 00000000..4e7416dc --- /dev/null +++ b/GFramework.Core.Tests/logging/CompositeFilterTests.cs @@ -0,0 +1,102 @@ +using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging.filters; +using NUnit.Framework; + +namespace GFramework.Core.Tests.logging; + +/// +/// 测试 CompositeFilter 的功能和行为 +/// +[TestFixture] +public class CompositeFilterTests +{ + [Test] + public void Constructor_WithNullFilters_ShouldThrowArgumentException() + { + Assert.Throws(() => new CompositeFilter(null!)); + } + + [Test] + public void Constructor_WithEmptyFilters_ShouldThrowArgumentException() + { + Assert.Throws(() => new CompositeFilter(Array.Empty())); + } + + [Test] + public void ShouldLog_WithAllFiltersReturningTrue_ShouldReturnTrue() + { + var filter1 = new LogLevelFilter(LogLevel.Info); + var filter2 = new NamespaceFilter("GFramework"); + var compositeFilter = new CompositeFilter(filter1, filter2); + + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "GFramework.Core", "Test", null, null); + + Assert.That(compositeFilter.ShouldLog(entry), Is.True); + } + + [Test] + public void ShouldLog_WithOneFilterReturningFalse_ShouldReturnFalse() + { + var filter1 = new LogLevelFilter(LogLevel.Warning); // 要求 Warning 以上 + var filter2 = new NamespaceFilter("GFramework"); + var compositeFilter = new CompositeFilter(filter1, filter2); + + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "GFramework.Core", "Test", null, null); + + Assert.That(compositeFilter.ShouldLog(entry), Is.False); + } + + [Test] + public void ShouldLog_WithAllFiltersReturningFalse_ShouldReturnFalse() + { + var filter1 = new LogLevelFilter(LogLevel.Warning); + var filter2 = new NamespaceFilter("MyApp"); + var compositeFilter = new CompositeFilter(filter1, filter2); + + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "GFramework.Core", "Test", null, null); + + Assert.That(compositeFilter.ShouldLog(entry), Is.False); + } + + [Test] + public void ShouldLog_WithMultipleFilters_ShouldApplyAndLogic() + { + var levelFilter = new LogLevelFilter(LogLevel.Info); + var namespaceFilter = new NamespaceFilter("GFramework"); + var compositeFilter = new CompositeFilter(levelFilter, namespaceFilter); + + // 满足所有条件 + var entry1 = new LogEntry(DateTime.UtcNow, LogLevel.Info, "GFramework.Core", "Test", null, null); + Assert.That(compositeFilter.ShouldLog(entry1), Is.True); + + // 级别不满足 + var entry2 = new LogEntry(DateTime.UtcNow, LogLevel.Debug, "GFramework.Core", "Test", null, null); + Assert.That(compositeFilter.ShouldLog(entry2), Is.False); + + // 命名空间不满足 + var entry3 = new LogEntry(DateTime.UtcNow, LogLevel.Info, "OtherNamespace", "Test", null, null); + Assert.That(compositeFilter.ShouldLog(entry3), Is.False); + + // 都不满足 + var entry4 = new LogEntry(DateTime.UtcNow, LogLevel.Debug, "OtherNamespace", "Test", null, null); + Assert.That(compositeFilter.ShouldLog(entry4), Is.False); + } + + [Test] + public void ShouldLog_WithNestedCompositeFilters_ShouldWork() + { + var filter1 = new LogLevelFilter(LogLevel.Info); + var filter2 = new NamespaceFilter("GFramework"); + var innerComposite = new CompositeFilter(filter1, filter2); + + var filter3 = new LogLevelFilter(LogLevel.Warning); + var outerComposite = new CompositeFilter(innerComposite, filter3); + + // 需要同时满足:Info 以上 AND GFramework 命名空间 AND Warning 以上 + var entry1 = new LogEntry(DateTime.UtcNow, LogLevel.Warning, "GFramework.Core", "Test", null, null); + Assert.That(outerComposite.ShouldLog(entry1), Is.True); + + var entry2 = new LogEntry(DateTime.UtcNow, LogLevel.Info, "GFramework.Core", "Test", null, null); + Assert.That(outerComposite.ShouldLog(entry2), Is.False); // 不满足 Warning + } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/logging/CompositeLoggerTests.cs b/GFramework.Core.Tests/logging/CompositeLoggerTests.cs new file mode 100644 index 00000000..cbf355c4 --- /dev/null +++ b/GFramework.Core.Tests/logging/CompositeLoggerTests.cs @@ -0,0 +1,182 @@ +using System.IO; +using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging; +using GFramework.Core.logging.appenders; +using GFramework.Core.logging.formatters; +using NUnit.Framework; + +namespace GFramework.Core.Tests.logging; + +/// +/// 测试 CompositeLogger 的功能和行为 +/// +[TestFixture] +public class CompositeLoggerTests +{ + [Test] + public void Constructor_WithNullAppenders_ShouldThrowArgumentException() + { + Assert.Throws(() => new CompositeLogger("Test", LogLevel.Info, null!)); + } + + [Test] + public void Constructor_WithEmptyAppenders_ShouldThrowArgumentException() + { + Assert.Throws(() => new CompositeLogger("Test", LogLevel.Info, Array.Empty())); + } + + [Test] + public void Write_ShouldWriteToAllAppenders() + { + var writer1 = new StringWriter(); + var writer2 = new StringWriter(); + var appender1 = new ConsoleAppender(new DefaultLogFormatter(), writer1, useColors: false); + var appender2 = new ConsoleAppender(new DefaultLogFormatter(), writer2, useColors: false); + + using var logger = new CompositeLogger("TestLogger", LogLevel.Info, appender1, appender2); + + logger.Info("Test message"); + + var output1 = writer1.ToString(); + var output2 = writer2.ToString(); + + Assert.That(output1, Does.Contain("Test message")); + Assert.That(output2, Does.Contain("Test message")); + + writer1.Dispose(); + writer2.Dispose(); + } + + [Test] + public void Log_WithStructuredProperties_ShouldWriteToAllAppenders() + { + var writer1 = new StringWriter(); + var writer2 = new StringWriter(); + var appender1 = new ConsoleAppender(new DefaultLogFormatter(), writer1, useColors: false); + var appender2 = new ConsoleAppender(new DefaultLogFormatter(), writer2, useColors: false); + + using var logger = new CompositeLogger("TestLogger", LogLevel.Info, appender1, appender2); + + logger.Log(LogLevel.Info, "User action", ("UserId", 12345), ("Action", "Login")); + + var output1 = writer1.ToString(); + var output2 = writer2.ToString(); + + Assert.That(output1, Does.Contain("User action")); + Assert.That(output1, Does.Contain("UserId=12345")); + Assert.That(output2, Does.Contain("User action")); + Assert.That(output2, Does.Contain("UserId=12345")); + + writer1.Dispose(); + writer2.Dispose(); + } + + [Test] + public void Log_WithException_ShouldWriteToAllAppenders() + { + var writer1 = new StringWriter(); + var writer2 = new StringWriter(); + var appender1 = new ConsoleAppender(new DefaultLogFormatter(), writer1, useColors: false); + var appender2 = new ConsoleAppender(new DefaultLogFormatter(), writer2, useColors: false); + + using var logger = new CompositeLogger("TestLogger", LogLevel.Info, appender1, appender2); + + var exception = new InvalidOperationException("Test exception"); + logger.Log(LogLevel.Error, "Error occurred", exception, ("ErrorCode", 500)); + + var output1 = writer1.ToString(); + var output2 = writer2.ToString(); + + Assert.That(output1, Does.Contain("Error occurred")); + Assert.That(output1, Does.Contain("InvalidOperationException")); + Assert.That(output2, Does.Contain("Error occurred")); + Assert.That(output2, Does.Contain("InvalidOperationException")); + + writer1.Dispose(); + writer2.Dispose(); + } + + [Test] + public void Flush_ShouldFlushAllAppenders() + { + var testAppender1 = new TestFlushAppender(); + var testAppender2 = new TestFlushAppender(); + + using var logger = new CompositeLogger("TestLogger", LogLevel.Info, testAppender1, testAppender2); + + logger.Info("Test message"); + logger.Flush(); + + Assert.That(testAppender1.FlushCalled, Is.True); + Assert.That(testAppender2.FlushCalled, Is.True); + } + + [Test] + public void Dispose_ShouldDisposeAllAppenders() + { + var testAppender1 = new TestDisposableAppender(); + var testAppender2 = new TestDisposableAppender(); + + var logger = new CompositeLogger("TestLogger", LogLevel.Info, testAppender1, testAppender2); + logger.Dispose(); + + Assert.That(testAppender1.DisposeCalled, Is.True); + Assert.That(testAppender2.DisposeCalled, Is.True); + } + + [Test] + public void Write_WithLevelFiltering_ShouldRespectMinLevel() + { + var writer = new StringWriter(); + var appender = new ConsoleAppender(new DefaultLogFormatter(), writer, useColors: false); + + using var logger = new CompositeLogger("TestLogger", LogLevel.Warning, appender); + + logger.Debug("Debug message"); + logger.Info("Info message"); + logger.Warn("Warning message"); + logger.Error("Error message"); + + var output = writer.ToString(); + + Assert.That(output, Does.Not.Contain("Debug message")); + Assert.That(output, Does.Not.Contain("Info message")); + Assert.That(output, Does.Contain("Warning message")); + Assert.That(output, Does.Contain("Error message")); + + writer.Dispose(); + } + + // 辅助测试类 + private class TestFlushAppender : ILogAppender + { + public bool FlushCalled { get; private set; } + + public void Append(LogEntry entry) + { + } + + public void Flush() + { + FlushCalled = true; + } + } + + private class TestDisposableAppender : ILogAppender, IDisposable + { + public bool DisposeCalled { get; private set; } + + public void Dispose() + { + DisposeCalled = true; + } + + public void Append(LogEntry entry) + { + } + + public void Flush() + { + } + } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/logging/ConsoleAppenderTests.cs b/GFramework.Core.Tests/logging/ConsoleAppenderTests.cs new file mode 100644 index 00000000..c170fd28 --- /dev/null +++ b/GFramework.Core.Tests/logging/ConsoleAppenderTests.cs @@ -0,0 +1,109 @@ +using System.IO; +using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging.appenders; +using GFramework.Core.logging.filters; +using GFramework.Core.logging.formatters; +using NUnit.Framework; + +namespace GFramework.Core.Tests.logging; + +/// +/// 测试 ConsoleAppender 的功能和行为 +/// +[TestFixture] +public class ConsoleAppenderTests +{ + [SetUp] + public void SetUp() + { + _stringWriter = new StringWriter(); + _appender = new ConsoleAppender(new DefaultLogFormatter(), _stringWriter, useColors: false); + } + + [TearDown] + public void TearDown() + { + _appender?.Dispose(); + _stringWriter?.Dispose(); + } + + private StringWriter _stringWriter = null!; + private ConsoleAppender _appender = null!; + + [Test] + public void Constructor_WithNullFormatter_ShouldThrowArgumentNullException() + { + Assert.Throws(() => new ConsoleAppender(null!)); + } + + [Test] + public void Append_ShouldWriteToWriter() + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null); + + _appender.Append(entry); + + var output = _stringWriter.ToString(); + Assert.That(output, Does.Contain("Test message")); + Assert.That(output, Does.Contain("INFO")); + } + + [Test] + public void Append_WithFilter_ShouldRespectFilter() + { + var filter = new LogLevelFilter(LogLevel.Warning); + var appender = new ConsoleAppender(new DefaultLogFormatter(), _stringWriter, useColors: false, filter: filter); + + var infoEntry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Info message", null, null); + var warningEntry = new LogEntry(DateTime.UtcNow, LogLevel.Warning, "TestLogger", "Warning message", null, null); + + appender.Append(infoEntry); + appender.Append(warningEntry); + + var output = _stringWriter.ToString(); + Assert.That(output, Does.Not.Contain("Info message")); + Assert.That(output, Does.Contain("Warning message")); + + appender.Dispose(); + } + + [Test] + public void Flush_ShouldFlushWriter() + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null); + + _appender.Append(entry); + _appender.Flush(); + + var output = _stringWriter.ToString(); + Assert.That(output, Does.Contain("Test message")); + } + + [Test] + public void Dispose_ShouldFlushWriter() + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null); + + _appender.Append(entry); + _appender.Dispose(); + + var output = _stringWriter.ToString(); + Assert.That(output, Does.Contain("Test message")); + } + + [Test] + public void Append_MultipleEntries_ShouldWriteAll() + { + for (int i = 0; i < 10; i++) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null); + _appender.Append(entry); + } + + var output = _stringWriter.ToString(); + for (int i = 0; i < 10; i++) + { + Assert.That(output, Does.Contain($"Message {i}")); + } + } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/logging/DefaultLogFormatterTests.cs b/GFramework.Core.Tests/logging/DefaultLogFormatterTests.cs new file mode 100644 index 00000000..19aec517 --- /dev/null +++ b/GFramework.Core.Tests/logging/DefaultLogFormatterTests.cs @@ -0,0 +1,117 @@ +using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging.formatters; +using NUnit.Framework; + +namespace GFramework.Core.Tests.logging; + +/// +/// 测试 DefaultLogFormatter 的功能和行为 +/// +[TestFixture] +public class DefaultLogFormatterTests +{ + [SetUp] + public void SetUp() + { + _formatter = new DefaultLogFormatter(); + } + + private DefaultLogFormatter _formatter = null!; + + [Test] + public void Format_WithBasicEntry_ShouldFormatCorrectly() + { + var timestamp = new DateTime(2026, 2, 26, 10, 30, 45, 123); + var entry = new LogEntry(timestamp, LogLevel.Info, "TestLogger", "Test message", null, null); + + var result = _formatter.Format(entry); + + Assert.That(result, Does.Contain("[2026-02-26 10:30:45.123]")); + Assert.That(result, Does.Contain("INFO")); + Assert.That(result, Does.Contain("[TestLogger]")); + Assert.That(result, Does.Contain("Test message")); + } + + [Test] + public void Format_WithException_ShouldIncludeException() + { + var exception = new InvalidOperationException("Test exception"); + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Error, "TestLogger", "Error occurred", exception, null); + + var result = _formatter.Format(entry); + + Assert.That(result, Does.Contain("Error occurred")); + Assert.That(result, Does.Contain("InvalidOperationException")); + Assert.That(result, Does.Contain("Test exception")); + } + + [Test] + public void Format_WithProperties_ShouldIncludeProperties() + { + var properties = new Dictionary + { + ["UserId"] = 12345, + ["UserName"] = "TestUser" + }; + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "User action", null, properties); + + var result = _formatter.Format(entry); + + Assert.That(result, Does.Contain("User action")); + Assert.That(result, Does.Contain("|")); + Assert.That(result, Does.Contain("UserId=12345")); + Assert.That(result, Does.Contain("UserName=TestUser")); + } + + [Test] + public void Format_WithNullProperty_ShouldHandleNull() + { + var properties = new Dictionary + { + ["Key1"] = null + }; + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, properties); + + var result = _formatter.Format(entry); + + Assert.That(result, Does.Contain("Key1=")); + } + + [Test] + public void Format_WithAllLogLevels_ShouldFormatCorrectly() + { + var levels = new[] + { LogLevel.Trace, LogLevel.Debug, LogLevel.Info, LogLevel.Warning, LogLevel.Error, LogLevel.Fatal }; + var expectedStrings = new[] { "TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "FATAL" }; + + for (int i = 0; i < levels.Length; i++) + { + var entry = new LogEntry(DateTime.UtcNow, levels[i], "TestLogger", "Test", null, null); + var result = _formatter.Format(entry); + + Assert.That(result, Does.Contain(expectedStrings[i])); + } + } + + [Test] + public void Format_WithLongMessage_ShouldNotTruncate() + { + var longMessage = new string('A', 1000); + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", longMessage, null, null); + + var result = _formatter.Format(entry); + + Assert.That(result, Does.Contain(longMessage)); + } + + [Test] + public void Format_WithSpecialCharacters_ShouldPreserveCharacters() + { + var message = "Test\nNew\tLine\r\nSpecial: <>&\"'"; + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", message, null, null); + + var result = _formatter.Format(entry); + + Assert.That(result, Does.Contain(message)); + } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/logging/FileAppenderTests.cs b/GFramework.Core.Tests/logging/FileAppenderTests.cs new file mode 100644 index 00000000..9906d778 --- /dev/null +++ b/GFramework.Core.Tests/logging/FileAppenderTests.cs @@ -0,0 +1,175 @@ +using System.IO; +using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging.appenders; +using GFramework.Core.logging.formatters; +using NUnit.Framework; + +namespace GFramework.Core.Tests.logging; + +/// +/// 测试 FileAppender 的功能和行为 +/// +[TestFixture] +public class FileAppenderTests +{ + [SetUp] + public void SetUp() + { + _testFilePath = Path.Combine(Path.GetTempPath(), $"test_log_{Guid.NewGuid()}.log"); + } + + [TearDown] + public void TearDown() + { + if (File.Exists(_testFilePath)) + { + try + { + File.Delete(_testFilePath); + } + catch + { + } + } + } + + private string _testFilePath = null!; + + [Test] + public void Constructor_WithNullFilePath_ShouldThrowArgumentException() + { + Assert.Throws(() => new FileAppender(null!)); + } + + [Test] + public void Constructor_WithEmptyFilePath_ShouldThrowArgumentException() + { + Assert.Throws(() => new FileAppender("")); + } + + [Test] + public void Constructor_WhenDirectoryDoesNotExist_ShouldCreateIt() + { + var dirPath = Path.Combine(Path.GetTempPath(), $"test_dir_{Guid.NewGuid()}"); + var filePath = Path.Combine(dirPath, "test.log"); + + try + { + using (new FileAppender(filePath)) + { + Assert.That(Directory.Exists(dirPath), Is.True); + } + } + finally + { + if (Directory.Exists(dirPath)) + { + Directory.Delete(dirPath, true); + } + } + } + + [Test] + public void Append_ShouldWriteToFile() + { + using (var appender = new FileAppender(_testFilePath)) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null); + appender.Append(entry); + appender.Flush(); + } + + var content = File.ReadAllText(_testFilePath); + Assert.That(content, Does.Contain("Test message")); + Assert.That(content, Does.Contain("INFO")); + } + + [Test] + public void Append_MultipleEntries_ShouldWriteAll() + { + using (var appender = new FileAppender(_testFilePath)) + { + for (int i = 0; i < 10; i++) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null); + appender.Append(entry); + } + + appender.Flush(); + } + + var lines = File.ReadAllLines(_testFilePath); + Assert.That(lines.Length, Is.EqualTo(10)); + for (int i = 0; i < 10; i++) + { + Assert.That(lines[i], Does.Contain($"Message {i}")); + } + } + + [Test] + public void Append_WithJsonFormatter_ShouldWriteJson() + { + using (var appender = new FileAppender(_testFilePath, new JsonLogFormatter())) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null); + appender.Append(entry); + appender.Flush(); + } + + var content = File.ReadAllText(_testFilePath); + Assert.That(content, Does.Contain("\"message\"")); + Assert.That(content, Does.Contain("\"level\"")); + } + + [Test] + public void Append_AfterDispose_ShouldThrowObjectDisposedException() + { + var appender = new FileAppender(_testFilePath); + appender.Dispose(); + + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null); + + Assert.Throws(() => appender.Append(entry)); + } + + [Test] + public void Append_WithAppendMode_ShouldAppendToExistingFile() + { + // 第一次写入 + using (var appender1 = new FileAppender(_testFilePath)) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "First message", null, null); + appender1.Append(entry); + appender1.Flush(); + } + + // 第二次写入 + using (var appender2 = new FileAppender(_testFilePath)) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Second message", null, null); + appender2.Append(entry); + appender2.Flush(); + } + + var lines = File.ReadAllLines(_testFilePath); + Assert.That(lines.Length, Is.EqualTo(2)); + Assert.That(lines[0], Does.Contain("First message")); + Assert.That(lines[1], Does.Contain("Second message")); + } + + [Test] + public void Flush_ShouldEnsureDataWritten() + { + using (var appender = new FileAppender(_testFilePath)) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null); + + appender.Append(entry); + appender.Flush(); + } + + // 立即读取文件应该能看到内容 + var content = File.ReadAllText(_testFilePath); + Assert.That(content, Does.Contain("Test message")); + } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/logging/JsonLogFormatterTests.cs b/GFramework.Core.Tests/logging/JsonLogFormatterTests.cs new file mode 100644 index 00000000..9c026a4a --- /dev/null +++ b/GFramework.Core.Tests/logging/JsonLogFormatterTests.cs @@ -0,0 +1,187 @@ +using System.Text.Json; +using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging.formatters; +using NUnit.Framework; + +namespace GFramework.Core.Tests.logging; + +/// +/// 测试 JsonLogFormatter 的功能和行为 +/// +[TestFixture] +public class JsonLogFormatterTests +{ + [SetUp] + public void SetUp() + { + _formatter = new JsonLogFormatter(); + } + + private JsonLogFormatter _formatter = null!; + + [Test] + public void Format_WithBasicEntry_ShouldProduceValidJson() + { + var timestamp = new DateTime(2026, 2, 26, 10, 30, 45, 123, DateTimeKind.Utc); + var entry = new LogEntry(timestamp, LogLevel.Info, "TestLogger", "Test message", null, null); + + var result = _formatter.Format(entry); + + Assert.That(() => JsonDocument.Parse(result), Throws.Nothing); + + var doc = JsonDocument.Parse(result); + Assert.That(doc.RootElement.GetProperty("level").GetString(), Is.EqualTo("INFO")); + Assert.That(doc.RootElement.GetProperty("logger").GetString(), Is.EqualTo("TestLogger")); + Assert.That(doc.RootElement.GetProperty("message").GetString(), Is.EqualTo("Test message")); + } + + [Test] + public void Format_WithException_ShouldIncludeExceptionDetails() + { + var exception = new InvalidOperationException("Test exception"); + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Error, "TestLogger", "Error occurred", exception, null); + + var result = _formatter.Format(entry); + + var doc = JsonDocument.Parse(result); + var exceptionObj = doc.RootElement.GetProperty("exception"); + + Assert.That(exceptionObj.GetProperty("type").GetString(), Does.Contain("InvalidOperationException")); + Assert.That(exceptionObj.GetProperty("message").GetString(), Is.EqualTo("Test exception")); + Assert.That(exceptionObj.TryGetProperty("stackTrace", out _), Is.True); + } + + [Test] + public void Format_WithProperties_ShouldIncludePropertiesObject() + { + var properties = new Dictionary + { + ["UserId"] = 12345, + ["UserName"] = "TestUser", + ["IsActive"] = true + }; + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "User action", null, properties); + + var result = _formatter.Format(entry); + + var doc = JsonDocument.Parse(result); + if (doc.RootElement.TryGetProperty("properties", out var propsObj)) + { + // 使用 TryGetProperty 来安全访问属性 + Assert.That( + propsObj.TryGetProperty("userId", out var userIdProp) || + propsObj.TryGetProperty("UserId", out userIdProp), Is.True, + $"userId/UserId not found. Available properties: {string.Join(", ", propsObj.EnumerateObject().Select(p => p.Name))}"); + Assert.That(userIdProp.GetInt32(), Is.EqualTo(12345)); + + Assert.That( + propsObj.TryGetProperty("userName", out var userNameProp) || + propsObj.TryGetProperty("UserName", out userNameProp), Is.True); + Assert.That(userNameProp.GetString(), Is.EqualTo("TestUser")); + + Assert.That( + propsObj.TryGetProperty("isActive", out var isActiveProp) || + propsObj.TryGetProperty("IsActive", out isActiveProp), Is.True); + Assert.That(isActiveProp.GetBoolean(), Is.True); + } + else + { + Assert.Fail($"Properties object should be present when properties are provided. JSON: {result}"); + } + } + + [Test] + public void Format_WithNullProperty_ShouldHandleNull() + { + var properties = new Dictionary + { + ["Key1"] = null, + ["Key2"] = "value" + }; + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, properties); + + var result = _formatter.Format(entry); + + var doc = JsonDocument.Parse(result); + if (doc.RootElement.TryGetProperty("properties", out var propsObj)) + { + // 使用 TryGetProperty 来安全访问属性 + Assert.That( + propsObj.TryGetProperty("key1", out var key1Prop) || propsObj.TryGetProperty("Key1", out key1Prop), + Is.True, + $"key1/Key1 not found. Available properties: {string.Join(", ", propsObj.EnumerateObject().Select(p => p.Name))}"); + Assert.That(key1Prop.ValueKind, Is.EqualTo(JsonValueKind.Null)); + + Assert.That( + propsObj.TryGetProperty("key2", out var key2Prop) || propsObj.TryGetProperty("Key2", out key2Prop), + Is.True); + Assert.That(key2Prop.GetString(), Is.EqualTo("value")); + } + else + { + Assert.Fail($"Properties object should be present when properties are provided. JSON: {result}"); + } + } + + [Test] + public void Format_WithAllLogLevels_ShouldFormatCorrectly() + { + var levels = new[] + { LogLevel.Trace, LogLevel.Debug, LogLevel.Info, LogLevel.Warning, LogLevel.Error, LogLevel.Fatal }; + var expectedStrings = new[] { "TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "FATAL" }; + + for (int i = 0; i < levels.Length; i++) + { + var entry = new LogEntry(DateTime.UtcNow, levels[i], "TestLogger", "Test", null, null); + var result = _formatter.Format(entry); + + var doc = JsonDocument.Parse(result); + Assert.That(doc.RootElement.GetProperty("level").GetString(), Is.EqualTo(expectedStrings[i])); + } + } + + [Test] + public void Format_WithSpecialCharacters_ShouldEscapeCorrectly() + { + var message = "Test \"quoted\" and \n newline"; + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", message, null, null); + + var result = _formatter.Format(entry); + + var doc = JsonDocument.Parse(result); + Assert.That(doc.RootElement.GetProperty("message").GetString(), Is.EqualTo(message)); + } + + [Test] + public void Format_ShouldUseIso8601Timestamp() + { + var timestamp = new DateTime(2026, 2, 26, 10, 30, 45, 123, DateTimeKind.Utc); + var entry = new LogEntry(timestamp, LogLevel.Info, "TestLogger", "Test", null, null); + + var result = _formatter.Format(entry); + + var doc = JsonDocument.Parse(result); + var timestampStr = doc.RootElement.GetProperty("timestamp").GetString(); + + Assert.That(timestampStr, Does.Contain("2026-02-26")); + Assert.That(timestampStr, Does.Contain("T")); + } + + [Test] + public void Format_WithComplexProperties_ShouldSerializeCorrectly() + { + var properties = new Dictionary + { + ["Number"] = 123, + ["String"] = "test", + ["Boolean"] = true, + ["Null"] = null, + ["Array"] = new[] { 1, 2, 3 } + }; + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test", null, properties); + + var result = _formatter.Format(entry); + + Assert.That(() => JsonDocument.Parse(result), Throws.Nothing); + } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/logging/LogContextTests.cs b/GFramework.Core.Tests/logging/LogContextTests.cs new file mode 100644 index 00000000..27402dce --- /dev/null +++ b/GFramework.Core.Tests/logging/LogContextTests.cs @@ -0,0 +1,204 @@ +using GFramework.Core.Abstractions.logging; +using NUnit.Framework; + +namespace GFramework.Core.Tests.logging; + +/// +/// 测试 LogContext 的功能和行为 +/// +[TestFixture] +public class LogContextTests +{ + [SetUp] + public void SetUp() + { + LogContext.Clear(); + } + + [TearDown] + public void TearDown() + { + LogContext.Clear(); + } + + [Test] + public void Current_WhenEmpty_ShouldReturnEmptyDictionary() + { + var current = LogContext.Current; + + Assert.That(current, Is.Not.Null); + Assert.That(current.Count, Is.EqualTo(0)); + } + + [Test] + public void Push_ShouldAddPropertyToContext() + { + using (LogContext.Push("Key1", "Value1")) + { + var current = LogContext.Current; + + Assert.That(current.Count, Is.EqualTo(1)); + Assert.That(current["Key1"], Is.EqualTo("Value1")); + } + } + + [Test] + public void Push_WithMultipleProperties_ShouldAddAllProperties() + { + using (LogContext.PushProperties(("Key1", "Value1"), ("Key2", 123))) + { + var current = LogContext.Current; + + Assert.That(current.Count, Is.EqualTo(2)); + Assert.That(current["Key1"], Is.EqualTo("Value1")); + Assert.That(current["Key2"], Is.EqualTo(123)); + } + } + + [Test] + public void Push_WithNestedContext_ShouldMergeProperties() + { + using (LogContext.Push("Key1", "Value1")) + { + using (LogContext.Push("Key2", "Value2")) + { + var current = LogContext.Current; + + Assert.That(current.Count, Is.EqualTo(2)); + Assert.That(current["Key1"], Is.EqualTo("Value1")); + Assert.That(current["Key2"], Is.EqualTo("Value2")); + } + } + } + + [Test] + public void Push_WithSameKey_ShouldOverrideValue() + { + using (LogContext.Push("Key1", "Value1")) + { + using (LogContext.Push("Key1", "Value2")) + { + var current = LogContext.Current; + + Assert.That(current.Count, Is.EqualTo(1)); + Assert.That(current["Key1"], Is.EqualTo("Value2")); + } + + // 释放后应该恢复原值 + var restored = LogContext.Current; + Assert.That(restored["Key1"], Is.EqualTo("Value1")); + } + } + + [Test] + public void Dispose_ShouldRestorePreviousValue() + { + using (LogContext.Push("Key1", "Value1")) + { + Assert.That(LogContext.Current["Key1"], Is.EqualTo("Value1")); + } + + // 释放后应该清空 + Assert.That(LogContext.Current.Count, Is.EqualTo(0)); + } + + [Test] + public void Clear_ShouldRemoveAllProperties() + { + using (LogContext.Push("Key1", "Value1")) + { + using (LogContext.Push("Key2", "Value2")) + { + LogContext.Clear(); + + Assert.That(LogContext.Current.Count, Is.EqualTo(0)); + } + } + } + + [Test] + public void Push_WithNullKey_ShouldThrowArgumentException() + { + Assert.Throws(() => LogContext.Push(null!, "Value")); + } + + [Test] + public void Push_WithEmptyKey_ShouldThrowArgumentException() + { + Assert.Throws(() => LogContext.Push("", "Value")); + } + + [Test] + public void Push_WithWhitespaceKey_ShouldThrowArgumentException() + { + Assert.Throws(() => LogContext.Push(" ", "Value")); + } + + [Test] + public void Push_WithNullValue_ShouldWork() + { + using (LogContext.Push("Key1", null)) + { + var current = LogContext.Current; + + Assert.That(current.Count, Is.EqualTo(1)); + Assert.That(current["Key1"], Is.Null); + } + } + + [Test] + public async Task Push_InAsyncContext_ShouldIsolateAcrossThreads() + { + var task1Values = new List(); + var task2Values = new List(); + + var task1 = Task.Run(() => + { + using (LogContext.Push("TaskId", "Task1")) + { + task1Values.Add(LogContext.Current["TaskId"]); + Task.Delay(50).Wait(); + task1Values.Add(LogContext.Current["TaskId"]); + } + }); + + var task2 = Task.Run(() => + { + using (LogContext.Push("TaskId", "Task2")) + { + task2Values.Add(LogContext.Current["TaskId"]); + Task.Delay(50).Wait(); + task2Values.Add(LogContext.Current["TaskId"]); + } + }); + + await Task.WhenAll(task1, task2); + + Assert.That(task1Values, Has.All.EqualTo("Task1")); + Assert.That(task2Values, Has.All.EqualTo("Task2")); + } + + [Test] + public void Push_WithComplexObject_ShouldStoreReference() + { + var obj = new { Name = "Test", Value = 123 }; + + using (LogContext.Push("Object", obj)) + { + var current = LogContext.Current; + + Assert.That(current["Object"], Is.SameAs(obj)); + } + } + + [Test] + public void Push_MultipleDispose_ShouldBeIdempotent() + { + var disposable = LogContext.Push("Key1", "Value1"); + + disposable.Dispose(); + disposable.Dispose(); // 第二次调用不应该抛出异常 + + Assert.That(LogContext.Current.Count, Is.EqualTo(0)); + } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/logging/LogEntryTests.cs b/GFramework.Core.Tests/logging/LogEntryTests.cs new file mode 100644 index 00000000..4e08d383 --- /dev/null +++ b/GFramework.Core.Tests/logging/LogEntryTests.cs @@ -0,0 +1,145 @@ +using GFramework.Core.Abstractions.logging; +using NUnit.Framework; + +namespace GFramework.Core.Tests.logging; + +/// +/// 测试 LogEntry 的功能和行为 +/// +[TestFixture] +public class LogEntryTests +{ + [Test] + public void Constructor_WithAllParameters_ShouldCreateEntry() + { + var timestamp = DateTime.UtcNow; + var properties = new Dictionary { ["Key1"] = "Value1" }; + var exception = new InvalidOperationException("Test"); + + var entry = new LogEntry(timestamp, LogLevel.Info, "TestLogger", "Test message", exception, properties); + + Assert.That(entry.Timestamp, Is.EqualTo(timestamp)); + Assert.That(entry.Level, Is.EqualTo(LogLevel.Info)); + Assert.That(entry.LoggerName, Is.EqualTo("TestLogger")); + Assert.That(entry.Message, Is.EqualTo("Test message")); + Assert.That(entry.Exception, Is.SameAs(exception)); + Assert.That(entry.Properties, Is.SameAs(properties)); + } + + [Test] + public void Constructor_WithNullException_ShouldWork() + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null); + + Assert.That(entry.Exception, Is.Null); + } + + [Test] + public void Constructor_WithNullProperties_ShouldWork() + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null); + + Assert.That(entry.Properties, Is.Null); + } + + [Test] + public void GetAllProperties_WithNoProperties_ShouldReturnContextProperties() + { + LogContext.Clear(); + using (LogContext.Push("ContextKey", "ContextValue")) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null); + + var allProps = entry.GetAllProperties(); + + Assert.That(allProps.Count, Is.EqualTo(1)); + Assert.That(allProps["ContextKey"], Is.EqualTo("ContextValue")); + } + + LogContext.Clear(); + } + + [Test] + public void GetAllProperties_WithProperties_ShouldReturnOnlyProperties() + { + LogContext.Clear(); + var properties = new Dictionary { ["PropKey"] = "PropValue" }; + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, properties); + + var allProps = entry.GetAllProperties(); + + Assert.That(allProps.Count, Is.EqualTo(1)); + Assert.That(allProps["PropKey"], Is.EqualTo("PropValue")); + } + + [Test] + public void GetAllProperties_WithBothPropertiesAndContext_ShouldMerge() + { + LogContext.Clear(); + using (LogContext.Push("ContextKey", "ContextValue")) + { + var properties = new Dictionary { ["PropKey"] = "PropValue" }; + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, properties); + + var allProps = entry.GetAllProperties(); + + Assert.That(allProps.Count, Is.EqualTo(2)); + Assert.That(allProps["ContextKey"], Is.EqualTo("ContextValue")); + Assert.That(allProps["PropKey"], Is.EqualTo("PropValue")); + } + + LogContext.Clear(); + } + + [Test] + public void GetAllProperties_WithConflictingKeys_ShouldPreferEntryProperties() + { + LogContext.Clear(); + using (LogContext.Push("Key1", "ContextValue")) + { + var properties = new Dictionary { ["Key1"] = "PropValue" }; + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, properties); + + var allProps = entry.GetAllProperties(); + + Assert.That(allProps.Count, Is.EqualTo(1)); + Assert.That(allProps["Key1"], Is.EqualTo("PropValue")); // 日志属性优先 + } + + LogContext.Clear(); + } + + [Test] + public void GetAllProperties_WithEmptyPropertiesAndEmptyContext_ShouldReturnEmpty() + { + LogContext.Clear(); + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null); + + var allProps = entry.GetAllProperties(); + + Assert.That(allProps.Count, Is.EqualTo(0)); + } + + [Test] + public void RecordEquality_WithSameValues_ShouldBeEqual() + { + var timestamp = DateTime.UtcNow; + var properties = new Dictionary { ["Key1"] = "Value1" }; + + var entry1 = new LogEntry(timestamp, LogLevel.Info, "TestLogger", "Test message", null, properties); + var entry2 = new LogEntry(timestamp, LogLevel.Info, "TestLogger", "Test message", null, properties); + + Assert.That(entry1, Is.EqualTo(entry2)); + } + + [Test] + public void RecordEquality_WithDifferentValues_ShouldNotBeEqual() + { + var timestamp = DateTime.UtcNow; + + var entry1 = new LogEntry(timestamp, LogLevel.Info, "TestLogger", "Test message 1", null, null); + var entry2 = new LogEntry(timestamp, LogLevel.Info, "TestLogger", "Test message 2", null, null); + + Assert.That(entry1, Is.Not.EqualTo(entry2)); + } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/logging/LogLevelFilterTests.cs b/GFramework.Core.Tests/logging/LogLevelFilterTests.cs new file mode 100644 index 00000000..3bdaec3f --- /dev/null +++ b/GFramework.Core.Tests/logging/LogLevelFilterTests.cs @@ -0,0 +1,59 @@ +using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging.filters; +using NUnit.Framework; + +namespace GFramework.Core.Tests.logging; + +/// +/// 测试 LogLevelFilter 的功能和行为 +/// +[TestFixture] +public class LogLevelFilterTests +{ + [Test] + public void ShouldLog_WithLevelAboveMinimum_ShouldReturnTrue() + { + var filter = new LogLevelFilter(LogLevel.Info); + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Warning, "TestLogger", "Test", null, null); + + Assert.That(filter.ShouldLog(entry), Is.True); + } + + [Test] + public void ShouldLog_WithLevelEqualToMinimum_ShouldReturnTrue() + { + var filter = new LogLevelFilter(LogLevel.Info); + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test", null, null); + + Assert.That(filter.ShouldLog(entry), Is.True); + } + + [Test] + public void ShouldLog_WithLevelBelowMinimum_ShouldReturnFalse() + { + var filter = new LogLevelFilter(LogLevel.Info); + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Debug, "TestLogger", "Test", null, null); + + Assert.That(filter.ShouldLog(entry), Is.False); + } + + [Test] + public void ShouldLog_WithAllLevels_ShouldWorkCorrectly() + { + var filter = new LogLevelFilter(LogLevel.Warning); + + var traceEntry = new LogEntry(DateTime.UtcNow, LogLevel.Trace, "TestLogger", "Test", null, null); + var debugEntry = new LogEntry(DateTime.UtcNow, LogLevel.Debug, "TestLogger", "Test", null, null); + var infoEntry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test", null, null); + var warningEntry = new LogEntry(DateTime.UtcNow, LogLevel.Warning, "TestLogger", "Test", null, null); + var errorEntry = new LogEntry(DateTime.UtcNow, LogLevel.Error, "TestLogger", "Test", null, null); + var fatalEntry = new LogEntry(DateTime.UtcNow, LogLevel.Fatal, "TestLogger", "Test", null, null); + + Assert.That(filter.ShouldLog(traceEntry), Is.False); + Assert.That(filter.ShouldLog(debugEntry), Is.False); + Assert.That(filter.ShouldLog(infoEntry), Is.False); + Assert.That(filter.ShouldLog(warningEntry), Is.True); + Assert.That(filter.ShouldLog(errorEntry), Is.True); + Assert.That(filter.ShouldLog(fatalEntry), Is.True); + } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/logging/LoggingConfigurationTests.cs b/GFramework.Core.Tests/logging/LoggingConfigurationTests.cs new file mode 100644 index 00000000..ae2910de --- /dev/null +++ b/GFramework.Core.Tests/logging/LoggingConfigurationTests.cs @@ -0,0 +1,311 @@ +using System.IO; +using System.Text.Json; +using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging; +using NUnit.Framework; + +namespace GFramework.Core.Tests.logging; + +/// +/// 测试 LoggingConfiguration 和 LoggingConfigurationLoader 的功能和行为 +/// +[TestFixture] +public class LoggingConfigurationTests +{ + [Test] + public void LoadFromJsonString_WithValidJson_ShouldDeserialize() + { + var json = @"{ + ""minLevel"": ""Debug"", + ""appenders"": [ + { + ""type"": ""Console"", + ""formatter"": ""Default"", + ""useColors"": true + } + ], + ""loggerLevels"": { + ""GFramework.Core"": ""Trace"", + ""MyApp"": ""Info"" + } + }"; + + var config = LoggingConfigurationLoader.LoadFromJsonString(json); + + Assert.That(config.MinLevel, Is.EqualTo(LogLevel.Debug)); + Assert.That(config.Appenders.Count, Is.EqualTo(1)); + Assert.That(config.Appenders[0].Type, Is.EqualTo("Console")); + Assert.That(config.LoggerLevels.Count, Is.EqualTo(2)); + Assert.That(config.LoggerLevels["GFramework.Core"], Is.EqualTo(LogLevel.Trace)); + } + + [Test] + public void LoadFromJsonString_WithInvalidJson_ShouldThrow() + { + var invalidJson = "{ invalid json }"; + + Assert.Throws(() => LoggingConfigurationLoader.LoadFromJsonString(invalidJson)); + } + + [Test] + public void CreateFactory_WithConsoleAppender_ShouldCreateFactory() + { + var json = @"{ + ""minLevel"": ""Info"", + ""appenders"": [ + { + ""type"": ""Console"", + ""formatter"": ""Default"", + ""useColors"": false + } + ] + }"; + + var config = LoggingConfigurationLoader.LoadFromJsonString(json); + var factory = LoggingConfigurationLoader.CreateFactory(config); + + Assert.That(factory, Is.Not.Null); + + var logger = factory.GetLogger("TestLogger"); + Assert.That(logger, Is.Not.Null); + Assert.That(logger.Name(), Is.EqualTo("TestLogger")); + } + + [Test] + public void CreateFactory_WithFileAppender_ShouldCreateFactory() + { + var testFile = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}.log"); + + try + { + var json = $@"{{ + ""minLevel"": ""Info"", + ""appenders"": [ + {{ + ""type"": ""File"", + ""filePath"": ""{testFile.Replace("\\", "\\\\")}"", + ""formatter"": ""Json"" + }} + ] + }}"; + + var config = LoggingConfigurationLoader.LoadFromJsonString(json); + var factory = LoggingConfigurationLoader.CreateFactory(config); + + var logger = factory.GetLogger("TestLogger"); + logger.Info("Test message"); + + // 验证文件是否创建 + Assert.That(File.Exists(testFile), Is.True); + } + finally + { + if (File.Exists(testFile)) + { + try + { + File.Delete(testFile); + } + catch + { + } + } + } + } + + [Test] + public void CreateFactory_WithLoggerLevels_ShouldApplyCorrectLevels() + { + var json = @"{ + ""minLevel"": ""Info"", + ""appenders"": [ + { + ""type"": ""Console"", + ""formatter"": ""Default"" + } + ], + ""loggerLevels"": { + ""GFramework.Core"": ""Trace"", + ""MyApp"": ""Warning"" + } + }"; + + var config = LoggingConfigurationLoader.LoadFromJsonString(json); + var factory = LoggingConfigurationLoader.CreateFactory(config); + + var logger1 = factory.GetLogger("GFramework.Core.Test"); + var logger2 = factory.GetLogger("MyApp.Controllers"); + var logger3 = factory.GetLogger("OtherNamespace"); + + Assert.That(logger1.IsTraceEnabled(), Is.True); + Assert.That(logger2.IsTraceEnabled(), Is.False); + Assert.That(logger2.IsWarnEnabled(), Is.True); + Assert.That(logger3.IsInfoEnabled(), Is.True); + } + + [Test] + public void CreateFactory_WithInvalidAppenderType_ShouldThrowException() + { + var json = @"{ + ""minLevel"": ""Info"", + ""appenders"": [ + { + ""type"": ""UnsupportedType"", + ""formatter"": ""Default"" + } + ] + }"; + + var config = LoggingConfigurationLoader.LoadFromJsonString(json); + Assert.Throws(() => LoggingConfigurationLoader.CreateFactory(config)); + } + + [Test] + public void CreateFactory_WithLogLevelFilter_ShouldApplyFilter() + { + var testFile = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}.log"); + + try + { + var json = $@"{{ + ""minLevel"": ""Info"", + ""appenders"": [ + {{ + ""type"": ""File"", + ""filePath"": ""{testFile.Replace("\\", "\\\\")}"", + ""formatter"": ""Default"", + ""filter"": {{ + ""type"": ""LogLevel"", + ""minLevel"": ""Warning"" + }} + }} + ] + }}"; + + var config = LoggingConfigurationLoader.LoadFromJsonString(json); + ILoggerFactory? factory = null; + try + { + factory = LoggingConfigurationLoader.CreateFactory(config); + + var logger = factory.GetLogger("TestLogger"); + logger.Info("Info message"); + logger.Warn("Warning message"); + } + finally + { + // 确保释放 factory 和所有 appenders + if (factory is IDisposable disposable) + { + disposable.Dispose(); + } + } + + // 只有 Warning 应该被写入 + var content = File.ReadAllText(testFile); + Assert.That(content, Does.Not.Contain("Info message")); + Assert.That(content, Does.Contain("Warning message")); + } + finally + { + if (File.Exists(testFile)) + { + try + { + File.Delete(testFile); + } + catch + { + } + } + } + } + + [Test] + public void CreateFactory_WithNamespaceFilter_ShouldApplyFilter() + { + var testFile = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}.log"); + + try + { + var json = $@"{{ + ""minLevel"": ""Info"", + ""appenders"": [ + {{ + ""type"": ""File"", + ""filePath"": ""{testFile.Replace("\\", "\\\\")}"", + ""formatter"": ""Default"", + ""filter"": {{ + ""type"": ""Namespace"", + ""namespaces"": [""GFramework""] + }} + }} + ] + }}"; + + var config = LoggingConfigurationLoader.LoadFromJsonString(json); + Assert.That(config.Appenders[0].Filter, Is.Not.Null); + Assert.That(config.Appenders[0].Filter.Type, Is.EqualTo("Namespace")); + } + finally + { + if (File.Exists(testFile)) + { + try + { + File.Delete(testFile); + } + catch + { + } + } + } + } + + [Test] + public void LoadFromJsonString_WithComplexConfiguration_ShouldWork() + { + var json = @"{ + ""minLevel"": ""Info"", + ""appenders"": [ + { + ""type"": ""Console"", + ""formatter"": ""Default"", + ""useColors"": true + }, + { + ""type"": ""File"", + ""filePath"": ""logs/app.log"", + ""formatter"": ""Json"", + ""filter"": { + ""type"": ""LogLevel"", + ""minLevel"": ""Warning"" + } + }, + { + ""type"": ""RollingFile"", + ""filePath"": ""logs/rolling.log"", + ""formatter"": ""Default"", + ""maxFileSize"": 10485760, + ""maxFileCount"": 5 + } + ], + ""loggerLevels"": { + ""GFramework.Core"": ""Debug"", + ""MyApp.Controllers"": ""Info"", + ""MyApp.Services"": ""Warning"" + } + }"; + + var config = LoggingConfigurationLoader.LoadFromJsonString(json); + + Assert.That(config.MinLevel, Is.EqualTo(LogLevel.Info)); + Assert.That(config.Appenders.Count, Is.EqualTo(3)); + Assert.That(config.Appenders[0].Type, Is.EqualTo("Console")); + Assert.That(config.Appenders[1].Type, Is.EqualTo("File")); + Assert.That(config.Appenders[1].Filter, Is.Not.Null); + Assert.That(config.Appenders[2].Type, Is.EqualTo("RollingFile")); + Assert.That(config.Appenders[2].MaxFileSize, Is.EqualTo(10485760)); + Assert.That(config.LoggerLevels.Count, Is.EqualTo(3)); + } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/logging/NamespaceFilterTests.cs b/GFramework.Core.Tests/logging/NamespaceFilterTests.cs new file mode 100644 index 00000000..7feb93e5 --- /dev/null +++ b/GFramework.Core.Tests/logging/NamespaceFilterTests.cs @@ -0,0 +1,85 @@ +using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging.filters; +using NUnit.Framework; + +namespace GFramework.Core.Tests.logging; + +/// +/// 测试 NamespaceFilter 的功能和行为 +/// +[TestFixture] +public class NamespaceFilterTests +{ + [Test] + public void Constructor_WithNullNamespaces_ShouldThrowArgumentException() + { + Assert.Throws(() => new NamespaceFilter(null!)); + } + + [Test] + public void Constructor_WithEmptyNamespaces_ShouldThrowArgumentException() + { + Assert.Throws(() => new NamespaceFilter(Array.Empty())); + } + + [Test] + public void ShouldLog_WithMatchingNamespace_ShouldReturnTrue() + { + var filter = new NamespaceFilter("GFramework.Core", "MyApp"); + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "GFramework.Core.Logging", "Test", null, null); + + Assert.That(filter.ShouldLog(entry), Is.True); + } + + [Test] + public void ShouldLog_WithNonMatchingNamespace_ShouldReturnFalse() + { + var filter = new NamespaceFilter("GFramework.Core", "MyApp"); + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "OtherNamespace", "Test", null, null); + + Assert.That(filter.ShouldLog(entry), Is.False); + } + + [Test] + public void ShouldLog_WithExactMatch_ShouldReturnTrue() + { + var filter = new NamespaceFilter("GFramework.Core"); + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "GFramework.Core", "Test", null, null); + + Assert.That(filter.ShouldLog(entry), Is.True); + } + + [Test] + public void ShouldLog_WithPrefixMatch_ShouldReturnTrue() + { + var filter = new NamespaceFilter("GFramework"); + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "GFramework.Core.Logging", "Test", null, null); + + Assert.That(filter.ShouldLog(entry), Is.True); + } + + [Test] + public void ShouldLog_IsCaseInsensitive() + { + var filter = new NamespaceFilter("gframework.core"); + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "GFramework.Core.Logging", "Test", null, null); + + Assert.That(filter.ShouldLog(entry), Is.True); + } + + [Test] + public void ShouldLog_WithMultipleNamespaces_ShouldMatchAny() + { + var filter = new NamespaceFilter("GFramework.Core", "MyApp.Services", "ThirdParty"); + + var entry1 = new LogEntry(DateTime.UtcNow, LogLevel.Info, "GFramework.Core.Logging", "Test", null, null); + var entry2 = new LogEntry(DateTime.UtcNow, LogLevel.Info, "MyApp.Services.UserService", "Test", null, null); + var entry3 = new LogEntry(DateTime.UtcNow, LogLevel.Info, "ThirdParty.Library", "Test", null, null); + var entry4 = new LogEntry(DateTime.UtcNow, LogLevel.Info, "OtherNamespace", "Test", null, null); + + Assert.That(filter.ShouldLog(entry1), Is.True); + Assert.That(filter.ShouldLog(entry2), Is.True); + Assert.That(filter.ShouldLog(entry3), Is.True); + Assert.That(filter.ShouldLog(entry4), Is.False); + } +} \ No newline at end of file diff --git a/GFramework.Core.Tests/logging/RollingFileAppenderTests.cs b/GFramework.Core.Tests/logging/RollingFileAppenderTests.cs new file mode 100644 index 00000000..09c70ab9 --- /dev/null +++ b/GFramework.Core.Tests/logging/RollingFileAppenderTests.cs @@ -0,0 +1,166 @@ +using System.IO; +using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging.appenders; +using NUnit.Framework; + +namespace GFramework.Core.Tests.logging; + +/// +/// 测试 RollingFileAppender 的功能和行为 +/// +[TestFixture] +public class RollingFileAppenderTests +{ + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), $"rolling_test_{Guid.NewGuid()}"); + Directory.CreateDirectory(_testDir); + _testFilePath = Path.Combine(_testDir, "app.log"); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_testDir)) + { + try + { + Directory.Delete(_testDir, true); + } + catch + { + } + } + } + + private string _testDir = null!; + private string _testFilePath = null!; + + [Test] + public void Constructor_WithInvalidMaxFileSize_ShouldThrowArgumentException() + { + Assert.Throws(() => new RollingFileAppender(_testFilePath, maxFileSize: 0)); + Assert.Throws(() => new RollingFileAppender(_testFilePath, maxFileSize: -1)); + } + + [Test] + public void Constructor_WithInvalidMaxFileCount_ShouldThrowArgumentException() + { + Assert.Throws(() => new RollingFileAppender(_testFilePath, maxFileCount: 0)); + Assert.Throws(() => new RollingFileAppender(_testFilePath, maxFileCount: -1)); + } + + [Test] + public void Append_WhenFileSizeExceedsLimit_ShouldRollFiles() + { + using (var appender = new RollingFileAppender(_testFilePath, maxFileSize: 500, maxFileCount: 3)) + { + // 写入足够多的日志触发轮转 + for (int i = 0; i < 20; i++) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", + $"This is a test message number {i} with some padding to increase size", null, null); + appender.Append(entry); + } + + appender.Flush(); + } + + // 检查是否生成了多个文件 + var files = Directory.GetFiles(_testDir, "*.log").OrderBy(f => f).ToArray(); + Assert.That(files.Length, Is.GreaterThan(1)); + } + + [Test] + public void Append_ShouldNotExceedMaxFileCount() + { + const int maxFileCount = 3; + using (var appender = new RollingFileAppender(_testFilePath, maxFileSize: 300, maxFileCount: maxFileCount)) + { + // 写入大量日志触发多次轮转 + for (int i = 0; i < 50; i++) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", + $"This is a test message number {i} with some padding to increase size significantly", null, null); + appender.Append(entry); + } + + appender.Flush(); + } + + var files = Directory.GetFiles(_testDir, "*.log"); + Assert.That(files.Length, Is.LessThanOrEqualTo(maxFileCount)); + } + + [Test] + public void Append_RolledFiles_ShouldHaveCorrectNaming() + { + using (var appender = new RollingFileAppender(_testFilePath, maxFileSize: 400, maxFileCount: 3)) + { + for (int i = 0; i < 30; i++) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", + $"Test message {i} with padding to trigger rolling", null, null); + appender.Append(entry); + } + + appender.Flush(); + } + + var files = Directory.GetFiles(_testDir, "*.log").Select(Path.GetFileName).OrderBy(f => f).ToArray(); + + // 应该有 app.log, app.1.log, app.2.log 等 + Assert.That(files, Does.Contain("app.log")); + if (files.Length > 1) + { + Assert.That(files.Any(f => f.StartsWith("app.") && f.EndsWith(".log") && f != "app.log"), Is.True); + } + } + + [Test] + public void Append_AfterDispose_ShouldThrowObjectDisposedException() + { + var appender = new RollingFileAppender(_testFilePath); + appender.Dispose(); + + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null); + + Assert.Throws(() => appender.Append(entry)); + } + + [Test] + public void Append_WithSmallMaxFileSize_ShouldRollFrequently() + { + using (var appender = new RollingFileAppender(_testFilePath, maxFileSize: 200, maxFileCount: 5)) + { + for (int i = 0; i < 10; i++) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", + "This is a longer message to trigger rolling more frequently", null, null); + appender.Append(entry); + } + + appender.Flush(); + } + + var files = Directory.GetFiles(_testDir, "*.log"); + Assert.That(files.Length, Is.GreaterThan(1)); + } + + [Test] + public void Flush_ShouldEnsureDataWritten() + { + using (var appender = new RollingFileAppender(_testFilePath)) + { + var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null); + + appender.Append(entry); + appender.Flush(); + } + + Assert.That(File.Exists(_testFilePath), Is.True); + var content = File.ReadAllText(_testFilePath); + Assert.That(content, Does.Contain("Test message")); + } +} \ No newline at end of file diff --git a/GFramework.Core/extensions/SpanExtensions.cs b/GFramework.Core/extensions/SpanExtensions.cs index d24145fc..575d42fe 100644 --- a/GFramework.Core/extensions/SpanExtensions.cs +++ b/GFramework.Core/extensions/SpanExtensions.cs @@ -21,7 +21,7 @@ public static class SpanExtensions /// } /// /// - public static bool TryParseValue(this ReadOnlySpan span, out T result) where T : ISpanParsable + public static bool TryParseValue(this ReadOnlySpan span, out T? result) where T : ISpanParsable { return T.TryParse(span, null, out result); } diff --git a/GFramework.Core/functional/Result.T.cs b/GFramework.Core/functional/Result.T.cs index a65c505c..637900cc 100644 --- a/GFramework.Core/functional/Result.T.cs +++ b/GFramework.Core/functional/Result.T.cs @@ -26,13 +26,23 @@ namespace GFramework.Core.Functional; // ------------------------------------------------------------------ 状态枚举 /// - /// 结果状态枚举,表示结果的不同状态 - /// 排序: Bottom < Faulted < Success + /// 表示 Result 结构体的内部状态 /// private enum ResultState : byte { + /// + /// 未初始化状态,表示 Result 尚未被赋值 + /// Bottom, + + /// + /// 失败状态,表示操作执行失败并包含异常信息 + /// Faulted, + + /// + /// 成功状态,表示操作执行成功并包含返回值 + /// Success } diff --git a/GFramework.Core/logging/AbstractLogger.cs b/GFramework.Core/logging/AbstractLogger.cs index f924af26..e9f8ff78 100644 --- a/GFramework.Core/logging/AbstractLogger.cs +++ b/GFramework.Core/logging/AbstractLogger.cs @@ -8,7 +8,7 @@ namespace GFramework.Core.logging; /// public abstract class AbstractLogger( string? name = null, - LogLevel minLevel = LogLevel.Info) : ILogger + LogLevel minLevel = LogLevel.Info) : IStructuredLogger { /// /// 根日志记录器的名称常量 @@ -451,42 +451,121 @@ public void Fatal(string msg, Exception t) #endregion - #region Core Pipeline + #region Generic Log Methods /// - /// 核心日志记录方法(无参数) + /// 使用指定的日志级别记录消息 /// /// 日志级别 - /// 日志消息 - private void Log(LogLevel level, string message) + /// 要记录的消息字符串 + public void Log(LogLevel level, string message) { if (!IsEnabled(level)) return; Write(level, message, null); } /// - /// 核心日志记录方法(带参数格式化) + /// 使用指定的日志级别根据格式和参数记录消息 /// /// 日志级别 - /// 格式化字符串 - /// 格式化参数数组 - private void Log(LogLevel level, string format, params object[] args) + /// 格式字符串 + /// 参数 + public void Log(LogLevel level, string format, object arg) + { + if (!IsEnabled(level)) return; + Write(level, string.Format(format, arg), null); + } + + /// + /// 使用指定的日志级别根据格式和参数记录消息 + /// + /// 日志级别 + /// 格式字符串 + /// 第一个参数 + /// 第二个参数 + public void Log(LogLevel level, string format, object arg1, object arg2) { if (!IsEnabled(level)) return; - Write(level, string.Format(format, args), null); + Write(level, string.Format(format, arg1, arg2), null); } /// - /// 核心日志记录方法(带异常) + /// 使用指定的日志级别根据格式和参数数组记录消息 + /// + /// 日志级别 + /// 格式字符串 + /// 参数数组 + public void Log(LogLevel level, string format, params object[] arguments) + { + if (!IsEnabled(level)) return; + Write(level, string.Format(format, arguments), null); + } + + /// + /// 使用指定的日志级别记录消息和异常 + /// + /// 日志级别 + /// 伴随异常的消息 + /// 要记录的异常 + public void Log(LogLevel level, string message, Exception exception) + { + if (!IsEnabled(level)) return; + Write(level, message, exception); + } + + #endregion + + #region Structured Log Methods + + /// + /// 使用指定的日志级别记录消息和结构化属性 + /// + /// 日志级别 + /// 日志消息 + /// 结构化属性键值对 + public virtual void Log(LogLevel level, string message, params (string Key, object? Value)[] properties) + { + if (!IsEnabled(level)) return; + + // 默认实现:将属性附加到消息后面 + if (properties.Length > 0) + { + var propsStr = string.Join(", ", properties.Select(p => $"{p.Key}={p.Value}")); + Write(level, $"{message} | {propsStr}", null); + } + else + { + Write(level, message, null); + } + } + + /// + /// 使用指定的日志级别记录消息、异常和结构化属性 /// /// 日志级别 /// 日志消息 /// 异常对象 - private void Log(LogLevel level, string message, Exception exception) + /// 结构化属性键值对 + public virtual void Log(LogLevel level, string message, Exception? exception, + params (string Key, object? Value)[] properties) { if (!IsEnabled(level)) return; - Write(level, message, exception); + + // 默认实现:将属性附加到消息后面 + if (properties.Length > 0) + { + var propsStr = string.Join(", ", properties.Select(p => $"{p.Key}={p.Value}")); + Write(level, $"{message} | {propsStr}", exception); + } + else + { + Write(level, message, exception); + } } #endregion + + #region Core Pipeline (Private) + + #endregion } \ No newline at end of file diff --git a/GFramework.Core/logging/CachedLoggerFactory.cs b/GFramework.Core/logging/CachedLoggerFactory.cs new file mode 100644 index 00000000..9aea055a --- /dev/null +++ b/GFramework.Core/logging/CachedLoggerFactory.cs @@ -0,0 +1,34 @@ +using System.Collections.Concurrent; +using GFramework.Core.Abstractions.logging; + +namespace GFramework.Core.logging; + +/// +/// 带缓存的日志工厂包装器,避免重复创建相同名称的日志记录器实例 +/// +public sealed class CachedLoggerFactory : ILoggerFactory +{ + private readonly ConcurrentDictionary _cache = new(); + private readonly ILoggerFactory _innerFactory; + + /// + /// 创建缓存日志工厂实例 + /// + /// 内部日志工厂 + public CachedLoggerFactory(ILoggerFactory innerFactory) + { + _innerFactory = innerFactory ?? throw new ArgumentNullException(nameof(innerFactory)); + } + + /// + /// 获取或创建指定名称的日志记录器(带缓存) + /// + /// 日志记录器名称 + /// 最小日志级别 + /// 日志记录器实例 + public ILogger GetLogger(string name, LogLevel minLevel = LogLevel.Info) + { + var cacheKey = $"{name}:{minLevel}"; + return _cache.GetOrAdd(cacheKey, _ => _innerFactory.GetLogger(name, minLevel)); + } +} \ No newline at end of file diff --git a/GFramework.Core/logging/CompositeLogger.cs b/GFramework.Core/logging/CompositeLogger.cs new file mode 100644 index 00000000..253cd80b --- /dev/null +++ b/GFramework.Core/logging/CompositeLogger.cs @@ -0,0 +1,134 @@ +using GFramework.Core.Abstractions.logging; + +namespace GFramework.Core.logging; + +/// +/// 组合日志记录器,支持同时输出到多个 Appender +/// +public sealed class CompositeLogger : AbstractLogger, IDisposable +{ + private readonly ILogAppender[] _appenders; + + /// + /// 创建组合日志记录器 + /// + /// 日志记录器名称 + /// 最小日志级别 + /// 日志输出器列表 + public CompositeLogger( + string name, + LogLevel minLevel, + params ILogAppender[] appenders) + : base(name, minLevel) + { + if (appenders == null || appenders.Length == 0) + throw new ArgumentException("At least one appender must be provided.", nameof(appenders)); + + _appenders = appenders; + } + + /// + /// 释放所有 Appender 资源 + /// + public void Dispose() + { + foreach (var appender in _appenders) + { + if (appender is IDisposable disposable) + { + disposable.Dispose(); + } + } + } + + /// + /// 写入日志到所有 Appender + /// + /// 日志级别 + /// 日志消息 + /// 异常对象 + protected override void Write(LogLevel level, string message, Exception? exception) + { + var entry = new LogEntry( + DateTime.UtcNow, + level, + Name(), + message, + exception, + null); + + foreach (var appender in _appenders) + { + appender.Append(entry); + } + } + + /// + /// 使用指定的日志级别记录消息和结构化属性 + /// + /// 日志级别 + /// 日志消息 + /// 结构化属性键值对 + public override void Log(LogLevel level, string message, params (string Key, object? Value)[] properties) + { + if (!IsEnabled(level)) return; + + var propsDict = properties.Length > 0 + ? properties.ToDictionary(p => p.Key, p => p.Value) + : null; + + var entry = new LogEntry( + DateTime.UtcNow, + level, + Name(), + message, + null, + propsDict); + + foreach (var appender in _appenders) + { + appender.Append(entry); + } + } + + /// + /// 使用指定的日志级别记录消息、异常和结构化属性 + /// + /// 日志级别 + /// 日志消息 + /// 异常对象 + /// 结构化属性键值对 + public override void Log(LogLevel level, string message, Exception? exception, + params (string Key, object? Value)[] properties) + { + if (!IsEnabled(level)) return; + + var propsDict = properties.Length > 0 + ? properties.ToDictionary(p => p.Key, p => p.Value) + : null; + + var entry = new LogEntry( + DateTime.UtcNow, + level, + Name(), + message, + exception, + propsDict); + + foreach (var appender in _appenders) + { + appender.Append(entry); + } + } + + /// + /// 刷新所有 Appender 的缓冲区 + /// + public void Flush() + { + foreach (var appender in _appenders) + { + appender.Flush(); + } + } +} \ No newline at end of file diff --git a/GFramework.Core/logging/ConsoleLogger.cs b/GFramework.Core/logging/ConsoleLogger.cs index 1645f18d..091c2900 100644 --- a/GFramework.Core/logging/ConsoleLogger.cs +++ b/GFramework.Core/logging/ConsoleLogger.cs @@ -12,6 +12,17 @@ public sealed class ConsoleLogger( TextWriter? writer = null, bool useColors = true) : AbstractLogger(name ?? RootLoggerName, minLevel) { + // 静态缓存日志级别字符串,避免重复格式化 + private static readonly string[] LevelStrings = + [ + "TRACE ", + "DEBUG ", + "INFO ", + "WARNING", + "ERROR ", + "FATAL " + ]; + private readonly bool _useColors = useColors && writer == Console.Out; private readonly TextWriter _writer = writer ?? Console.Out; @@ -23,8 +34,8 @@ public sealed class ConsoleLogger( /// 异常信息,可为空 protected override void Write(LogLevel level, string message, Exception? exception) { - var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"); - var levelStr = level.ToString().ToUpper().PadRight(7); + var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff"); + var levelStr = LevelStrings[(int)level]; var log = $"[{timestamp}] {levelStr} [{Name()}] {message}"; // 添加异常信息到日志 @@ -39,40 +50,32 @@ protected override void Write(LogLevel level, string message, Exception? excepti #region Internal Core /// - /// 以指定颜色写入日志消息 + /// 以指定颜色写入日志消息(使用 ANSI 转义码) /// /// 日志级别 /// 日志消息 private void WriteColored(LogLevel level, string message) { - var original = Console.ForegroundColor; - try - { - Console.ForegroundColor = GetColor(level); - _writer.WriteLine(message); - } - finally - { - Console.ForegroundColor = original; - } + var colorCode = GetAnsiColorCode(level); + _writer.WriteLine($"\x1b[{colorCode}m{message}\x1b[0m"); } /// - /// 根据日志级别获取对应的颜色 + /// 根据日志级别获取对应的 ANSI 颜色代码 /// /// 日志级别 - /// 控制台颜色 - private static ConsoleColor GetColor(LogLevel level) + /// ANSI 颜色代码 + private static string GetAnsiColorCode(LogLevel level) { return level switch { - LogLevel.Trace => ConsoleColor.DarkGray, - LogLevel.Debug => ConsoleColor.Cyan, - LogLevel.Info => ConsoleColor.White, - LogLevel.Warning => ConsoleColor.Yellow, - LogLevel.Error => ConsoleColor.Red, - LogLevel.Fatal => ConsoleColor.Magenta, - _ => ConsoleColor.White + LogLevel.Trace => "90", // 暗灰色 + LogLevel.Debug => "36", // 青色 + LogLevel.Info => "37", // 白色 + LogLevel.Warning => "33", // 黄色 + LogLevel.Error => "31", // 红色 + LogLevel.Fatal => "35", // 洋红色 + _ => "37" }; } diff --git a/GFramework.Core/logging/ConsoleLoggerFactoryProvider.cs b/GFramework.Core/logging/ConsoleLoggerFactoryProvider.cs index 9751f555..52583a3e 100644 --- a/GFramework.Core/logging/ConsoleLoggerFactoryProvider.cs +++ b/GFramework.Core/logging/ConsoleLoggerFactoryProvider.cs @@ -7,18 +7,28 @@ namespace GFramework.Core.logging; /// public sealed class ConsoleLoggerFactoryProvider : ILoggerFactoryProvider { + private readonly ILoggerFactory _cachedFactory; + + /// + /// 初始化控制台日志记录器工厂提供程序 + /// + public ConsoleLoggerFactoryProvider() + { + _cachedFactory = new CachedLoggerFactory(new ConsoleLoggerFactory()); + } + /// /// 获取或设置日志记录器的最小日志级别,低于此级别的日志将被忽略 /// public LogLevel MinLevel { get; set; } = LogLevel.Info; /// - /// 创建一个日志记录器实例 + /// 创建一个日志记录器实例(带缓存) /// /// 日志记录器的名称,用于标识特定的日志源 /// 配置了指定名称和最小日志级别的ILogger实例 public ILogger CreateLogger(string name) { - return new ConsoleLoggerFactory().GetLogger(name, MinLevel); + return _cachedFactory.GetLogger(name, MinLevel); } } \ No newline at end of file diff --git a/GFramework.Core/logging/LoggingConfiguration.cs b/GFramework.Core/logging/LoggingConfiguration.cs new file mode 100644 index 00000000..37f7ff86 --- /dev/null +++ b/GFramework.Core/logging/LoggingConfiguration.cs @@ -0,0 +1,101 @@ +using GFramework.Core.Abstractions.logging; + +namespace GFramework.Core.logging; + +/// +/// 日志配置类 +/// +public sealed class LoggingConfiguration +{ + /// + /// 全局最小日志级别 + /// + public LogLevel MinLevel { get; set; } = LogLevel.Info; + + /// + /// Appender 配置列表 + /// + public List Appenders { get; set; } = new(); + + /// + /// 特定 Logger 的日志级别配置 + /// + public Dictionary LoggerLevels { get; set; } = new(); +} + +/// +/// Appender 配置 +/// +public sealed class AppenderConfiguration +{ + /// + /// Appender 类型(Console, File, RollingFile, Async) + /// + public string Type { get; set; } = string.Empty; + + /// + /// 格式化器类型(Default, Json) + /// + public string Formatter { get; set; } = "Default"; + + /// + /// 文件路径(仅用于 File 和 RollingFile) + /// + public string? FilePath { get; set; } + + /// + /// 是否使用颜色(仅用于 Console) + /// + public bool UseColors { get; set; } = true; + + /// + /// 缓冲区大小(仅用于 Async) + /// + public int BufferSize { get; set; } = 10000; + + /// + /// 最大文件大小(仅用于 RollingFile,字节) + /// + public long MaxFileSize { get; set; } = 10 * 1024 * 1024; // 10MB + + /// + /// 最大文件数量(仅用于 RollingFile) + /// + public int MaxFileCount { get; set; } = 5; + + /// + /// 过滤器配置 + /// + public FilterConfiguration? Filter { get; set; } + + /// + /// 内部 Appender 配置(仅用于 Async) + /// + public AppenderConfiguration? InnerAppender { get; set; } +} + +/// +/// 过滤器配置 +/// +public sealed class FilterConfiguration +{ + /// + /// 过滤器类型(LogLevel, Namespace, Composite) + /// + public string Type { get; set; } = "LogLevel"; + + /// + /// 最小日志级别(用于 LogLevel 过滤器) + /// + public LogLevel? MinLevel { get; set; } + + /// + /// 命名空间前缀列表(用于 Namespace 过滤器) + /// + public List? Namespaces { get; set; } + + /// + /// 子过滤器列表(用于 Composite 过滤器) + /// + public List? Filters { get; set; } +} \ No newline at end of file diff --git a/GFramework.Core/logging/LoggingConfigurationLoader.cs b/GFramework.Core/logging/LoggingConfigurationLoader.cs new file mode 100644 index 00000000..293f0f1a --- /dev/null +++ b/GFramework.Core/logging/LoggingConfigurationLoader.cs @@ -0,0 +1,192 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging.appenders; +using GFramework.Core.logging.filters; +using GFramework.Core.logging.formatters; + +namespace GFramework.Core.logging; + +/// +/// 日志配置加载器 +/// +public static class LoggingConfigurationLoader +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: true) } + }; + + /// + /// 从 JSON 文件加载配置 + /// + /// 配置文件路径 + /// 日志配置对象 + public static LoggingConfiguration LoadFromJson(string filePath) + { + if (!File.Exists(filePath)) + throw new FileNotFoundException($"Configuration file not found: {filePath}"); + + var json = File.ReadAllText(filePath); + var config = JsonSerializer.Deserialize(json, JsonOptions); + + return config ?? throw new InvalidOperationException("Failed to deserialize configuration."); + } + + /// + /// 从 JSON 字符串加载配置 + /// + /// JSON 字符串 + /// 日志配置对象 + public static LoggingConfiguration LoadFromJsonString(string json) + { + var config = JsonSerializer.Deserialize(json, JsonOptions); + return config ?? throw new InvalidOperationException("Failed to deserialize configuration."); + } + + /// + /// 根据配置创建 Logger 工厂 + /// + /// 日志配置 + /// Logger 工厂 + public static ILoggerFactory CreateFactory(LoggingConfiguration config) + { + return new ConfigurableLoggerFactory(config); + } + + /// + /// 根据配置创建 Appender + /// + internal static ILogAppender CreateAppender(AppenderConfiguration config) + { + var formatter = CreateFormatter(config.Formatter); + var filter = config.Filter != null ? CreateFilter(config.Filter) : null; + + return config.Type.ToLowerInvariant() switch + { + "console" => new ConsoleAppender(formatter, useColors: config.UseColors, filter: filter), + + "file" => new FileAppender( + config.FilePath ?? throw new InvalidOperationException("FilePath is required for File appender."), + formatter, + filter), + + "rollingfile" => new RollingFileAppender( + config.FilePath ?? + throw new InvalidOperationException("FilePath is required for RollingFile appender."), + config.MaxFileSize, + config.MaxFileCount, + formatter, + filter), + + "async" => new AsyncLogAppender( + CreateAppender(config.InnerAppender ?? + throw new InvalidOperationException("InnerAppender is required for Async appender.")), + config.BufferSize), + + _ => throw new NotSupportedException($"Appender type '{config.Type}' is not supported.") + }; + } + + /// + /// 根据配置创建格式化器 + /// + internal static ILogFormatter CreateFormatter(string formatterType) + { + return formatterType.ToLowerInvariant() switch + { + "default" => new DefaultLogFormatter(), + "json" => new JsonLogFormatter(), + _ => throw new NotSupportedException($"Formatter type '{formatterType}' is not supported.") + }; + } + + /// + /// 根据配置创建过滤器 + /// + internal static ILogFilter CreateFilter(FilterConfiguration config) + { + return config.Type.ToLowerInvariant() switch + { + "loglevel" => new LogLevelFilter( + config.MinLevel ?? throw new InvalidOperationException("MinLevel is required for LogLevel filter.")), + + "namespace" => new NamespaceFilter( + config.Namespaces?.ToArray() ?? + throw new InvalidOperationException("Namespaces is required for Namespace filter.")), + + "composite" => new CompositeFilter( + config.Filters?.Select(CreateFilter).ToArray() ?? + throw new InvalidOperationException("Filters is required for Composite filter.")), + + _ => throw new NotSupportedException($"Filter type '{config.Type}' is not supported.") + }; + } +} + +/// +/// 可配置的 Logger 工厂 +/// +internal sealed class ConfigurableLoggerFactory : ILoggerFactory, IDisposable +{ + private readonly ILogAppender[] _appenders; + private readonly LoggingConfiguration _config; + private bool _disposed; + + public ConfigurableLoggerFactory(LoggingConfiguration config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + _appenders = config.Appenders.Select(LoggingConfigurationLoader.CreateAppender).ToArray(); + } + + public void Dispose() + { + if (_disposed) + return; + + foreach (var appender in _appenders) + { + if (appender is IDisposable disposable) + { + disposable.Dispose(); + } + } + + _disposed = true; + } + + public ILogger GetLogger(string name, LogLevel minLevel = LogLevel.Info) + { + // 检查是否有特定 Logger 的级别配置(支持前缀匹配) + var effectiveLevel = _config.MinLevel; + + foreach (var kvp in _config.LoggerLevels) + { + // 精确匹配或前缀匹配(命名空间层级) + if (name == kvp.Key || name.StartsWith(kvp.Key + ".", StringComparison.Ordinal)) + { + effectiveLevel = kvp.Value; + break; + } + } + + // 如果没有 Appender,返回简单的 ConsoleLogger + if (_appenders.Length == 0) + { + return new ConsoleLogger(name, effectiveLevel); + } + + // 如果只有一个 Appender 且是 ConsoleAppender,优化为 ConsoleLogger + if (_appenders.Length == 1 && _appenders[0] is ConsoleAppender) + { + return new ConsoleLogger(name, effectiveLevel); + } + + // 返回 CompositeLogger + return new CompositeLogger(name, effectiveLevel, _appenders); + } +} \ No newline at end of file diff --git a/GFramework.Core/logging/appenders/AsyncLogAppender.cs b/GFramework.Core/logging/appenders/AsyncLogAppender.cs new file mode 100644 index 00000000..1c864eed --- /dev/null +++ b/GFramework.Core/logging/appenders/AsyncLogAppender.cs @@ -0,0 +1,150 @@ +using System.Threading.Channels; +using GFramework.Core.Abstractions.logging; + +namespace GFramework.Core.logging.appenders; + +/// +/// 异步日志输出器,使用 Channel 实现非阻塞日志写入 +/// +public sealed class AsyncLogAppender : ILogAppender, IDisposable +{ + private readonly Channel _channel; + private readonly CancellationTokenSource _cts; + private readonly ILogAppender _innerAppender; + private readonly Task _processingTask; + private bool _disposed; + + /// + /// 创建异步日志输出器 + /// + /// 内部日志输出器 + /// 缓冲区大小(默认 10000) + public AsyncLogAppender(ILogAppender innerAppender, int bufferSize = 10000) + { + _innerAppender = innerAppender ?? throw new ArgumentNullException(nameof(innerAppender)); + + if (bufferSize <= 0) + throw new ArgumentException("Buffer size must be greater than 0.", nameof(bufferSize)); + + // 创建有界 Channel + var options = new BoundedChannelOptions(bufferSize) + { + FullMode = BoundedChannelFullMode.Wait, // 缓冲区满时等待 + SingleReader = true, + SingleWriter = false + }; + + _channel = Channel.CreateBounded(options); + _cts = new CancellationTokenSource(); + + // 启动后台处理任务 + _processingTask = Task.Run(() => ProcessLogsAsync(_cts.Token)); + } + + /// + /// 获取当前缓冲区中的日志数量 + /// + public int PendingCount => _channel.Reader.Count; + + /// + /// 获取是否已完成处理 + /// + public bool IsCompleted => _channel.Reader.Completion.IsCompleted; + + /// + /// 释放资源 + /// + public void Dispose() + { + if (_disposed) return; + + // 标记 Channel 为完成状态 + _channel.Writer.Complete(); + + // 等待处理任务完成(最多等待 5 秒) + if (!_processingTask.Wait(TimeSpan.FromSeconds(5))) + { + _cts.Cancel(); + } + + // 释放内部 Appender + if (_innerAppender is IDisposable disposable) + { + disposable.Dispose(); + } + + _cts.Dispose(); + _disposed = true; + } + + /// + /// 追加日志条目(非阻塞) + /// + /// 日志条目 + public void Append(LogEntry entry) + { + if (_disposed) + throw new ObjectDisposedException(nameof(AsyncLogAppender)); + + // 尝试非阻塞写入,如果失败则丢弃(避免阻塞调用线程) + _channel.Writer.TryWrite(entry); + } + + /// + /// 刷新缓冲区,等待所有日志写入完成 + /// + public void Flush() + { + if (_disposed) return; + + // 等待 Channel 中的所有消息被处理 + while (_channel.Reader.Count > 0) + { + Thread.Sleep(10); + } + + _innerAppender.Flush(); + } + + /// + /// 后台处理日志的异步方法 + /// + private async Task ProcessLogsAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var entry in _channel.Reader.ReadAllAsync(cancellationToken)) + { + try + { + _innerAppender.Append(entry); + } + catch (Exception ex) + { + // 记录内部错误到控制台(避免递归) + await Console.Error.WriteLineAsync($"[AsyncLogAppender] Error processing log entry: {ex.Message}"); + } + } + } + catch (OperationCanceledException) + { + // 正常取消,忽略 + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"[AsyncLogAppender] Fatal error in processing task: {ex}"); + } + finally + { + // 确保最后刷新 + try + { + _innerAppender.Flush(); + } + catch + { + // 忽略刷新错误 + } + } + } +} \ No newline at end of file diff --git a/GFramework.Core/logging/appenders/ConsoleAppender.cs b/GFramework.Core/logging/appenders/ConsoleAppender.cs new file mode 100644 index 00000000..b7aebe55 --- /dev/null +++ b/GFramework.Core/logging/appenders/ConsoleAppender.cs @@ -0,0 +1,91 @@ +using System.IO; +using GFramework.Core.Abstractions.logging; + +namespace GFramework.Core.logging.appenders; + +/// +/// 控制台日志输出器 +/// +public sealed class ConsoleAppender : ILogAppender, IDisposable +{ + private readonly ILogFilter? _filter; + private readonly ILogFormatter _formatter; + private readonly bool _useColors; + private readonly TextWriter _writer; + + /// + /// 创建控制台日志输出器 + /// + /// 日志格式化器 + /// 文本写入器(默认为 Console.Out) + /// 是否使用颜色(默认为 true) + /// 日志过滤器(可选) + public ConsoleAppender( + ILogFormatter formatter, + TextWriter? writer = null, + bool useColors = true, + ILogFilter? filter = null) + { + _formatter = formatter ?? throw new ArgumentNullException(nameof(formatter)); + _writer = writer ?? Console.Out; + _useColors = useColors && _writer == Console.Out; + _filter = filter; + } + + /// + /// 释放资源 + /// + public void Dispose() + { + _writer.Flush(); + } + + /// + /// 追加日志条目到控制台 + /// + /// 日志条目 + public void Append(LogEntry entry) + { + if (_filter != null && !_filter.ShouldLog(entry)) + return; + + var message = _formatter.Format(entry); + + if (_useColors) + { + WriteColored(entry.Level, message); + } + else + { + _writer.WriteLine(message); + } + } + + /// + /// 刷新控制台输出 + /// + public void Flush() + { + _writer.Flush(); + } + + private void WriteColored(LogLevel level, string message) + { + var colorCode = GetAnsiColorCode(level); + _writer.WriteLine($"\x1b[{colorCode}m{message}\x1b[0m"); + } + + private static string GetAnsiColorCode(LogLevel level) + { + return level switch + { + LogLevel.Trace => "90", // 暗灰色 + LogLevel.Debug => "36", // 青色 + LogLevel.Info => "37", // 白色 + LogLevel.Warning => "33", // 黄色 + LogLevel.Error => "31", // 红色 + LogLevel.Fatal => "35", // 洋红色 + _ => "37" + }; + } +} \ No newline at end of file diff --git a/GFramework.Core/logging/appenders/FileAppender.cs b/GFramework.Core/logging/appenders/FileAppender.cs new file mode 100644 index 00000000..a92ea256 --- /dev/null +++ b/GFramework.Core/logging/appenders/FileAppender.cs @@ -0,0 +1,105 @@ +using System.IO; +using System.Text; +using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging.formatters; + +namespace GFramework.Core.logging.appenders; + +/// +/// 文件日志输出器(线程安全) +/// +public sealed class FileAppender : ILogAppender, IDisposable +{ + private readonly string _filePath; + private readonly ILogFilter? _filter; + private readonly ILogFormatter _formatter; + private readonly object _lock = new(); + private bool _disposed; + private StreamWriter? _writer; + + /// + /// 创建文件日志输出器 + /// + /// 日志文件路径 + /// 日志格式化器 + /// 日志过滤器(可选) + public FileAppender( + string filePath, + ILogFormatter? formatter = null, + ILogFilter? filter = null) + { + if (string.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException("File path cannot be null or whitespace.", nameof(filePath)); + + _filePath = filePath; + _formatter = formatter ?? new DefaultLogFormatter(); + _filter = filter; + + EnsureDirectoryExists(); + InitializeWriter(); + } + + /// + /// 释放资源 + /// + public void Dispose() + { + if (_disposed) return; + + lock (_lock) + { + _writer?.Flush(); + _writer?.Dispose(); + _writer = null; + _disposed = true; + } + } + + /// + /// 追加日志条目到文件 + /// + /// 日志条目 + public void Append(LogEntry entry) + { + if (_disposed) + throw new ObjectDisposedException(nameof(FileAppender)); + + if (_filter != null && !_filter.ShouldLog(entry)) + return; + + var message = _formatter.Format(entry); + + lock (_lock) + { + _writer?.WriteLine(message); + } + } + + /// + /// 刷新文件缓冲区 + /// + public void Flush() + { + lock (_lock) + { + _writer?.Flush(); + } + } + + private void EnsureDirectoryExists() + { + var directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + } + + private void InitializeWriter() + { + _writer = new StreamWriter(_filePath, append: true, Encoding.UTF8) + { + AutoFlush = true + }; + } +} \ No newline at end of file diff --git a/GFramework.Core/logging/appenders/RollingFileAppender.cs b/GFramework.Core/logging/appenders/RollingFileAppender.cs new file mode 100644 index 00000000..34564b16 --- /dev/null +++ b/GFramework.Core/logging/appenders/RollingFileAppender.cs @@ -0,0 +1,207 @@ +using System.IO; +using System.Text; +using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging.formatters; + +namespace GFramework.Core.logging.appenders; + +/// +/// 滚动文件日志输出器,支持按大小自动轮转日志文件 +/// +public sealed class RollingFileAppender : ILogAppender, IDisposable +{ + private readonly string _baseFilePath; + private readonly ILogFilter? _filter; + private readonly ILogFormatter _formatter; + private readonly object _lock = new(); + private readonly int _maxFileCount; + private readonly long _maxFileSize; + private long _currentSize; + private bool _disposed; + private StreamWriter? _writer; + + /// + /// 创建滚动文件日志输出器 + /// + /// 基础文件路径(例如: logs/app.log) + /// 单个文件最大大小(字节),默认 10MB + /// 保留的文件数量,默认 5 + /// 日志格式化器 + /// 日志过滤器(可选) + public RollingFileAppender( + string baseFilePath, + long maxFileSize = 10 * 1024 * 1024, + int maxFileCount = 5, + ILogFormatter? formatter = null, + ILogFilter? filter = null) + { + if (string.IsNullOrWhiteSpace(baseFilePath)) + throw new ArgumentException("Base file path cannot be null or whitespace.", nameof(baseFilePath)); + + if (maxFileSize <= 0) + throw new ArgumentException("Max file size must be greater than 0.", nameof(maxFileSize)); + + if (maxFileCount <= 0) + throw new ArgumentException("Max file count must be greater than 0.", nameof(maxFileCount)); + + _baseFilePath = baseFilePath; + _maxFileSize = maxFileSize; + _maxFileCount = maxFileCount; + _formatter = formatter ?? new DefaultLogFormatter(); + _filter = filter; + + EnsureDirectoryExists(); + InitializeWriter(); + } + + /// + /// 释放资源 + /// + public void Dispose() + { + if (_disposed) return; + + lock (_lock) + { + _writer?.Flush(); + _writer?.Dispose(); + _writer = null; + _disposed = true; + } + } + + /// + /// 追加日志条目到文件 + /// + /// 日志条目 + public void Append(LogEntry entry) + { + if (_disposed) + throw new ObjectDisposedException(nameof(RollingFileAppender)); + + if (_filter != null && !_filter.ShouldLog(entry)) + return; + + var message = _formatter.Format(entry); + var messageBytes = Encoding.UTF8.GetByteCount(message) + Environment.NewLine.Length; + + lock (_lock) + { + // 检查是否需要轮转 + if (_currentSize + messageBytes > _maxFileSize) + { + RollFiles(); + } + + _writer?.WriteLine(message); + _currentSize += messageBytes; + } + } + + /// + /// 刷新文件缓冲区 + /// + public void Flush() + { + lock (_lock) + { + _writer?.Flush(); + } + } + + /// + /// 轮转日志文件 + /// + private void RollFiles() + { + // 关闭当前文件 + _writer?.Flush(); + _writer?.Dispose(); + _writer = null; + + // 删除最旧的文件(如果存在) + var oldestFile = GetRolledFileName(_maxFileCount - 1); + if (File.Exists(oldestFile)) + { + try + { + File.Delete(oldestFile); + } + catch + { + // 忽略删除错误 + } + } + + // 重命名现有文件: app.log -> app.1.log -> app.2.log -> ... + for (int i = _maxFileCount - 2; i >= 0; i--) + { + var sourceFile = i == 0 ? _baseFilePath : GetRolledFileName(i); + var targetFile = GetRolledFileName(i + 1); + + if (File.Exists(sourceFile)) + { + try + { + if (File.Exists(targetFile)) + { + File.Delete(targetFile); + } + + File.Move(sourceFile, targetFile); + } + catch + { + // 忽略移动错误 + } + } + } + + // 重新初始化写入器 + InitializeWriter(); + } + + /// + /// 获取轮转后的文件名 + /// + /// 文件索引 + /// 轮转后的文件路径 + private string GetRolledFileName(int index) + { + var directory = Path.GetDirectoryName(_baseFilePath); + var fileNameWithoutExt = Path.GetFileNameWithoutExtension(_baseFilePath); + var extension = Path.GetExtension(_baseFilePath); + + var rolledFileName = $"{fileNameWithoutExt}.{index}{extension}"; + + return string.IsNullOrEmpty(directory) + ? rolledFileName + : Path.Combine(directory, rolledFileName); + } + + /// + /// 确保目录存在 + /// + private void EnsureDirectoryExists() + { + var directory = Path.GetDirectoryName(_baseFilePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + } + + /// + /// 初始化写入器 + /// + private void InitializeWriter() + { + _writer = new StreamWriter(_baseFilePath, append: true, Encoding.UTF8) + { + AutoFlush = true + }; + + // 获取当前文件大小 + _currentSize = File.Exists(_baseFilePath) ? new FileInfo(_baseFilePath).Length : 0; + } +} \ No newline at end of file diff --git a/GFramework.Core/logging/filters/CompositeFilter.cs b/GFramework.Core/logging/filters/CompositeFilter.cs new file mode 100644 index 00000000..b07f50c1 --- /dev/null +++ b/GFramework.Core/logging/filters/CompositeFilter.cs @@ -0,0 +1,33 @@ +using GFramework.Core.Abstractions.logging; + +namespace GFramework.Core.logging.filters; + +/// +/// 组合多个过滤器的过滤器(AND 逻辑) +/// +public sealed class CompositeFilter : ILogFilter +{ + private readonly ILogFilter[] _filters; + + /// + /// 创建组合过滤器 + /// + /// 要组合的过滤器列表 + public CompositeFilter(params ILogFilter[] filters) + { + if (filters == null || filters.Length == 0) + throw new ArgumentException("At least one filter must be provided.", nameof(filters)); + + _filters = filters; + } + + /// + /// 判断日志是否通过所有过滤器(AND 逻辑) + /// + /// 日志条目 + /// 如果所有过滤器都返回 true 则返回 true + public bool ShouldLog(LogEntry entry) + { + return _filters.All(filter => filter.ShouldLog(entry)); + } +} \ No newline at end of file diff --git a/GFramework.Core/logging/filters/LogLevelFilter.cs b/GFramework.Core/logging/filters/LogLevelFilter.cs new file mode 100644 index 00000000..7241d185 --- /dev/null +++ b/GFramework.Core/logging/filters/LogLevelFilter.cs @@ -0,0 +1,30 @@ +using GFramework.Core.Abstractions.logging; + +namespace GFramework.Core.logging.filters; + +/// +/// 按日志级别过滤的过滤器 +/// +public sealed class LogLevelFilter : ILogFilter +{ + private readonly LogLevel _minLevel; + + /// + /// 创建日志级别过滤器 + /// + /// 最小日志级别 + public LogLevelFilter(LogLevel minLevel) + { + _minLevel = minLevel; + } + + /// + /// 判断日志级别是否满足最小级别要求 + /// + /// 日志条目 + /// 如果日志级别大于等于最小级别返回 true + public bool ShouldLog(LogEntry entry) + { + return entry.Level >= _minLevel; + } +} \ No newline at end of file diff --git a/GFramework.Core/logging/filters/NamespaceFilter.cs b/GFramework.Core/logging/filters/NamespaceFilter.cs new file mode 100644 index 00000000..2d678d46 --- /dev/null +++ b/GFramework.Core/logging/filters/NamespaceFilter.cs @@ -0,0 +1,33 @@ +using GFramework.Core.Abstractions.logging; + +namespace GFramework.Core.logging.filters; + +/// +/// 按命名空间前缀过滤的过滤器 +/// +public sealed class NamespaceFilter : ILogFilter +{ + private readonly string[] _allowedPrefixes; + + /// + /// 创建命名空间过滤器 + /// + /// 允许的命名空间前缀列表 + public NamespaceFilter(params string[] allowedPrefixes) + { + if (allowedPrefixes == null || allowedPrefixes.Length == 0) + throw new ArgumentException("At least one namespace prefix must be provided.", nameof(allowedPrefixes)); + + _allowedPrefixes = allowedPrefixes; + } + + /// + /// 判断日志记录器名称是否匹配允许的命名空间前缀 + /// + /// 日志条目 + /// 如果匹配任一前缀返回 true + public bool ShouldLog(LogEntry entry) + { + return _allowedPrefixes.Any(prefix => entry.LoggerName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } +} \ No newline at end of file diff --git a/GFramework.Core/logging/formatters/DefaultLogFormatter.cs b/GFramework.Core/logging/formatters/DefaultLogFormatter.cs new file mode 100644 index 00000000..48aef885 --- /dev/null +++ b/GFramework.Core/logging/formatters/DefaultLogFormatter.cs @@ -0,0 +1,56 @@ +using System.Text; +using GFramework.Core.Abstractions.logging; + +namespace GFramework.Core.logging.formatters; + +/// +/// 默认日志格式化器,保持与现有格式兼容 +/// +public sealed class DefaultLogFormatter : ILogFormatter +{ + private static readonly string[] LevelStrings = + [ + "TRACE ", + "DEBUG ", + "INFO ", + "WARNING", + "ERROR ", + "FATAL " + ]; + + /// + /// 将日志条目格式化为默认格式 + /// + /// 日志条目 + /// 格式化后的日志字符串 + public string Format(LogEntry entry) + { + var timestamp = entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff"); + var levelStr = LevelStrings[(int)entry.Level]; + var sb = new StringBuilder(); + + sb.Append('[').Append(timestamp).Append("] ") + .Append(levelStr).Append(" [") + .Append(entry.LoggerName).Append("] ") + .Append(entry.Message); + + // 添加结构化属性 + var properties = entry.GetAllProperties(); + if (properties.Count > 0) + { + sb.Append(" |"); + foreach (var prop in properties) + { + sb.Append(' ').Append(prop.Key).Append('=').Append(prop.Value); + } + } + + // 添加异常信息 + if (entry.Exception != null) + { + sb.Append(Environment.NewLine).Append(entry.Exception); + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/GFramework.Core/logging/formatters/JsonLogFormatter.cs b/GFramework.Core/logging/formatters/JsonLogFormatter.cs new file mode 100644 index 00000000..407aac7d --- /dev/null +++ b/GFramework.Core/logging/formatters/JsonLogFormatter.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using GFramework.Core.Abstractions.logging; + +namespace GFramework.Core.logging.formatters; + +/// +/// JSON 格式化器,将日志输出为 JSON 格式 +/// +public sealed class JsonLogFormatter : ILogFormatter +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// + /// 将日志条目格式化为 JSON 格式 + /// + /// 日志条目 + /// JSON 格式的日志字符串 + public string Format(LogEntry entry) + { + var logObject = new Dictionary + { + ["timestamp"] = entry.Timestamp.ToString("O"), // ISO 8601 格式 + ["level"] = entry.Level.ToString().ToUpperInvariant(), + ["logger"] = entry.LoggerName, + ["message"] = entry.Message + }; + + // 添加结构化属性 + var properties = entry.GetAllProperties(); + if (properties.Count > 0) + { + logObject["properties"] = properties; + } + + // 添加异常信息 + if (entry.Exception != null) + { + logObject["exception"] = new + { + type = entry.Exception.GetType().FullName, + message = entry.Exception.Message, + stackTrace = entry.Exception.StackTrace + }; + } + + return JsonSerializer.Serialize(logObject, JsonOptions); + } +} \ No newline at end of file diff --git a/GFramework.Game.Abstractions/setting/data/AudioSettings.cs b/GFramework.Game.Abstractions/setting/data/AudioSettings.cs index 56651ce5..15b75fe1 100644 --- a/GFramework.Game.Abstractions/setting/data/AudioSettings.cs +++ b/GFramework.Game.Abstractions/setting/data/AudioSettings.cs @@ -39,7 +39,7 @@ public void Reset() /// /// 获取设置数据最后修改的时间 /// - public DateTime LastModified { get; } = DateTime.Now; + public DateTime LastModified { get; } = DateTime.UtcNow; /// /// 从指定的数据源加载音频设置 diff --git a/GFramework.Game.Abstractions/setting/data/GraphicsSettings.cs b/GFramework.Game.Abstractions/setting/data/GraphicsSettings.cs index ba0bfef0..63213a10 100644 --- a/GFramework.Game.Abstractions/setting/data/GraphicsSettings.cs +++ b/GFramework.Game.Abstractions/setting/data/GraphicsSettings.cs @@ -38,7 +38,7 @@ public void Reset() /// /// 获取设置数据最后修改的时间 /// - public DateTime LastModified { get; } = DateTime.Now; + public DateTime LastModified { get; } = DateTime.UtcNow; /// /// 从指定的数据源加载图形设置 diff --git a/GFramework.Game.Abstractions/setting/data/LocalizationSettings.cs b/GFramework.Game.Abstractions/setting/data/LocalizationSettings.cs index 1f11d331..72fe044c 100644 --- a/GFramework.Game.Abstractions/setting/data/LocalizationSettings.cs +++ b/GFramework.Game.Abstractions/setting/data/LocalizationSettings.cs @@ -42,7 +42,7 @@ public void Reset() /// /// 获取设置数据最后修改的时间 /// - public DateTime LastModified { get; } = DateTime.Now; + public DateTime LastModified { get; } = DateTime.UtcNow; /// /// 从指定的数据源加载本地化设置 diff --git a/GFramework.Game/storage/FileStorage.cs b/GFramework.Game/storage/FileStorage.cs index 003ebcd5..a5dcc61c 100644 --- a/GFramework.Game/storage/FileStorage.cs +++ b/GFramework.Game/storage/FileStorage.cs @@ -239,11 +239,12 @@ public Task> ListDirectoriesAsync(string path = "") { var fullPath = string.IsNullOrEmpty(path) ? _rootPath : Path.Combine(_rootPath, path); if (!Directory.Exists(fullPath)) - return Task.FromResult>(Array.Empty()); + return Task.FromResult>([]); var dirs = Directory.GetDirectories(fullPath) .Select(Path.GetFileName) - .Where(name => !string.IsNullOrEmpty(name) && !name.StartsWith(".", StringComparison.Ordinal)) + .OfType() + .Where(name => !string.IsNullOrEmpty(name) && !name.StartsWith('.')) .ToList(); return Task.FromResult>(dirs); @@ -262,6 +263,7 @@ public Task> ListFilesAsync(string path = "") var files = Directory.GetFiles(fullPath) .Select(Path.GetFileName) + .OfType() .Where(name => !string.IsNullOrEmpty(name)) .ToList(); diff --git a/GFramework.Godot/logging/GodotLogger.cs b/GFramework.Godot/logging/GodotLogger.cs index ab1cc95a..8a0cfdb9 100644 --- a/GFramework.Godot/logging/GodotLogger.cs +++ b/GFramework.Godot/logging/GodotLogger.cs @@ -15,6 +15,17 @@ public sealed class GodotLogger( string? name = null, LogLevel minLevel = LogLevel.Info) : AbstractLogger(name ?? RootLoggerName, minLevel) { + // 静态缓存日志级别字符串,避免重复格式化 + private static readonly string[] LevelStrings = + [ + "TRACE ", + "DEBUG ", + "INFO ", + "WARNING", + "ERROR ", + "FATAL " + ]; + /// /// 写入日志的核心方法。 /// 格式化日志消息并根据日志级别调用 Godot 的输出方法。 @@ -25,8 +36,8 @@ public sealed class GodotLogger( protected override void Write(LogLevel level, string message, Exception? exception) { // 构造时间戳和日志前缀 - var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"); - var levelStr = level.ToString().ToUpper().PadRight(7); + var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff"); + var levelStr = LevelStrings[(int)level]; var logPrefix = $"[{timestamp}] {levelStr} [{Name()}]"; // 添加异常信息到日志消息中 @@ -46,7 +57,16 @@ protected override void Write(LogLevel level, string message, Exception? excepti case LogLevel.Warning: GD.PushWarning(logMessage); break; - default: // Trace / Debug / Info + case LogLevel.Trace: + GD.PrintRich($"[color=gray]{logMessage}[/color]"); + break; + case LogLevel.Debug: + GD.PrintRich($"[color=cyan]{logMessage}[/color]"); + break; + case LogLevel.Info: + GD.Print(logMessage); + break; + default: GD.Print(logMessage); break; } diff --git a/GFramework.Godot/logging/GodotLoggerFactoryProvider.cs b/GFramework.Godot/logging/GodotLoggerFactoryProvider.cs index cf9aa989..d753805b 100644 --- a/GFramework.Godot/logging/GodotLoggerFactoryProvider.cs +++ b/GFramework.Godot/logging/GodotLoggerFactoryProvider.cs @@ -1,4 +1,5 @@ using GFramework.Core.Abstractions.logging; +using GFramework.Core.logging; namespace GFramework.Godot.logging; @@ -7,18 +8,28 @@ namespace GFramework.Godot.logging; /// public sealed class GodotLoggerFactoryProvider : ILoggerFactoryProvider { + private readonly ILoggerFactory _cachedFactory; + + /// + /// 初始化Godot日志记录器工厂提供程序 + /// + public GodotLoggerFactoryProvider() + { + _cachedFactory = new CachedLoggerFactory(new GodotLoggerFactory()); + } + /// /// 获取或设置最小日志级别 /// public LogLevel MinLevel { get; set; } /// - /// 创建指定名称的日志记录器实例 + /// 创建指定名称的日志记录器实例(带缓存) /// /// 日志记录器的名称 /// 返回配置了最小日志级别的Godot日志记录器实例 public ILogger CreateLogger(string name) { - return new GodotLoggerFactory().GetLogger(name, MinLevel); + return _cachedFactory.GetLogger(name, MinLevel); } } \ No newline at end of file diff --git a/docs/zh-CN/best-practices/architecture-patterns.md b/docs/zh-CN/best-practices/architecture-patterns.md index 774564cd..a0b6b7d3 100644 --- a/docs/zh-CN/best-practices/architecture-patterns.md +++ b/docs/zh-CN/best-practices/architecture-patterns.md @@ -1012,7 +1012,7 @@ public class SaveSystem : AbstractSystem Level = 1, Health = 100, Position = Vector3.Zero, - CreatedAt = DateTime.Now + CreatedAt = DateTime.UtcNow }; } } diff --git a/docs/zh-CN/core/events.md b/docs/zh-CN/core/events.md index 328bd4dc..3509f313 100644 --- a/docs/zh-CN/core/events.md +++ b/docs/zh-CN/core/events.md @@ -439,7 +439,7 @@ public class EventBridge : AbstractSystem this.SendEvent(new PublicPlayerDiedEvent { PlayerId = e.Id, - Timestamp = DateTime.Now + Timestamp = DateTime.UtcNow }); }); } diff --git a/docs/zh-CN/core/logging.md b/docs/zh-CN/core/logging.md index 2699d72e..faff9bfd 100644 --- a/docs/zh-CN/core/logging.md +++ b/docs/zh-CN/core/logging.md @@ -118,7 +118,7 @@ public class CustomLogger : AbstractLogger protected override void Write(LogLevel level, string message, Exception? exception) { // 自定义日志输出逻辑 - var logMessage = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [{level}] {message}"; + var logMessage = $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}] [{level}] {message}"; if (exception != null) logMessage += $"\n{exception}"; diff --git a/docs/zh-CN/core/utility.md b/docs/zh-CN/core/utility.md index f7ff3bf5..60a9f1d4 100644 --- a/docs/zh-CN/core/utility.md +++ b/docs/zh-CN/core/utility.md @@ -432,7 +432,7 @@ public class LogUtility : IUtility _ => "" }; - string timestamp = DateTime.Now.ToString("HH:mm:ss"); + string timestamp = DateTime.UtcNow.ToString("HH:mm:ss"); GD.Print($"{timestamp} {prefix} {message}"); } diff --git a/docs/zh-CN/game/index.md b/docs/zh-CN/game/index.md index 28889374..dca69222 100644 --- a/docs/zh-CN/game/index.md +++ b/docs/zh-CN/game/index.md @@ -452,7 +452,7 @@ public class GameDataManager public void SaveGame(int slotId, SaveData data) { _saveStorage.Write($"slot_{slotId}", data); - _saveStorage.Write($"slot_{slotId}/timestamp", DateTime.Now); + _saveStorage.Write($"slot_{slotId}/timestamp", DateTime.UtcNow); _saveStorage.Write($"slot_{slotId}/version", data.Version); } @@ -639,13 +639,13 @@ public class CachedStorage : IStorage if (!_cacheTimestamps.TryGetValue(key, out var timestamp)) return true; - return DateTime.Now - timestamp > _cacheExpiry; + return DateTime.UtcNow - timestamp > _cacheExpiry; } private void UpdateCache(string key, T value) { _cache[key] = value; - _cacheTimestamps[key] = DateTime.Now; + _cacheTimestamps[key] = DateTime.UtcNow; } } ``` @@ -928,7 +928,7 @@ public partial class GameManager : Node, IController var profile = new GameProfile { PlayerName = playerName, - LastPlayed = DateTime.Now, + LastPlayed = DateTime.UtcNow, TotalPlayTime = 0 }; @@ -1029,7 +1029,7 @@ public partial class GameManager : Node, IController PlayerHealth = Context.GetModel().Health.Value, CurrentLevel = Context.GetModel().CurrentLevel.Value, Inventory = Context.GetModel().GetData(), - Timestamp = DateTime.Now, + Timestamp = DateTime.UtcNow, Version = 1 }; } @@ -1096,7 +1096,7 @@ public class AutoSaveSystem : AbstractSystem // 保存到自动存档槽 var storage = Context.GetUtility(); storage.Write("autosave", saveData); - storage.Write("autosave/timestamp", DateTime.Now); + storage.Write("autosave/timestamp", DateTime.UtcNow); Logger.Debug("Auto-save completed successfully"); } diff --git a/docs/zh-CN/godot/storage.md b/docs/zh-CN/godot/storage.md index 32590c3e..03b7da1a 100644 --- a/docs/zh-CN/godot/storage.md +++ b/docs/zh-CN/godot/storage.md @@ -144,7 +144,7 @@ storage.Write("user://saves/slot_001.dat", saveData); // 存储调试信息(普通路径) var debugLog = new DebugLog { /* ... */ }; -storage.Write("logs/debug_" + DateTime.Now.Ticks + ".json", debugLog); +storage.Write("logs/debug_" + DateTime.UtcNow.Ticks + ".json", debugLog); ``` ### 存在性检查 diff --git a/docs/zh-CN/tutorials/advanced-patterns.md b/docs/zh-CN/tutorials/advanced-patterns.md index 50386f69..f2d0a200 100644 --- a/docs/zh-CN/tutorials/advanced-patterns.md +++ b/docs/zh-CN/tutorials/advanced-patterns.md @@ -51,7 +51,7 @@ public class CreatePlayerCommand : AbstractCommand MaxHealth = 100, Mana = 50, MaxMana = 50, - CreatedAt = DateTime.Now + CreatedAt = DateTime.UtcNow }; // 保存玩家数据 @@ -440,13 +440,13 @@ public interface IDomainEvent public record PlayerCreatedDomainEvent(PlayerId PlayerId, PlayerName PlayerName, PlayerClass Class) : IDomainEvent { - public DateTime OccurredAt { get; } = DateTime.Now; + public DateTime OccurredAt { get; } = DateTime.UtcNow; } public record PlayerLevelUpDomainEvent(PlayerId PlayerId, PlayerName PlayerName, int NewLevel, int OldLevel) : IDomainEvent { - public DateTime OccurredAt { get; } = DateTime.Now; + public DateTime OccurredAt { get; } = DateTime.UtcNow; } // 领域服务 @@ -620,7 +620,7 @@ public interface IEventStore public record AggregateSnapshot(string StreamId, int Version) : IDomainEvent { - public DateTime OccurredAt { get; } = DateTime.Now; + public DateTime OccurredAt { get; } = DateTime.UtcNow; } public class ConcurrencyException : Exception @@ -855,7 +855,7 @@ public class PlayerEventHandler : IEventHandler, if (player != null) { player.IsAlive = false; - player.DeathTime = DateTime.Now; + player.DeathTime = DateTime.UtcNow; _playerRepository.Update(player); // 发送通知 @@ -1160,7 +1160,7 @@ public class ChatService : IChatService { PlayerId = playerId, Message = FilterMessage(message), - Timestamp = DateTime.Now, + Timestamp = DateTime.UtcNow, Channel = channel }; @@ -1176,7 +1176,7 @@ public class ChatService : IChatService { PlayerId = "system", Message = message, - Timestamp = DateTime.Now, + Timestamp = DateTime.UtcNow, Channel = "system" }; diff --git a/docs/zh-CN/tutorials/godot-integration.md b/docs/zh-CN/tutorials/godot-integration.md index 1cd06f91..6c4ef3a2 100644 --- a/docs/zh-CN/tutorials/godot-integration.md +++ b/docs/zh-CN/tutorials/godot-integration.md @@ -569,7 +569,7 @@ public partial class SmartResourceLoader : Node, IController { Path = path, Priority = priority, - RequestTime = DateTime.Now + RequestTime = DateTime.UtcNow }; _loadQueue.Enqueue(request);