# 07. 核心服务 - 日志记录与聚合过滤设计 本文档详细阐述了基于NLog的、带有智能聚合过滤功能的日志系统设计方案。 ## 1. 设计目标 ### 1.1. 设计思路与考量 * **全面记录**:捕获日志发生的时间、级别、消息、异常、调用点(文件、方法、行号)等所有关键上下文信息,便于问题追溯和分析。 * **持久化存储**:将日志信息存储到数据库中,实现日志的长期保存、集中管理和便捷查询。 * **防止“日志风暴”**:在工业应用中,设备故障或网络抖动可能导致大量重复日志在短时间内爆发。传统的日志系统会因此被刷爆,导致磁盘空间耗尽,有效信息被淹没。因此,需要一个智能的聚合过滤机制。 * **性能优化**:日志记录不应阻塞业务逻辑的执行。 ### 1.2. 设计优势 * **高效排障**:详细的日志信息能极大提高问题定位和解决的效率。 * **资源节约**:聚合过滤功能显著减少了磁盘I/O和存储空间占用,同时避免了日志系统自身的性能瓶颈。 * **信息浓缩**:即使在高频场景下,也能保留关键的首次日志信息和事件发生频率,提供有价值的洞察。 * **可配置性**:NLog提供了灵活的配置选项,可以根据环境(开发、测试、生产)调整日志级别、输出目标等。 ### 1.3. 设计劣势/权衡 * **实现复杂性**:自定义NLog Target以实现聚合过滤功能,增加了额外的开发和维护成本。 * **实时性损失**:聚合过滤机制意味着某些重复日志不会立即写入数据库,而是等待聚合周期结束,这可能对需要严格实时性的监控场景造成影响(但对于大多数日志分析场景是可接受的)。 * **内存消耗**:`ThrottlingDatabaseTarget` 需要在内存中维护一个缓存来跟踪重复日志,当日志种类非常多且聚合周期较长时,可能会占用较多内存。 ## 2. 数据库实体 (`DMS.Infrastructure`) ### 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; /// /// 数据库实体:对应数据库中的 Logs 表,用于存储应用程序日志。 /// [SugarTable("Logs")] public class DbLog { [SugarColumn(IsPrimaryKey = true, IsIdentity = true)] public long Id { get; set; } /// /// 日志记录的时间戳。 /// public DateTime Logged { get; set; } /// /// 日志级别 (e.g., "Info", "Warn", "Error", "Debug")。 /// public string Level { get; set; } /// /// 日志消息主体。 /// [SugarColumn(Length = -1)] // 映射为NVARCHAR(MAX)或类似类型 public string Message { get; set; } /// /// 异常信息,包括堆栈跟踪。如果无异常则为null。 /// [SugarColumn(IsNullable = true, Length = -1)] public string Exception { get; set; } /// /// 记录日志的调用点信息 (文件路径:行号)。 /// public string CallSite { get; set; } /// /// 记录日志的方法名。 /// public string MethodName { get; set; } /// /// (用于聚合) 此条日志在指定时间窗口内被触发的总次数。默认为1。 /// public int AggregatedCount { get; set; } = 1; } ``` ## 3. NLog 自定义Target (`DMS.Infrastructure`) ### 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; } } /// /// 自定义NLog Target,实现日志的聚合过滤功能,并将日志写入数据库。 /// [Target("ThrottlingDatabase")] public class ThrottlingDatabaseTarget : TargetWithLayout { // 缓存正在被节流的日志条目,键是日志的唯一标识,值是LogCacheEntry private readonly ConcurrentDictionary _throttleCache = new(); // 聚合时间窗口,例如30秒 private readonly TimeSpan _throttleTime = TimeSpan.FromSeconds(30); // NLog会通过反射设置这个属性,用于获取数据库连接字符串 [RequiredParameter] public string ConnectionString { get; set; } /// /// NLog核心写入方法,每当有日志事件发生时被调用。 /// /// 日志事件信息。 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); } } } } /// /// 定时器回调方法,用于将聚合后的日志写入数据库。 /// /// 日志的唯一键。 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); } } } /// /// 将 LogEventInfo 转换为 DbLog 实体并写入数据库。 /// /// 要写入的日志事件。 /// 此日志事件在聚合周期内的总次数。 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`) ### 4.1. 设计思路与考量 * **外部配置**:NLog允许通过XML文件进行配置,使得日志行为可以在不修改代码的情况下进行调整。 * **Target注册**:通过 `` 标签注册自定义的 `ThrottlingDatabaseTarget`。 * **规则路由**:通过 `` 标签定义日志的路由规则,例如,将所有 `Info` 级别及以上的日志写入数据库。 * **全局上下文**:使用 `${gdc:item=connectionString}` 从NLog的全局诊断上下文获取数据库连接字符串,避免硬编码。 ### 4.2. 示例:`nlog.config` ```xml internalLogLevel="Info" internalLogFile="c:\temp\internal-nlog.txt"> ``` ## 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; /// /// 应用程序的通用日志服务接口。 /// public interface ILoggerService { /// /// 记录信息级别日志。 /// /// 日志消息。 void Info(string message); /// /// 记录警告级别日志。 /// /// 日志消息。 void Warn(string message); /// /// 记录错误级别日志,包含异常信息。 /// /// 发生的异常。 /// 可选的日志消息。 void Error(Exception ex, string message = null); /// /// 记录调试级别日志。 /// /// 日志消息。 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; /// /// ILoggerService 的 NLog 实现。 /// 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容器配置和主窗口显示 } // ... } ```