Serilog 四路日志输出
这是「从零搭建灌装监控系统」系列第14篇。报警系统需要日志,设备通信需要日志,配置保存也需要日志。真正让人头疼的不是写一条日志,而是同一条日志如何同时服务现场操作员、开发调试、历史查询和问题定位。这篇用 Serilog 设计文件、SQLite、控制台和 UI 四路输出。
出了问题,日志却只在窗口里
调试阶段,最方便的写法是把信息追加到 RichTextBox。程序运行时看起来很直观,出问题时却发现窗口早就被刷新掉了;如果程序在启动阶段崩溃,UI 还没有创建,连第一行日志都没有。
后来又加了文件日志,但业务代码里到处是File.AppendAllText。不同模块的时间格式不一致,异常堆栈被截断,多个线程同时写文件还会碰到占用异常。
日志应该是基础设施。业务代码只需要说“记录一条警告”,至于写到哪里,由日志配置决定。Serilog 的价值就在这里:一个统一的日志事件,可以挂接多个 Sink。
四路日志各自解决什么问题
| 输出 | 主要读者 | 适合内容 |
|---|---|---|
| 文件滚动 | 现场维护、开发人员 | 完整运行日志和异常堆栈 |
| SQLite | 日志查看器、统计功能 | 可筛选、可分页的历史日志 |
| 控制台 | 调试人员、自动化测试 | 启动参数和实时诊断 |
| UI | 操作员 | 当前运行状态、重要警告 |
四路输出不等于四份不同的日志代码。业务层只调用LogService.Info、Warn、Error,Serilog 负责分发。
从最小配置开始
Log.Logger=newLoggerConfiguration().MinimumLevel.Debug().Enrich.FromLogContext().WriteTo.Console().WriteTo.File(Path.Combine(logDirectory,"app-.log"),rollingInterval:RollingInterval.Day,retainedFileCountLimit:14,shared:true).CreateLogger();文件名使用日期滚动,保留数量要有限制。现场机器磁盘空间通常不是无限的,日志如果没有清理策略,几个月后就会变成一个隐蔽的部署问题。
shared: true能改善多个进程或工具读取文件时的兼容性,但它不能替代完整的并发设计。不要让业务代码自己打开同一个文件。
SQLite Sink:让日志可以查询
文件适合追查完整异常,SQLite 适合做日志查看器。日志表至少需要时间、级别、消息和异常信息:
publicclassSystemLog{[Column(IsIdentity=true,IsPrimary=true)]publiclongId{get;set;}publicDateTimeTimestamp{get;set;}publicstringLevel{get;set;}="";publicstringMessage{get;set;}="";publicstring?Exception{get;set;}}可以使用现成的 SQLite Sink,也可以写一个轻量的自定义 Sink,把LogEvent映射成SystemLog:
publicsealedclassSqliteLogSink:ILogEventSink{publicvoidEmit(LogEventlogEvent){varrow=newSystemLog{Timestamp=logEvent.Timestamp.LocalDateTime,Level=logEvent.Level.ToString(),Message=logEvent.RenderMessage(),Exception=logEvent.Exception?.ToString()};_=DbProvider.Fsql.Insert(row).ExecuteAffrowsAsync();}}自定义 Sink 里启动异步写入要谨慎。Emit本身是同步调用,如果每一条日志都阻塞等待数据库,业务线程会被日志拖慢;如果完全 fire-and-forget,又要考虑程序退出时未写完的日志。中小型项目可以使用有界 Channel 做日志队列,退出时等待队列排空。
如果日志量不大,直接使用成熟 Sink 更省心。自定义 Sink 适合确实需要特殊字段、批量写入或统一队列的场景。
UI Sink:不要在后台线程直接改控件
UI 日志是展示层能力,不应该让 Serilog 知道 RichTextBox 的存在。可以定义一个事件或 Channel:
publicsealedclassUiLogSink:ILogEventSink{publicstaticeventAction<UiLogItem>?Published;publicvoidEmit(LogEventlogEvent){varitem=newUiLogItem(logEvent.Timestamp.LocalDateTime,logEvent.Level,logEvent.RenderMessage());Published?.Invoke(item);}}ViewModel 订阅后切换到 Dispatcher:
privatevoidOnUiLog(UiLogItemitem){Application.Current.Dispatcher.BeginInvoke(()=>{VisibleLogs.Insert(0,item);while(VisibleLogs.Count>200)VisibleLogs.RemoveAt(VisibleLogs.Count-1);});}UI 只显示最近一段,不要把一天的日志全部塞进 ObservableCollection。历史日志在 SQLite,当前窗口只承担实时反馈。
另外,UI 日志最好做级别过滤。操作员通常关心信息、警告和错误,SQL Verbose、调试轮询和心跳消息会干扰判断。
统一 LogService
业务层不应该依赖 Serilog 的所有 API。封装一层的好处是以后替换日志库、增加上下文或统一脱敏时只改一个地方:
publicstaticclassLogService{publicstaticvoidInfo(stringmessage)=>Log.Information(message);publicstaticvoidWarn(stringmessage)=>Log.Warning(message);publicstaticvoidDebug(stringmessage)=>Log.Debug(message);publicstaticvoidVerbose(stringmessage)=>Log.Verbose(message);publicstaticvoidError(stringmessage,Exception?ex=null){if(ex==null)Log.Error(message);elseLog.Error(ex,message);}}日志模板尽量使用结构化参数:
Log.Information("设备连接成功,模式={Mode},地址={Address}",settings.Mode,settings.Address);不要把密码、完整连接字符串、Cookie 或内部路径写进日志。异常对象本身可能包含路径和参数,生产环境也要评估日志的可见范围。
启动顺序很重要
日志初始化必须早于大部分服务初始化,否则启动阶段的异常没有去处:
protectedoverridevoidOnStartup(StartupEventArgse){varlogDirectory=Path.Combine(AppContext.BaseDirectory,"Logs");Directory.CreateDirectory(logDirectory);LogBootstrapper.Initialize(logDirectory);try{InitializeCoreServices();ConfigureDeviceServices();base.OnStartup(e);}catch(Exceptionex){LogService.Fatal("应用启动失败",ex);Log.CloseAndFlush();throw;}}Logs目录先创建,日志配置再加载,核心服务最后初始化。这样数据库、设备和配置初始化失败时,至少能留下错误。
应用退出时调用Log.CloseAndFlush(),给异步 Sink 一个排空机会:
protectedoverridevoidOnExit(ExitEventArgse){Log.Information("应用退出");Log.CloseAndFlush();base.OnExit(e);}如果进程被强制终止,任何异步日志方案都不能保证最后一条一定落盘。所以关键动作不能只依赖日志,真正重要的数据还应该写入业务表。
级别不是越详细越好
常见级别可以这样使用:
Verbose:SQL、轮询细节,只在调试时开启;Debug:连接尝试、状态转换等诊断信息;Information:正常启动、保存成功、记录入库;Warning:自动重连、配置恢复、非致命异常;Error:一次操作失败,但程序还能继续;Fatal:应用无法继续运行的严重错误。
如果所有事情都写Error,真正的错误会失去优先级;如果所有轮询都写Information,日志会很快失去可读性。级别是给人筛选用的,不是给代码作者发泄用的。
脱敏和日志保留
日志比文章更容易泄露现场信息,因为它往往包含设备地址、路径和异常参数。发布前检查文章和示例日志时要去掉:
- 真实设备 IP、端口和串口号;
- 公司目录、用户名和部署盘符;
- 用户名、密码和令牌;
- 真实产品批次号和客户信息。
文章里的日志示例使用127.0.0.1、COM1和泛化后的批次号,只为解释格式,不代表真实现场配置。
踩坑记录
日志初始化太晚
启动异常没有记录,排查只能靠猜。日志引导程序要放在核心服务之前。
UI Sink 直接引用控件
日志基础设施一旦依赖窗口,后台测试和无界面启动都会变麻烦。Sink 发布事件,ViewModel 决定怎么显示。
SQLite 日志无限增长
日志表也需要保留策略,可以按时间清理或限制数据库大小。不要因为“数据库便宜”就永远不清理。
把异常消息当作用户提示
异常消息可能包含技术细节和路径。日志保留完整异常,UI 显示可理解的简短提示。
本篇小结
| 目标 | 做法 |
|---|---|
| 统一入口 | LogService封装 Serilog |
| 现场排查 | 日期滚动文件 |
| 历史筛选 | SQLite Sink |
| 实时提示 | UI Sink + Dispatcher |
| 调试诊断 | 控制台和 Verbose 级别 |
| 启动可靠 | 先初始化日志,再初始化业务服务 |
四路日志接通以后,下一篇把 SQLite 里的日志展示出来:按级别、日期和关键字筛选,并使用固定分页避免一次加载太多数据。
下期预告
第15篇:日志查看器:筛选与分页
下一篇完成LogsViewModel,实现 50 条一页、条件查询、页码切换和 CSV 导出入口。