diff --git a/GFramework.Godot.Tests/Logging/GodotLogAppenderTests.cs b/GFramework.Godot.Tests/Logging/GodotLogAppenderTests.cs new file mode 100644 index 00000000..d16e73b9 --- /dev/null +++ b/GFramework.Godot.Tests/Logging/GodotLogAppenderTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using GFramework.Core.Abstractions.Logging; +using GFramework.Godot.Logging; + +namespace GFramework.Godot.Tests.Logging; + +/// +/// Verifies the Godot appender edge that adapts Core log entries to Godot output rendering. +/// +[TestFixture] +public sealed class GodotLogAppenderTests +{ + /// + /// Verifies that the appender renders Core log entry data and merged structured properties. + /// + [Test] + public void Render_Should_Use_Core_LogEntry_And_Merged_Properties() + { + LogContext.Clear(); + using var sceneContext = LogContext.Push("Scene", "Boot"); + var appender = new GodotLogAppender(new GodotLoggerOptions + { + Mode = GodotLoggerMode.Release, + ReleaseOutputTemplate = "{timestamp:yyyyMMdd}|{level:u3}|{category}|{message}{properties}" + }); + var entry = new LogEntry( + new DateTime(2026, 5, 3, 4, 5, 6, DateTimeKind.Utc), + LogLevel.Info, + "Game.Services.Inventory", + "Ready", + null, + new Dictionary(StringComparer.Ordinal) + { + [" "] = "ignored", + ["Score"] = 12.5m + }); + + var result = appender.Render(entry); + + Assert.That(result, Is.EqualTo("20260503|INF|Game.Services.Inventory|Ready | Scene=Boot, Score=12.5")); + } + + /// + /// Verifies that dynamic option providers are evaluated for each rendered log entry. + /// + [Test] + public void Render_Should_Use_Latest_Options_From_Provider() + { + var options = new GodotLoggerOptions + { + Mode = GodotLoggerMode.Release, + ReleaseOutputTemplate = "[release] {message}" + }; + var appender = new GodotLogAppender(() => options); + var entry = new LogEntry( + DateTime.UtcNow, + LogLevel.Warning, + "Game", + "Reloaded", + null, + null); + + var releaseResult = appender.Render(entry); + + options = new GodotLoggerOptions + { + Mode = GodotLoggerMode.Debug, + DebugOutputTemplate = "[debug] {message}" + }; + + var debugResult = appender.Render(entry); + + Assert.Multiple(() => + { + Assert.That(releaseResult, Is.EqualTo("[release] Reloaded")); + Assert.That(debugResult, Is.EqualTo("[debug] Reloaded")); + }); + } +} diff --git a/GFramework.Godot/Logging/GodotLogAppender.cs b/GFramework.Godot/Logging/GodotLogAppender.cs new file mode 100644 index 00000000..dd41b8df --- /dev/null +++ b/GFramework.Godot/Logging/GodotLogAppender.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using GFramework.Core.Abstractions.Logging; +using Godot; + +namespace GFramework.Godot.Logging; + +/// +/// Writes Core instances to the Godot output APIs. +/// +/// +/// This appender is the Godot-specific edge of the Core logging pipeline. It keeps formatting, color selection, and +/// Godot debugger routing in the host package while allowing consumers to compose Godot output with Core +/// features such as CompositeLogger, filters, and async appenders. The appender +/// does not own unmanaged resources; and are therefore no-op lifecycle +/// hooks that satisfy the shared appender contract. +/// +public sealed class GodotLogAppender : ILogAppender +{ + private static readonly IReadOnlyDictionary EmptyProperties = + new Dictionary(StringComparer.Ordinal); + + private readonly Func _optionsProvider; + + /// + /// Initializes a Godot appender with default Godot logger options. + /// + public GodotLogAppender() + : this(new GodotLoggerOptions()) + { + } + + /// + /// Initializes a Godot appender with fixed Godot logger options. + /// + /// The formatting and routing options used for every appended entry. + /// is . + public GodotLogAppender(GodotLoggerOptions options) + : this(CreateFixedOptionsProvider(options)) + { + } + + /// + /// Initializes a Godot appender with a dynamic options provider. + /// + /// + /// Provides the latest formatting and routing options for each append operation. + /// + /// + /// The Godot logger provider uses this constructor so cached loggers observe hot-reloaded settings without + /// being recreated. The provider must be fast and thread-safe because it is called on the logging path. + /// + internal GodotLogAppender(Func optionsProvider) + { + _optionsProvider = optionsProvider ?? throw new ArgumentNullException(nameof(optionsProvider)); + } + + /// + /// Appends one Core log entry to Godot's console and debugger output. + /// + /// The Core log entry to render. + /// is . + public void Append(LogEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + var options = _optionsProvider(); + var rendered = Render(entry, options); + + if (options.Mode == GodotLoggerMode.Debug) + { + WriteDebug(entry.Level, rendered); + } + else + { + GD.Print(rendered); + } + + if (entry.Exception != null) + { + GD.PrintErr(entry.Exception.ToString()); + } + } + + /// + /// Completes pending writes. + /// + /// + /// Godot output APIs are synchronous from this appender's point of view, so there is no buffered state to + /// flush. + /// + public void Flush() + { + } + + /// + /// Releases appender resources. + /// + /// + /// The appender does not own disposable Godot resources. This method exists to honor the Core appender + /// lifecycle contract and to remain composable with factories that dispose appenders uniformly. + /// + public void Dispose() + { + } + + /// + /// Formats structured properties for the {properties} template placeholder. + /// + /// The already-merged property set from a Core . + /// + /// A leading separator plus formatted properties, or an empty string when no valid properties exist. + /// + /// + /// Blank keys are ignored because they cannot produce useful structured output and can come from + /// caller-provided tuples. Valid keys are trimmed at render time so the appender never mutates the original + /// property dictionary. + /// + internal static string FormatProperties(IReadOnlyDictionary? properties) + { + if (properties == null || properties.Count == 0) + { + return string.Empty; + } + + var formattedProperties = properties + .Where(static pair => !string.IsNullOrWhiteSpace(pair.Key)) + .Select(static pair => $"{pair.Key.Trim()}={FormatValue(pair.Value)}") + .ToArray(); + + return formattedProperties.Length == 0 + ? string.Empty + : " | " + string.Join(", ", formattedProperties); + } + + /// + /// Renders a Core log entry without writing it to Godot. + /// + /// The Core log entry to render. + /// The line that would be sent to the selected Godot output API. + /// + /// Tests use this method to verify template and structured-property behavior without depending on Godot's + /// static output APIs. + /// + internal string Render(LogEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + return Render(entry, _optionsProvider()); + } + + private static Func CreateFixedOptionsProvider(GodotLoggerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return () => options; + } + + private static string Render(LogEntry entry, GodotLoggerOptions options) + { + var templateText = options.Mode == GodotLoggerMode.Debug + ? options.DebugOutputTemplate + : options.ReleaseOutputTemplate; + var context = new GodotLogRenderContext( + entry.Timestamp, + entry.Level, + entry.LoggerName, + entry.Message, + options.GetColor(entry.Level), + FormatProperties(GetMergedProperties(entry))); + + return GodotLogTemplate.Parse(templateText).Render(context); + } + + private static IReadOnlyDictionary GetMergedProperties(LogEntry entry) + { + var allProperties = entry.GetAllProperties(); + return allProperties.Count == 0 ? EmptyProperties : allProperties; + } + + private static string FormatValue(object? value) + { + if (value == null) + { + return "null"; + } + + return value switch + { + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty + }; + } + + private static void WriteDebug(LogLevel level, string rendered) + { + GD.PrintRich(rendered); + + switch (level) + { + case LogLevel.Fatal: + case LogLevel.Error: + GD.PushError(rendered); + break; + case LogLevel.Warning: + GD.PushWarning(rendered); + break; + } + } +} diff --git a/GFramework.Godot/Logging/GodotLogger.cs b/GFramework.Godot/Logging/GodotLogger.cs index d6dbe974..763d480e 100644 --- a/GFramework.Godot/Logging/GodotLogger.cs +++ b/GFramework.Godot/Logging/GodotLogger.cs @@ -1,19 +1,23 @@ using System; using System.Collections.Generic; -using System.Globalization; -using System.Linq; using GFramework.Core.Abstractions.Logging; using GFramework.Core.Logging; -using Godot; namespace GFramework.Godot.Logging; /// /// Godot platform logger implementation. /// +/// +/// This logger preserves the existing entry point while delegating output to +/// so Godot rendering remains compatible with the Core appender pipeline. +/// public sealed class GodotLogger : AbstractLogger { - private readonly Func _optionsProvider; + private static readonly IReadOnlyDictionary EmptyProperties = + new Dictionary(StringComparer.Ordinal); + + private readonly GodotLogAppender _appender; /// /// Initializes a logger that preserves the historical fixed-format template. @@ -45,11 +49,14 @@ public sealed class GodotLogger : AbstractLogger /// Initializes the core logger with dynamic options and level providers. /// /// The resolved logger name used in rendered output. - /// The provider that supplies the latest rendering options for each write. + /// + /// The provider that supplies the latest rendering options for each write. + /// /// The provider that supplies the latest effective minimum level. /// /// The Godot factory uses this constructor so cached logger instances can observe hot-reloaded settings without - /// being recreated. The default public constructor supplies a fixed provider to avoid allocation on the log path. + /// being recreated. The default public constructor supplies a fixed provider to avoid allocation on the log + /// path. /// internal GodotLogger( string name, @@ -57,7 +64,8 @@ public sealed class GodotLogger : AbstractLogger Func minLevelProvider) : base(name, minLevelProvider ?? throw new ArgumentNullException(nameof(minLevelProvider))) { - _optionsProvider = optionsProvider ?? throw new ArgumentNullException(nameof(optionsProvider)); + _appender = new GodotLogAppender( + optionsProvider ?? throw new ArgumentNullException(nameof(optionsProvider))); } /// @@ -74,6 +82,9 @@ public sealed class GodotLogger : AbstractLogger /// /// Uses Godot-aware structured rendering instead of the base string concatenation fallback. /// + /// The log level. + /// The message body before Godot template rendering. + /// Structured properties appended through the configured Godot template. public override void Log(LogLevel level, string message, params (string Key, object? Value)[] properties) { if (!IsEnabled(level)) @@ -87,6 +98,10 @@ public sealed class GodotLogger : AbstractLogger /// /// Uses Godot-aware structured rendering instead of the base string concatenation fallback. /// + /// The log level. + /// The message body before Godot template rendering. + /// The optional exception written after the rendered message. + /// Structured properties appended through the configured Godot template. public override void Log( LogLevel level, string message, @@ -107,73 +122,44 @@ public sealed class GodotLogger : AbstractLogger Exception? exception, (string Key, object? Value)[]? properties) { - var options = _optionsProvider(); - var templateText = options.Mode == GodotLoggerMode.Debug - ? options.DebugOutputTemplate - : options.ReleaseOutputTemplate; - var context = new GodotLogRenderContext( + var entry = new LogEntry( DateTime.UtcNow, level, Name(), message, - options.GetColor(level), - FormatProperties(properties)); - var rendered = GodotLogTemplate.Parse(templateText).Render(context); + exception, + ToPropertiesDictionary(properties)); - if (options.Mode == GodotLoggerMode.Debug) - { - WriteDebug(level, rendered); - } - else - { - GD.Print(rendered); - } - - if (exception != null) - { - GD.PrintErr(exception.ToString()); - } + _appender.Append(entry); } private static string FormatProperties((string Key, object? Value)[]? properties) { - var merged = MergeProperties(properties); - if (merged.Count == 0) - { - return string.Empty; - } - - return " | " + string.Join(", ", merged.Select(static pair => $"{pair.Key}={FormatValue(pair.Value)}")); + return GodotLogAppender.FormatProperties(ToPropertiesDictionary(properties)); } - private static IReadOnlyDictionary MergeProperties((string Key, object? Value)[]? properties) + private static IReadOnlyDictionary ToPropertiesDictionary( + (string Key, object? Value)[]? properties) { - var contextProperties = LogContext.Current; - if ((properties == null || properties.Length == 0) && contextProperties.Count == 0) + if (properties == null || properties.Length == 0) { return EmptyProperties; } - var merged = new Dictionary(contextProperties, StringComparer.Ordinal); - if (properties != null) + var result = new Dictionary(StringComparer.Ordinal); + foreach (var property in properties) { - foreach (var property in properties) + if (string.IsNullOrWhiteSpace(property.Key)) { - if (string.IsNullOrWhiteSpace(property.Key)) - { - continue; - } - - merged[property.Key.Trim()] = property.Value; + continue; } + + result[property.Key.Trim()] = property.Value; } - return merged; + return result.Count == 0 ? EmptyProperties : result; } - private static readonly IReadOnlyDictionary EmptyProperties = - new Dictionary(StringComparer.Ordinal); - private static Func CreateFixedOptionsProvider(LogLevel minLevel) { var options = GodotLoggerOptions.ForMinimumLevel(minLevel); @@ -191,34 +177,4 @@ public sealed class GodotLogger : AbstractLogger ArgumentNullException.ThrowIfNull(options); return () => options.GetEffectiveMinLevel(); } - - private static string FormatValue(object? value) - { - if (value == null) - { - return "null"; - } - - return value switch - { - IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), - _ => value.ToString() ?? string.Empty - }; - } - - private static void WriteDebug(LogLevel level, string rendered) - { - GD.PrintRich(rendered); - - switch (level) - { - case LogLevel.Fatal: - case LogLevel.Error: - GD.PushError(rendered); - break; - case LogLevel.Warning: - GD.PushWarning(rendered); - break; - } - } } diff --git a/GFramework.Godot/README.md b/GFramework.Godot/README.md index 3e42f487..7f89c45f 100644 --- a/GFramework.Godot/README.md +++ b/GFramework.Godot/README.md @@ -72,7 +72,7 @@ Scene / UI、配置、存储、设置、日志与协程能力接到 `Node`、`Sc - 节点扩展与 `Signal(...)` fluent API - `GodotTimeSource` 与协程时间分段 -- Godot 日志 provider +- Godot 日志 provider 与 `GodotLogAppender` - 暂停处理、节点池与富文本效果支持 这些目录都是“宿主适配层”,不是新的 gameplay 抽象层。 diff --git a/ai-plan/public/godot-logging-core-sink/todos/godot-logging-core-sink-tracking.md b/ai-plan/public/godot-logging-core-sink/todos/godot-logging-core-sink-tracking.md index e1ac8d93..0c04f2bf 100644 --- a/ai-plan/public/godot-logging-core-sink/todos/godot-logging-core-sink-tracking.md +++ b/ai-plan/public/godot-logging-core-sink/todos/godot-logging-core-sink-tracking.md @@ -7,12 +7,12 @@ ## 当前恢复点 -- 恢复点编号:`GODOT-LOGGING-CORE-SINK-RP-001` -- 当前阶段:`启动与边界确认` +- 恢复点编号:`GODOT-LOGGING-CORE-SINK-RP-002` +- 当前阶段:`Godot appender 最小实现已验证` - 当前焦点: - - 复查 `GFramework.Core` 当前日志抽象、provider、appender / sink 能力与扩展边界 - - 对照已归档的 `godot-logging-compliance-polish` 结论,确认哪些能力应迁移为 Core 通用扩展,哪些能力应保留在 Godot 宿主层 - - 形成最小实现路径,避免同时引入第二套日志 API 或破坏现有 `GodotLog` 入口 + - `GFramework.Godot.Logging.GodotLogAppender` 已作为 Core `ILogAppender` 的 Godot 宿主落点落地 + - `GodotLogger` 保留原有 `ILogger` 入口,但底层输出委托给 appender + - Godot / Core logging 文档已说明 provider 与 appender 的组合边界 ## 已知输入 @@ -25,19 +25,26 @@ ## 待办 -1. 盘点 `GFramework.Core` 日志扩展点与 Godot 侧 logger/provider 的实际耦合点 -2. 判断 Core appender / sink 抽象是否已足够承载 Godot 输出,还是需要先补齐抽象层能力 -3. 制定兼容路径:保留 `GodotLog` 用户入口,同时让底层输出走 Core 可组合管线 -4. 为选定方案补充 targeted tests 与 `docs/zh-CN/` adoption guidance +1. 已完成:盘点 `GFramework.Core` 日志扩展点与 Godot 侧 logger/provider 的实际耦合点 +2. 已完成:确认现有 Core `ILogAppender` 足够承载 Godot 输出,无需新增第二套 sink API +3. 已完成:保留 `GodotLog` / `GodotLoggerFactoryProvider` 入口,并让 `GodotLogger` 底层走 `GodotLogAppender` +4. 已完成:补充 `GodotLogAppender` targeted tests 与 `docs/zh-CN/` adoption guidance +5. 待确认:是否还需要在后续阶段补一个配置化 factory 示例,把 `GodotLogAppender` 与文件 / async appender 显式组合 ## 验证 - `dotnet build GFramework.sln -c Release` - 结果:通过,`0 warning / 0 error` - 备注:2026-05-03 在创建本 active topic 前已验证归档收尾分支;后续实现改动需要按受影响项目重新验证 +- `dotnet test GFramework.Godot.Tests -c Release` + - 结果:通过,`75 passed / 0 failed / 0 skipped` + - 备注:覆盖 `GodotLogAppender` 渲染、动态 options provider、既有 Godot logging tests +- `dotnet build GFramework.Godot -c Release` + - 结果:通过,`0 warning / 0 error` + - 备注:验证受影响运行时项目 ## 下一步 -1. 从 `GFramework.Core` 与 `GFramework.Godot.Logging` 源码开始做只读盘点 -2. 在 trace 中记录候选设计和不采用的扩张路径 -3. 确认实现边界后再修改代码与文档 +1. 提交当前 appender 实现、测试、文档与 tracking 更新 +2. 如继续扩展本主题,优先评估是否需要示例化 `CompositeLogger + GodotLogAppender + FileAppender`,而不是新增 API +3. 若无新增需求,本主题可在 PR 验证通过后归档 diff --git a/ai-plan/public/godot-logging-core-sink/traces/godot-logging-core-sink-trace.md b/ai-plan/public/godot-logging-core-sink/traces/godot-logging-core-sink-trace.md index e487900b..78abfb76 100644 --- a/ai-plan/public/godot-logging-core-sink/traces/godot-logging-core-sink-trace.md +++ b/ai-plan/public/godot-logging-core-sink/traces/godot-logging-core-sink-trace.md @@ -29,3 +29,32 @@ 1. 只读盘点 Core logging 抽象与 Godot logger/provider 的耦合点 2. 记录候选设计,明确哪些能力进入 Core,哪些保留在 Godot 宿主层 3. 确认方案后进入实现与文档更新 + +### RP-002 Godot Appender 最小实现 + +- 盘点结论: + - Core 已有 `ILogAppender`、`LogEntry`、`CompositeLogger`、filter、formatter 与 async appender + - Godot 侧主要耦合点是 `GodotLogger` 直接持有模板渲染和 `GD.*` 输出逻辑 + - 不需要先新增 Core sink 抽象;把 Godot 输出沉淀为 Godot 包内的 `ILogAppender` 已能复用 Core 管线 +- 已实施: + - 新增 `GFramework.Godot.Logging.GodotLogAppender` + - `GodotLogger` 保留原有 public API,并把输出委托给 `GodotLogAppender` + - 新增 `GFramework.Godot.Tests/Logging/GodotLogAppenderTests.cs` + - 更新 `GFramework.Godot/README.md`、`docs/zh-CN/core/logging.md`、`docs/zh-CN/godot/index.md`、 + `docs/zh-CN/godot/logging.md` +- 采用的兼容边界: + - `GodotLog`、`GodotLoggerFactory`、`GodotLoggerFactoryProvider` 不改用户调用方式 + - Godot 输出可作为 Core appender 被自定义 factory / `CompositeLogger` 组合 + - 文件、JSON、namespace filter、async 等仍由 Core logging 组件负责,Godot 包只提供宿主控制台落点 + +### 验证 + +- `dotnet test GFramework.Godot.Tests -c Release` + - 结果:通过,`75 passed / 0 failed / 0 skipped` +- `dotnet build GFramework.Godot -c Release` + - 结果:通过,`0 warning / 0 error` + +### 下一步 + +1. 提交当前 appender 实现与文档更新 +2. 若继续推进本主题,优先补充组合示例或归档 topic,不新增第二套日志 API diff --git a/docs/zh-CN/core/logging.md b/docs/zh-CN/core/logging.md index 0b46635c..9382cf37 100644 --- a/docs/zh-CN/core/logging.md +++ b/docs/zh-CN/core/logging.md @@ -79,6 +79,10 @@ using (LogContext.Push("RequestId", requestId)) `LoggingConfigurationLoader` 读取 `LoggingConfiguration`,再把自定义 `ILoggerFactoryProvider` 挂到 `ArchitectureConfiguration.LoggerProperties.LoggerFactoryProvider` 或 `LoggerFactoryResolver.Provider`。 +宿主包也可以提供自己的 appender。Godot 项目如果需要把 Core 日志管线输出到 Godot 控制台,可以引用 +`GFramework.Godot.Logging.GodotLogAppender`,再用 `CompositeLogger` 或自定义 factory 把它和文件、JSON、异步输出等 +Core 组件组合在同一条调用面下。 + ## 什么时候该换 provider 下面这些场景通常不该只靠改 `MinLevel`: @@ -87,5 +91,6 @@ using (LogContext.Push("RequestId", requestId)) - 需要按 namespace / level 做过滤 - 需要 JSON 格式日志 - 需要组合多个 appender +- 需要把输出落到 Godot、Unity 或其他宿主控制台 这时更合理的做法是保留 `ILogger` 调用面不变,只替换 provider / factory / formatter / appender 组合。 diff --git a/docs/zh-CN/godot/index.md b/docs/zh-CN/godot/index.md index e3b6d1f0..5b0bfe34 100644 --- a/docs/zh-CN/godot/index.md +++ b/docs/zh-CN/godot/index.md @@ -16,7 +16,7 @@ description: 以当前 GFramework.Godot 源码、测试与 CoreGrid 接线为准 - 节点运行时辅助:`WaitUntilReadyAsync()`、`AddChildXAsync()`、`QueueFreeX()`、`UnRegisterWhenNodeExitTree(...)` - Godot 风格的 Scene / UI 工厂与 registry:`GodotSceneFactory`、`GodotUiFactory` - Godot 特化的存储、设置与配置加载:`GodotFileStorage`、`GodotAudioSettings`、`GodotYamlConfigLoader` -- 少量面向运行时交互的扩展:`Signal(...)` fluent API、暂停处理、富文本效果、协程时间源 +- 少量面向运行时交互的扩展:`Signal(...)` fluent API、`GodotLogAppender`、暂停处理、富文本效果、协程时间源 它不是 `[GetNode]`、`[BindNodeSignal]`、`AutoLoads`、`InputActions` 的来源。这些能力属于 `GFramework.Godot.SourceGenerators`。 diff --git a/docs/zh-CN/godot/logging.md b/docs/zh-CN/godot/logging.md index b6a71b44..a850d247 100644 --- a/docs/zh-CN/godot/logging.md +++ b/docs/zh-CN/godot/logging.md @@ -9,6 +9,7 @@ description: 以当前 GFramework.Godot.Logging 源码与 CoreGrid 接线为准 除了把日志写到 Godot 控制台,它现在还补上了 Godot 宿主常见的接入便利层: - `GodotLog` 静态入口 +- `GodotLogAppender`,用于接入 Core appender 管线 - 配置文件自动发现 - 运行期配置热重载 - 延迟 logger 解析,适合 `static readonly` 字段 @@ -20,7 +21,9 @@ description: 以当前 GFramework.Godot.Logging 源码与 CoreGrid 接线为准 ### `GodotLogger` -`GodotLogger` 继承自 `AbstractLogger`,负责把日志写到 Godot 的输出 API: +`GodotLogger` 继承自 `AbstractLogger`,保留原有 `ILogger` 使用面。它现在把实际输出委托给 +`GodotLogAppender`,所以 `GodotLoggerFactoryProvider` 继续可用,同时 Godot 输出也能作为 Core appender 管线的一个 +可组合目标: ```csharp public sealed class GodotLogger( @@ -32,10 +35,27 @@ public sealed class GodotLogger( 当前实现里的几个关键语义: - 时间戳使用 `DateTime.UtcNow` -- 输出前缀格式是 `[yyyy-MM-dd HH:mm:ss.fff] LEVEL [LoggerName]` -- `exception` 不会被单独结构化处理,而是直接追加到消息后面 -- `Trace` / `Debug` 走 `GD.PrintRich(...)` -- `Info` / `Warning` / `Error` / `Fatal` 分别走 Godot 自身的普通、警告和错误输出通道 +- 模板、级别、颜色仍由 `GodotLoggerOptions` 或配置文件控制 +- 结构化属性来自 `IStructuredLogger` 参数和 `LogContext` +- `exception` 会在渲染后的主消息之后写入 Godot 错误输出 + +### `GodotLogAppender` + +`GodotLogAppender` 实现 Core 的 `ILogAppender`: + +```csharp +public sealed class GodotLogAppender : ILogAppender +{ + public GodotLogAppender(); + public GodotLogAppender(GodotLoggerOptions options); + public void Append(LogEntry entry); + public void Flush(); +} +``` + +它适合在已经使用 `CompositeLogger`、`AsyncLogAppender`、filter 或自定义 factory 的项目里,把 Godot 控制台输出作为 +其中一个落点,而不是为 Godot 重新定义一套业务日志 API。`Flush()` 和 `Dispose()` 没有额外副作用,因为 Godot 输出 API +对这个 appender 来说没有持有的缓冲区或外部资源。 ### `GodotLoggerFactory` @@ -109,7 +129,7 @@ var logger = GodotLog.CreateLogger
(); "Mode": "Debug", "DebugMinLevel": "Debug", "ReleaseMinLevel": "Info", - "DebugOutputTemplate": "[{timestamp:HH:mm:ss.fff}] [color={color}][{level:u3}][/color] [{category:l16}] {message}{properties}", + "DebugOutputTemplate": "[{timestamp:HH:mm:ss.fff}] [color={color}]{level:u3}[/color] {message}{properties}", "ReleaseOutputTemplate": "[{timestamp:HH:mm:ss.fff}] [{level:u3}] [{category:l16}] {message}{properties}", "Colors": { "Info": "white", @@ -214,16 +234,16 @@ RegisterHandler(new LoggingTransitionHandler()); ## Godot 控制台输出语义 -当前 `GodotLogger.Write(...)` 的级别映射如下: +当前 `GodotLogAppender.Append(...)` 的级别映射如下: | 日志级别 | Godot 输出 API | 当前行为 | | --- | --- | --- | -| `Trace` | `GD.PrintRich(...)` | 使用灰色富文本输出 | -| `Debug` | `GD.PrintRich(...)` | 使用青色富文本输出 | -| `Info` | `GD.Print(...)` | 普通控制台输出 | -| `Warning` | `GD.PushWarning(...)` | 进入 Godot 警告通道 | -| `Error` | `GD.PrintErr(...)` | 输出到错误流 | -| `Fatal` | `GD.PushError(...)` | 进入 Godot 错误通道 | +| `Trace` | `GD.PrintRich(...)` 或 `GD.Print(...)` | Debug 模式使用富文本,Release 模式使用普通输出 | +| `Debug` | `GD.PrintRich(...)` 或 `GD.Print(...)` | Debug 模式使用富文本,Release 模式使用普通输出 | +| `Info` | `GD.PrintRich(...)` 或 `GD.Print(...)` | Debug 模式使用富文本,Release 模式使用普通输出 | +| `Warning` | `GD.PrintRich(...)` + `GD.PushWarning(...)` 或 `GD.Print(...)` | Debug 模式同时进入 Godot 警告通道 | +| `Error` | `GD.PrintRich(...)` + `GD.PushError(...)` 或 `GD.Print(...)` | Debug 模式同时进入 Godot 错误通道 | +| `Fatal` | `GD.PrintRich(...)` + `GD.PushError(...)` 或 `GD.Print(...)` | Debug 模式同时进入 Godot 错误通道 | 结构化属性如果通过 `IStructuredLogger` 或 `LogContext` 传入,也会追加到模板里的 `{properties}` 占位符。 @@ -259,8 +279,8 @@ System.IO.IOException: ... - 当前推荐接法仍然是把 `GodotLoggerFactoryProvider` 放进 `ArchitectureConfiguration.LoggerProperties`;如果项目是纯 Godot 宿主,也可以在入口直接调用 `GodotLog.UseAsDefaultProvider()` -- `GFramework.Godot.Logging` 只解决 Godot 控制台输出,不提供文件落盘、JSON formatter、异步 appender 或按 namespace - 的复杂过滤 +- `GFramework.Godot.Logging` 只提供 Godot 控制台 appender;文件落盘、JSON formatter、异步 appender 或按 namespace + 的复杂过滤继续使用 Core 日志组件组合 - `GodotLogger` 只改变输出方式,不改变 `ILogger` 接口本身;业务代码不需要切换到 Godot 专用日志 API - `[Log]`、`[ContextAware]` 这类字段注入能力不属于 `GFramework.Godot.Logging` - Scene / UI 的 `LoggingTransitionHandler` 位于 `GFramework.Game`,Godot 侧只是通过 provider 让它们输出到 Godot 控制台