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

本文档详细阐述了一套基于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)`) 确保 `Message` 和 `Exception` 字段能够存储任意长度的文本。

### 2.2. 示例：`DbLog.cs`

```csharp
// 文件: 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. 设计思路与考量

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

### 3.2. 示例：`ThrottlingDatabaseTarget.cs`

```csharp
// 文件: 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
<?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`

```csharp
// 文件: 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`

```csharp
// 文件: 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. 示例代码

```csharp
// 文件: 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容器配置和主窗口显示
    }

    // ...
}
```