首页 / 资讯中心 / 文章详情

从零搭建灌装监控系统(十三):报警系统设计,生命周期与通知

从零搭建灌装监控系统(十三):报警系统设计,生命周期与通知 ★ FEATURED ARTICLE
报警系统设计生命周期与通知这是「从零搭建灌装监控系统」系列第13篇。报警系统不能只写一个IsAlarm true。一条报警需要知道什么时候触发、什么时候恢复、是否确认、由谁确认以及如何通知页面和日志。这篇把报警生命周期拆开再用服务、数据库和事件把它们串起来。红灯亮了但没人知道它为什么亮早期版本的报警处理很简单设备状态异常时把一个布尔属性设成true界面用红色触发器显示状态恢复后设回false。这种实现演示时很漂亮现场却马上暴露问题。操作员离开页面再回来刚才的报警已经没有记录同一个异常持续十分钟系统每200ms 都在重复发送提示报警恢复了但没人知道它持续了多久。报警至少包含四个时间点或动作触发、持续、恢复、确认。确认也不等于恢复操作员可以先确认“我看到了”设备故障仍然存在。AlarmRecord把状态变成记录publicclassAlarmRecord{[Column(IsIdentitytrue,IsPrimarytrue)]publiclongId{get;set;}publicAlarmCodeCode{get;set;}publicAlarmSeveritySeverity{get;set;}publicstringTitle{get;set;};publicstringMessage{get;set;};publicDateTimeStartTime{get;set;}publicDateTime?EndTime{get;set;}publicboolIsActive{get;set;}publicdouble?DurationSeconds{get;set;}publicboolIsAcknowledged{get;set;}publicDateTime?AcknowledgedTime{get;set;}publicstring?AcknowledgedUser{get;set;}}级别是报警的属性不是生命周期publicenumAlarmSeverity{All0,Info1,Warning2,Error3,Critical4}编码也要稳定。界面显示文字可以改代码和数据库里的AlarmCode不应该因为换了语言就变化publicenumAlarmCode{None0,LowLiquidLevel1001,Overfill1002,Leak1003,LowAirPressure2001,HighTemperature3001,SensorFailure4001,CommunicationError5001,SystemError6001}这些是教学用的通用报警码真实设备的地址和内部编号不应直接写进文章。触发同类活动报警只保留一条报警触发可能来自多个轮询周期。第一件事是检查同一编码是否已经有活动记录publicstaticasyncTaskTriggerAlarmAsync(AlarmRecordrecord){varexistsawaitDbProvider.Fsql.SelectAlarmRecord().Where(xx.Coderecord.Codex.IsActive).AnyAsync();if(exists)return;record.StartTimeDateTime.Now;record.IsActivetrue;record.IsAcknowledgedfalse;awaitDbProvider.Fsql.Insert(record).ExecuteAffrowsAsync();LogService.Warn($报警触发{record.Code}-{record.Message});AlarmTriggered?.Invoke(null,record);}这个查询解决了重复弹窗和重复插入但还存在并发窗口两个线程可能同时查询都看到“没有活动报警”接着各自插入。严谨做法是在服务层加异步锁或者给数据库增加活动报警的唯一约束。现场报警不应该因为一次并发检测就出现两条一模一样的活动记录。恢复计算持续时间不删除记录报警恢复时不要删除原记录。历史记录的价值就在于它能回答“什么时候发生过”publicstaticasyncTaskRecoverAlarmAsync(AlarmCodecode){varalarmawaitDbProvider.Fsql.SelectAlarmRecord().Where(xx.Codecodex.IsActive).FirstAsync();if(alarmnull)return;varendDateTime.Now;awaitDbProvider.Fsql.UpdateAlarmRecord().Set(xx.IsActive,false).Set(xx.EndTime,end).Set(xx.DurationSeconds,(end-alarm.StartTime).TotalSeconds).Where(xx.Idalarm.Id).ExecuteAffrowsAsync();alarm.IsActivefalse;alarm.EndTimeend;alarm.DurationSeconds(end-alarm.StartTime).TotalSeconds;LogService.Info($报警恢复{code});AlarmRecovered?.Invoke(null,alarm);}恢复动作应该是幂等的。没有活动记录时直接返回再次收到恢复信号不应该报错也不应该创建一条“恢复记录”。确认操作员承认看到了确认是人为动作和设备恢复是两条线publicstaticasyncTaskboolAcknowledgeAlarmAsync(longalarmId,stringoperatorName){varaffectedawaitDbProvider.Fsql.UpdateAlarmRecord().Set(xx.IsAcknowledged,true).Set(xx.AcknowledgedTime,DateTime.Now).Set(xx.AcknowledgedUser,operatorName).Where(xx.IdalarmId!x.IsAcknowledged).ExecuteAffrowsAsync();if(affected0)returnfalse;LogService.Info($报警确认{alarmId}by{operatorName});returntrue;}Where(... !x.IsAcknowledged)很重要。两个页面同时点确认时只有第一个更新成功第二个得到 false。数据库条件本身就是并发保护的一部分。一个已确认但仍活动的报警应该继续显示在活动报警列表里只是颜色、按钮或提示方式可以变化。确认不应该让故障从系统中消失。事件通知 UI但服务不依赖 UI报警服务写数据库和日志页面通过事件刷新自己的集合publicstaticeventEventHandlerAlarmRecord?AlarmTriggered;publicstaticeventEventHandlerAlarmRecord?AlarmRecovered;ViewModel 订阅事件publicAlarmsViewModel(){AlarmService.AlarmTriggeredOnAlarmTriggered;AlarmService.AlarmRecoveredOnAlarmRecovered;_LoadActiveAlarmsAsync();}privatevoidOnAlarmTriggered(object?sender,AlarmRecordrecord){Application.Current.Dispatcher.Invoke((){ActiveAlarms.Insert(0,AlarmUIModel.FromAlarmRecord(record));ActiveAlarmCountActiveAlarms.Count;});}事件回调可能在后台轮询线程触发不能直接修改 WPF 的ObservableCollection。通过 Dispatcher 切回 UI 线程是这类代码的基本安全线。服务不应该直接调用MessageBox.Show或引用某个页面。否则报警服务无法测试也无法复用到后台模式。声音、弹窗、灯光和 UI 列表都属于订阅者各自决定如何响应。是否轮询读到异常状态TriggerAlarmAsync同类活动报警已存在?直接返回不重复触发插入活动记录写警告日志触发 AlarmTriggered 事件Dispatcher 切回 UI 线程活动报警列表插入一条活动列表和历史列表是两种查询活动报警只查IsActive true按开始时间倒序。历史报警则要支持时间和级别筛选还要分页publicstaticasyncTask(ListAlarmRecordItems,longTotal)GetAlarmHistoryAsync(intpageIndex,intpageSize,DateTime?startnull,DateTime?endnull,AlarmSeverityseverityAlarmSeverity.All){varqueryDbProvider.Fsql.SelectAlarmRecord();if(start.HasValue)queryquery.Where(xx.StartTimestart.Value);if(end.HasValue)queryquery.Where(xx.StartTimeend.Value);if(severity!AlarmSeverity.All)queryquery.Where(xx.Severityseverity);vartotalawaitquery.CountAsync();varitemsawaitquery.OrderByDescending(xx.StartTime).Page(pageIndex,pageSize).ToListAsync();return(items,total);}总数和当前页数据最好使用一致的查询条件否则分页控件显示的总页数会和实际内容对不上。从设备状态到报警服务报警判断应该集中在状态处理层不要散落在每个控件的属性回调里privateasyncTaskEvaluateStateAsync(DeviceStatesstate){if(state.VolumeLimits.MinimumVolume){awaitAlarmService.TriggerAlarmAsync(AlarmFactory.LowLiquidLevel(state.Volume));}else{awaitAlarmService.RecoverAlarmAsync(AlarmCode.LowLiquidLevel);}if(state.TemperatureLimits.MaximumTemperature){awaitAlarmService.TriggerAlarmAsync(AlarmFactory.HighTemperature(state.Temperature));}}触发条件和报警记录创建可以再拆成规则对象但不要为了追求“通用引擎”把两三个规则包装成十层接口。先保证规则可读、恢复路径明确、日志能定位。踩坑记录一次采样直接弹一次窗轮询是状态采样报警是状态边沿。必须做活动报警去重。恢复时删除记录删除让报警历史无法追溯也无法计算持续时间。恢复只更新状态和结束信息。把确认当成恢复操作员点击确认只表示“我看到了”设备异常仍然存在时报警必须继续活动。事件订阅不解除页面反复导航会重复订阅最终一次报警触发多次刷新。页面生命周期结束时要解除订阅或者让订阅者使用统一的生命周期管理。本篇小结生命周期数据变化典型通知触发插入活动记录列表、声音、灯光持续保持活动周期监视恢复写入结束时间和持续时间消除活动提示确认写入操作员和确认时间记录人工处理报警数据已经能保存和通知但系统还需要一个可靠的日志出口。下一篇把 Serilog 接到文件、SQLite、控制台和 UI 四个方向。下期预告第14篇Serilog 四路日志输出下一篇设计统一日志入口让同一条日志按级别流向文件、数据库、控制台和界面而不是在业务代码里到处写文件操作。
阅读完成 · 觉得有帮助?
咨询建站