gewuyou c967b4df3d fix(godot): 修复日志 review 反馈
- 修复 DeferredLogger 格式化重载提前 string.Format 的热路径问题

- 修复 GodotLogger 默认 options 分配与结构化属性无效 key 处理

- 补充 Godot logging XML 文档、回归测试和 appsettings 接入示例

- 更新 Godot logging PR review 跟踪与验证记录
2026-05-03 09:00:41 +08:00

225 lines
7.0 KiB
C#

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;
/// <summary>
/// Godot platform logger implementation.
/// </summary>
public sealed class GodotLogger : AbstractLogger
{
private readonly Func<GodotLoggerOptions> _optionsProvider;
/// <summary>
/// Initializes a logger that preserves the historical fixed-format template.
/// </summary>
/// <param name="name">The logger name.</param>
/// <param name="minLevel">The minimum enabled log level.</param>
public GodotLogger(string? name = null, LogLevel minLevel = LogLevel.Info)
: this(
name ?? RootLoggerName,
CreateFixedOptionsProvider(minLevel),
() => minLevel)
{
}
/// <summary>
/// Initializes a logger with Godot-specific formatting options.
/// </summary>
/// <param name="name">The logger name.</param>
/// <param name="options">The logger options.</param>
public GodotLogger(string? name, GodotLoggerOptions options)
: this(
name ?? RootLoggerName,
CreateOptionsProvider(options),
CreateMinLevelProvider(options))
{
}
/// <summary>
/// Initializes the core logger with dynamic options and level providers.
/// </summary>
/// <param name="name">The resolved logger name used in rendered output.</param>
/// <param name="optionsProvider">The provider that supplies the latest rendering options for each write.</param>
/// <param name="minLevelProvider">The provider that supplies the latest effective minimum level.</param>
/// <remarks>
/// 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.
/// </remarks>
internal GodotLogger(
string name,
Func<GodotLoggerOptions> optionsProvider,
Func<LogLevel> minLevelProvider)
: base(name, minLevelProvider ?? throw new ArgumentNullException(nameof(minLevelProvider)))
{
_optionsProvider = optionsProvider ?? throw new ArgumentNullException(nameof(optionsProvider));
}
/// <summary>
/// Writes a log entry to Godot.
/// </summary>
/// <param name="level">The log level.</param>
/// <param name="message">The rendered message body.</param>
/// <param name="exception">The optional exception.</param>
protected override void Write(LogLevel level, string message, Exception? exception)
{
WriteEntry(level, message, exception, properties: null);
}
/// <summary>
/// Uses Godot-aware structured rendering instead of the base string concatenation fallback.
/// </summary>
public override void Log(LogLevel level, string message, params (string Key, object? Value)[] properties)
{
if (!IsEnabled(level))
{
return;
}
WriteEntry(level, message, exception: null, properties);
}
/// <summary>
/// Uses Godot-aware structured rendering instead of the base string concatenation fallback.
/// </summary>
public override void Log(
LogLevel level,
string message,
Exception? exception,
params (string Key, object? Value)[] properties)
{
if (!IsEnabled(level))
{
return;
}
WriteEntry(level, message, exception, properties);
}
private void WriteEntry(
LogLevel level,
string message,
Exception? exception,
(string Key, object? Value)[]? properties)
{
var options = _optionsProvider();
var templateText = options.Mode == GodotLoggerMode.Debug
? options.DebugOutputTemplate
: options.ReleaseOutputTemplate;
var context = new GodotLogRenderContext(
DateTime.UtcNow,
level,
Name(),
message,
options.GetColor(level),
FormatProperties(properties));
var rendered = GodotLogTemplate.Parse(templateText).Render(context);
if (options.Mode == GodotLoggerMode.Debug)
{
WriteDebug(level, rendered);
}
else
{
GD.Print(rendered);
}
if (exception != null)
{
GD.PrintErr(exception.ToString());
}
}
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)}"));
}
private static IReadOnlyDictionary<string, object?> MergeProperties((string Key, object? Value)[]? properties)
{
var contextProperties = LogContext.Current;
if ((properties == null || properties.Length == 0) && contextProperties.Count == 0)
{
return EmptyProperties;
}
var merged = new Dictionary<string, object?>(contextProperties, StringComparer.Ordinal);
if (properties != null)
{
foreach (var property in properties)
{
if (string.IsNullOrWhiteSpace(property.Key))
{
continue;
}
merged[property.Key.Trim()] = property.Value;
}
}
return merged;
}
private static readonly IReadOnlyDictionary<string, object?> EmptyProperties =
new Dictionary<string, object?>(StringComparer.Ordinal);
private static Func<GodotLoggerOptions> CreateFixedOptionsProvider(LogLevel minLevel)
{
var options = GodotLoggerOptions.ForMinimumLevel(minLevel);
return () => options;
}
private static Func<GodotLoggerOptions> CreateOptionsProvider(GodotLoggerOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return () => options;
}
private static Func<LogLevel> CreateMinLevelProvider(GodotLoggerOptions options)
{
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;
}
}
}