WPF 日志集成
大约 14 分钟约 4155 字
WPF 日志集成
简介
WPF 应用的日志集成涵盖从调试输出到结构化日志的完整方案。通过集成 Serilog、NLog 等日志框架,实现文件日志、事件日志和远程日志收集,帮助定位运行时问题和性能瓶颈。在工业上位机场景中,日志系统尤为重要——设备通信记录、异常追踪和操作审计都依赖完善的日志体系。
日志体系的分层
一个完善的日志体系通常分为以下几个层次:
┌─────────────────────────────────────────────┐
│ 应用层(Application Layer) │
│ ViewModel / Service / Communication │
│ → 通过 ILogger 接口写入日志 │
├─────────────────────────────────────────────┤
│ 框架层(Framework Layer) │
│ Serilog / NLog / Microsoft.Extensions.Logging│
│ → 日志级别过滤、格式化、Enrichment │
├─────────────────────────────────────────────┤
│ 输出层(Sink/Appender Layer) │
│ 文件 / 控制台 / Seq / Elasticsearch │
│ → 日志写入目标 │
├─────────────────────────────────────────────┤
│ 分析层(Analytics Layer) │
│ Kibana / Seq / Grafana │
│ → 日志搜索、分析和可视化 │
└─────────────────────────────────────────────┘日志级别说明
| 级别 | 值 | 用途 | 示例 |
|---|---|---|---|
| Trace | 0 | 最详细的调试信息 | 变量值、方法进入/退出 |
| Debug | 1 | 调试信息 | 通信帧内容、计算中间值 |
| Information | 2 | 一般信息 | 应用启动、设备连接成功 |
| Warning | 3 | 警告信息 | 设备响应超时、配置缺失 |
| Error | 4 | 错误信息 | 通信失败、数据库异常 |
| Fatal | 5 | 致命错误 | 应用崩溃、未处理异常 |
特点
实现
Serilog 集成
基础配置
// ========== NuGet 包 ==========
// Serilog.Extensions.Hosting
// Serilog.Sinks.Console
// Serilog.Sinks.File
// Serilog.Sinks.Seq(可选)
// Serilog.Enrichers.Environment
// Serilog.Enrichers.Thread
// Serilog.Enrichers.Process
// Serilog.Exceptions
// ========== App.xaml.cs — Serilog 初始化 ==========
public partial class App : Application
{
public static ILogger Logger { get; private set; } = null!;
protected override void OnStartup(StartupEventArgs e)
{
// 配置 Serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
// 微软框架日志默认 Information
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.MinimumLevel.Override("System", LogEventLevel.Warning)
// 丰富日志上下文
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithThreadId()
.Enrich.WithProcessId()
.Enrich.WithProcessName()
.Enrich.WithProperty("Application", "DeviceMonitor")
.Enrich.WithExceptionDetails()
// 输出到控制台(开发环境)
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}",
theme: AnsiConsoleTheme.Literate)
// 输出到文件(滚动日志)
.WriteTo.File(
path: "logs/app-.log",
rollingInterval: RollingInterval.Day,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{SourceContext}] [{ThreadId}] {Message:lj}{NewLine}{Exception}",
retainedFileCountLimit: 30,
fileSizeLimitBytes: 100 * 1024 * 1024, // 100MB 单文件上限
rollOnFileSizeLimit: true,
shared: false,
flushToDiskInterval: TimeSpan.FromSeconds(1))
// 输出到错误日志(单独文件)
.WriteTo.File(
path: "logs/error-.log",
rollingInterval: RollingInterval.Day,
restrictedToMinimumLevel: LogEventLevel.Error,
retainedFileCountLimit: 90,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}")
// 输出到 Seq(可选,远程日志收集)
// .WriteTo.Seq("http://localhost:5341", apiKey: "your-api-key")
.CreateLogger();
Logger = Log.Logger;
// 注册到 Microsoft.Extensions.Logging(可选,用于 DI 容器)
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddSerilog());
var serviceProvider = services.BuildServiceProvider();
Log.Information("应用启动,版本={Version}", Assembly.GetEntryAssembly()?.GetName().Version);
// ===== 捕获全局异常 =====
// 1. 非 UI 线程未处理异常
AppDomain.CurrentDomain.UnhandledException += (s, args) =>
{
Log.Fatal(args.ExceptionObject as Exception, "非 UI 线程未处理异常,IsTerminating={IsTerminating}", args.IsTerminating);
};
// 2. UI 线程未处理异常
DispatcherUnhandledException += (s, args) =>
{
Log.Error(args.Exception, "UI 线程未处理异常");
args.Handled = true; // 防止应用崩溃
};
// 3. Task 未观察异常
TaskScheduler.UnobservedTaskException += (s, args) =>
{
Log.Error(args.Exception, "Task 未观察异常");
args.SetObserved(); // 防止进程终止
};
base.OnStartup(e);
}
protected override void OnExit(ExitEventArgs e)
{
Log.Information("应用退出,ExitCode={ExitCode}", e.ApplicationExitCode);
Log.CloseAndFlush(); // 重要:确保所有日志写入磁盘
base.OnExit(e);
}
}使用依赖注入注入 ILogger
// ========== DI 容器中注册 Serilog ==========
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddSerilogLogging(this IServiceCollection services)
{
services.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddSerilog(dispose: true);
});
return services;
}
}
// 在 App.xaml.cs 中使用 DI
public partial class App : Application
{
private ServiceProvider? _serviceProvider;
protected override void OnStartup(StartupEventArgs e)
{
// 初始化 Serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day)
.CreateLogger();
// 配置 DI
var services = new ServiceCollection();
services.AddSerilogLogging();
services.AddTransient<DeviceViewModel>();
services.AddTransient<AlarmViewModel>();
_serviceProvider = services.BuildServiceProvider();
base.OnStartup(e);
}
}ViewModel 中使用日志
基础日志记录
// ========== ViewModel 日志使用 ==========
public class DeviceViewModel : ObservableObject
{
private readonly ILogger<DeviceViewModel> _logger;
private readonly IDeviceService _deviceService;
public DeviceViewModel(ILogger<DeviceViewModel> logger, IDeviceService deviceService)
{
_logger = logger;
_deviceService = deviceService;
}
/// <summary>
/// 使用 BeginScope 为一组操作添加上下文
/// </summary>
public async Task ConnectDeviceAsync(string deviceId)
{
// BeginScope 中的属性会自动附加到该作用域内的所有日志
using (_logger.BeginScope("DeviceId={DeviceId}", deviceId))
{
_logger.LogInformation("开始连接设备");
try
{
var stopwatch = Stopwatch.StartNew();
await _deviceService.ConnectAsync(deviceId);
stopwatch.Stop();
_logger.LogInformation("设备连接成功,耗时={ElapsedMs}ms", stopwatch.ElapsedMilliseconds);
}
catch (TimeoutException ex)
{
_logger.LogWarning(ex, "设备连接超时,已重试");
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "设备连接失败");
throw;
}
}
}
/// <summary>
/// 使用结构化日志记录设备数据
/// </summary>
public void OnDataReceived(string deviceId, double value, string unit)
{
_logger.LogDebug(
"收到设备数据: DeviceId={DeviceId}, Value={Value}, Unit={Unit}",
deviceId, value, unit);
}
}日志消息模板最佳实践
// ========== 日志消息模板规范 ==========
// 正确:使用占位符(属性化日志,支持结构化查询)
_logger.LogInformation("设备 {DeviceId} 连接成功,耗时 {ElapsedMs}ms", deviceId, elapsedMs);
// 错误:使用字符串插值(纯文本日志,无法按属性查询)
_logger.LogInformation($"设备 {deviceId} 连接成功,耗时 {elapsedMs}ms");
// 正确:为日志属性指定语义化名称
_logger.LogInformation(
"订单 {OrderId} 状态变更: {OldStatus} → {NewStatus}",
orderId, oldStatus, newStatus);
// 正确:在占位符中使用格式化
_logger.LogInformation("数值: {Value:F2}", 3.14159); // 输出: 数值: 3.14
// 正确:为不同模块使用不同的 SourceContext
// ILogger<DeviceViewModel> → SourceContext = "MyApp.ViewModels.DeviceViewModel"
// ILogger<AlarmService> → SourceContext = "MyApp.Services.AlarmService"敏感信息过滤
// ========== 敏感信息脱敏 ==========
/// <summary>
/// Serilog Destructuring 策略 — 自动脱敏敏感属性
/// </summary>
public class SensitiveDataDestructuringPolicy : IDestructuringPolicy
{
private static readonly string[] SensitivePropertyNames =
{
"Password", "Token", "ApiKey", "Secret", "ConnectionString",
"CreditCard", "SSN", "PhoneNumber"
};
public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyValueFactory,
out LogEventPropertyValue result)
{
result = null!;
if (value == null) return false;
var type = value.GetType();
var properties = type.GetProperties();
var logProperties = new List<LogEventProperty>();
foreach (var prop in properties)
{
var propValue = prop.GetValue(value);
if (propValue == null) continue;
if (SensitivePropertyNames.Any(s =>
prop.Name.IndexOf(s, StringComparison.OrdinalIgnoreCase) >= 0))
{
// 敏感属性脱敏
logProperties.Add(new LogEventProperty(
prop.Name,
new ScalarValue("***REDACTED***")));
}
else
{
logProperties.Add(new LogEventProperty(
prop.Name,
propertyValueFactory.CreatePropertyValue(propValue)));
}
}
result = new StructureValue(logProperties);
return true;
}
}
// 注册脱敏策略
Log.Logger = new LoggerConfiguration()
.Destructure.With<SensitiveDataDestructuringPolicy>()
// ... 其他配置
.CreateLogger();日志 Enrichment(丰富器)
自定义 Enricher
// ========== 自定义 Enricher ==========
/// <summary>
/// 设备上下文 Enricher — 为每条日志自动附加设备信息
/// </summary>
public class DeviceContextEnricher : ILogEventEnricher
{
private readonly IDeviceContext _deviceContext;
public DeviceContextEnricher(IDeviceContext deviceContext)
{
_deviceContext = deviceContext;
}
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
if (_deviceContext.CurrentDeviceId != null)
{
var property = propertyFactory.CreateProperty(
"CurrentDeviceId", _deviceContext.CurrentDeviceId);
logEvent.AddPropertyIfAbsent(property);
}
}
}
public interface IDeviceContext
{
string? CurrentDeviceId { get; }
}
/// <summary>
/// 用户操作 Enricher — 记录当前操作用户
/// </summary>
public class UserContextEnricher : ILogEventEnricher
{
private readonly IUserSession _userSession;
public UserContextEnricher(IUserSession userSession)
{
_userSession = userSession;
}
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
if (_userSession.CurrentUser != null)
{
logEvent.AddPropertyIfAbsent(
propertyFactory.CreateProperty("UserName", _userSession.CurrentUser));
}
}
}
public interface IUserSession
{
string? CurrentUser { get; }
}
// 注册自定义 Enricher
Log.Logger = new LoggerConfiguration()
.Enrich.With(new DeviceContextEnricher(deviceContext))
.Enrich.With(new UserContextEnricher(userSession))
// ... 其他配置
.CreateLogger();自定义日志属性
// ========== 通过 LogContext 推入自定义属性 ==========
public class DeviceCommunicationService
{
private readonly ILogger<DeviceCommunicationService> _logger;
public async Task PollDeviceAsync(string deviceId, int registerAddress)
{
// 使用 LogContext.PushProperty 为后续日志添加属性
// 这些属性会自动附加到当前作用域内的所有日志
using (LogContext.PushProperty("DeviceId", deviceId))
using (LogContext.PushProperty("RegisterAddress", registerAddress))
{
_logger.LogInformation("开始轮询设备寄存器");
try
{
var value = await ReadRegisterAsync(deviceId, registerAddress);
_logger.LogDebug("读取值: {Value}", value);
}
catch (Exception ex)
{
// 此日志会自动包含 DeviceId 和 RegisterAddress
_logger.LogError(ex, "设备寄存器读取失败");
}
}
}
private Task<int> ReadRegisterAsync(string deviceId, int address)
=> Task.FromResult(42);
}NLog 替代方案
// ========== NLog 配置(替代 Serilog 的选择)==========
// 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" throwExceptions="false">
<targets>
<target name="file" xsi:type="File"
fileName="${basedir}/logs/app-${shortdate}.log"
layout="${longdate} [${level:uppercase=true}] [${logger}] [${threadid}] ${message} ${exception:format=toString}"
maxArchiveFiles="30"
archiveEvery="Day"
archiveFileName="${basedir}/logs/app-{#}.log" />
<target name="errorfile" xsi:type="File"
fileName="${basedir}/logs/error-${shortdate}.log"
layout="${longdate} [${level:uppercase=true}] [${logger}] ${message} ${exception:format=toString}"
maxArchiveFiles="90" />
<target name="console" xsi:type="ColoredConsole"
layout="${time} [${level:uppercase=true:padding=-5}] ${message}" />
</targets>
<rules>
<logger name="*" minlevel="Debug" writeTo="console,file" />
<logger name="*" minlevel="Error" writeTo="errorfile" />
</rules>
</nlog>
*/
// C# 中使用 NLog
public class NLogExample
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
public void DoWork()
{
Logger.Info("使用 NLog 记录日志");
Logger.Error(ex, "发生错误: {Message}", ex.Message);
}
}UI 日志查看器
实时日志收集器
// ========== 实时日志收集器 ==========
/// <summary>
/// 内存日志 Sink — 收集最近的日志条目供 UI 显示
/// </summary>
public class InMemoryLogSink : ILogEventSink, IDisposable
{
private readonly ConcurrentQueue<LogEntry> _entries = new();
private const int MaxEntries = 2000;
private readonly int _maxDisplayEntries;
private readonly object _lock = new();
public event Action? NewLogEntry;
public InMemoryLogSink(int maxDisplayEntries = 500)
{
_maxDisplayEntries = maxDisplayEntries;
}
/// <summary>
/// Serilog 调用此方法写入日志
/// </summary>
public void Emit(LogEvent logEvent)
{
var entry = new LogEntry(
Timestamp: logEvent.Timestamp.LocalDateTime,
Level: logEvent.Level.ToString(),
SourceContext: GetSourceContext(logEvent),
Message: logEvent.RenderMessage(),
Exception: logEvent.Exception?.ToString(),
LevelEnum: logEvent.Level);
_entries.Enqueue(entry);
// 限制内存中保存的条目数
while (_entries.Count > MaxEntries)
_entries.TryDequeue(out _);
// 通知 UI 更新
NewLogEntry?.Invoke();
}
/// <summary>
/// 获取最近的日志条目
/// </summary>
public IEnumerable<LogEntry> GetRecent(int count)
{
lock (_lock)
{
return _entries.TakeLast(count).ToList();
}
}
/// <summary>
/// 按级别过滤
/// </summary>
public IEnumerable<LogEntry> GetFiltered(LogEventLevel? minLevel = null, string? keyword = null)
{
var query = _entries.AsEnumerable();
if (minLevel.HasValue)
query = query.Where(e => e.LevelEnum >= minLevel.Value);
if (!string.IsNullOrEmpty(keyword))
query = query.Where(e =>
e.Message.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
(e.SourceContext?.Contains(keyword, StringComparison.OrdinalIgnoreCase) ?? false));
return query.TakeLast(_maxDisplayEntries);
}
public void Clear() => _entries.Clear();
private static string? GetSourceContext(LogEvent logEvent)
{
return logEvent.Properties.TryGetValue("SourceContext", out var value)
? value?.ToString().Trim('"')
: null;
}
public void Dispose() => Clear();
}
public record LogEntry(
DateTime Timestamp, string Level, string? SourceContext,
string Message, string? Exception, LogEventLevel LevelEnum);注册到 Serilog
// 将 InMemoryLogSink 注册到 Serilog
var memorySink = new InMemoryLogSink();
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Sink(memorySink)
.WriteTo.Console()
.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day)
.CreateLogger();日志查看器 ViewModel
// ========== 日志查看器 ViewModel ==========
public class LogViewerViewModel : ObservableObject, IDisposable
{
private readonly InMemoryLogSink _sink;
private readonly DispatcherTimer _timer;
private LogEventLevel? _minLevel;
private string _filterKeyword = "";
private int _autoScrollPosition;
[ObservableProperty]
private ObservableCollection<LogEntry> _displayedEntries = new();
[ObservableProperty]
private int _totalCount;
[ObservableProperty]
private string _statusText = "就绪";
public LogViewerViewModel(InMemoryLogSink sink)
{
_sink = sink;
_sink.NewLogEntry += OnNewLogEntry;
// 使用 DispatcherTimer 定时轮询(500ms)
_timer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(500)
};
_timer.Tick += OnTimerTick;
_timer.Start();
}
/// <summary>
/// 设置日志级别过滤
/// </summary>
public void SetLevelFilter(LogEventLevel? level)
{
_minLevel = level;
RefreshEntries();
}
/// <summary>
/// 设置关键字过滤
/// </summary>
[ObservableProperty]
private string filterKeyword = "";
partial void OnFilterKeywordChanged(string value)
{
_filterKeyword = value;
RefreshEntries();
}
/// <summary>
/// 清空日志
/// </summary>
[RelayCommand]
private void ClearLogs()
{
_sink.Clear();
DisplayedEntries.Clear();
TotalCount = 0;
}
/// <summary>
/// 导出日志到文件
/// </summary>
[RelayCommand]
private void ExportLogs()
{
var filePath = $"log_export_{DateTime.Now:yyyyMMdd_HHmmss}.txt";
var entries = _sink.GetRecent(5000);
var lines = entries.Select(e =>
$"[{e.Timestamp:HH:mm:ss.fff}] [{e.Level}] [{e.SourceContext}] {e.Message}" +
(e.Exception != null ? $"\n{e.Exception}" : ""));
File.WriteAllLines(filePath, lines);
StatusText = $"已导出 {entries.Count()} 条日志到 {filePath}";
}
private void OnNewLogEntry()
{
// 标记需要刷新(由 Timer 统一处理)
}
private void OnTimerTick(object? sender, EventArgs e)
{
RefreshEntries();
}
private void RefreshEntries()
{
var filtered = _sink.GetFiltered(_minLevel, _filterKeyword).ToList();
TotalCount = filtered.Count;
// 计算差异,只更新新增条目
var existingIds = new HashSet<string>(
DisplayedEntries.Select(e => $"{e.Timestamp}_{e.Message}"));
foreach (var entry in filtered.Where(e => !existingIds.Contains($"{e.Timestamp}_{e.Message}")))
{
DisplayedEntries.Add(entry);
}
// 限制显示条目数
while (DisplayedEntries.Count > 1000)
DisplayedEntries.RemoveAt(0);
StatusText = $"共 {TotalCount} 条日志,显示 {DisplayedEntries.Count} 条";
}
public void Dispose()
{
_timer.Stop();
_sink.NewLogEntry -= OnNewLogEntry;
}
}日志查看器 XAML
<!-- LogViewerControl.xaml -->
<UserControl x:Class="MyApp.Controls.LogViewerControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 工具栏 -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="4">
<ComboBox SelectedIndex="0" Width="100" Margin="2"
SelectionChanged="LevelFilter_Changed">
<ComboBoxItem Content="全部"/>
<ComboBoxItem Content="Debug"/>
<ComboBoxItem Content="Information"/>
<ComboBoxItem Content="Warning"/>
<ComboBoxItem Content="Error"/>
</ComboBox>
<TextBox Text="{Binding FilterKeyword, UpdateSourceTrigger=PropertyChanged}"
Width="200" Margin="2" ToolTip="搜索关键字"/>
<Button Content="清空" Command="{Binding ClearLogsCommand}" Margin="2" Padding="8,2"/>
<Button Content="导出" Command="{Binding ExportLogsCommand}" Margin="2" Padding="8,2"/>
</StackPanel>
<!-- 日志列表 -->
<ListBox Grid.Row="1" ItemsSource="{Binding DisplayedEntries}"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
ScrollViewer.HorizontalScrollBarVisibility="Auto">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock>
<Run Text="{Binding Timestamp, StringFormat='[{0:HH:mm:ss.fff}]'}"
Foreground="Gray"/>
<Run Text="{Binding Level, StringFormat=' [{0}]'}"
Foreground="{Binding Level, Converter={StaticResource LevelColorConverter}}"/>
<Run Text="{Binding Message}"/>
</TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- 状态栏 -->
<TextBlock Grid.Row="2" Text="{Binding StatusText}" Margin="4" FontSize="11" Foreground="Gray"/>
</Grid>
</UserControl>日志性能优化
// ========== 日志性能优化策略 ==========
/// <summary>
/// 高频日志采样器 — 减少高频日志的输出量
/// 适用于设备轮询等高频操作
/// </summary>
public class SamplingLogger
{
private readonly ILogger _logger;
private int _sampleCounter;
private readonly int _sampleRate; // 每隔多少条记录一次
private DateTime _lastLogTime = DateTime.MinValue;
private readonly TimeSpan _minLogInterval;
/// <summary>
/// </summary>
/// <param name="logger">日志记录器</param>
/// <param name="sampleRate">采样率(每 N 条记录一次)</param>
/// <param name="minIntervalMs">最小日志间隔(毫秒)</param>
public SamplingLogger(ILogger logger, int sampleRate = 100, int minIntervalMs = 5000)
{
_logger = logger;
_sampleRate = sampleRate;
_minLogInterval = TimeSpan.FromMilliseconds(minIntervalMs);
}
/// <summary>
/// 采样记录日志
/// </summary>
public void LogInformation(string message, params object?[] args)
{
Interlocked.Increment(ref _sampleCounter);
// 按采样率记录
if (_sampleCounter % _sampleRate == 0)
{
_logger.LogInformation(
$"{message} (采样: 第{{Counter}}条)", [..args, _sampleCounter]);
}
// 按时间间隔记录
var now = DateTime.UtcNow;
if (now - _lastLogTime >= _minLogInterval)
{
_lastLogTime = now;
_logger.LogInformation(
$"{message} (采样: 最近{_minLogInterval.TotalSeconds:F0}秒)", args);
}
}
/// <summary>
/// 重置计数器
/// </summary>
public void Reset() => Interlocked.Exchange(ref _sampleCounter, 0);
}
// 使用示例
public class DevicePollingService
{
private readonly SamplingLogger _samplingLogger;
public DevicePollingService(ILogger<DevicePollingService> logger)
{
// 每秒只记录一条日志(假设轮询频率为 1000ms/次)
_samplingLogger = new SamplingLogger(logger, sampleRate: 1000, minIntervalMs: 1000);
}
public void OnDataReceived(string deviceId, double value)
{
// 高频调用,但实际日志输出被采样控制
_samplingLogger.LogInformation(
"设备 {DeviceId} 数据: {Value}", deviceId, value);
}
}异步日志写入
// ========== 异步日志 Sink — 减少日志 I/O 对应用性能的影响 ==========
/// <summary>
/// 异步日志 Sink — 使用 Channel 缓冲日志,后台线程写入
/// </summary>
public class AsyncFileSink : ILogEventSink, IDisposable
{
private readonly Channel<LogEvent> _channel;
private readonly Task _writeTask;
private readonly ILogEventSink _innerSink;
private readonly CancellationTokenSource _cts = new();
public AsyncFileSink(ILogEventSink innerSink, int boundedCapacity = 10000)
{
_innerSink = innerSink;
_channel = Channel.CreateBounded<LogEvent>(new BoundedChannelOptions(boundedCapacity)
{
FullMode = BoundedChannelFullMode.DropWrite,
SingleWriter = false,
SingleReader = true
});
_writeTask = Task.Run(ProcessChannelAsync);
}
public void Emit(LogEvent logEvent)
{
// 非阻塞写入 Channel
if (!_channel.Writer.TryWrite(logEvent))
{
// Channel 已满,丢弃日志(或可记录到备用 Sink)
}
}
private async Task ProcessChannelAsync()
{
var reader = _channel.Reader;
try
{
await foreach (var logEvent in reader.ReadAllAsync(_cts.Token))
{
_innerSink.Emit(logEvent);
}
}
catch (OperationCanceledException) { }
}
public void Dispose()
{
_cts.Cancel();
_channel.Writer.TryComplete();
try { _writeTask.Wait(TimeSpan.FromSeconds(5)); } catch { }
_cts.Dispose();
}
}优点
缺点
总结
WPF 日志集成推荐使用 Serilog 实现结构化日志,通过 Enrich 添加上下文信息。在 App.xaml.cs 中初始化日志并捕获全局异常(AppDomain、Dispatcher、TaskScheduler)。建议为不同模块使用不同的 SourceContext,方便日志过滤和检索。高频场景使用采样或异步写入优化性能。
关键知识点
- Serilog 的 Enrich 机制为每条日志自动附加上下文属性。
- 三种全局异常捕获确保应用不会因为未处理异常静默崩溃。
- 结构化日志使用占位符({DeviceId})而非字符串插值($"{deviceId}")。
- 日志级别:Verbose → Debug → Information → Warning → Error → Fatal。
- Log.CloseAndFlush() 必须在应用退出时调用,确保所有日志写入磁盘。
- Microsoft.Extensions.Logging.ILogger 是 .NET 标准日志抽象,推荐在 DI 中使用。
项目落地视角
- 按日期滚动日志文件,限制保留天数(建议 30 天)和单文件大小(建议 100MB)。
- 生产环境设置最低级别为 Information,Debug/Trace 级别仅在开发环境启用。
- 使用 BeginScope 为一组操作添加关联上下文(如设备 ID、操作 ID)。
- 实现敏感信息脱敏策略,防止密码和 Token 被记录到日志。
- 为日志查看器提供级别过滤和关键字搜索功能。
常见误区
- 使用字符串插值($"")而非占位符({})记录日志,失去结构化查询能力。
- 在循环中高频记录日志影响性能,应使用采样器。
- 日志中包含密码、Token 等敏感信息,应使用脱敏策略。
- 忘记调用 Log.CloseAndFlush() 导致应用退出时丢失日志。
- 日志文件路径权限不足导致写入失败。
进阶路线
- 学习 ELK(Elasticsearch + Logstash + Kibana)日志分析平台。
- 实现 OpenTelemetry 集成,实现分布式追踪和指标收集。
- 研究日志采样和聚合策略,减少高频日志量。
- 集成 Seq 实现结构化日志的实时搜索和分析。
适用场景
- 需要记录设备通信、用户操作和异常信息。
- 需要在 UI 中显示实时运行日志。
- 需要远程收集多台终端的运行日志。
- 需要审计用户操作(如配置修改、设备控制)。
落地建议
- 封装 ILogger 为项目通用服务,通过 DI 注入。
- 定义日志消息模板规范,统一日志格式和占位符命名。
- 为关键操作添加 BeginScope 关联上下文。
- 实现日志导出功能,方便用户发送日志给技术支持。
排错清单
- 检查日志文件是否正确写入(路径权限、磁盘空间)。
- 确认日志级别配置是否正确过滤。
- 检查 Log.CloseAndFlush 是否在应用退出时调用。
- 验证结构化日志的占位符是否正确(不是字符串插值)。
- 检查异步日志 Sink 是否丢弃了日志(Channel 满)。
复盘问题
- 如何在不影响性能的前提下记录高频设备数据?
- 日志量增长到什么程度需要考虑集中式日志平台?
- 如何确保日志中的敏感信息被脱敏处理?
- 应用崩溃时如何确保最后的日志已经被写入磁盘?
