news 2026/10/26 2:04:25

C# WinForms 日志输出例程:分级、异步落盘与全局异常兜底

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C# WinForms 日志输出例程:分级、异步落盘与全局异常兜底

简介:面向C# WinForm初学者与需要快速接入日志模块的开发者,这套日志输出例程围绕系统事件与操作记录展开,演示程序启动、关闭及日常操作如何同时呈现在窗口并落盘保存,适合学习日志记录思路或直接嵌入小型桌面项目。压缩包共36个文件、约745KB,包含9个cs源码、5个txt说明、3个exe可执行文件、3个config配置及sln、csproj、resx等工程文件,结构完整便于对照编译与运行。已有2123人学习,说明其上手门槛较低、参考价值较直观。读者可获得可直接运行的示例工程、按日期生成且重复打开不覆盖的日志文件记录方式,以及窗口实时输出与后台文件写入并行的实现参考,便于快速理解并复用到自己的WinForm应用中。

1. 从「点了没反应」说起:这套 C# WinForms 日志例程解决什么

去年接手一个 WinForms 客户端,界面上三十多个按钮,用户报障只有一句“点了保存没反应”。没有系统日志,也没有操作日志,只能顺着代码一行行猜,三个小时才定位到一个被 try-catch 吞掉的异步异常。桌面端项目里,日志总被排到最后一刻,等真出事才发现根本没有后悔药。

这份 C# WinForms 日志输出例程源码,就是把这件事一次做完整的模板:按级别输出、系统日志与操作日志分文件落地、界面里实时滚动显示、全局异常兜住未捕获的崩溃,四件事都在。它适合两类人:一类是刚接手桌面项目、还没建立日志习惯的开发者;另一类是项目里已经有日志,但只是往控制台 print 一句、发版后什么都看不到的人。

2. 结构与链路:日志例程的工程分层和四条输出通道

拿到一份例程源码,先别急着改代码,先看清它分了几个工程、日志从调用到落地经过哪几站。这决定了你后面加一个数据库输出是改一处还是改十处。

2.1 三个工程的职责边界

我按这个结构把例程整理过一遍,主程序、日志库、调用演示分开,日志库不引用任何 WinForms 程序集,才能被控制台工具或服务端复用。

LogDemo/ ├─ LogDemo.sln ├─ LogDemo/ 客户端主工程(WinForms) │ ├─ Program.cs 入口,全局异常注册 │ ├─ Forms/MainForm.cs 主窗体,日志面板与操作日志示例 │ ├─ Forms/MainForm.Designer.cs │ └─ App.config MinLevel、目录、保留天数 ├─ LogDemo.Logging/ 日志核心类库(纯 .NET,无 UI 依赖) │ ├─ LogLevel.cs 级别枚举 │ ├─ LogCategory.cs System / Operation │ ├─ LogEntity.cs 日志实体与格式化 │ ├─ LogHelper.cs 静态入口 + 内存队列 + 后台线程 │ ├─ LogWriter.cs 落盘、滚动、过期清理 │ └─ LogConfig.cs 配置读取与默认值 └─ LogDemo.Demo/ 控制台示例,演示四种调用姿势

这个拆法有个直接好处:LogDemo.Logging单独编译出来就是一个 dll,你把它丢进任何 .NET 项目里,改一下App.config就能用,不需要拖控件、不需要引 WinForms。很多人写日志组件喜欢直接写进主工程,第一次复用就得复制粘贴一遍,粘完两份代码各自演化,后面全是血泪经验。

LogConfig.cs负责读取配置,缺省值必须能跑起来,配置项缺失不该让程序起不来。常见做法是在静态构造函数里读ConfigurationManager.AppSettings,读不到就走内置默认值,并且把最终生效的配置本身也写一条 Info 日志——排查问题时第一个要看的就是“当时到底用的什么配置”。

2.2 一条日志从调用到落地的四个环节

链路是这样的:调用方LogHelper.Write(...)→ 级别过滤并组装LogEntity→ 入ConcurrentQueue并唤醒后台线程 → 后台线程批量出队 →LogWriter按分类分文件写入,同时通过事件把条目推给界面。

中间加队列这一层,是整个例程最关键的设计。如果直接在业务线程里File.AppendAllText,一次写盘按 1~5ms 算,界面上连点十几下、每次记三四条日志,卡顿就出来了;万一碰上磁盘忙或者杀毒软件扫描,几十毫秒的阻塞直接让按钮“点下去没反应”——这恰恰是日志组件自己制造的故障。入队是内存操作,纳秒级,写盘丢给单独的后台线程,业务线程几乎零成本。

事件推送那条通道要单独说:LogHelper只负责OnLogReceived?.Invoke(entity),它不知道也不关心订阅方是 RichTextBox、是状态栏还是托盘气泡。UI 侧自己负责切线程,这条边界划清楚了,日志库才能在控制台程序里用。

2.3 配置项与默认值

配置项不多,但每一个都对应一个真实场景,默认值我建议照下面这样定,能覆盖 90% 的桌面项目。

配置键默认值作用与调优点
Log:MinLevelInfo低于此级别直接丢弃,Release 环境可提到Warn
Log:Directory%LOCALAPPDATA%\LogDemo\Logs别写 exe 同目录,Program Files 下不可写
Log:FilePrefixapp文件名前缀,多实例部署时区分
Log:MaxFileSizeKB5120单文件超限加序号,避免几十兆的日志打不开
Log:RetainDays7启动时清理过期文件,防止硬盘被日志吃满
Log:FlushIntervalMs500后台线程最长等待时间,也是崩溃时最多丢的日志窗口
Log:EnableUiPushtrue关掉后不再触发OnLogReceived,压测时有用

FlushIntervalMs这个值要理解清楚:它不是“延迟 500ms 才写”,而是“队列空了最多等 500ms”,有日志进来AutoResetEvent.Set()立刻唤醒。它真正的含义是崩溃丢日志的上限——进程被强杀时,队列里还没落盘的那批就没了。要求日志零丢失的场景(比如涉及资金流水),就得改成同步写或者每写一条就 flush 一次,用性能换可靠。

3. 核心写盘链路:从 LogHelper 到文件落地的逐段拆解

这一章把核心类逐个拆开。代码不长,但每一处都有“为什么这么写”的理由,抄的时候最好连注释一起抄。

3.1 日志实体与级别枚举

先定义数据形状。级别用枚举而不是字符串,比较和过滤都快,也避免手抖写成"Erorr"导致过滤失效。

namespace LogDemo.Logging { /// <summary>日志级别,数值越大越严重,过滤时按数值比较</summary> public enum LogLevel { Debug = 0, Info = 1, Warn = 2, Error = 3, Fatal = 4 } /// <summary>日志分类:系统日志记异常与运行状态,操作日志记用户行为</summary> public enum LogCategory { System = 0, Operation = 1 } }

LogEntity承载一条日志的全部信息,重点是Caller和Exception两个字段。很多人只记ex.Message,结果日志里全是“对象引用未设置为对象的实例”,没有堆栈等于没记。

using System.Text; public class LogEntity { public DateTime Time { get; set; } public LogLevel Level { get; set; } public LogCategory Category { get; set; } public int ThreadId { get; set; } public string Caller { get; set; } // 类名.方法名,定位到具体代码位置 public string Message { get; set; } public Exception Exception { get; set; } /// <summary>格式化为单行文本,异常堆栈整段追加在后面</summary> public string ToLine() { var sb = new StringBuilder(256); sb.Append(Time.ToString("yyyy-MM-dd HH:mm:ss.fff")).Append(" | ") .Append(Level.ToString().PadRight(5)).Append(" | ") .Append("T").Append(ThreadId.ToString().PadLeft(3)).Append(" | ") .Append(Caller).Append(" | ") .Append(Message); if (Exception != null) sb.AppendLine().Append(Exception); // ToString() 自带堆栈和 InnerException return sb.ToString(); } }

时间格式固定到毫秒fff,因为桌面端很多问题是“两次点击间隔太短”引起的并发冲突,秒级精度看不出先后。线程号ThreadId在排查跨线程访问控件、死锁时是唯一线索,别省这两个字符。

3.2 单例入口与线程安全队列

LogHelper是唯一对外入口,用静态类实现,省掉实例传递的麻烦。内部靠ConcurrentQueue+AutoResetEvent做生产者消费者。

using System.Collections.Concurrent; using System.Runtime.CompilerServices; using System.Threading; public static class LogHelper { private static readonly ConcurrentQueue<LogEntity> _queue = new ConcurrentQueue<LogEntity>(); private static readonly AutoResetEvent _signal = new AutoResetEvent(false); private static readonly Thread _worker; private static volatile bool _running = true; private static readonly LogConfig _config; /// <summary>界面订阅此事件即可拿到实时日志,注意自行切回 UI 线程</summary> public static event Action<LogEntity> OnLogReceived; static LogHelper() { _config = LogConfig.Load(); _worker = new Thread(ConsumeLoop) { IsBackground = true, // 后台线程,不阻止进程退出 Name = "LogWorker", Priority = ThreadPriority.BelowNormal }; _worker.Start(); } public static void Write(LogLevel level, LogCategory category, string message, Exception ex = null, [CallerMemberName] string member = null, [CallerFilePath] string file = null) { if (level < _config.MinLevel) return; // 先过滤再入队,Debug 全开也不至于撑爆队列 var entity = new LogEntity { Time = DateTime.Now, Level = level, Category = category, ThreadId = Thread.CurrentThread.ManagedThreadId, Caller = $"{System.IO.Path.GetFileNameWithoutExtension(file)}.{member}", Message = message, Exception = ex }; _queue.Enqueue(entity); _signal.Set(); // 唤醒后台线程,别等超时 try { OnLogReceived?.Invoke(entity); } // 订阅方异常不能影响写盘 catch { /* 忽略 */ } } }

[CallerMemberName]和[CallerFilePath]这两个特性是 C# 5 之后才有的,编译期自动填参,运行时零开销,比反射取调用栈快几个数量级,也比手工传"MainForm.btnSave_Click"靠谱得多。注意Caller字段只用文件名不带完整路径,避免把开发机的目录结构写进用户日志里。

_running加volatile是为了让后台线程能及时看到停止信号,退出时调用Shutdown()把_running置 false、_signal.Set()唤醒线程、Join(2000)等它把剩余队列刷完,再让进程退出——少了这一步,关闭程序瞬间的日志必丢。

3.3 后台消费线程与批量出队

后台线程的核心是“等信号 + 批量取 + 一次写”。批量是关键,100 条日志一次写盘和 100 次写盘,磁盘 IO 次数差两个数量级。

private static void ConsumeLoop() { var batch = new List<LogEntity>(256); while (_running) { // 有信号立刻返回;没信号最多等 FlushIntervalMs,兜底刷一次 _signal.WaitOne(_config.FlushIntervalMs); while (batch.Count < 256 && _queue.TryDequeue(out var item)) batch.Add(item); if (batch.Count == 0) continue; try { LogWriter.Write(batch, _config); } catch { // 日志组件自身绝不向外抛异常,否则会引发二次崩溃和无限递归 } finally { batch.Clear(); // Clear 保留容量,避免反复扩容 } } // 退出前把队列排干 while (_queue.TryDequeue(out var last)) LogWriter.Write(new List<LogEntity> { last }, _config); }

batch容量给 256,List扩容一次是翻倍拷贝,日志量大时这点开销也不该浪费,用Clear()而不是new List,容量能一直复用。单批上限 256 是防止积压瞬间一次性拉太多导致内存尖峰,队列里剩的下一轮再取。

catch里什么都不做看着不优雅,但这是有意为之:日志组件是所有业务的底座,它自己抛异常会把原本正常的流程打断,甚至出现“处理异常 → 写日志失败 → 抛新异常 → 又去写日志”的死循环。至少要在LogWriter里维护一个“已失败”标志,同一类写入错误只尝试一次并降级到备用通道,而不是每条日志都重试一遍。

3.4 文件滚动、命名与过期清理

落盘部分的策略是“短连接 + 按分类分文件 + 按天和大小双重滚动”。

using System.IO; using System.Text; internal static class LogWriter { private static readonly object _fileLock = new object(); public static void Write(List<LogEntity> batch, LogConfig cfg) { lock (_fileLock) // 单后台线程其实不需要,留给将来多线程写多目标的情况 { foreach (var group in batch.GroupBy(x => x.Category)) { string path = BuildPath(group.Key, cfg); string text = string.Join(Environment.NewLine, group.Select(x => x.ToLine())) + Environment.NewLine; // 用 AppendAllText 短连接:写完立刻释放句柄,用户能双击打开日志文件查看 File.AppendAllText(path, text, new UTF8Encoding(true)); } } CleanExpired(cfg); } private static string BuildPath(LogCategory cat, LogConfig cfg) { string tag = cat == LogCategory.System ? "sys" : "op"; string baseName = $"{cfg.FilePrefix}_{tag}_{DateTime.Now:yyyyMMdd}"; string path = Path.Combine(cfg.Directory, baseName + ".log"); // 单文件超过阈值就加序号滚动,避免记事本打开几十兆文件直接卡死 if (File.Exists(path) && new FileInfo(path).Length > cfg.MaxFileSizeKB * 1024L) { int index = 1; do { path = Path.Combine(cfg.Directory, $"{baseName}_{index}.log"); index++; } while (File.Exists(path) && new FileInfo(path).Length > cfg.MaxFileSizeKB * 1024L); } return path; } private static void CleanExpired(LogConfig cfg) { foreach (var file in Directory.GetFiles(cfg.Directory, "*.log")) { try { if (File.GetLastWriteTime(file) < DateTime.Now.AddDays(-cfg.RetainDays)) File.Delete(file); } catch { /* 文件被占用就跳过,下次启动再删 */ } } } }

两个参数值得说清楚。UTF8Encoding(true)里的true是“写 BOM”,目的是让 Excel 和部分老编辑器能正确识别编码,中文不乱码;如果你要在 Linux 上处理这些日志,用无 BOM 的 UTF-8 更省事,这个取舍按部署环境定。MaxFileSizeKB给 5MB,是因为 WinForms 客户端日志量通常在每天几 MB 级别,5MB 一天滚两三个文件,用记事本、Notepad-- 打开都不费劲。

CleanExpired每批都调一次看着浪费,实际Directory.GetFiles在几百个文件量级下开销可以忽略;真要抠性能,就在LogConfig里记一个_lastCleanDate,一天只清一次。删除失败必须 catch 掉——用户正开着昨天的日志文件在找问题呢,你删不掉不能因此把今天的日志也写失败。

4. 界面联动与操作日志:把用户行为单独记一笔

日志库写好了,剩下两件事:让它在界面上滚动起来,以及让“用户做了什么”和“程序出了什么错”分开记录。

4.1 跨线程刷新 RichTextBox

OnLogReceived是在后台线程或业务线程触发的,直接碰控件必然抛“线程间操作无效”。标准做法是BeginInvoke切回 UI 线程,但有两个细节决定了界面会不会越跑越卡。

private const int MaxUiChars = 200_000; private void BindLogger() { LogHelper.OnLogReceived += AppendToUi; FormClosing += (s, e) => LogHelper.OnLogReceived -= AppendToUi; // 必须解绑 } private void AppendToUi(LogEntity log) { if (IsDisposed || !IsHandleCreated) return; if (InvokeRequired) { // BeginInvoke 异步投递,不阻塞业务线程;用 Invoke 会在日志高峰期把线程拖死 BeginInvoke(new Action<LogEntity>(AppendToUi), log); return; } if (rtbLog.TextLength > MaxUiChars) rtbLog.Clear(); // 界面缓存必须有上限,否则内存一路涨 rtbLog.AppendText(log.ToLine() + Environment.NewLine); rtbLog.SelectionStart = rtbLog.TextLength; rtbLog.ScrollToCaret(); }

BeginInvoke和Invoke的区别在这里是致命的。Invoke会阻塞调用线程直到 UI 线程处理完,一条日志几毫秒,两百条就是几百毫秒,业务线程全堵在日志上,队列越积越长,最后 UI 消息泵也被塞满——这就是典型的“加了日志反而更卡”。

MaxUiChars给 20 万字符,是因为RichTextBox的渲染开销随文本长度线性上升,超过这个量滚动就开始拖影。清空后用户可能想翻上面的内容,所以正式项目里更常见的做法是只保留最近 N 条到内存集合、界面用虚拟模式或者干脆只显示 Error 及以上级别,详细内容让用户去日志文件里看。

FormClosing里解绑事件是血泪教训。不解绑的话,窗体关了但静态事件还持有窗体的委托引用,AppendToUi继续对新日志触发,IsDisposed判断挡得住,但内存里那个窗体对象永远回收不掉,多开几次窗口就露馅了。

4.2 全局异常的三道闸

桌面程序最怕的是闪退并且什么都没留下。.NET 里有三个入口能兜住未处理异常,缺一不可。

[STAThread] static void Main() { // 必须在创建任何窗口之前调用,否则不生效 Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); // 闸一:UI 线程上的未处理异常 Application.ThreadException += (s, e) => { LogHelper.Write(LogLevel.Fatal, LogCategory.System, "UI线程未处理异常", e.Exception); MessageBox.Show("程序遇到问题,详细信息已记录到日志目录。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning); }; // 闸二:非 UI 线程(含线程池)上的未处理异常,这个兜住后进程通常已无法继续 AppDomain.CurrentDomain.UnhandledException += (s, e) => LogHelper.Write(LogLevel.Fatal, LogCategory.System, "后台线程未处理异常", e.ExceptionObject as Exception); // 闸三:Task 里被吞掉的异常,只有 GC 回收时才触发 TaskScheduler.UnobservedTaskException += (s, e) => { LogHelper.Write(LogLevel.Error, LogCategory.System, "Task异常未被观察", e.Exception); e.SetObserved(); }; Application.EnableVisualStyles(); Application.Run(new MainForm()); }

三个闸的分工不一样:Application.ThreadException兜住按钮事件里没 catch 的异常,进程还能继续跑,适合弹个提示让用户继续操作;AppDomain.UnhandledException兜住后台线程,这时候进程基本到头了,只能保证日志写出去;TaskScheduler.UnobservedTaskException最阴,async void或者忘了await的 Task 抛异常,直到 GC 回收 Task 对象才会触发,时间点完全不可控,不订阅的话日志里一条都没有。

SetUnhandledExceptionMode那一行放在最前面不是摆设,它必须在第一个窗口句柄创建之前执行,写在Application.Run之后的任何位置都无效,而这个坑在代码上看不出来,只能靠崩溃时日志为空来发现。

4.3 操作日志只记“做了什么”,系统日志才记堆栈

两类日志的内容规范要定死,否则日志文件会变成一锅粥。

private void btnSave_Click(object sender, EventArgs e) { var sw = Stopwatch.StartNew(); string no = _current.No; try { _service.Save(_current); // 操作日志:谁、对什么、做了什么、结果、耗时 LogHelper.Write(LogLevel.Info, LogCategory.Operation, $"保存单据成功 No={no} 耗时={sw.ElapsedMilliseconds}ms"); } catch (Exception ex) { // 系统日志:完整堆栈,供开发排查 LogHelper.Write(LogLevel.Error, LogCategory.System, $"保存单据异常 No={no}", ex); // 操作日志:只记业务结果,不带堆栈,供业务方和客服查询 LogHelper.Write(LogLevel.Error, LogCategory.Operation, $"保存单据失败 No={no} 原因={ex.Message}"); MessageBox.Show("保存失败,请联系管理员并提供单据号:" + no); } }

关键规范有三条。第一,操作日志里带业务主键(单据号、订单号、客户编号),客服拿到用户报障能直接grep到对应记录,不用问“你大概几点点的”。第二,操作日志不带堆栈,它面向的是业务人员,堆栈对他们毫无意义,还会把文件撑大三四倍。第三,密码、身份证、银行卡这类字段必须脱敏后再进日志,$"登录成功 密码={pwd}"这种写法一旦进了生产环境,日志文件本身就变成了安全事故。

耗时字段ElapsedMilliseconds也建议常驻。用户抱怨“软件慢”的时候,日志里能直接看出是数据库慢、接口慢还是界面渲染慢,比开性能分析器快得多。

5. 避坑与排查:这套例程最容易翻车的五个地方

下面五条都是我在实际项目里踩过或者见别人踩过的,每条按“现象 → 原因 → 解决”写。跑之前先看一遍,能省下大半天。

5.1 写入链路上的三个坑

坑一:日志文件写着写着报“文件正被另一进程使用”。

现象是程序运行中偶尔弹出写入失败的异常框,或者某个时间段之后的日志全断了。原因通常是两件事撞在一起:一是你在LogWriter里长期持有FileStream没释放,二是用户或者运维用记事本、Excel、日志查看器打开着这个文件,句柄被占。解决方法是改成File.AppendAllText短连接,写完即释放;如果非要保持长连接,那就必须用new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite),显式允许别的进程读。另外LogWriter.Write外层一定要有 try-catch,写失败降级到备用文件名(比如加_fallback后缀),别让一条写不进去的日志把整个程序拖崩。

坑二:日志组件自己抛出异常,程序在处理错误的过程中二次崩溃。

现象很典型:原本只是一个空引用异常,结果弹出来的是IOException或者UnauthorizedAccessException,堆栈指向日志代码。原因在于日志目录不可写(部署到Program Files下)、磁盘满了、或者路径里有非法字符,而Write方法里没有任何防护。解决办法是两道:一是所有对外方法内部 try-catch 全吞,日志组件不允许抛异常;二是启动时做一次目录可写探测,探测失败就把目录回退到%LOCALAPPDATA%并把这件事本身记进 Windows 事件日志——虽然绕了一圈,但至少你知道日志为什么是空的。

坑三:开了 Debug 级别跑一天,磁盘直接告警。

现象是某个客户现场硬盘满了,一看日志目录几十个 G。原因很常见:开发阶段为了看清流程把MinLevel调成Debug,发布时忘了改回来,而某个高频操作(比如鼠标移动、定时刷新、每帧一次的状态同步)每次都在写 Debug。解决办法有三层:配置默认值给Info,发布包用单独的App.Release.config做转换;高频循环里的日志要么删掉,要么加采样(每 100 次记一条);RetainDays加过期清理兜底,别指望人记得删。

5.2 界面与部署上的两个坑

坑四:界面开了几小时越来越卡,内存涨到几百兆。

现象是日志面板滚动越来越慢,任务管理器里内存曲线一路上扬。原因基本是两个叠加:RichTextBox无限AppendText没有上限,以及每次追加都触发一次ScrollToCaret引起重绘。如果把Invoke写成同步的,还会额外叠一层死锁风险。解决办法是三件事一起做:文本长度超过阈值就清空或者只保留尾部;日志高峰期不要每条都刷新界面,用一个System.Windows.Forms.Timer(间隔 200ms)从内存缓冲里批量拉一次,一次AppendText追加多行;FormClosing里解绑OnLogReceived,避免静态事件持有已销毁窗体。

坑五:本地一切正常,装到客户机器上一条日志都没有。

这个最折磨人。现象是开发机跑得好好的,装到现场双击运行,日志目录空的,出了事什么线索都没有。原因有两类:一是路径问题,exe 装在C:\Program Files\下,普通用户权限写不进去,File.AppendAllText抛异常被 catch 吞掉,表面看就是“没日志”;二是相对路径问题,用Application.StartupPath拼出来的目录在快捷方式启动、计划任务启动时工作目录可能不同,文件写到了别的地方。解决办法是把日志目录固定成绝对路径,优先Environment.GetFolderPath(SpecialFolder.LocalApplicationData),其次CommonApplicationData;启动时把最终生效的日志目录用MessageBox或者关于窗口显示出来,用户报障时让他截个图,比远程连过去猜快得多。

6. 二次开发改造:把例程变成一个能上生产的日志组件

例程能跑通只是起点。真正要往自己的项目里搬,最值得做的改造是把输出目标抽象出来,代码量和收益的性价比最高。

第一步,抽一个ILogTarget接口,把LogWriter里写文件那段挪进FileTarget。接口只需要一个方法:

public interface ILogTarget { string Name { get; } void Write(IReadOnlyList<LogEntity> batch); }

然后LogHelper里维护一个List<ILogTarget> _targets,后台线程遍历调用,每个 target 自己 try-catch。这样加数据库输出只要写一个DbTarget(攒够 50 条或超过 3 秒批量 insert,别一条一条 insert),加实时上报就写一个HttpTarget(失败要退避,不能因为服务端挂了把客户端拖死),加控制台输出就是几行Console.WriteLine。改造前后差别很明显:

维度例程原样抽象出 ILogTarget 之后
新增输出目标改LogWriter,动核心代码新增一个类,注册一行
某个目标写失败可能影响文件写入只影响自己,其他照常
单元测试只能测最终文件内容用假 target 断言收到了哪些日志
多实例部署容易写同一个文件各 target 独立控制开关

第二步,给日志加上下文。真实排查时经常需要“把这一次操作的日志串起来”,做法是用AsyncLocal<string>存一个TraceId,在按钮点击或者消息入口处生成一个,LogEntity里加字段,ToLine()里带上。这样一次导入操作产生的几十条日志在文件里能一眼筛出来,比按时间戳猜靠谱得多。

第三步,结构化输出。文本格式给人看很舒服,但要做日志采集就得是 JSON。FileTarget里加个开关,ToLine()换成序列化,每行一个 JSON 对象(JSON Lines),后面接采集工具时不用再写正则去解析。注意Exception字段要单独放一个属性存ToString()的结果,直接序列化Exception对象会出循环引用。

验证改造是否有效,我一般跑三个用例。第一个是压测:循环写 10 万条 Info,看总耗时和进程内存,正常情况下入队应该在几百毫秒内完成,内存增长不超过几十兆,如果内存持续上涨说明队列消费跟不上或者批处理有泄漏。第二个是故障注入:把日志目录设成只读,跑一遍,确认程序不崩、有降级提示、其他功能正常。第三个是多线程并发:起 20 个线程各写 5000 条,跑完用grep -c数行数,应该正好 10 万条,少一条就说明队列或者退出逻辑有问题。

还有一个技巧值得单独提:给LogHelper加一个Flush()方法,等队列排空后返回。所有“程序即将退出”的路径——FormClosing、托盘退出菜单、AppDomain.ProcessExit——都要调一次再退。我见过太多项目,最后一条日志恰好是崩溃前那条,但因为还在队列里没落盘,日志文件停在几秒之前,等于白记。

从那以后我每次接桌面项目,第一件事就是先把日志目录探测、全局异常三道闸和退出时 Flush 这三件事固定下来,再开始写业务代码。看起来多花了半天,实际省下的是无数个“用户说有问题但复现不了”的深夜。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 2:04:23

FUSB302驱动开发指南:从架构原理到调试与二次开发

简介&#xff1a;面向嵌入式Linux开发者的USB Type-C控制芯片FUSB302驱动源码&#xff0c;聚焦RK平台内核中正反插切换能力缺失的实际问题。原厂驱动未定义正反插切换管脚&#xff0c;导致正插USB3.0设备正常、反插仅能识别USB2.0设备&#xff0c;本驱动通过新增fusb340-switch…

作者头像 李华
网站建设 2026/10/26 2:03:16

在 Windows 输入框上悬浮显示中英输入状态指示器

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 2:02:38

计算机组成原理3.1~3.3学习笔记

3.1 存储系统概述3.1.1 存储系统的层次结构计算机存储系统采用分级存储结构&#xff0c;核心目标&#xff1a;平衡速度、容量、成本三者的矛盾。速度&#xff1a;Cache > 主存 > 辅存容量&#xff1a;Cache < 主存 < 辅存单位价格&#xff1a;Cache最贵&#xff0c…

作者头像 李华
网站建设 2026/10/26 2:01:47

基于DEAP与SEED-IV的EEG情绪检测:从预处理到通道级SVM分类全流程

简介&#xff1a;该资源是一篇面向情感计算与脑机接口方向学习者的研究论文&#xff0c;聚焦基于EEG信号的情绪检测&#xff0c;适合做毕业设计、课程研究或入门脑电信号处理的学生参考。论文以DEAP和SEED-IV两个公开数据库为数据来源&#xff0c;采用离散小波变换将预处理信号…

作者头像 李华