资讯动态

C# Winform日志输出实战:线程安全与按天切分

发布时间:2026/10/21 12:11:35 来源:尧图企业网站定制
简介这份C# Winform日志输出例程源码面向刚接触桌面开发的初学者与需要快速集成日志功能的开发者解决程序运行过程中系统事件与操作行为难以追踪、日志文件易被覆盖的问题。资源包共36个文件约745KB以9个cs源码文件为核心配合csproj、sln工程文件与config配置、resx资源、manifest清单等另有exe可执行文件、pdb调试符号及txt说明结构完整可直接编译运行。已有2122人学习下载说明其上手门槛低、参考价值较高。例程实现了日志的窗口实时显示与后台文件记录覆盖程序启动、关闭等系统事件以及用户执行的具体操作并按日期每天生成独立日志文件反复打开程序也不会覆盖历史记录。读者可据此掌握日志分级输出、文件追加写入与按天归档的基本思路将现成代码直接嵌入自己的Winform项目省去从零搭建日志模块的时间。1. 日志不是打印完就完事一个 Winform 例程把输出链路讲透了很多做 C# Winform 的开发者第一次写日志都是Console.WriteLine或者MessageBox.Show跑起来看着没问题一旦交付到现场就抓瞎——用户说“刚才点保存报错了”你问具体报什么对方只会说“就弹了个框我关了”。日志的价值不在于“程序运行时能看到”而在于事后能还原现场。这份 C# Winform 日志输出例程源码解决的就是这个最朴素也最容易被忽略的问题在桌面端把系统日志、操作日志稳定地落到文件里并且按天切分、按级别过滤、线程安全地写入。它适合刚接触 Winform 项目、需要给现有系统补一套日志能力的开发者也适合想把日志模块从主业务里解耦出来的老手。例程本身不依赖第三方日志框架纯 .NET 原生实现拿来就能嵌进自己的工程。2. 先看清结构例程的目录划分与日志分层设计2.1 从解决方案到文件落盘的目录布局拿到源码先别急着 F5把目录结构过一遍后面改起来才不迷路。这个例程的典型结构大致是这样组织的LogDemo/ ├── LogDemo.sln ├── LogDemo/ │ ├── Program.cs // 程序入口 │ ├── Forms/ │ │ ├── MainForm.cs // 主窗体演示按钮触发日志 │ │ └── MainForm.Designer.cs │ ├── Logging/ │ │ ├── LogLevel.cs // 日志级别枚举 │ │ ├── LogItem.cs // 单条日志的数据模型 │ │ ├── FileLogger.cs // 文件写入核心类 │ │ └── LogManager.cs // 对外统一入口 │ ├── Config/ │ │ └── log.config // 日志路径、级别、保留天数 │ └── Logs/ // 运行后生成的日志目录 └── README.md这个划分的核心思路是界面归界面日志归日志。Forms里只负责调用LogManager不关心日志怎么写、写去哪Logging目录自成一体可以整个文件夹拷到别的项目里复用。我见过太多项目把File.AppendAllText直接写在按钮事件里结果日志格式五花八门想统一加个时间戳都得改十几个地方。例程把写入逻辑收口到FileLogger就是为了避免这种翻车。Config/log.config单独放配置文件而不是塞进App.config好处是改日志级别不用重新编译现场运维直接改文本就行。Logs目录是运行时自动创建的源码包里通常是个空目录或者带.gitkeep第一次运行才会真正写入文件。2.2 日志级别与数据模型先定义清楚再动手写日志级别不是越多越好例程一般用四到五个就够Debug、Info、Warn、Error、Fatal。级别的作用是过滤——生产环境只记 Info 以上开发阶段全开。先看级别枚举// LogLevel.cs public enum LogLevel { Debug 0, // 调试细节生产环境通常关闭 Info 1, // 正常操作记录如用户登录成功 Warn 2, // 可恢复的异常如配置文件缺失使用默认值 Error 3, // 业务异常需要关注 Fatal 4 // 致命错误程序可能无法继续 }枚举值用数字是有意为之后面过滤时直接比大小if (item.Level minLevel) return;比字符串比较快也更不容易写错。再看单条日志的数据模型LogItem它决定了日志文件里每一行长什么样// LogItem.cs public class LogItem { public DateTime Time { get; set; } // 发生时间精确到毫秒 public LogLevel Level { get; set; } // 级别 public string ThreadId { get; set; } // 线程号排查并发问题关键 public string Source { get; set; } // 来源一般是类名或模块名 public string Message { get; set; } // 日志正文 public Exception Ex { get; set; } // 异常对象可空 // 格式化成一行文本供文件写入 public override string ToString() { string head ${Time:yyyy-MM-dd HH:mm:ss.fff} [{Level}] [T{ThreadId}] [{Source}] {Message}; if (Ex ! null) { // 异常堆栈换行缩进避免一行过长 head Environment.NewLine Ex.ToString().Replace(\n, \n ); } return head; } }这里有几个参数值得说。Time精确到毫秒是因为桌面端操作密集时秒级时间戳根本分不清先后顺序。ThreadId看着不起眼但 Winform 里 UI 线程和后台线程同时写日志是常态出问题时能一眼看出是哪条线程干的。Source建议传this.GetType().Name或者模块名别偷懒写死字符串。Ex单独存对象而不是提前拼成字符串是为了让ToString()里能统一控制堆栈缩进也方便以后扩展成结构化日志。提示ToString()里对异常堆栈做了缩进处理是为了让日志文件用记事本打开时也能看清层级不然堆栈会顶到行首和正常日志混在一起。3. 核心写入链路FileLogger 怎么做到线程安全又不卡界面3.1 写入队列与后台线程把 IO 从 UI 线程剥离Winform 最容易犯的错就是在按钮事件里直接File.AppendAllText。文件小的时候没事日志一多UI 直接卡成幻灯片。例程的做法是调用方只管往队列里塞后台单独一个线程负责落盘。先看FileLogger的核心字段和入队方法// FileLogger.cs public class FileLogger { private readonly BlockingCollectionLogItem _queue new BlockingCollectionLogItem(); private readonly string _logDir; private readonly LogLevel _minLevel; private Thread _worker; public FileLogger(string logDir, LogLevel minLevel) { _logDir logDir; _minLevel minLevel; if (!Directory.Exists(_logDir)) Directory.CreateDirectory(_logDir); // 目录不存在就建 } // 对外写入一条日志非阻塞 public void Write(LogItem item) { if (item.Level _minLevel) return; // 级别不够直接丢 _queue.Add(item); // 入队即返回不碰磁盘 } // 启动后台消费线程 public void Start() { _worker new Thread(ConsumeLoop) { IsBackground true, // 主程序退出时自动结束 Name FileLoggerWorker }; _worker.Start(); } }BlockingCollectionLogItem是 .NET 自带的线程安全集合Add和Take内部已经处理了锁不用自己写lock。Write方法只做两件事级别过滤、入队全程不碰磁盘所以 UI 线程调用它几乎零开销。IsBackground true很关键否则关窗体时后台线程不退出进程会残留。3.2 消费循环与按天切分文件命名和滚动策略后台线程拿到队列里的日志后要决定写到哪个文件。例程按天切分文件名带日期跨天自动换新文件// FileLogger.cs 续 private void ConsumeLoop() { foreach (var item in _queue.GetConsumingEnumerable()) { try { string file GetTodayFile(); File.AppendAllText(file, item.ToString() Environment.NewLine, Encoding.UTF8); } catch { // 写日志失败不能再抛异常否则会拖垮调用方 } } } private string GetTodayFile() { // 文件名形如2025-01-15.log string name DateTime.Now.ToString(yyyy-MM-dd) .log; return Path.Combine(_logDir, name); }GetConsumingEnumerable()会在队列为空时阻塞等待有新日志进来立刻唤醒不会空转烧 CPU。File.AppendAllText每次打开关闭文件对桌面端日志量来说完全够用如果日志量特别大常见做法是改成StreamWriter持有句柄但那样要处理跨天切换时的关闭重开例程为了简单没这么做。按天切分的好处是排查方便——用户说“昨天下午出的问题”直接翻昨天的文件。文件名用yyyy-MM-dd而不是yyyyMMdd是为了在资源管理器里排序更直观。编码固定 UTF8避免中文日志在别的机器上打开乱码。3.3 LogManager 单例给业务代码一个统一入口业务代码不应该到处new FileLogger例程用LogManager做统一门面内部持有单例// LogManager.cs public static class LogManager { private static FileLogger _logger; public static void Init(string logDir, LogLevel minLevel) { _logger new FileLogger(logDir, minLevel); _logger.Start(); } public static void Info(string source, string msg) _logger?.Write(new LogItem { Time DateTime.Now, Level LogLevel.Info, ThreadId Thread.CurrentThread.ManagedThreadId.ToString(), Source source, Message msg }); public static void Error(string source, string msg, Exception ex null) _logger?.Write(new LogItem { Time DateTime.Now, Level LogLevel.Error, ThreadId Thread.CurrentThread.ManagedThreadId.ToString(), Source source, Message msg, Ex ex }); }Init在Program.cs的Main里调用一次之后整个程序用LogManager.Info(MainForm, 用户点击保存)就行。_logger?.的空判断是为了防止忘记Init时抛空引用——虽然不优雅但比崩溃强。ThreadId在这里统一取当前线程调用方不用自己传。注意LogManager是静态类全局只有一个FileLogger实例。如果项目里有多套日志需求比如操作日志和系统日志分开存常见做法是改成字典管理多个实例按名字取。4. 跑起来才算数从编译到看到第一条日志4.1 环境准备与首次运行例程基于 .NET Framework 或 .NET 6 的 Winform具体看源码里的.csproj。用 Visual Studio 打开.sln确认目标框架已安装直接 F5 就能跑。首次运行前检查两件事一是Config/log.config里的路径默认一般是相对路径Logs会生成在bin\Debug\Logs下二是级别开发阶段建议设成Debug能看到最全的信息。运行后主窗体上通常有几个按钮分别触发 Info、Warn、Error 级别的日志。点几下然后去bin\Debug\Logs目录看应该出现一个以当天日期命名的.log文件。用记事本打开能看到类似这样的内容2025-01-15 14:32:07.451 [Info] [T1] [MainForm] 用户点击了保存按钮 2025-01-15 14:32:08.102 [Error] [T1] [MainForm] 保存失败磁盘只读 System.UnauthorizedAccessException: 对路径的访问被拒绝。 at System.IO.FileStream..ctor(...)如果文件没生成先看Logs目录有没有被创建再看Init有没有被调用。最常见的翻车是Init写在窗体构造函数里但窗体还没显示就抛异常了日志自然没初始化。4.2 关键配置项怎么改log.config一般就三四个键改起来没负担配置项含义常用值LogDir日志存放目录Logs 或 D:\AppLogsMinLevel最低记录级别Debug / Info / Warn / ErrorKeepDays日志保留天数7 / 30 / 0不清理MinLevel设成Warn时Info和Debug会被Write方法直接丢弃连队列都不进性能最好。KeepDays如果例程里实现了清理逻辑通常在Init时扫一遍目录把超过天数的旧文件删掉没实现的话可以自己加逻辑就是遍历Logs下的*.log按文件名日期判断。4.3 在业务代码里埋点三个典型位置日志不是越多越好埋点位置比数量重要。例程演示了三个典型场景窗体加载、按钮操作、异常捕获。对应到自己的项目我一般会在这几个地方加// 1. 程序启动记录版本和环境 LogManager.Info(Program, $启动版本 {Application.ProductVersion}); // 2. 关键操作前后记录参数和结果 LogManager.Info(OrderForm, $提交订单订单号{orderId}); try { SaveOrder(orderId); LogManager.Info(OrderForm, $订单 {orderId} 保存成功); } catch (Exception ex) { // 3. 异常捕获把 ex 对象传进去堆栈自动带上 LogManager.Error(OrderForm, $订单 {orderId} 保存失败, ex); MessageBox.Show(保存失败请查看日志); }异常这里有个细节MessageBox只给用户看一句人话具体堆栈进日志。别把ex.ToString()直接弹给用户既看不懂也不安全。Source参数用窗体名或模块名排查时能快速定位。5. 避坑与排查日志模块最容易踩的五个坑5.1 日志文件被占用删不掉也打不开现象想清理旧日志提示“文件正在被另一进程使用”。原因如果用了StreamWriter长期持有句柄且没释放文件就被锁住了。例程用File.AppendAllText每次开关一般不会出现但如果你自己改成了持有句柄就要注意。解决要么改回每次开关要么在跨天切换时Dispose旧 writer要么用FileShare.ReadWrite打开。5.2 中文日志乱码现象日志文件里中文变成问号或方块。原因写入时没指定编码或者用了系统默认编码GBK而查看工具按 UTF8 解析。解决File.AppendAllText第三个参数固定传Encoding.UTF8例程里已经这么做了。如果接手的是老代码全局搜一下AppendAllText有没有漏传编码。5.3 关窗体后进程不退出现象点了关闭任务管理器里进程还在。原因后台消费线程不是后台线程主线程结束后它还在跑。解决创建线程时设IsBackground true例程里已经设了。如果还不行检查有没有别的非后台线程比如定时器。5.4 日志级别改了不生效现象配置文件改成Error但Info还是写进去了。原因配置只在Init时读一次运行中改文件不会重新加载。解决要么重启程序要么加个FileSystemWatcher监听配置文件变化后重新Init。例程为了简单没做热加载知道这个限制就行。5.5 日志把磁盘写满现象跑了一段时间磁盘空间告警。原因没有清理策略或者KeepDays设成了 0 且没实现删除。解决Init时加一段清理逻辑遍历目录删除过期文件同时给日志目录设个大小上限超了就删最旧的。生产环境这个必须有血泪经验。6. 二次开发把例程改成适合自己项目的日志模块例程能跑通只是起点真正要用起来多少得按自己项目改。我一般从三个方向动手输出目标扩展、格式结构化、异步落盘优化。先说输出目标。例程只写文件但实际项目经常需要同时输出到控制台、数据库或者 UI 的日志面板。改造思路是把FileLogger里的写入动作抽象成接口public interface ILogTarget { void Write(LogItem item); } public class FileTarget : ILogTarget { /* 原 FileLogger 的落盘逻辑 */ } public class ConsoleTarget : ILogTarget { /* Console.WriteLine */ } public class UiTarget : ILogTarget { /* 通过事件推给窗体 */ }然后LogManager持有一个ListILogTargetWrite时遍历所有 target。这样加一个输出目标不用动核心逻辑符合开闭原则。UI 面板那个尤其有用——现场调试时用户能直接在界面上看到日志滚动不用去翻文件。再说格式结构化。纯文本日志人看方便但机器解析费劲。如果项目要接日志分析工具可以把ToString()改成输出 JSONpublic string ToJson() { return JsonConvert.SerializeObject(new { time Time.ToString(o), level Level.ToString(), thread ThreadId, source Source, message Message, stack Ex?.ToString() }); }一行一个 JSON 对象用jq或者日志平台都能直接解析。代价是文件体积变大人读起来累所以常见做法是开发环境用文本、生产环境用 JSON通过配置切换。最后说异步落盘优化。例程用BlockingCollection已经不错但如果日志量极大比如每秒上千条File.AppendAllText每次开关文件的开销就显出来了。优化方向是批量写消费线程一次Take多条攒够一批或者等 100 毫秒再统一写。BlockingCollection的TryTake(out item, timeout)配合List缓冲就能实现。不过要小心批量写意味着程序崩溃时缓冲区里的日志可能丢所以Error和Fatal级别建议强制立即落盘不走批量。验证改造是否成功我有个笨办法但很管用写一个压测方法开十个线程各写一万条日志跑完看文件行数对不对、有没有交错错乱、程序退出后文件句柄有没有释放。这个测试能同时验证线程安全、性能和资源释放比肉眼看代码靠谱。从那以后我每次给项目加日志模块都强制先跑一遍这个压测确认队列不丢、文件不锁、进程能退再往业务里埋点。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑