按照软件设计文档开始重构代码01
This commit is contained in:
428
软件设计文档/08-日志记录与聚合过滤设计.md
Normal file
428
软件设计文档/08-日志记录与聚合过滤设计.md
Normal file
@@ -0,0 +1,428 @@
|
||||
# 软件开发文档 - 日志记录与聚合过滤设计
|
||||
|
||||
本文档详细阐述了一套基于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容器配置和主窗口显示
|
||||
}
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user