告别WinForm:用C# Blazor打造跨平台PLC Web监控上位机实战
前言为什么我们要把上位机搬进浏览器做过工控的朋友都知道传统上位机开发基本被WinForm和WPF垄断了。部署麻烦、依赖.NET Framework特定版本、只能在Windows上跑、远程访问还得搞内网穿透或RDP这些都是老生常谈的痛点。尤其是当甲方提出“我想在手机上看一下设备状态”或者“车间大屏要展示实时数据”时传统CS架构就显得力不从心。这两年Blazor逐渐成熟特别是.NET 8之后性能提升明显让我看到了Web化上位机的真正可行性。Blazor Server模式天然适合工业场景UI渲染在服务端完成浏览器只负责DOM事件转发和界面更新这意味着敏感逻辑不出服务端PLC通信代码、数据库连接串全在服务器浏览器端拿不到任何后端源码低延迟双向通信基于SignalR长连接不是HTTP轮询毫秒级响应真正的跨平台客户端Chrome、Edge、Safari甚至手机浏览器都能直接用复用现有C#生态S7.Net、ModbusTcpClient这些库直接拿来用不用学TypeScript本文不讲Hello World直接以一个S7-1200 PLC数据采集与反向控制的完整项目为例聊聊我在实际落地过程中踩过的坑和解决方案。一、 整体架构设计先看一下系统拓扑理解数据是怎么流动的┌─────────────────────────────────────────────────────┐ │ Blazor Server App │ │ │ │ ┌──────────┐ SignalR ┌───────────────────┐ │ │ │ Browser │◄══════════════►│ Circuit (Server) │ │ │ │ (Thin) │ WebSocket │ │ │ │ └──────────┘ │ ┌──────────────┐ │ │ │ │ │ BackgroundSvc│ │ │ │ │ │ (PLC Polling)│ │ │ │ │ └──────┬───────┘ │ │ │ │ │ │ │ │ │ ┌──────▼───────┐ │ │ │ │ │ Shared State │ │ │ │ │ │ (Singleton) │ │ │ │ │ └──────┬───────┘ │ │ │ └─────────┼─────────┘ │ └──────────────────────────────────────┼──────────────┘ │ TCP/IP ┌──────▼──────┐ │ S7-1200 │ │ PLC │ └─────────────┘⚠️关键设计决策PLC通信绝对不能放在Blazor组件的生命周期里必须抽离为独立的BackgroundService通过共享状态服务向UI层推送数据。这是很多初学者翻车的第一个点。二、 PLC通信层稳定比花哨重要我选用S7.NetPlus社区维护的S7.Net增强版支持异步读写且修复了不少原版bug。2.1 封装PLC服务不要直接在页面里new Plc对象封装一个单例服务publicclassPlcService:BackgroundService,IDisposable{privatereadonlyPlc_plc;privatereadonlyILoggerPlcService_logger;privatereadonlyIPlcStateStore_stateStore;// 共享状态// S7-1200 默认机架0槽1publicPlcService(IPlcStateStorestateStore,ILoggerPlcServicelogger){_plcnewPlc(CpuType.S71200,192.168.0.1,0,1);_stateStorestateStore;_loggerlogger;}protectedoverrideasyncTaskExecuteAsync(CancellationTokenstoppingToken){while(!stoppingToken.IsCancellationRequested){try{if(!_plc.IsConnected){await_plc.OpenAsync(stoppingToken);_logger.LogInformation(PLC连接成功);}// 批量读取减少TCP往返次数vardataItemsnewListDataItem{new(){DataTypeDataType.DataBlock,DB1,StartByteAdr0,VarTypeVarType.Real,Count1},// DB1.DBX0.0 温度new(){DataTypeDataType.DataBlock,DB1,StartByteAdr4,VarTypeVarType.Real,Count1},// DB1.DBX4.0 压力new(){DataTypeDataType.DataBlock,DB1,StartByteAdr8,VarTypeVarType.Bit,Count1},// DB1.DBX8.0 运行标志};varresultsawait_plc.ReadMultipleVarsAsync(dataItems,stoppingToken);// 更新共享状态线程安全_stateStore.Update(newPlcSnapshot{Temperature(float)results[0].Value,Pressure(float)results[1].Value,IsRunning(bool)results[2].Value,LastUpdatedDateTime.Now});}catch(Exceptionex)when(exisnotOperationCanceledException){_logger.LogError(ex,PLC通信异常5秒后重试);_stateStore.UpdateConnectionStatus(false);awaitTask.Delay(5000,stoppingToken);}// 采集周期100ms根据实际需求调整awaitTask.Delay(100,stoppingToken);}}/// summary/// 写入控制命令供UI调用/// /summarypublicasyncTaskWriteControlAsync(stringaddress,objectvalue){await_plc.WriteAsync(address,value);}publicvoidDispose()_plc?.Close();}2.2 几个血泪教训问题原因解决方案频繁断连每次Read都新建连接保持长连接异常时才重连数据错乱多Circuit并发读写同一PLCPLC服务注册为Singleton所有操作串行化内存泄漏DataItem未释放使用ReadMultipleVars批量读取代替循环单次读取UI卡顿采集频率过高导致SignalR消息堆积服务端做节流UI刷新频率≤20fps即可三、 状态管理解耦通信与UI的核心这是整个架构最关键的一环。Blazor Server每个用户会话是一个独立的Circuit但PLC数据是全局共享的。我们需要一个发布-订阅模式的状态容器publicinterfaceIPlcStateStore{PlcSnapshotCurrent{get;}boolIsConnected{get;}eventActionPlcSnapshot?OnDataUpdated;eventActionbool?OnConnectionChanged;voidUpdate(PlcSnapshotsnapshot);voidUpdateConnectionStatus(boolconnected);}// 注册为 SingletonpublicclassPlcStateStore:IPlcStateStore{privatereadonlyobject_locknew();publicPlcSnapshotCurrent{get;privateset;}new();publicboolIsConnected{get;privateset;}publiceventActionPlcSnapshot?OnDataUpdated;publiceventActionbool?OnConnectionChanged;publicvoidUpdate(PlcSnapshotsnapshot){lock(_lock){Currentsnapshot;}OnDataUpdated?.Invoke(snapshot);}publicvoidUpdateConnectionStatus(boolconnected){IsConnectedconnected;OnConnectionChanged?.Invoke(connected);}}为什么不用StateHasChanged暴力刷新因为Blazor Server的渲染是通过SignalR发送diff到浏览器的。如果每秒触发10次StateHasChanged网络带宽和浏览器DOM操作都会成为瓶颈。正确做法是只在数据变化超过阈值或固定帧率时才通知UI。可以在PlcStateStore.Update中加一个简单的节流privateDateTime_lastNotifyDateTime.MinValue;privatestaticreadonlyTimeSpanNotifyIntervalTimeSpan.FromMilliseconds(50);// 20fpspublicvoidUpdate(PlcSnapshotsnapshot){lock(_lock){Currentsnapshot;}varnowDateTime.UtcNow;if(now-_lastNotifyNotifyInterval){_lastNotifynow;OnDataUpdated?.Invoke(snapshot);}}四、 UI组件实现4.1 实时监控面板page /monitor implements IDisposable inject IPlcStateStore StateStore div classdashboard div classstatus-bar (StateStore.IsConnected ? online : offline) PLC: (StateStore.IsConnected ? 在线 : 离线) | 最后更新: _lastUpdate.ToString(HH:mm:ss.fff) /div div classcard-grid div classcard h3温度/h3 div classvalue_temp.ToString(F2) °C/div div classbar stylewidth:(_temp / 150 * 100)%/div /div div classcard h3压力/h3 div classvalue_pressure.ToString(F2) MPa/div /div div classcard h3设备状态/h3 div classindicator (_isRunning ? running : stopped) (_isRunning ? 运行中 : 已停止) /div /div /div !-- 反向控制 -- div classcontrol-panel button onclickToggleDevice disabled(!_canControl) (_isRunning ? 停止设备 : 启动设备) /button /div /div code { private float _temp, _pressure; private bool _isRunning, _canControl; private DateTime _lastUpdate; protected override void OnInitialized() { // 首次加载当前值 RefreshFromState(StateStore.Current); // 订阅后续更新 StateStore.OnDataUpdated HandleDataUpdated; StateStore.OnConnectionChanged HandleConnectionChanged; } private void HandleDataUpdated(PlcSnapshot snapshot) { // 注意这个回调不在UI线程需要InvokeAsync InvokeAsync(() { RefreshFromState(snapshot); StateHasChanged(); }); } private void RefreshFromState(PlcSnapshot s) { _temp s.Temperature; _pressure s.Pressure; _isRunning s.IsRunning; _lastUpdate s.LastUpdated; } private void HandleConnectionChanged(bool connected) { InvokeAsync(() { _canControl connected; StateHasChanged(); }); } private async Task ToggleDevice() { // 通过API或直接注入PlcService写入 // 这里建议走一层CommandService做权限校验和日志记录 await PlcCommandService.ExecuteAsync(DB1.DBX10.0, !_isRunning); } public void Dispose() { // ⚠️ 必须取消订阅否则Circuit断开后仍然持有引用 → 内存泄漏 StateStore.OnDataUpdated - HandleDataUpdated; StateStore.OnConnectionChanged - HandleConnectionChanged; } }4.2 关于图表选型工业监控离不开趋势图。推荐两个方案轻量级直接用canvas Chart.js通过JS Interop传数据够用专业级集成GrafanaInfluxDB/TDengine作时序存储Blazor只做iframe嵌入或API对接对于纯前端实时曲线我目前用的是uPlot性能远好于Chart.js万级数据点不卡// wwwroot/js/uplot-helper.jswindow.uplotHelper{create:(id,opts){returnnewuPlot(opts,[],document.getElementById(id));},setData:(chart,data){chart.setData(data);}};五、 生产环境必须考虑的问题5.1 SignalR连接稳定性Blazor Server的生命线就是SignalR。工厂网络环境复杂必须做好断线处理// Program.csbuilder.Services.AddServerSideBlazor(options{options.DetailedErrorsfalse;// 生产环境关闭options.DisconnectedCircuitMaxRetained50;options.DisconnectedCircuitRetentionPeriodTimeSpan.FromMinutes(3);}).AddHubOptions(options{options.ClientTimeoutIntervalTimeSpan.FromSeconds(30);options.KeepAliveIntervalTimeSpan.FromSeconds(15);});前端加上自动重连提示!-- Pages/_Host.cshtml --scriptsrc_framework/blazor.server.jsautostartfalse/scriptscriptBlazor.start({circuit:{configureSignalR:builder{builder.withAutomaticReconnect([0,2000,5000,10000]);}}});Blazor.reconnectDisplay{show:()document.getElementById(reconnect-overlay).style.displayflex,hide:()document.getElementById(reconnect-overlay).style.displaynone,failed:()location.reload(),rejected:()location.reload()};/script5.2 安全加固Web化上位机暴露在网络上安全不能马虎强制HTTPS HSTS头操作审计所有写入PLC的操作记录到数据库包含操作员、时间、目标地址、写入值分级权限查看和控制分离用ASP.NET Core Identity或集成企业AD/LDAPIP白名单如果只在车间使用Kestrel/Nginx层面限制访问来源CSRF防护Blazor内置了Antiforgery确保不被绕过5.3 部署方案推荐使用Docker Nginx反向代理FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime WORKDIR /app COPY publish/ . EXPOSE 8080 ENTRYPOINT [dotnet, PlcMonitor.Web.dll]Nginx配置要点WebSocket支持location / { proxy_pass http://localhost:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_cache_bypass $http_upgrade; }六、 Blazor Web上位机的边界在哪说了这么多优点也得泼盆冷水。以下场景不建议用Blazor Web方案硬实时控制10ms响应SignalR有协议开销这种场景还是得上专用运动控制卡本地程序大量本地硬件交互USB串口、加密狗、本地打印机等浏览器沙箱限制太多离线运行要求Blazor Server强依赖服务端断网即瘫痪WASM模式可以离线但首次加载慢且无法直连PLC超大规模HMI单页500动态元素SignalR diff传输会成为瓶颈考虑拆分页面或用Canvas渲染但对于中小型产线监控、设备OEE看板、远程运维诊断这类场景Blazor Web上位机已经是性价比极高的选择。总结维度传统WinFormBlazor Server Web部署逐台安装一次部署浏览器即用跨平台❌✅移动端适配❌✅ 响应式安全性反编译风险高逻辑在服务端实时性优秀良好100ms级离线能力✅❌学习成本低中等需理解SignalR/Circuit模型技术选型没有银弹只有取舍。希望这篇文章能给正在评估Web化上位机方案的同行一些参考。项目核心代码已整理评论区交流。参考资料ASP.NET Core Blazor Server官方文档S7.NetPlus GitHub仓库uPlot高性能图表库《Industrial Internet of Things》 - Springer