真正把车间设备数据推到手机屏幕上,往往比想象中麻烦得多。做了十来年上位机和工业 IoT 的兄弟应该都有这种感觉:读 PLC 点位、读数控机床的扭矩值、把传感器状态拉回来,这些事半小时就搞定了;但一旦牵扯到把数据开放给手机 App、Web 前端或者第三方系统,就得重新搭一整套服务框架。这个 OPC 转 Web API 的 C# 服务器框架,就是专门解决这一段的——底层接 OPC DA、OPC UA、Modbus 协议,中间做缓存和标签管理,对外暴露标准的 RESTful API,再配一套手机 App 测试程序,从采集到终端展示一条链路走通。本文把这套框架的架构设计、核心代码、高并发调优和部署中踩过的坑完整写出来,给正要搞工业 IoT 数据网关的朋友做个参考。
1. 从车间到手机:这套框架解决的真实痛点
1.1 传统方案断在哪一环
工业现场的数据采集,说透了就两条路:一条走 PLC 驱动直连,一条走 OPC 协议。OPC 发展到今天已经非常成熟,西门子、施耐德、罗克韦尔这些厂商的软件都自带 OPC Server,连 Power Focus 6000 这类拧紧工具也有对应的 OPC 服务可以读出实时扭矩值。问题从来不在采集端,而在消费端。
老一套的 SCADA 系统把所有采集、画面、报表、报警都揉在一个软件里,界面跑在工控机上,想看数据就得坐到中控室。MES 系统倒是能接 OPC,但那是给生产管理用的,数据接口重、部署周期长,一个人想快速搭一套轻量的数据开放服务,犯不着上那么重的系统。而手机端、Web 端、云端这套生态是完全不同的技术栈,它们只认 HTTP、JSON、WebSocket 这些互联网协议,认不了 OPC 的 COM/DCOM 调用,也不认 OPC UA 的二进制会话。
所以中间需要一个网关层:往下用 OPC 协议把设备数据拿过来,往上用 Web API 把数据暴露出去,App 和前端不用关心底层是 PLC 还是传感器,给它一个 JSON 就行。这就是这个项目最初的定位——一个薄而稳的协议转换和数据开放层。
1.2 框架的定位与边界
做这个框架之前我给自己立了几条规矩,明确哪些该做、哪些不该做。
该做的:设备接入(OPC DA / OPC UA / Modbus TCP)、标签点表管理、数据缓存与变化推送、Web API 接口、手机 App 联调测试。
不该做的:不做复杂组态、不做历史大数据分析、不做报警联动。这些属于 SCADA 和大数据平台的领域,硬塞进来只会把框架弄臃肿,部署和排错都变得困难。
很多初学者容易犯的毛病是把网关做成"什么都能干的大杂烩",结果协议解析、数据存储、权限管理、设备管理全堆在一起,现场一跑就各种出问题。这个框架坚持单向数据流:设备 -> 采集模块 -> 标签缓存 -> API 输出。每个模块只干一件事,出问题时查链路也方便。
正是这种清晰的边界,让我可以在几天内完成"OPC 转 Web API + 手机端查看"这条完整数据通路,而不是陷入一个庞大的工业平台项目里无法自拔。下面的架构部分展开说。
2. 整体架构与技术选型:为什么锁定 C#/.NET
2.1 三层结构:采集、缓存、发布
整个框架我拆成了三个层次,对应三个不同的关注点:
采集层负责和物理世界打交道。它维护到 PLC、传感器、机床控制器、拧紧工具等设备的连接,按设定的轮询周期读取寄存器或者订阅 OPC 数据变化。这一层不关心数据将来给谁用,只负责把数据拿到手。
网关内核层是核心协调者。它维护一份标签表——每个标签对应设备的某个点位,比如"1号注塑机-模温""2号拧紧枪-扭矩值"。采集层推上来的原始数据会在这里做转换(单位换算、量程变换、状态判断),写入统一的内存缓存,同时标记时间戳和品质。只有变化超过死区的数据才会被放出来,避免无效数据刷爆接口。
发布层对外提供 HTTP API 和 WebSocket 推送。API 负责"请求-响应"式的数据访问,适合 App 轮询;WebSocket 负责实时推送,适合监控画面实时刷新。发布层完全不感知设备的通信细节。
这个分层带来的实际好处:换设备协议时只需要动采集层,API 和 App 一行不用改;加新消费端时只需要在发布层加接口,设备接入不受影响。
2.2 C#/.NET 版本与库选型复盘
选 C# 不是情怀,是这套场景下最现实的选择。
OPC 生态和 Windows 平台关系紧密。OPC DA 本身基于 COM/DCOM,只有 C# 和 C++ 用得最顺手;OPC UA 官方也维护了 .NET Standard 版本的 SDK。做工业 IoT 如果选 Java 或 Python,OPC DA 那边基本绕不过 COM 互操作,Python 的 open62541 绑定也远不如 .NET SDK 完整。Node.js 做高并发 HTTP 确实爽,但让它去维护一堆 OPC DA 的 COM 连接,稳定性堪忧。
具体选型:
.NET 版本用 8.0 LTS(当时用的 6.0 LTS,后来迁移到 8.0)。工业环境求稳,不要追最新的大版本,LTS 是底线。
OPC UA 用官方 OPCFoundation 的 Opc.Ua package。网上有人嫌它复杂,短时间内确实有学习成本,但它对证书管理、会话恢复、订阅机制的支持是最完整的,调试信息也详细。
OPC DA 用 Interop.OPCAutomation 和 OPCDAAuto 两件套,配合服务器的 ProgID 创建连接。这里注意 64 位进程调用 32 位 OPC Server 时容易出问题,下面部署章节会专门讲。
Modbus TCP 用 NModbus4 或者自己封装一个简单的 TCP 读写。Modbus 协议本身够简单,如果没有特殊要求,直接基于 TCPClient 手写读写寄存器也行,反而是最可控的,少了第三方库的版本兼容问题。
ASP.NET Core Web API 直接用框架内置的。Kestrel 自带的高并发能力完全够用,不需要额外引入网关组件。
数据库方面,这个框架我没放数据库,标签历史数据直接交给 InfluxDB 或 TDengine,网关只做转发。如果你需要简单存一下告警记录,SQLite 就够了,千万不要一上来就配 MySQL、SQL Server 集群,现场没人给你运维。
3. 采集层:OPC UA、OPC DA 与 Modbus 的接入细节
3.1 OPC UA 连接、会话与订阅
OPC UA 的接入看着门槛高,其实把套路理顺了就那么几步:配置应用程序、创建会话、订阅数据项、循环处理数据变化。
框架里我封装了一个 OpcUaClientManager,核心逻辑如下:
var config = new ApplicationConfiguration { ApplicationName = "Opc2WebApi.Gateway", ApplicationUri = "urn:localhost:opc2webapi:gateway", SecurityConfiguration = new SecurityConfiguration { ApplicationCertificate = new CertificateIdentifier { StoreType = "Directory", StorePath = Path.Combine(basePath, "OpcUaCertificates"), SubjectName = "CN=Opc2WebApi.Gateway" } }, TransportQuotas = new TransportQuotas { MaxMessageSize = 4194304 } }; await config.Validate(ApplicationType.Client); var endpointDescription = new SelectedEndpoint { EndpointUrl = "opc.tcp://192.168.1.10:4840", SecurityPolicyUri = "http://opcfoundation.org/UA/SecurityPolicy#None", SecurityMode = MessageSecurityMode.None }; var session = await Session.Create( config, endpointDescription, true, "OpcGatewaySession", 60000, new UserIdentity("operator", "password"), null);这段代码里有两个关键点:
第一是证书问题。OPC UA 的安全机制要求双向认证,客户端访问服务器必须先在服务器侧受信任。我在框架里放了一个 CertificateManager 来管理本地证书,开发环境可以临时跳过验证,生产环境绝对不能关。现场 90% 的 UA 连接失败都是证书信任问题,报错信息大多是一句"Certificate rejected",排查时先看服务器日志里的具体原因,而不是拿着客户端日志瞎猜。
第二是会话超时。工业网络不稳定,特别是跨路由器的场景,TCP 长连接很容易被设备侧网关断开。Session.Create 里的 timeout 参数我设为 60000ms,同时启动一个保活任务,每 30 秒调用一次 session.KeepAlive,如果返回 false 就自动重连,重连时重建订阅。没有这层保活,半夜设备重启一次,第二天数据就全断了,现场的人完全蒙住。
订阅部分我更推荐用MonitoredItem订阅数据变化,而不是定时去读:
var subscription = new Subscription { PublishingInterval = 1000, KeepAliveCount = 10, LifetimeCount = 100 }; session.AddSubscription(subscription); subscription.Create(); var item = new MonitoredItem { StartNodeId = new NodeId("ns=2;s=Line1.PLC.Pressure", 2), AttributeId = Attributes.Value, SamplingInterval = 500, QueueSize = 10, DiscardOldest = true }; subscription.AddItem(item); subscription.Publish(); item.FastDataChange += OnDataChanged;订阅方式比轮询的实时性好一个数量级,而且数据变化由服务器主动推送,客户端这边的 CPU 占用可以忽略。但对于那种没有 UA Server 只有 Modbus 的老旧设备,还是得老老实实按周期轮询,这就是下面的场景。
3.2 Modbus TCP 轮询与点位映射
很多老设备走不了 OPC UA,但一定开了 Modbus TCP。我的框架里另外放了一个 ModbusTcpClientManager,配置好设备 IP、端口、从站号和数据区后,按预设周期读取。
Modbus TCP 的核心操作就是读写保持寄存器和输入寄存器。用一个简单的 NModbus 封装,读取一段连续寄存器:
using var tcpClient = new TcpClient(deviceIp, 502); using var modbusMaster = new ModbusFactory().CreateMaster(tcpClient); // 读取 1 号从站从地址 0 开始的 20 个保持寄存器 ushort startAddress = 0; ushort numberOfPoints = 20; ushort[] registers = modbusMaster.ReadHoldingRegisters(slaveId: 1, startAddress, numberOfPoints);这里容易踩的坑是:Modbus 的寄存器地址和 PLC 内部的地址不是一回事。西门子 S7-200 的保持寄存器可能映射到 Modbus 地址 40001 到 40020,但 Modbus TCP 报文里用的地址是 0 到 19。也就是说,你拿着 PLC 编程软件里看到的地址去读,会错位一整段。
框架里我专门做了一个地址映射表,统一了数据字典,把设备原生地址翻译成 Modbus 线缆上的地址。配置写在 JSON 文件里:
{ "tagName": "Line1.CoolantTemp", "deviceAddress": "192.168.1.20", "slaveId": 1, "functionCode": 3, "startAddress": 4, "dataType": "float", "byteOrder": "ABCD", "scale": 1.0, "offset": 0.0 }这个配置格式把"业务关心的标签名"和"业务不关心的设备点位"分离。最终 App 里看到的只有Line1.CoolantTemp,至于这个值是从哪个寄存器读来的、用了什么字节序,全部由配置决定。后期现场调整点位,改 JSON 重启服务即可,不用改一行代码。
3.3 标签缓存与变化检测:别把糟数据推给上层
从采集层拿回来的原始数据不能直接丢给 API,否则会有两个问题:高频变化的数据打爆带宽,无效数据干扰上层判断。
我实现了一个 TagCache 模块,内部用 ConcurrentDictionary 存所有标签的最新值、时间戳和品质:
public class TagCache { private readonly ConcurrentDictionary<string, TagValue> _tagValues = new(); public void Update(string tagName, double rawValue, DateTime utcTime) { // 死区判断 if (_tagValues.TryGetValue(tagName, out var existing)) { if (Math.Abs(rawValue - existing.Value) < DeadBand) return; } var converted = ConvertRawToEngineering(rawValue, tagName); _tagValues[tagName] = new TagValue(converted, utcTime, Quality.Good); } public TagValue Get(string tagName) { return _tagValues.TryGetValue(tagName, out var value) ? value : new TagValue(0, DateTime.MinValue, Quality.Bad); } }死区这是个关键参数。比如冷却水温度在 25.01 和 25.03 之间波动,实际毫无意义,但对手机 App 的画面来说,每秒跳几个小数点只会让人看得心烦。把死区设成 0.1,只有当变化超过 0.1 时才刷新缓存。实测下来,一个一百点位的设备,没有死区时平均每秒推送 80 条数据,设了死区后降到 5 条以内,客户端流畅度完全不一样。
除了死区,状态量还有单独的判断逻辑。比如数控机床的"运行中/停止/报警"状态,框架里会对比状态码的变化,只在状态切换时记录一条事件。这样 App 端做状态变更提醒时,不会收到重复的事件。
4. Web API 层的高并发设计:缓存、限流与推送
4.1 RESTful 接口如何设计
OPC 领域老程序员往往习惯把 API 设计成"读一个点位传一个点位的名字",前端要拿一百个点位就调一百次接口,性能极差。这完全不是 Web API 的玩法。
我设计的接口以批量查询为主,单独查询为辅:
GET /api/v1/tags/{tagName}获取单个标签当前值GET /api/v1/tags?names=Line1.Pressure,Line1.Temp批量获取多个标签GET /api/v1/tags获取全部标签快照GET /api/v1/status获取设备在线状态与系统健康状态GET /api/v1/history/{tagName}?start=&end=&interval=获取指定时间段内的历史数据(由外部时序库支撑)
以批量接口为例,App 一次性传入 50 个标签名,服务端在内存缓存里查好结果,输出一个 JSON 数组。一次 HTTP 往返解决全部数据刷新,实实在在的 50 倍效率提升。
每个接口的返回格式统一包裹一层:
{ "code": 0, "message": "ok", "data": { "Line1.Pressure": { "value": 2.35, "timestamp": "2024-11-20T10:32:15.000Z", "quality": "good" }, "Line1.Temp": { "value": 25.4, "timestamp": "2024-11-20T10:32:14.500Z", "quality": "good" } }, "serverTime": "2024-11-20T10:32:15.120Z" }这里所有时间统一用 UTC ISO8601 格式返回,App 端按本地时区展示。你如果直接返回服务器本地时间,跨时区远程访问时会被坑得很惨。
4.2 高并发调优配置
Kestrel 默认配置对大多数工控场景都够用,但真正压测到高并发时,你会发现瓶颈不在 Web 服务器,而在几个不起眼的地方。
第一,线程池线程数。ASP.NET Core 的线程池默认最小线程数 4 到 8,工业场景下如果大量请求都涉及网络 I/O 等待,线程池扩容是需要时间的,会出现"请求量突然暴增,最初几百毫秒响应变慢"的现象。解决方法是启动时把线程池最小值调大:
ThreadPool.SetMinThreads(workerThreads: 200, completionPortThreads: 200);这行代码放在 Program.cs 启动入口即可。注意这是设置"最小值",不是上限,它只保证高峰时线程池能快速扩容。
第二,序列化开销。企业开发里大家都爱用 Newtonsoft.Json,但它的反射性能比 System.Text.Json 慢不少。既然用 .NET 8,直接用 System.Text.Json,并且把标签缓存改为预序列化——每次数据更新时顺便把 JSON 字符串算好缓存起来,请求来了直接返回字符串,省掉每次请求的序列化时间。这个优化非常猛,实测 QPS 提升接近一倍。
第三,GC 模式。服务端大量短生命周期对象(JSON、响应包)会产生不少内存垃圾。框架在 csproj 中设置:
<PropertyGroup> <ServerGarbageCollection>true</ServerGarbageCollection> </PropertyGroup>服务端 GC 模式牺牲了一点暂停时间换吞吐量,对 Web API 场景是最合适的。
第四,并发连接上限。给 Kestrel 设置合理的并发连接上限,防止某个 App 端异常频繁重连把连接数打满:
builder.WebHost.ConfigureKestrel(options => { options.Limits.MaxConcurrentConnections = 10000; options.Limits.MaxConcurrentUpgradedConnections = 5000; });这些参数都是可以按需调整的,不要盲目抄网上的大数字。上限设太高反而会因为系统资源耗尽导致崩溃,合理值取决于服务器内存和业务模型。
4.3 WebSocket 实时推送的取舍
HTTP 请求-响应适合低频查询,真正常刷新监控画面还得靠 WebSocket。
在框架里,客户端通过 WebSocket 连接后发送订阅请求:
{"command":"subscribe","tags":["Line1.Pressure","Line1.Temp"]}服务端维护一个订阅管理器,把每个 WebSocket 连接的 ID 和它关心的标签对应起来。标签缓存更新时,只把关心该标签的连接推过去。这就是典型的观察者模式,实现起来不复杂,但要注意推送失败时的处理:
var sendTask = webSocket.SendAsync(buffer, WebSocketMessageType.Text, true, CancellationToken.None); _socketTasks[key] = sendTask;不要对同一个 WebSocket 并发发送多条消息。如果上一个 SendAsync 还没完成,下一个已经开始,底层会抛 "InvalidOperationException"。框架里我用了一个并发队列加单发线程,每个 socket 只允许一个发送任务在跑,发完一条再从队里取下一条。
然后还要做心跳检测。设备端不会主动关闭 TCP 连接,客户端可能直接进了隧道没信号。服务端每隔 60 秒 Ping 一次,连续两次没响应就主动关闭连接并清理订阅列表,避免死连接占着资源。
5. 手机 App 联调测试:从 Demo 到稳定落地
5.1 测试工具与联调方案
这个项目名里特别挂了"带手机 app 测试",因为很多网关项目开发完就只拿 Postman 调接口,真到了手机上跑,问题一大把。
我的联调分三层走:
第一层,用 Postman 和 WebSocket 测试工具调通基本接口,确认返回格式和数据正确。这一步主要排除逻辑问题。
第二层,用手机浏览器直接访问 Web 页面(Vue.js 写的一个简单监控面板),看数据加载、WebSocket 推送、断线重连是否正常。手机浏览器的特性往往能暴露不少问题:弱网环境下的会话超时、HTTPS 证书不受信任、屏幕适配等等。
第三层,真机上的原生 App(我用了一个同事开发的 Android 测试壳),做持续性压力测试,验证 App 长时间挂着不闪退、不卡死、推送消息不丢失。
给一个 Android 原生测试壳最简单的 WebSocket 核心片段:
var ws = new ClientWebSocket(); await ws.ConnectAsync(new Uri("ws://192.168.1.100:5000/ws"), CancellationToken.None); var subscribe = Encoding.UTF8.GetBytes( "{\"command\":\"subscribe\",\"tags\":[\"Line1.Pressure\"]}"); await ws.SendAsync(subscribe, WebSocketMessageType.Text, true, CancellationToken.None); while (ws.State == WebSocketState.Open) { var buffer = new byte[4096]; var result = await ws.ReceiveAsync(buffer, CancellationToken.None); var msg = Encoding.UTF8.GetString(buffer, 0, result.Count); // 更新 UI }注意手机端在锁屏状态下 WebSocket 会被挂起,系统省电策略经常把后台网络连接停掉。因此 App 侧必须有重连机制:锁屏唤醒后检查连接状态,断了就自动重连并重新订阅。
5.2 压测数据与性能瓶颈
我用压测工具做了三轮压力测试,这里贴在个人笔记本(i5 四核 16G 内存)上的数据供参考:
| 并发连接数 | 请求 QPS | 平均响应延迟 | CPU 占用 | 内存占用 |
|---|---|---|---|---|
| 50 | 1200 | 3ms | 12% | 220MB |
| 200 | 4500 | 8ms | 35% | 280MB |
| 1000 | 18000 | 22ms | 78% | 420MB |
这组数据说明一个事实:瓶颈根本不在框架本身,而在你对接的外部系统。压测越往后,数据库历史记录写入的排队时间越长,API 响应时间中占比最大的反而是时序库的写入。
第一次压测时发现一个意外瓶颈:批量查询 100 个标签,响应时间居然比查 1 个标签慢了二十倍。排查后定位到问题出在序列化上——每次请求都对 100 个数据做了实时序列化,字典频繁扩容导致大量 CPU 消耗。后来改成预序列化缓存,同样的批量查询直接提升到和单标签几乎一样的耗时。
5.3 手机端监控页面的实际测试
最后再换个角度,从使用端看这套联调的真实感受。框架跑起来后,手机监控页面的实际表现是这样的:
页面打开后先发起一次GET /api/v1/tags拉取全部标签快照,剩下的事情全部交给 WebSocket 推送。标签值变化了,服务端推送新值,页面自动刷新。整个过程没有任何轮询请求,流量开销很小,电池消耗也明显更低。
测试中有一个印象深刻的场景:把手机切到飞行模式再打开,模拟隧道掉线,再回到网络环境下,页面能不能自动恢复。第一次测试结果很惨,App 的 WebSocket 断线后重连了,但订阅列表丢了——因为重连是新建了一个连接,服务端不知道这个连接应该继续关心哪些标签。修复方法很直接:App 重连成功后必须重新发送一次 subscribe 命令,把这套逻辑写在连接成功的事件钩子里,而不是只在页面初始化时执行一次。
这个坑,做 WebSocket 推送的兄弟十有八九会遇到,提前排掉能少掉很多头发。
6. 部署与运维:文档里不会写的那些坑
6.1 OPC UA 证书信任的连环坑
OPC UA 的证书机制现场第一次部署时基本必炸。客户端访问服务器时,服务器会拒绝不受信任的客户端证书;反过来,客户端也会拒绝不受信任的服务器证书。
第一次连接时控制台输出的报错往往是BadCertificateUntrusted,或者客户端这边直接抛ServiceResultException: Certificate rejected。
解决方案分两步。服务器端,把生成的客户端证书(通常在%ProgramData%\OPC Foundation\CertificateStores\MachineDefault\certs目录下)复制到 UA Server 受信任证书目录,重启 UA Server 即可。客户端这边,把 UA Server 的证书导入到本地受信任目录。
这套事做一次不难,但如果是几十台设备都带各自的 UA Server,资产一多就烦死。我在框架里实现了一个 AutoTrust 工具,开发环境下扫描局域网内所有 UA Server 并自动导入证书,一键处理。生产环境依然保持严格校验,这个切换逻辑由一个配置项Security.AutoTrust = true/false控制。
6.2 Windows 服务权限与发布形式
框架发布到一个工控机,或者一台 Windows 10 IoT Enterprise LTS 2021 的盒子上,最常见的部署方式是把 ASP.NET Core 发布成独立可执行文件,然后用 NSSM 封装成 Windows 服务。
但这块有个非常隐蔽的坑:OPC UA 客户端初始化时会在当前用户的证书目录下生成证书。如果你把服务配成以 LocalSystem 账户运行,证书目录就在 SYSTEM 账户的目录下;如果配成 NetworkService 或者特定的域账户,目录又不一样。一旦改了运行账户,证书可能失效,UA 连接全部失败。
更诡异的是普通用户登录交互式桌面能看到 OPC UA 连接正常,但服务模式下死活连不上。遇到这种情况,先查服务账户的证书目录和防火墙,再做别的排查。
发布时我还用 Costura.Fody 把依赖程序集合并成单一 exe,方便拷贝。但注意 OPC UA 的某些原生加密 DLL 不能合并,比如Opc.Ua.Security.Certificates依赖的 Windows 原生库,要单独保留在可执行文件目录下。这个细节花了我一个下午才定位到。
6.3 IIS 回收、防火墙与探测器
如果非要用 IIS 托管,IIS 的应用池回收机制会杀掉所有后台任务,包括 OPC UA 的订阅和 Modbus 的轮询线程。解决办法:配置 IIS 进程外托管且禁用应用池定期回收,或者干脆不要走 IIS,直接以 Kestrel 独立运行为主,前面挂一层 Nginx 做反向代理。
防火墙是另一大坑。OPC UA 默认使用 4840 端口,Modbus TCP 默认 502 端口,Web API 我用 5000 端口。这三者都必须放行。如果现场有工控防火墙,还得在防火墙上打开对应的 TCP 出站/入站规则。有次客户现场网关连不上设备,排查了半天发现是安全组把 Modbus 的 502 端口给禁了。
工控机上查看端口监听是否正常:
netstat -ano | findstr 5000同时务必把 Windows 防火墙的"文件和打印机共享"规则打开,否则局域网设备找 OPC 服务器会迷路。
6.4 时间戳与数据质量的坑
最后必须强调一个看起来不大但影响深远的问题:时间。
OPC UA 服务器返回的时间是服务器本地时间;Modbus 协议本身不携带时间戳;PLC 内部时钟可能已经偏了好几分钟。如果框架不统一处理,App 页面上看到的时间就是乱的,历史曲线的坐标轴更是没法看。
我的做法是统一以网关服务器时间为准,所有缓存的数据在采集入口打上DateTime.UtcNow时间戳,下游所有模块都用这个时间戳。PLC 自身的时间只用于设备侧诊断,不参与 API 输出。这样避免了多设备时钟不同步导致的排序错乱,也方便将来接入 InfluxDB 等时序库。
数据品质方面,OPC UA 规范里的 Quality 字段分为 Good、Uncertain、Bad。Modbus 没有这个机制,超时读到旧值会被当作新值推送出去。我在框架里对 Modbus 的读操作做了超时判断,如果超过 3 秒没有响应,标签值标记为 Bad 并保留上次有效时间戳,API 返回的quality字段就是bad。App 端判断到bad就显示灰色或打问号,而不是显示一个过期的假数据。不少现场事故就是因为界面显示了一个"看起来正常但其实是旧值"的数字,操作员误判了设备状态,教训够深刻。
部署完之后这套框架在我承担的几个设备数据采集项目里跑了小半年,OPC UA 十几个会话稳定在线,Modbus 每个周期约 200ms 完成一轮扫描,Web API 日常几百个 App 客户端连接没有崩过一次。中间重启过几次服务,唯一的经验就是要确保采集线程的优雅退出:服务停止前先把 OPC 订阅取消,Modbus 轮询循环退出,然后关闭连接,再释放 Web API 的监听端口。如果你也刚接手类似的 OPC 转 Web API 网关项目,可以先照这个思路把采集层跑通,再做缓存和 API,一步步来,千万别图一步到位。最后再提一个小技巧:每一步都配上对应的压测脚本,数据和接口行为有没有回归,跑一遍立刻知道,比改完代码盯着控制台看半天靠谱得多。