简介:这是基于.NET8开发的跨平台物联网网关完整工程包,面向物联网平台开发者、工业现场集成人员以及边缘计算技术研究者。其核心价值在于通过可视化配置,就能接入可编程逻辑控制器、扫码枪、数控机床、串口设备、OPC服务器、MQTT服务器等多种异构数据源,并与Thingsboard、IoTSharp或自建MES、SCADA系统双向通信;同时还提供简洁的驱动开发接口和边缘计算机制,便于开发者按需扩展协议和本地数据处理,有效降低设备接入门槛。压缩包共1233个文件,大小约30MB,以C#源码为核心,包含大量页面文件、界面截图与演示动画,并附带项目解决方案、Docker部署文件及配置文档,结构清晰,便于直接编译、部署和二次开发。目前已有45人学习/下载,适合快速搭建工业物联网网关原型,也可作为企业设备接入和数据采集的基础框架,供技术选型与工程落地参考。
1. 一张可视化界面背后,.NET8 物联网网关替你挡掉了什么
在产线现场见得最多的,是设备端和平台端各讲各的话:Modbus TCP 设备吐的是寄存器地址,Thingsboard 要的是 JSON 遥测点,MES 那边等着下发工单指令。中间隔着一层写死的桥接程序,改一个点位映射就要改代码重新发布,网关成了整个产线上最怕碰的东西。基于 .NET8 的跨平台物联网网关要解决的,就是把协议差异、平台差异和运维差异一起收进一个可视化配置模型里。网关本体跑在 Windows 或 Linux 上不挑环境,用 Blazor 或 Vue 页面把设备连接、点位映射、上报规则摊开成表单,底层用 Worker Service 管驱动生命周期,用 MQTTnet 与 Thingsboard 这类平台做双向通讯。这篇稿子适合两类人:一类是 .NET 团队想自建物联网网关、不想被商业网关按通道数收年费的;另一类是架构师在评估边缘侧到底应该放 Node-RED 还是自研网关,需要看清边界和成本。
2. .NET8 物联网网关的架构:Worker Service 与设备驱动如何组织
2.1 为什么 .NET8 跨平台物联网网关把托管和驱动拆成两层
选择 .NET8 做物联网网关,不是因为写起来顺手,而是它把最难的系统性问题提前解决了。第一层是宿主托管,Worker Service 提供的 BackgroundService 生命周期能让你控制设备重连、平台断线、优雅停机,这些在无人值守的边缘硬件上就是命根子。第二层是跨平台发布,.NET8 的 SingleFile 发布加上 RuntimeIdentifier 指定 linux-arm64 或 win-x64,一个网关程序在工控机、树莓派、云服务器上能用同一套配置跑起来,不需要为每种平台单独编译一套带界面的大客户端。
很多人第一次做网关时,容易把所有逻辑堆在一个 MQTT 回调里,设备数据进来就转发到平台。这样写 demo 可以,一旦接入协议超过三种,代码就乱成一团。常见做法是把网关从逻辑上分成两层:设备驱动层负责把各家协议变成统一的点位值,平台适配层负责把点位值变成 Thingsboard、IoTSharp 或自建系统的消息。中间用一个数据总线连接,两边互不知道对方存在,后期换平台或加协议都只动一层。
对比一下常见的三种自建方案,能更直观看出 .NET8 在这条路上的位置:
| 方案 | 设备协议支持 | 平台对接 | 可视化配置 | 边缘资源占用 |
|---|---|---|---|---|
| Node-RED 流程编排 | 靠节点生态,质量参差 | HTTP/MQTT 节点齐全 | 流程图直观但无法管控复杂表格 | 常驻 Node 进程,内存占用偏高 |
| Java Spring Boot 网关 | 需自己集成 Modbus/OPC UA 库 | 成熟,适合企业后端 | 通常要另做管理后台 | 稳定但启动慢,部署体积大 |
| .NET8 Worker Service | System.IO.Ports、NModbus、OPCFoundation 齐全 | MQTTnet 轻量可控 | 可单独拆成 Web 管理端 | 单文件发布后镜像小,内存可控 |
网关里真正需要长期演进的是驱动抽象。我一般会先定义IDeviceDriver接口,所有协议驱动只实现这个接口,配置层拿到协议名就实例化对应的实现,这样界面下拉框里新增协议时,不需要改动采集主流程。
2.2 网关最小可运行骨架:Worker Service 与驱动接口
先看驱动的接口定义。这个接口把设备连接和点位读写收敛成四个成员,任何协议都能套进去。
// 统一设备侧协议入口 public interface IDeviceDriver { string Protocol { get; } // 读取一批点位,返回 key-value 对,key 对应界面里配置的点位键名 Task<IReadOnlyDictionary<string, object?>> PollAsync(DeviceConnection connection, CancellationToken ct); // 下行写操作,MES/SCADA 平台下发指令就是走这个方法 Task WriteAsync(DeviceConnection connection, string pointKey, object value, CancellationToken ct); }接口里PollAsync和WriteAsync是关键。PollAsync是一次批量采集,不是每个点位单独读,这样对 Modbus TCP 这种支持批量读的协议非常友好;WriteAsync是平台下发的命令落到设备侧的通道,后面对接 Thingsboard RPC 时就是调用它。DeviceConnection是整个网关配置的最小单元,里面包含设备地址、协议参数、点位集合。
主服务用 .NET8 的BackgroundService把每个连接跑成一个独立任务。常见做法是每个连接一个CancellationTokenSource,停工时逐层通知,避免设备那边留下半开连接。
public sealed class DeviceGatewayWorker : BackgroundService { private readonly IEnumerable<IDeviceDriver> _drivers; private readonly IConfiguration _configuration; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var connections = _configuration.GetSection("DeviceConnections").Get<List<DeviceConnection>>(); // 每个连接独立运行,互不阻塞 var tasks = connections.Select(conn => RunConnectionAsync(conn, stoppingToken)); await Task.WhenAll(tasks); } private async Task RunConnectionAsync(DeviceConnection conn, CancellationToken ct) { var driver = _drivers.First(d => d.Protocol == conn.Protocol); var buffer = new Channel<DeviceValue>(new BoundedChannelOptions(5000)); // 采集循环:按界面配置的周期读取点位,写入内存缓冲 var pollTask = PollLoopAsync(driver, conn, buffer.Writer, ct); // 上报循环:从缓冲取数据,由平台适配层发送 var reportTask = ReportLoopAsync(conn, buffer.Reader, ct); await Task.WhenAll(pollTask, reportTask); } }Channel<DeviceValue>是 .NET 内置的无锁生产者消费者队列,替代手工ConcurrentQueue加信号量的写法。采集循环只负责把驱动读出来的值写入 Channel,上报循环只负责消费并交到平台适配层,两边的速度天然解耦。BoundedChannelOptions(5000)表示缓冲区最大 5000 条,超过时默认等待而非丢弃,这个特性在处理设备突发上报时能兜底。
2.3 协议插件如何被配置驱动加载
驱动实例化不需要硬编码。配置里写protocol: "modbus-tcp",网关启动时从依赖注入容器里取出对应的驱动即可。新增协议时只写一个新的IDeviceDriver实现并在Program.cs注册,界面下拉框就会自动出现这个选项,这就是可视化配置连接设备的基础。
设备侧连接参数按协议分类,界面也按协议渲染不同的表单。常用协议在配置模型里的关键参数如下:
| 协议 | 连接参数 | 常见坑 |
|---|---|---|
| Modbus TCP | host、port、slaveId、timeoutMs | 寄存器地址是 0 基,很多设备文档写 1 基,差一位点位全错 |
| Modbus RTU | portName、baudRate、dataBits、stopBits、parity | 串口被占用时不会报错,但会一直超时,要加连接检测 |
| OPC UA | endpointUrl、nodeId 列表、anonymous / user 认证 | 安全策略不一致会导致握手失败,优先配 None 模式调试 |
| 自定义 MQTT 设备 | brokerUrl、clientId、topicFilter、payloadTemplate | 设备端 payload 格式不固定,要留 JsonPath 表达式配置 |
这一层做厚之后,可视化配置界面其实只是这张表的表单化。后面章节会具体展开配置模型和校验,这里先记住一个原则:网关的配置入口是连接,不是点位;点位必须挂在连接下面,界面才符合现场维护人员的思考习惯。
3. 可视化配置的落地方案:点位映射、校验与热加载
3.1 可视化配置的整体编排:四层模型决定界面怎么画
网关的可视化配置不能只做一个照片墙式的连接按钮。真正能落地到产线的界面,至少要拆成四层:连接配置层管设备在哪、怎么连;点位映射层管寄存器变点位的换算逻辑;平台配置层管数据往谁家发;调度配置层管采集周期和上报周期。四层分开建模,界面才有办法做成分步向导,而不是一张几千行的 JSON 编辑器。
连接配置层和平台配置层最容易理解,点位映射层是信息密度最高的地方。多条设备协议差异最终都会表现在点位映射上:Modbus 里 float 的字节序、OPC UA 里 NodeId 的命名空间、自定义 MQTT 报文里 JSON 字段的取值路径。把这些都收进点位配置项,界面就能用同一张表格应付所有协议。
调度配置层常被忽略,却是现场值班最依赖的配置。采集周期与上报周期如果绑死,设备数据量大时会挤爆平台写入限额。我一般建议把这两个周期分开:采集周期按设备实时性需求设,上报周期按平台配额设,中间用缓冲队列消化差值。界面里两个下拉框并排,现场人员能直接看懂。
3.2 一份设备配置 JSON 结构与点位映射参数
下面的 JSON 是一个打包线设备的完整配置示例,覆盖四层模型中的三层,调度和点位都在这里面。
{ "connectionName": "packaging-line-01", "protocol": "modbus-tcp", "pollIntervalMs": 1000, "reportIntervalMs": 5000, "endpoint": { "host": "192.168.1.12", "port": 502, "slaveId": 1, "timeoutMs": 3000 }, "points": [ { "key": "temp_a", "register": 0, "type": "float32", "byteOrder": "ABCD", "scale": 0.1, "offset": 0 }, { "key": "status", "register": 10, "type": "uint16", "bitmask": 3 }, { "key": "runtime_minutes", "register": 11, "type": "uint32", "scale": 1, "offset": 0 } ] }字段说明里最关键的是byteOrder和bitmask。Modbus 协议本身不定义 float 字节序,不同厂家设备可能是 ABCD、CDAB、BADC 三种中的任一种,只给type: "float32"而不给字节序,采集出来的温度值会错得莫名其妙。bitmask用于从单个寄存器里取位状态,很多设备把运行、故障、急停堆在一个寄存器里,没有位掩码字段就只能靠后续脚本拆数据。
scale和offset表示线性换算:实际值 = 原始值 × scale + offset。温度探头输出值放大 10 倍上传时就配scale: 0.1,这些在 UI 上做成可编辑表格比写代码改映射更符合可视化配置的定位。type字段的取值范围建议统一做成枚举,界面渲染成下拉框,防止手工输入导致驱动解析失败。
配置存储不要用本地 JSON 文件直读直写。常见做法是网关连接 SQLite 或 PostgreSQL,启动时把全量配置加载到内存,修改配置后先写入数据库再通知后台服务重载。这样做的好处是,配置变更可以记录操作日志,多人维护时能追溯哪条连接被谁改了。
3.3 配置校验与热加载:改配置不重启网关
界面把配置保存下来只完成了一半,另一半是校验。校验规则里最容易踩坑的是两点:pollIntervalMs过小会把设备 CPU 占满;points里两个点位指向同一个寄存器、同样的type和byteOrder,会导致驱动解析重复甚至冲突。
用 FluentValidation 做校验规则比较直接。下面这段代码处理点位重复和采集周期是否合理。
public class DeviceConnectionValidator : AbstractValidator<DeviceConnection> { public DeviceConnectionValidator() { RuleFor(x => x.PollIntervalMs) .GreaterThanOrEqualTo(200) .WithMessage("采集周期不能小于 200ms,多数 Modbus 设备承受不了"); RuleFor(x => x.Points) .Must(points => points .GroupBy(p => new { p.Register, p.Type, p.ByteOrder }) .All(g => g.Count() == 1)) .WithMessage("点位映射存在重复寄存器、重复类型和字节序的组合"); RuleForEach(x => x.Points) .Must(p => p.ByteOrder is "ABCD" or "CDAB" or "BADC") .WithMessage("float 字节序只支持 ABCD、CDAB、BADC 三种"); } }校验通过后,配置热加载是必须的。现场改完一个点位就当晚上线,没人愿意半夜重启网关服务。做法是在 Worker 里订阅一个配置变更事件,对比变更前后的连接集合,只重启被修改的那条连接任务,其他连接保持不动。
private async Task ReloadConnectionAsync(string connectionName, CancellationToken ct) { // 只取消该连接对应的任务 if (_connectionCts.TryGetValue(connectionName, out var oldCts)) { oldCts.Cancel(); oldCts.Dispose(); } var conn = _configuration.GetConnection(connectionName); var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); _connectionCts[connectionName] = cts; _ = RunConnectionAsync(conn, cts.Token); }热加载的关键是_connectionCts这个字典维护了连接名与令牌源的对应关系。注意修改连接参数时,设备侧的半开 Socket 并不会因为释放CancellationTokenSource立刻关闭,正确做法是驱动内监听ct.IsCancellationRequested并主动释放网络资源。如果驱动里没有释放逻辑,重载多次后会看到句柄数不断上涨,最终连不上任何设备。
4. 对接 Thingsboard 的双向通讯:遥测、属性与 RPC 命令
4.1 Thingsboard MQTT 主题表:双向通讯的语义边界
Thingsboard 的 MQTT 接入语义全部体现在主题上,主题本身就是技术协议。单设备接入和网关接入是两套主题体系,作为物联网网关,要用网关接入方式,也就是v1/gateway/telemetry这一族主题。网关模式下,上行用一个主题承载多个子设备的数据,下行则按子设备名路由到对应设备的 RPC 处理逻辑。
| 用途 | 主题 | 消息体格式 | QoS 建议 |
|---|---|---|---|
| 遥测上报 | v1/gateway/telemetry | {"deviceName": "packaging-line-01", "data": {"temp_a": 23.5}} | 0 或 1 |
| 客户端属性上报 | v1/gateway/attributes | {"deviceName": "...", "data": {"firmware": "1.2.0"}} | 1 |
| 子设备信息上报 | v1/gateway/connect | {"device": "packaging-line-01"} | 1 |
| RPC 请求接收 | v1/gateway/rpc | {"device": "packaging-line-01", "data": {"method": "setSpeed", "params": {"rpm": 1200}}} | 0 |
| RPC 响应发送 | v1/gateway/rpc/response | {"device": "...", "id": 1, "data": {"success": true}} | 1 |
| 共享属性更新 | v1/gateway/attributes/response | 同上,含 requestId | 1 |
双向通讯的核心是 RPC 这条链路。Thingsboard 的规则引擎或设备控制面板下发setSpeed命令,网关收到后解析出method和params,调用设备驱动的WriteAsync执行写操作,再把执行结果通过v1/gateway/rpc/response原路返回。RPC 请求默认有超时时间,如果网关在超时时间内没有返回响应,Thingsboard 就会标记该命令为失败。这就是双向通讯的完整闭环。
4.2 用 MQTTnet 实现遥测上报与 RPC 响应
MQTTnet 是目前 .NET 生态里最主流的 MQTT 客户端库,API 设计风格清晰,异步方法齐全。下面这段代码实现了一个对接 Thingsboard 网关的传输层关键逻辑。
public sealed class ThingsboardGatewayClient { private readonly IMqttClient _client; private readonly string _gatewayAccessToken; public ThingsboardGatewayClient(string brokerUrl, string gatewayAccessToken) { _gatewayAccessToken = gatewayAccessToken; var mqttFactory = new MqttFactory(); _client = mqttFactory.CreateMqttClient(); } public async Task ConnectAsync(CancellationToken ct) { var options = new MqttClientOptionsBuilder() .WithTcpServer("localhost", 1883) .WithCredentials(_gatewayAccessToken) // Thingsboard 用 access token 做用户名,密码留空 .WithCleanSession(true) .WithKeepAlivePeriod(TimeSpan.FromSeconds(30)) .WithWillTopic("v1/gateway/attributes") // 遗嘱消息:上报离线状态 .WithWillPayload("{\"device\":\"packaging-line-01\",\"data\":{\"online\":false}}") .Build(); await _client.ConnectAsync(options, ct); } public async Task PublishTelemetryAsync(string deviceName, Dictionary<string, object> payload, CancellationToken ct) { var wrapper = new { deviceName, data = payload }; var message = new MqttApplicationMessageBuilder() .WithTopic("v1/gateway/telemetry") .WithPayload(JsonSerializer.Serialize(wrapper)) .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtMostOnce) .WithRetainFlag(false) .Build(); // 上报一条遥测,QoS 0 配合本地缓存,避免网络抖动时重传风暴 await _client.PublishAsync(message, ct); } public async Task OnRpcAsync(Func<string, string, object, Task<object>> handler, CancellationToken ct) { _client.ApplicationMessageReceivedAsync += async args => { var topic = args.ApplicationMessage.Topic; if (topic != "v1/gateway/rpc") return; var rpcEnvelope = JsonSerializer.Deserialize<RpcEnvelope>(args.ApplicationMessage.Payload); var result = await handler(rpcEnvelope.Device, rpcEnvelope.Data.Method, rpcEnvelope.Data.Params); // 必须原路返回,携带原始 id,否则 Thingsboard 无法关联请求 var response = new { device = rpcEnvelope.Device, id = rpcEnvelope.Data.Id, data = result }; var responseMessage = new MqttApplicationMessageBuilder() .WithTopic("v1/gateway/rpc/response") .WithPayload(JsonSerializer.Serialize(response)) .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) .Build(); await _client.PublishAsync(responseMessage, ct); }; } }代码里值得注意的参数配置:WithCredentials(_gatewayAccessToken)是 Thingsboard 的认证方式,用户名传 token,密码留空,配置错了会收到 CONNACK 返回码 5,即未授权。WithWillTopic和WithWillPayload是遗嘱消息,网关断电时 Thingsboard 能及时感知设备离线,这在产线监控里相当重要。
RPC 处理函数里需要注意,Thingsboard 发来的params可能是任意类型,正好对应可视化配置里点位写值的参数。把params透传给IDeviceDriver.WriteAsync时,要按点位type做一次类型转换,不然 JSON 里的数值可能因为类型不匹配被驱动拒绝。响应消息的id字段必须与请求中的id一致,否则超时排查时会在日志里看到请求和响应对不上。
4.3 换到 IoTSharp 或自建 MES/SCADA 的适配思路
Thingsboard 只是平台适配层的一个实现。物联网网关能对接的设备与平台数量,决定它在产线里的寿命。处理这个问题时,我一般给平台适配定义接口IPlatformClient,与前面的IDeviceDriver对称,保证设备侧与平台侧互换互不影响。
public interface IPlatformClient { string PlatformType { get; } Task ConnectAsync(CancellationToken ct); Task PublishTelemetryAsync(string deviceName, Dictionary<string, object> payload, CancellationToken ct); Task PublishAttributeAsync(string deviceName, Dictionary<string, object> attributes, CancellationToken ct); Task SubscribeRpcAsync(Func<RpcCommand, Task<object>> handler, CancellationToken ct); }Thingsboard 和 IoTSharp 都是 MQTT 协议,IPlatformClient的两个实现差别主要在主题和消息体上。自建 MES 或 SCADA 的情况更复杂:有的暴露 HTTP 接口,有的走私有 TCP 协议。常见做法是给自建平台做一个 HTTP 实现,把遥测数据 Post 到对方接口,RPC 则用轮询或 WebSocket 接收工单指令。这样网关主体逻辑完全复用,平台差异被隔离在适配层。
对接自建平台还有一个务实的取舍:不要一上来就追求平台适配层的绝对统一。MES 和 SCADA 的指令模型差异很大,MES 下发的是工单和工艺参数,SCADA 下发的是瞬时控制指令。强制把它们套进同一个RpcCommand模型,有时会写出过度抽象的低质量代码。合适的粒度是遥测和属性上报完全统一,RPC 链路各平台单独实现。
5. 网关在边缘侧真正能跑久的 3 个细节:离线补发、QoS 与状态观测
5.1 离线补发:持久缓存比内存队列更可靠
产线网关跑到半夜,断网是常态。网关和平台之间的链路断开时,设备数据还在继续采集,这些数据不能丢,否则第二天早上报表和产量统计就是错的。很多人第一个想到的是内存队列,把断线期间的数据积压在Channel里。问题在于进程一重启队列就没了,而边缘网关重启几乎和断网一样频繁。
我会选择 SQLite 做持久化缓存。每条数据写入telemetry_cache表,字段包括设备名、采集时间戳、JSON 数据体、投递状态。断线时只写不读,恢复连接后按时间顺序补发。表结构大致是:id自增、device_name、ts、payload、sent标记。补发完成后及时删掉已发送记录,避免表无限增长。容量上限可以设置 5 万条或按磁盘空间算,达到上限时丢弃最老的数据,同时记录一条内存计数以便在可视化界面上展示缓存水位。
5.2 QoS 与心跳:连接参数不是越大越好
MQTT 的 QoS 选型最能看出网关设计经验。遥测数据用 QoS 0 是合理的,因为数据是周期性的,丢了下一周期还能补上。而配置为 QoS 2 会造成大量确认报文,在弱网环境下反而拖慢整体吞吐。RPC 响应建议 QoS 1,命令下发必须至少送达一次。需要注意的是,Thingsboard 对遥测写入支持批量接口,断线补发时把多条遥测合并成一条数组消息发送,比逐条发送效率高很多。
Keep Alive 参数也常被忽略。默认 60 秒对部分设备够用,但在移动网络环境下 30 秒更合适。网关侧还要设置WithSessionExpiryInterval,断线重连后是否能恢复会话与这个参数有关。如果业务上不需要离线消息,设为 0 让会话即时过期,重连开销更小。
5.3 用 ECharts 与时序日志验证双向链路
网关接完 Thingsboard 后,第一件事不是看设备数据,而是验证双向链路。打开 Thingsboard 的设备详情页,进入最新遥测标签页,能看到网关上报的temp_a和status值。在仪表板里把 ECharts 时序图组件绑定到这些遥测键上,就能看到数据曲线实时滚动。
RPC 下行验证更直接。在设备详情页的 RPC 调试窗口下发setSpeed命令,同时盯着网关进程控制台的WriteAsync日志,如果设备侧有响应值,再观察 RPC 调试窗口是否返回成功。简单场景用这个操作就够了,复杂场景我一般会把网关日志和 Thingsboard 规则引擎的调试输出放在同一窗口,配合时间戳对齐排查问题。比如网关显示已发送,平台没收到,那就是平台规则链没把遥测路由到正确的实体上;平台显示超时,网关日志没收到 RPC,问题则在连接层或 token 配置。
本文还有配套的精品资源,点击获取