Files
DMS/软件设计文档/08-日志记录与聚合过滤设计.md

17 KiB
Raw Permalink Blame History

软件开发文档 - 日志记录与聚合过滤设计

本文档详细阐述了一套基于NLog框架的、带有智能聚合过滤功能的日志系统设计方案旨在提供详细、高效且能避免“日志风暴”的日志记录能力。

1. 设计目标

1.1. 设计思路与考量

  • 全面记录:捕获日志发生的时间、级别、消息、异常、调用点(文件、方法、行号)等所有关键上下文信息,便于问题追溯和分析。
  • 持久化存储:将日志信息存储到数据库中,实现日志的长期保存、集中管理和便捷查询。
  • 防止“日志风暴”:在工业应用中,设备故障或网络抖动可能导致大量重复日志在短时间内爆发。传统的日志系统会因此被刷爆,导致磁盘空间耗尽,有效信息被淹没。因此,需要一个智能的聚合过滤机制。
  • 性能优化:日志记录不应阻塞业务逻辑的执行。

1.2. 设计优势

  • 高效排障:详细的日志信息能极大提高问题定位和解决的效率。
  • 资源节约聚合过滤功能显著减少了磁盘I/O和存储空间占用同时避免了日志系统自身的性能瓶颈。
  • 信息浓缩:即使在高频场景下,也能保留关键的首次日志信息和事件发生频率,提供有价值的洞察。
  • 可配置性NLog提供了灵活的配置选项可以根据环境开发、测试、生产调整日志级别、输出目标等。

1.3. 设计劣势/权衡

  • 实现复杂性自定义NLog Target以实现聚合过滤功能增加了额外的开发和维护成本。
  • 实时性损失:聚合过滤机制意味着某些重复日志不会立即写入数据库,而是等待聚合周期结束,这可能对需要严格实时性的监控场景造成影响(但对于大多数日志分析场景是可接受的)。
  • 内存消耗ThrottlingDatabaseTarget 需要在内存中维护一个缓存来跟踪重复日志,当日志种类非常多且聚合周期较长时,可能会占用较多内存。

2. 数据库实体 (DMS.Infrastructure)

我们创建一个 DbLog 实体来定义日志在数据库中的存储结构。

2.1. 设计思路与考量

  • 字段完备DbLog 实体包含了日志所需的所有关键信息,特别是 CallSite (调用点) 和 AggregatedCount (聚合计数),这些是实现高级日志分析和过滤的基础。
  • 可扩展性:使用 Length = -1 (对应SQL Server的 NVARCHAR(MAX)) 确保 MessageException 字段能够存储任意长度的文本。

2.2. 示例:DbLog.cs

// 文件: DMS.Infrastructure/Entities/DbLog.cs
using SqlSugar;
using System;

namespace DMS.Infrastructure.Entities;

/// <summary>
/// 数据库实体:对应数据库中的 Logs 表,用于存储应用程序日志。
/// </summary>
[SugarTable("Logs")]
public class DbLog
{
    [SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
    public long Id { get; set; }

    /// <summary>
    /// 日志记录的时间戳。
    /// </summary>
    public DateTime Logged { get; set; }

    /// <summary>
    /// 日志级别 (e.g., "Info", "Warn", "Error", "Debug")。
    /// </summary>
    public string Level { get; set; }

    /// <summary>
    /// 日志消息主体。
    /// </summary>
    [SugarColumn(Length = -1)] // 映射为NVARCHAR(MAX)或类似类型
    public string Message { get; set; }

    /// <summary>
    /// 异常信息包括堆栈跟踪。如果无异常则为null。
    /// </summary>
    [SugarColumn(IsNullable = true, Length = -1)]
    public string Exception { get; set; }

    /// <summary>
    /// 记录日志的调用点信息 (文件路径:行号)。
    /// </summary>
    public string CallSite { get; set; }

    /// <summary>
    /// 记录日志的方法名。
    /// </summary>
    public string MethodName { get; set; }

    /// <summary>
    /// (用于聚合) 此条日志在指定时间窗口内被触发的总次数。默认为1。
    /// </summary>
    public int AggregatedCount { get; set; } = 1;
}

3. NLog 自定义Target (DMS.Infrastructure)

这是实现聚合过滤功能的核心。我们创建一个继承自 NLog.Targets.TargetWithLayout 的自定义Target。

3.1. 设计思路与考量

  • 自定义TargetNLog允许通过继承 TargetWithLayout 创建自定义的日志目标。这是实现复杂日志处理逻辑(如聚合过滤)的入口。
  • 内存缓存:使用 ConcurrentDictionary 作为内存缓存,以 logKey (日志级别+消息+调用点) 为键,存储 LogCacheEntry。这使得我们能够快速查找和更新重复日志。
  • 定时器触发:为每个首次出现的日志启动一个 System.Threading.Timer。当定时器到期时,触发 FlushEntry 方法,将聚合后的日志写入数据库。
  • 原子操作:使用 Interlocked.IncrementConcurrentDictionary 的原子操作确保在多线程环境下缓存的正确性。

3.2. 示例:ThrottlingDatabaseTarget.cs

// 文件: DMS.Infrastructure/Logging/ThrottlingDatabaseTarget.cs
using NLog;
using NLog.Targets;
using System.Collections.Concurrent;
using System.Threading;
using System;
using SqlSugar;
using DMS.Infrastructure.Entities;

namespace DMS.Infrastructure.Logging;

// 内部类,用于存储日志的缓存信息
file class LogCacheEntry
{
    public LogEventInfo FirstLogEvent { get; set; }
    public int Count { get; set; }
    public Timer Timer { get; set; }
}

/// <summary>
/// 自定义NLog Target实现日志的聚合过滤功能并将日志写入数据库。
/// </summary>
[Target("ThrottlingDatabase")]
public class ThrottlingDatabaseTarget : TargetWithLayout
{
    // 缓存正在被节流的日志条目键是日志的唯一标识值是LogCacheEntry
    private readonly ConcurrentDictionary<string, LogCacheEntry> _throttleCache = new();
    // 聚合时间窗口例如30秒
    private readonly TimeSpan _throttleTime = TimeSpan.FromSeconds(30);

    // NLog会通过反射设置这个属性用于获取数据库连接字符串
    [RequiredParameter]
    public string ConnectionString { get; set; }

    /// <summary>
    /// NLog核心写入方法每当有日志事件发生时被调用。
    /// </summary>
    /// <param name="logEvent">日志事件信息。</param>
    protected override void Write(LogEventInfo logEvent)
    {
        // 构建一个唯一的键来标识这个日志源(级别 + 消息 + 调用点)
        // 这样可以区分不同位置或不同内容的重复日志
        string logKey = $"{logEvent.Level}|{logEvent.FormattedMessage}|{logEvent.CallerFilePath}:{logEvent.CallerLineNumber}";

        // 尝试从缓存中获取条目
        if (_throttleCache.TryGetValue(logKey, out var entry))
        {
            // 如果存在,说明在当前聚合周期内,这条日志已经记录过一次
            // 我们只增加计数,不立即写入数据库
            Interlocked.Increment(ref entry.Count);
        }
        else
        {
            // 如果不存在,这是这条日志在当前聚合周期内的第一次出现
            var newEntry = new LogCacheEntry
            {
                FirstLogEvent = logEvent,
                Count = 1,
            };

            // 创建一个定时器,在 _throttleTime 后触发 FlushEntry 方法
            // Timeout.Infinite 表示定时器只触发一次
            newEntry.Timer = new Timer(
                callback: _ => FlushEntry(logKey), 
                state: null,
                dueTime: _throttleTime,
                period: Timeout.InfiniteTime 
            );

            // 尝试将新条目原子性地添加到缓存中
            if (_throttleCache.TryAdd(logKey, newEntry))
            {
                // 第一次的日志,立即写入数据库
                WriteToDatabase(logEvent, 1);
            }
            else
            {
                // 极小概率的并发情况在TryAdd之前另一个线程已经添加了。
                // 此时,简单地增加已存在条目的计数。
                if (_throttleCache.TryGetValue(logKey, out var existingEntry))
                {
                    Interlocked.Increment(ref existingEntry.Count);
                }
            }
        }
    }

    /// <summary>
    /// 定时器回调方法,用于将聚合后的日志写入数据库。
    /// </summary>
    /// <param name="logKey">日志的唯一键。</param>
    private void FlushEntry(string logKey)
    {
        // 从缓存中移除条目
        if (_throttleCache.TryRemove(logKey, out var entry))
        {
            entry.Timer?.Dispose(); // 释放定时器资源

            // 如果在聚合周期内有超过1次的调用则记录一条聚合日志
            if (entry.Count > 1)
            {
                // 构建聚合消息
                var aggregateMessage = $"[聚合日志] 此消息在过去 {_throttleTime.TotalSeconds} 秒内共出现 {entry.Count} 次。首次消息: {entry.FirstLogEvent.FormattedMessage}";
                
                // 创建一个新的LogEventInfo来记录聚合信息
                var aggregateLogEvent = new LogEventInfo(
                    entry.FirstLogEvent.Level,
                    entry.FirstLogEvent.LoggerName,
                    aggregateMessage
                );
                // 复制其他重要属性,如调用点、异常信息等
                aggregateLogEvent.Exception = entry.FirstLogEvent.Exception;
                aggregateLogEvent.CallerFilePath = entry.FirstLogEvent.CallerFilePath;
                aggregateLogEvent.CallerLineNumber = entry.FirstLogEvent.CallerLineNumber;
                aggregateLogEvent.CallerMemberName = entry.FirstLogEvent.CallerMemberName;

                // 将聚合日志写入数据库,并记录总次数
                WriteToDatabase(aggregateLogEvent, entry.Count);
            }
        }
    }

    /// <summary>
    /// 将 LogEventInfo 转换为 DbLog 实体并写入数据库。
    /// </summary>
    /// <param name="logEvent">要写入的日志事件。</param>
    /// <param name="count">此日志事件在聚合周期内的总次数。</param>
    private void WriteToDatabase(LogEventInfo logEvent, int count)
    {
        try
        {
            // 使用 NLog 的 Layout 渲染消息,确保所有信息都包含在内
            var message = Layout.Render(logEvent);

            var dbLog = new DbLog
            {
                Logged = logEvent.TimeStamp,
                Level = logEvent.Level.ToString(),
                Message = message,
                Exception = logEvent.Exception?.ToString(),
                CallSite = $"{logEvent.CallerFilePath}:{logEvent.CallerLineNumber}",
                MethodName = logEvent.CallerMemberName,
                AggregatedCount = count
            };

            // 使用 SqlSugar 客户端将 DbLog 插入数据库
            // 注意:这里需要一个新的 SqlSugarClient 实例,因为 NLog Target 是独立的。
            // 更好的做法是使用一个连接池或单例的 SqlSugarClient但为了简化示例这里直接创建。
            using (var db = new SqlSugarClient(new ConnectionConfig { ConnectionString = ConnectionString, DbType = DbType.SqlServer, IsAutoCloseConnection = true }))
            {
                db.Insertable(dbLog).ExecuteCommand();
            }
        }
        catch (Exception ex)
        {
            // 记录写入数据库失败的错误通常写入内部NLog文件或控制台
            InternalLogger.Error(ex, "Failed to write log to database.");
        }
    }
}

4. NLog 配置 (nlog.config)

在WPF项目中添加 nlog.config 文件,并设置为“如果较新则复制”。

4.1. 设计思路与考量

  • 外部配置NLog允许通过XML文件进行配置使得日志行为可以在不修改代码的情况下进行调整。
  • Target注册:通过 <extensions> 标签注册自定义的 ThrottlingDatabaseTarget
  • 规则路由:通过 <rules> 标签定义日志的路由规则,例如,将所有 Info 级别及以上的日志写入数据库。
  • 全局上下文:使用 ${gdc:item=connectionString} 从NLog的全局诊断上下文获取数据库连接字符串避免硬编码。

4.2. 示例:nlog.config

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      autoReload="true"              <!-- 配置文件修改后自动重新加载 -->
      internalLogLevel="Info"        <!-- NLog内部日志级别 -->
      internalLogFile="c:\temp\internal-nlog.txt"> <!-- NLog内部日志文件 -->

  <!-- 1. 注册我们的自定义Target所在的程序集 -->
  <extensions>
    <add assembly="DMS.Infrastructure"/>
  </extensions>

  <!-- 2. 定义Target -->
  <targets>
    <!-- 文件日志,用于调试,通常在开发环境开启 -->
    <target name="logfile" xsi:type="File" fileName="logs/app.log"
            layout="${longdate}|${level:uppercase=true}|${callsite}|${message} ${exception:format=tostring}" />

    <!-- 我们自定义的数据库Target用于生产环境的日志持久化和聚合 -->
    <target name="db" xsi:type="ThrottlingDatabase"
            connectionString="${gdc:item=connectionString}">
      <!-- 这里可以定义布局但我们在代码中直接访问LogEventInfo的属性来构建DbLog -->
    </target>
  </targets>

  <!-- 3. 定义规则将日志路由到我们的Target -->
  <rules>
    <!-- 所有级别的日志都写入文件,便于本地调试 -->
    <logger name="*" minlevel="Trace" writeTo="logfile" />
    
    <!-- Info及以上级别的日志写入数据库并经过聚合过滤 -->
    <logger name="*" minlevel="Info" writeTo="db" />
  </rules>
</nlog>

5. 封装与初始化

5.1. ILoggerService (可选,但推荐)

5.1.1. 设计思路与考量

  • 封装NLog:通过引入一个简单的 ILoggerService 接口将NLog的具体实现细节封装起来业务代码只依赖于这个抽象。
  • 统一日志接口:提供统一的日志记录方法(如 Info, Warn, Error),简化业务代码中的日志调用。

5.1.2. 设计优势

  • 解耦业务代码不直接依赖NLog未来更换日志框架时只需修改 NLogService 的实现。
  • 简化调用提供更简洁的API减少日志记录的样板代码。
  • 可测试性可以轻松地Mock ILoggerService,便于单元测试。

5.1.3. 示例:ILoggerService.cs

// 文件: DMS.Application/Interfaces/ILoggerService.cs
namespace DMS.Application.Interfaces;

/// <summary>
/// 应用程序的通用日志服务接口。
/// </summary>
public interface ILoggerService
{
    /// <summary>
    /// 记录信息级别日志。
    /// </summary>
    /// <param name="message">日志消息。</param>
    void Info(string message);

    /// <summary>
    /// 记录警告级别日志。
    /// </summary>
    /// <param name="message">日志消息。</param>
    void Warn(string message);

    /// <summary>
    /// 记录错误级别日志,包含异常信息。
    /// </summary>
    /// <param name="ex">发生的异常。</param>
    /// <param name="message">可选的日志消息。</param>
    void Error(Exception ex, string message = null);

    /// <summary>
    /// 记录调试级别日志。
    /// </summary>
    /// <param name="message">日志消息。</param>
    void Debug(string message);
}

5.1.4. 示例:NLogService.cs

// 文件: DMS.Infrastructure/Logging/NLogService.cs
using DMS.Application.Interfaces;
using NLog;
using System;

namespace DMS.Infrastructure.Logging;

/// <summary>
/// ILoggerService 的 NLog 实现。
/// </summary>
public class NLogService : ILoggerService
{
    // 获取当前类的NLog Logger实例
    private static readonly ILogger _logger = LogManager.GetCurrentClassLogger();

    public void Info(string message) => _logger.Info(message);
    public void Warn(string message) => _logger.Warn(message);
    public void Error(Exception ex, string message = null) => _logger.Error(ex, message);
    public void Debug(string message) => _logger.Debug(message);
}

5.2. 初始化 (App.xaml.cs)

5.2.1. 设计思路与考量

  • 早期配置NLog需要在应用程序启动的早期阶段进行配置特别是数据库连接字符串等全局参数。
  • 全局诊断上下文NLog的 GlobalDiagnosticsContext 提供了一种在应用程序范围内传递配置信息的方式,避免了硬编码。

5.2.2. 示例代码

// 文件: DMS.WPF/App.xaml.cs
using NLog;
using System.Windows;

namespace DMS.WPF;

public partial class App : System.Windows.Application
{
    // ...

    protected override void OnStartup(StartupEventArgs e)
    {
        // 在程序启动的最开始就设置好数据库连接字符串
        // 这样 NLog 的 ThrottlingDatabaseTarget 就能获取到它
        GlobalDiagnosticsContext.Set("connectionString", "your_db_connection_string_here");

        // ... DI容器配置和主窗口显示
    }

    // ...
}