简介:这份C# Winform日志输出例程源码面向刚接触桌面开发的初学者与需要快速集成日志功能的开发者,解决程序运行过程中系统事件与用户操作难以留痕、排查不便的问题。资源包共36个文件,约745KB,以9个cs源码文件为核心,配合csproj、sln工程文件、config配置、resx与resources资源文件及txt说明,结构完整,可直接在Visual Studio中打开编译运行。例程同时实现窗口实时显示与后台文件记录,覆盖程序启动、关闭等系统事件以及各类操作行为,并按日期每天生成独立日志文件,反复打开程序也不会覆盖历史记录,便于长期追溯。目前已有2122人学习下载,适合作为日志模块的入门参考或现成代码直接复用,帮助读者快速理解日志分级、写入与持久化的基本思路,并在此基础上扩展自己的业务记录需求。
1. 从一次线上事故说起:为什么我劝你把日志当基础设施来搭
去年帮一个朋友排查他们公司内部工具的问题,一个 Winform 写的设备管理客户端,现场反馈“偶尔卡死,重启就好”。我远程连上去,翻遍整个项目目录,只在catch里找到一句MessageBox.Show(ex.Message),用户点掉弹窗,现场就没了。没有文件日志、没有时间戳、没有调用栈,连是哪个按钮触发的都不知道。最后靠猜和复现,折腾了三天才定位到一个串口超时没做异步处理。那次之后我给自己定了个规矩:任何 Winform 项目,日志模块必须在写第一个业务窗体之前就搭好。
这份 C# Winform 日志输出例程源码,解决的就是这件事。它把日志从“随手写个Console.WriteLine”升级成一套可落地的输出机制——分级、落盘、按天切分、线程安全、UI 不卡。适合两类人:一是刚接触 Winform、还在用弹窗做错误提示的新手,照着改就能用;二是手上有老项目、日志写得乱七八糟、想统一收口的老手,可以拿它当重构模板。系统日志和操作日志这两条线,它都给了可抄的写法。
2. 日志分级与输出目标:先把“记什么、记到哪”定死
2.1 为什么不能只用 Debug.WriteLine
新手最容易犯的错,是把Debug.WriteLine当日志用。这东西只在 Debug 编译下有效,Release 发布出去直接消失;而且它没有级别、没有时间、没有落盘,出问题的时候你手里什么都没有。另一个极端是到处MessageBox.Show,用户被弹窗轰炸,你还拿不到任何可回溯的信息。
合格的日志至少要满足四个条件:有级别(Debug/Info/Warn/Error/Fatal)、有时间戳(精确到毫秒)、有落盘(文件或数据库)、有上下文(哪个类、哪个方法、什么参数)。这份例程的核心就是围绕这四点设计的。它把日志分成系统日志和操作日志两类:系统日志记录程序自身的运行状态(启动、关闭、异常、配置加载),操作日志记录用户干了什么(点了哪个按钮、改了什么参数、导出了什么文件)。分开记的好处是排查时能快速区分“程序自己出问题”还是“用户操作触发问题”。
2.2 输出目标的选型:文件、控制台还是数据库
例程默认走文件输出,这也是 Winform 场景下最稳的选择。控制台在 Winform 里基本没用(没有控制台窗口),数据库写入虽然方便查询,但一旦数据库连接出问题,日志本身就成了故障源,容易形成“日志写不进去导致程序崩溃”的死循环。
文件输出的关键参数有三个:路径、命名规则、保留天数。例程里路径默认放在AppDomain.CurrentDomain.BaseDirectory下的Logs文件夹,命名按yyyy-MM-dd切分,保留最近 30 天。这三个参数我建议都做成可配置项,不要硬编码。下面是一个典型的配置结构:
// LogConfig.cs —— 日志配置类,所有参数集中管理 public class LogConfig { // 日志根目录,默认在程序运行目录下的 Logs 文件夹 public string LogDirectory { get; set; } = Path.Combine( AppDomain.CurrentDomain.BaseDirectory, "Logs"); // 文件命名格式,按天切分 public string FileNameFormat { get; set; } = "yyyy-MM-dd"; // 日志保留天数,超过自动清理 public int RetentionDays { get; set; } = 30; // 最低输出级别,低于此级别的不写入 public LogLevel MinLevel { get; set; } = LogLevel.Info; // 单文件大小上限(MB),超过则滚动 public int MaxFileSizeMB { get; set; } = 10; }这段代码的逻辑很直白:把散落在各处的"Logs"、30、10这些魔法数字收进一个类,改配置只改一处。MinLevel这个参数特别有用——开发阶段设成Debug,上线后设成Info,不用改代码重新编译。MaxFileSizeMB是防止某天日志暴涨把磁盘写满,超过就滚动到新文件。
提示:
AppDomain.CurrentDomain.BaseDirectory在 Winform 里指向 exe 所在目录,但如果你用 ClickOnce 发布,这个路径会变,建议改成Application.StartupPath或显式配置绝对路径。
2.3 日志级别的实际使用边界
级别不是越多越好,五个级别够用了,关键是什么场景用什么级别。我的习惯是:Debug只放开发期调试信息,比如“进入方法 X,参数 Y=Z”;Info放正常业务流程节点,比如“用户登录成功”“导出完成,共 120 条”;Warn放可恢复的异常,比如“串口重试第 2 次”;Error放业务异常,比如“文件写入失败”;Fatal放程序级崩溃,比如“数据库连接池耗尽”。
例程里每个级别对应不同的输出格式和颜色(如果输出到控制台或 UI 控件)。文件里则统一格式,方便 grep。这里有个血泪经验:不要把Exception的Message直接当Error日志写,一定要带StackTrace。很多异常Message只有一句“对象引用未设置到对象的实例”,没有堆栈你根本不知道哪一行。
3. 线程安全与 UI 不卡:Winform 日志最容易翻车的地方
3.1 多线程写文件的锁粒度问题
Winform 里日志写入往往发生在多个线程:UI 线程、串口接收线程、后台任务线程。如果多个线程同时往一个文件写,轻则日志内容交错,重则文件被占用抛IOException。新手常见的做法是给整个写文件方法加lock,这能解决交错问题,但会带来新问题——锁粒度太大,高频日志场景下线程排队,UI 响应变慢。
例程的做法是“队列 + 单写线程”。所有线程把日志消息丢进一个ConcurrentQueue,后台一个专用线程从队列取消息写文件。这样写文件永远只有一个线程,不需要锁,其他线程入队几乎无阻塞。核心结构如下:
// AsyncFileLogger.cs —— 异步文件日志写入器 public class AsyncFileLogger : IDisposable { private readonly ConcurrentQueue<LogMessage> _queue = new(); private readonly AutoResetEvent _signal = new(false); private readonly Thread _worker; private volatile bool _running = true; public AsyncFileLogger() { // 后台线程,IsBackground=true 保证主程序退出时线程自动结束 _worker = new Thread(ProcessQueue) { IsBackground = true }; _worker.Start(); } // 供业务线程调用,入队即返回,不阻塞 public void Enqueue(LogMessage msg) { _queue.Enqueue(msg); _signal.Set(); // 唤醒后台线程 } private void ProcessQueue() { while (_running) { if (_queue.TryDequeue(out var msg)) { WriteToFile(msg); // 只有这个线程写文件 } else { _signal.WaitOne(100); // 没消息时等 100ms,避免空转 } } } private void WriteToFile(LogMessage msg) { // 按天切分文件,追加写入 var file = Path.Combine( LogConfig.LogDirectory, $"{DateTime.Now.ToString(LogConfig.FileNameFormat)}.log"); File.AppendAllText(file, msg.ToString() + Environment.NewLine); } public void Dispose() { _running = false; _signal.Set(); _worker.Join(2000); // 最多等 2 秒,保证队列写完 } }这段代码的关键点有三个。第一,ConcurrentQueue是无锁队列,多线程入队安全且快。第二,AutoResetEvent用来在没消息时让后台线程休眠,避免while(true)空转吃 CPU。第三,Dispose里先停标志再Join,保证程序退出前队列里的日志能落盘,不然最后几条关键日志会丢。Join(2000)的 2 秒是兜底,防止后台线程卡死导致主程序退不出去。
3.2 跨线程更新 UI 的正确姿势
日志除了写文件,很多时候还要显示在界面的TextBox或ListView上。Winform 的控件只能由创建它的线程访问,后台线程直接改控件会抛InvalidOperationException。正确做法是用Control.Invoke或BeginInvoke切回 UI 线程。
例程里封装了一个SafeAppend方法,业务代码不用关心线程问题:
// LogViewer.cs —— 日志显示控件封装 public partial class LogViewer : UserControl { private TextBox _txtLog; // 线程安全的追加方法,任何线程都能调 public void SafeAppend(string text) { if (_txtLog.InvokeRequired) { // 跨线程时切回 UI 线程执行 _txtLog.BeginInvoke(new Action(() => SafeAppend(text))); return; } _txtLog.AppendText(text + Environment.NewLine); _txtLog.SelectionStart = _txtLog.TextLength; _txtLog.ScrollToCaret(); // 自动滚到底部 } }InvokeRequired判断当前线程是不是 UI 线程,不是就BeginInvoke切回去。用BeginInvoke而不是Invoke的区别是:Invoke会阻塞后台线程直到 UI 处理完,高频日志下会拖慢后台任务;BeginInvoke是异步投递,后台线程不等。ScrollToCaret保证新日志始终可见,但日志量特别大时这个调用本身有开销,建议超过 1000 行时清空重填或改用虚拟列表。
注意:
BeginInvoke在窗体已经销毁后会抛异常,关闭窗体前记得先停掉日志线程,或者加IsDisposed判断。
3.3 操作日志的埋点位置
系统日志好办,在异常处理和关键节点写就行。操作日志的难点是“在哪埋”。我的习惯是三层埋点:窗体级(打开/关闭)、控件级(按钮点击、下拉框变更)、业务级(保存成功、删除确认)。不要在每个事件处理函数里手写Logger.Info("点击了按钮"),那样代码里全是日志语句,业务逻辑被淹没。
例程给了一个取巧的做法:用一个LogAction扩展方法,配合nameof自动记录控件名和方法名:
// LogExtensions.cs —— 操作日志埋点扩展 public static class LogExtensions { // 在事件处理函数开头调用,自动记录控件名和事件名 public static void LogAction(this Control control, string action) { Logger.Info($"[操作] {control.Name} -> {action}"); } } // 使用示例:按钮点击事件 private void btnExport_Click(object sender, EventArgs e) { this.btnExport.LogAction("导出报表"); // 一行搞定埋点 // ... 业务逻辑 }这样埋点只占一行,不干扰阅读。control.Name是设计器里给控件起的名字,比如btnExport,比手写字符串靠谱。如果控件没命名(默认button1),日志里就会出现button1,所以养成给控件起有意义名字的习惯,这本身就是个好实践。
4. 避坑与排查:日志模块自己出问题怎么办
4.1 日志文件被占用,写入抛 IOException
现象:程序运行一段时间后,日志不再更新,或者直接弹异常“文件正由另一进程使用”。
原因:最常见的是用File.AppendAllText的同时,另一个进程(比如你手动打开的记事本、或者杀毒软件扫描)占用了文件。另一个原因是自己的代码里用了FileStream没释放。
解决:写入时用FileShare.ReadWrite打开,允许其他进程读。例程里WriteToFile可以改成:
using var fs = new FileStream(file, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); using var sw = new StreamWriter(fs); sw.WriteLine(msg.ToString());FileShare.ReadWrite是关键参数,它告诉系统“我写的时候别人可以读也可以写”。但要注意,这不能解决两个进程同时写同一文件的内容交错问题,只是不抛异常。真正的多进程日志应该用不同的文件名或走系统日志服务。
4.2 日志目录不存在导致启动失败
现象:程序在开发机跑得好好的,拷到客户机上启动就崩,异常指向日志初始化。
原因:AppDomain.CurrentDomain.BaseDirectory在客户机上可能没有写权限(比如装在C:\Program Files下),或者Logs目录被清理软件删了。
解决:初始化时先判断目录是否存在,不存在就创建,并且捕获创建失败的异常,降级到用户目录:
try { if (!Directory.Exists(LogConfig.LogDirectory)) Directory.CreateDirectory(LogConfig.LogDirectory); } catch (UnauthorizedAccessException) { // 没权限就退到用户 AppData 目录 LogConfig.LogDirectory = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MyApp", "Logs"); Directory.CreateDirectory(LogConfig.LogDirectory); }这个降级逻辑很重要,客户机环境千奇百怪,不能假设程序目录一定可写。
4.3 日志量太大把磁盘写满
现象:程序跑了几个月,客户反馈 C 盘满了,一查日志文件夹几十个 G。
原因:RetentionDays设了但没生效,或者清理逻辑只在启动时跑一次,程序长期不重启就一直不清理。
解决:清理逻辑要定时跑,不能只在启动时跑。例程里用System.Threading.Timer每天凌晨清理一次:
// 每天凌晨 3 点清理过期日志 var timer = new System.Threading.Timer(_ => { var cutoff = DateTime.Now.AddDays(-LogConfig.RetentionDays); foreach (var file in Directory.GetFiles(LogConfig.LogDirectory, "*.log")) { if (File.GetCreationTime(file) < cutoff) File.Delete(file); } }, null, TimeSpan.Zero, TimeSpan.FromHours(24));同时MaxFileSizeMB要真的生效,单文件超过上限就滚动到2024-01-01_1.log这样的名字,避免一个文件无限增长。
4.4 异常日志丢了 StackTrace
现象:日志里只有“未将对象引用设置到对象的实例”,没有堆栈,定位不到代码行。
原因:写日志时只传了ex.Message,没传ex.ToString()或ex.StackTrace。
解决:统一封装异常日志方法,强制带堆栈:
public static void Error(string message, Exception ex = null) { var text = ex == null ? message : $"{message}{Environment.NewLine}{ex}"; // ex.ToString() 自带堆栈 Logger.Log(LogLevel.Error, text); }ex.ToString()比ex.Message多出异常类型、堆栈、内部异常,排查时信息量差一个数量级。这个坑我踩过不止一次,后来直接在代码审查里加了一条:catch块里写日志不允许只写ex.Message。
4.5 日志时间戳不准
现象:日志时间比实际时间差几个小时,排查时序完全乱套。
原因:用了DateTime.Now但客户机时区设置不对,或者用了DateTime.UtcNow没转本地时间。
解决:统一用DateTime.Now并格式化成yyyy-MM-dd HH:mm:ss.fff,毫秒必须有,不然高频日志看不出先后。如果涉及跨时区部署,存 UTC 时间,显示时再转本地。例程默认用本地时间加毫秒,够用。
5. 进阶:把日志接到 UI 和文件之外的地方
5.1 日志分级过滤与实时查看器
文件日志适合事后排查,但开发阶段更想要实时看。例程里可以加一个简单的日志查看窗体,支持按级别过滤。核心是用一个List<LogMessage>做内存缓冲,配合BindingList绑定到DataGridView,过滤就是Where一下重新绑定。这个查看器不要做太复杂,能按级别筛、能清空、能导出当前视图就够了。我一般还会加一个“暂停滚动”按钮,方便日志刷太快时停下来看某一条。
5.2 把日志同时输出到多个目标
实际项目里经常需要日志既写文件又发到远程(比如内部监控平台)。例程的架构支持扩展:AsyncFileLogger只负责文件,再加一个RemoteLogger实现同样的Enqueue接口,初始化时组合起来。这里的关键是不要让远程日志的失败影响主流程——网络超时、服务不可用都要 catch 掉,最多在本地记一条“远程日志发送失败”。日志模块本身不能成为故障点,这是底线。
5.3 一个我常用的验证习惯
日志模块写完,别急着接业务,先写个压力测试:开 10 个线程,每个线程每秒写 100 条日志,跑 5 分钟,然后检查三件事——文件里有没有内容交错、程序内存有没有持续上涨、UI 有没有卡顿。这个测试能暴露 90% 的线程安全和性能问题。从那以后我每次搭日志模块,都强制走一遍这个压力测试,宁可前期多花半小时,也不想上线后被日志问题反咬一口。
希望这份例程和上面的踩坑记录,能帮你把 Winform 的日志从“能用”做到“敢用”。
本文还有配套的精品资源,点击获取