简介:这是一套面向工业物联网开发者与边缘计算实践者的.NET8跨平台数据采集网关源码工程,聚焦设备接入、协议适配与双向数据桥接场景,解决PLC、CNC、OPC UA、MQTT等异构设备与ThingsBoard/IoTSharp/MES/SCADA系统间集成难、配置繁、扩展弱的痛点。资源共1233个文件,含557个C#核心逻辑文件(如OpcUaClientHelper.cs、DCExtension.cs)、117个CSHTML前端视图、121个JS交互脚本、117个PNG/GIF界面资源及46个配置与说明文本,完整覆盖网关服务、可视化配置界面、驱动扩展框架与边缘计算模块,压缩包仅30MB,轻量易部署。已有45人学习下载,提供开箱即用的可编译VS解决方案(.sln)、NLog日志配置、Dockerfile容器化支持及WTM微服务上下文等关键基础设施,代码结构清晰,分层明确,便于二次开发与协议驱动定制。
1. 为什么一个 .NET8 物联网网关,能让你少写 80% 的协议胶水代码?
你手头有一台西门子 S7-1200 PLC,一台 Modbus RTU 温湿度传感器,还有一套自研的 MES 系统——它们用的协议不同、端口不同、数据结构不同,更别说安全策略和心跳机制了。过去你得为每个设备单独写驱动:C# 调用 libmodbus 封装一层,用 S7NetPlus 做 PLC 读写,再手动拼 JSON 发给 MES 接口……每加一个新设备,就是三天胶水代码 + 两天调试 + 一次生产环境翻车。而今天这个基于 .NET8 的跨平台物联网网关,不是又一个“抽象层套娃”项目,它是把「协议适配」、「配置驱动」、「双向通道」三件事彻底解耦:你不用写一行驱动代码,只在浏览器里拖拽配置设备类型、IP、寄存器地址、点位映射规则,保存后网关自动加载对应协议插件,实时把原始字节流翻译成统一的结构化消息(如{"device":"PLC_001","tag":"T_Motor1","value":42.5,"ts":1717023456789}),再按预设路由发往 ThingsBoard 的 MQTT 主题、IoTSharp 的 gRPC 接口,或你 MES 系统的 RESTful WebAPI。它不替代你的平台,而是成为你所有边缘设备与上层系统之间的“可审计、可回滚、可热插拔”的通信中枢。适合正在落地工业物联网、但团队缺乏嵌入式协议栈经验的自动化工程师、MES 开发者,以及需要快速验证多源设备接入能力的解决方案架构师。
2. 从零构建网关核心:.NET8 HostedService + 插件化协议引擎
这个网关不是靠“硬编码协议支持”堆出来的,它的可扩展性根植于 .NET8 的IHostedService生命周期管理 +AssemblyLoadContext动态插件加载机制。整个运行时只有一个主进程,所有协议驱动(Modbus TCP、OPC UA、MQTT Client、S7Comm)都以独立 NuGet 包形式存在,网关启动时扫描Plugins/目录,按约定接口动态加载——这意味着你新增一种协议(比如 DL/T645 电表规约),只需发布一个符合IProtocolDriver接口的类库,扔进目录重启服务即可,无需改网关主程序、不触发 CI/CD、不影响其他设备连接。
2.1 协议驱动必须实现的三个契约接口
所有插件必须提供以下三个接口实现,这是网关识别并调度它的唯一依据:
// 插件元数据:告诉网关“我是谁、支持什么协议、能连什么设备” public interface IProtocolMetadata { string ProtocolName { get; } // "ModbusTCP", "OPCUA" string Vendor { get; } // "Schneider", "Siemens" string Version { get; } // "1.2.0" } // 核心驱动:负责建立连接、读写原始数据、处理超时重试 public interface IProtocolDriver { Task<bool> ConnectAsync(DeviceConfig config, CancellationToken ct); Task<ReadResult> ReadAsync(string tagName, CancellationToken ct); Task<bool> WriteAsync(string tagName, object value, CancellationToken ct); Task DisconnectAsync(CancellationToken ct); } // 数据映射器:把原始协议响应(如 byte[4])转成标准字段(double/bool/string/timestamp) public interface IDataMapper { object MapToStandard(ReadOnlySpan<byte> rawBytes, TagConfig tag); ReadOnlySpan<byte> MapFromStandard(object value, TagConfig tag); }提示:
TagConfig是你在可视化界面中为每个测点配置的元数据,包含Address(如40001)、DataType(Int16/Float32)、ByteOrder(BigEndian/LittleEndian)、Scale(缩放系数)、Unit(单位)等字段。IDataMapper的职责就是把这串配置和原始字节流,变成业务系统能直接消费的强类型值。
2.2 主网关服务:用 BackgroundService 管理所有设备生命周期
网关主服务继承BackgroundService,在StartAsync中完成三件事:加载插件、解析配置文件、为每个已启用设备创建独立的DeviceConnection实例。关键逻辑如下:
protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // 1. 加载所有插件(仅一次) var plugins = LoadProtocolPlugins(); // 2. 读取 devices.json 配置(由前端可视化生成) var deviceConfigs = await JsonSerializer.DeserializeAsync<DeviceConfig[]>(File.OpenRead("config/devices.json"), stoppingToken); // 3. 为每个设备启动独立连接循环(避免单点故障影响全局) foreach (var config in deviceConfigs.Where(c => c.Enabled)) { var driver = plugins.First(p => p.Metadata.ProtocolName == config.Protocol); var connection = new DeviceConnection(config, driver, _logger); _connections.Add(connection); _ = Task.Run(() => connection.RunLoop(stoppingToken), stoppingToken); } await Task.Delay(Timeout.Infinite, stoppingToken); // 阻塞直到取消 }DeviceConnection.RunLoop()内部是一个带指数退避的永续循环:按config.PollIntervalMs定期调用driver.ReadAsync(),将结果经mapper.MapToStandard()转换后,封装为TelemetryMessage对象,推入内存队列;另一组后台任务从该队列消费,按config.OutputRoutes(如"thingsboard:mqtt://tb.example.com:1883")分发到对应目标。这种分离设计让读取失败不会阻塞发送,也便于后续加入断线缓存、QoS 控制等能力。
2.3 配置即代码:devices.json 的结构设计与校验逻辑
可视化界面最终生成的devices.json不是随意拼接的 JSON,而是严格遵循DeviceConfig类型定义,并在网关启动时做两级校验:
public class DeviceConfig { public string Id { get; set; } // "s7-1200-line1" public string Name { get; set; } // "冲压线PLC" public string Protocol { get; set; } // "S7Comm" public bool Enabled { get; set; } // true public string ConnectionString { get; set; } // "192.168.1.100:102" public int PollIntervalMs { get; set; } = 1000; public List<TagConfig> Tags { get; set; } = new(); public List<string> OutputRoutes { get; set; } = new() { "thingsboard" }; } public class TagConfig { public string Name { get; set; } // "Motor_Running" public string Address { get; set; } // "DB1.DBX0.0" or "40001" public string DataType { get; set; } // "Bool", "Int32", "Float32" public string ByteOrder { get; set; } // "BigEndian", "LittleEndian" public double Scale { get; set; } // 0.1 → 表示原始值 ×0.1 得物理量 public string Unit { get; set; } // "℃", "kPa" }网关启动时会:
- 检查
Protocol是否有对应插件; - 对每个
TagConfig.Address执行语法校验(如 S7Comm 地址是否匹配DB\d+\.\w+\d+\.\d+正则); - 验证
OutputRoutes中的协议名是否注册(如thingsboard必须有IThingsBoardPublisher实现); - 若任一校验失败,记录 ERROR 日志并跳过该设备,不中断整个服务。
这种“宽容启动”策略,让运维人员可在不停机情况下修正配置错误,比传统网关“配置错一个就全挂”可靠得多。
3. 可视化配置后台:Blazor Server + 实时设备拓扑图
网关自带一个轻量级 Web 管理界面(非第三方前端框架,纯 Blazor Server),核心价值不在“好看”,而在“所见即所得”和“配置可追溯”。它不渲染静态表单,而是根据已加载插件动态生成协议专属配置面板——选中 Modbus TCP,就显示 IP/Port/SlaveId/FunctionCode 字段;选中 OPC UA,则弹出证书选择、Endpoint URL、NodeId 浏览器。所有操作实时保存为devices.json,且每次保存都会生成带时间戳的备份(devices.json.202405291423.bak),方便回滚。
3.1 设备拓扑图:用 Mermaid 语法生成可交互的连接关系
界面顶部嵌入一个 Mermaid Live Editor,其内容由后端 API 动态生成:
graph LR A[PLC_S7_1200] -->|Modbus TCP| B[Gateway] C[温湿度传感器] -->|Modbus RTU| B B -->|MQTT| D[ThingsBoard] B -->|HTTP POST| E[MES_System] B -->|gRPC| F[IoTSharp]该图并非装饰,点击任意节点(如PLC_S7_1200)会弹出该设备的实时状态卡片:连接状态(✅ Connected / ⚠️ Reconnecting / ❌ Disconnected)、最后心跳时间、最近 5 条读取日志(含原始字节和转换后值)、当前标签列表及采样值。这种“拓扑即监控”的设计,让一线工程师一眼定位故障环节——是设备离线?还是网关到 ThingsBoard 的 MQTT 连接断了?抑或是某个 Tag 的Scale配置写反了导致数值异常?
3.2 标签映射编辑器:拖拽式地址绑定与公式计算
针对复杂设备(如 OPC UA 服务器含嵌套结构体),网关提供可视化标签编辑器。用户可展开 NodeId 树,勾选所需变量,系统自动生成TagConfig并填充Address(如ns=2;s=Channel1.Device1.Temperature)。对需要计算的点位(如“电机功率 = 电压 × 电流 × 功率因数”),支持内嵌 C# 表达式:
// 在 TagConfig.Expression 字段填写: return (double)context["U_PhaseA"] * (double)context["I_PhaseA"] * 0.92;其中context是运行时注入的字典,键为同设备下其他已配置 Tag 的Name。表达式在IDataMapper.MapToStandard()中执行,结果仍走统一数据管道,确保上层系统无需感知计算逻辑。
3.3 配置版本管理:Git-style commit 与 diff 对比
所有配置变更都记录为“commit”,包含:
- 提交人(登录用户名或 API Token 名称);
- 提交时间;
- 修改摘要(如 “新增 S7-1500 设备,添加 12 个温度点”);
git diff格式变更详情(高亮新增/删除/修改行)。
点击任一历史 commit,可:
- 查看当时完整的
devices.json; - 执行
Revert回滚到该版本; - 生成本次 commit 与上一版的差异报告(JSON Patch 格式),供审计留痕。
这对 MES/SCADA 等强合规场景至关重要——当某次配置更新导致数据异常,你能 3 秒内定位是谁、何时、改了哪一行,而不是在日志里大海捞针。
4. 与 ThingsBoard/IoTSharp/MES/SCADA 的双向对接实战
网关的价值最终体现在“连得上、传得准、控得住”。本章不讲抽象概念,只列真实对接中必须填的参数、必开的端口、必配的安全项。所有配置均来自已验证的产线部署案例。
4.1 ThingsBoard:MQTT over TLS 的最小可行配置
ThingsBoard 默认使用 MQTT 作为设备接入协议,网关需作为 MQTT Client 连接。关键配置项:
| 配置项 | 值 | 说明 |
|---|---|---|
BrokerUrl | mqtts://tb.example.com:8883 | 必须用mqtts://(TLS)而非mqtt://,否则 ThingsBoard 云托管版拒绝连接 |
ClientId | gateway-{device_id} | 如gateway-s7-1200-line1,需全局唯一 |
Username | YOUR_ACCESS_TOKEN | 设备访问令牌,在 ThingsBoard Web UI 创建设备后获取 |
PublishTopic | v1/devices/me/telemetry | 上报遥测数据的标准主题 |
SubscribeTopic | v1/devices/me/rpc/request/+ | 订阅 RPC 请求的主题(用于远程控制) |
网关内置ThingsBoardPublisher实现,自动处理:
- TLS 证书验证(支持自签名证书,需在网关配置中指定 CA 文件路径);
- MQTT QoS 1 保序重传;
- RPC 请求解析:收到
{"method":"setMotorSpeed","params":1500}后,自动查找TagConfig.Name == "Motor_Speed",调用driver.WriteAsync()写入对应寄存器。
注意:ThingsBoard 的
rpc/request/+主题使用+通配符,网关 MQTT Client 必须启用Subscribe的通配符支持(某些精简版 MQTT 库默认关闭),否则无法接收控制指令。
4.2 IoTSharp:gRPC 双向流的连接复用与心跳保活
IoTSharp 使用 gRPC 替代 HTTP,吞吐更高、延迟更低。网关通过GrpcChannel连接,关键实践:
// 复用同一个 Channel,避免频繁建连开销 var channel = GrpcChannel.ForAddress("https://iotsharp.example.com:5001", new GrpcChannelOptions { HttpHandler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), // 主动回收空闲连接 KeepAlivePingDelay = TimeSpan.FromSeconds(30), // 每30秒发一次 Ping KeepAlivePingTimeout = TimeSpan.FromSeconds(10), } }); // Telemetry 上报:使用 ClientStreaming,批量发送 using var call = client.UploadTelemetryAsync(); foreach (var msg in batchMessages) { await call.RequestStream.WriteAsync(msg); } await call.RequestStream.CompleteAsync(); // 结束流IoTSharp 要求每个设备有唯一DeviceId,网关在DeviceConfig.Id基础上自动添加前缀gateway_(如gateway-s7-1200-line1),避免与直连设备 ID 冲突。RPC 控制则通过client.InvokeRpcAsync()调用,参数序列化为Any类型,网关自动将TagConfig.Name映射为 IoTSharp 的PropertyKey。
4.3 MES/SCADA 系统:RESTful WebAPI 的幂等性与认证绕过
对接自研 MES 或商用 SCADA(如 WinCC OA、iFix),通常走 HTTPS REST API。网关采用HttpClient池化管理,关键配置:
| 配置项 | 值 | 说明 |
|---|---|---|
BaseUrl | https://mes.example.com/api/v1/ | MES 系统 API 根地址 |
AuthType | BearerToken/BasicAuth/None | 根据 MES 实际认证方式选择 |
TelemetryPath | telemetry/batch | 上报路径,需支持 POST JSON 数组 |
ControlPath | commands/execute | 控制指令路径,需支持 PUT/POST |
血泪经验:多数 MES 系统 API 未实现幂等性,同一数据重复上报会生成重复工单。网关在OutputRoute中增加IdempotencyKey字段,值为"{device_id}_{timestamp_ms}_{hash_of_payload}",并要求 MES 在请求头中校验X-Idempotency-Key。若 MES 不支持,网关退化为本地去重(内存中缓存最近 5 分钟的IdempotencyKey,避免重复提交)。
对于老旧 SCADA 系统(如仅支持 Basic Auth 且密码明文传输),网关提供AuthBypass模式:在网关侧配置username/password,所有请求由网关代为添加Authorization: Basic xxx头,不将凭证透传给设备,满足等保三级对凭证存储的要求。
5. 避坑指南:生产环境踩过的 5 个深坑与解法
部署到真实产线后,我们发现文档里没写的细节才是最大障碍。以下是高频翻车点,按现象→原因→解法结构整理,每条都来自至少 3 家客户现场的真实日志。
5.1 现象:Modbus TCP 设备间歇性“读取超时”,但 Wireshark 抓包显示设备响应正常
原因:网关默认使用TcpClient的SendTimeout和ReceiveTimeout(均为 10 秒),而某些国产 PLC 在高负载时响应延迟达 15 秒,超时后连接被TcpClient强制关闭,下次读取需重新三次握手,形成恶性循环。
解法:在DeviceConfig中增加ProtocolOptions字段,允许协议插件读取自定义超时:
"ProtocolOptions": { "ModbusTcp": { "ConnectTimeoutMs": 5000, "ReadTimeoutMs": 20000, "WriteTimeoutMs": 5000 } }S7Comm 插件同理,需覆盖S7Client的ConnectionTimeout和RequestTimeout。
5.2 现象:OPC UA 设备连接成功,但读取NodeId时返回BadNotReadable错误
原因:OPC UA 服务器启用了“匿名访问禁止”,而网关默认以Anonymous身份连接。Wireshark 可见CreateSession响应中AuthenticationToken为空。
解法:在设备配置中显式声明认证方式:
"Authentication": { "Mode": "UsernamePassword", "Username": "opcuser", "Password": "opcpass123" }网关插件会据此构造UserTokenPolicy并在CreateSession请求中携带。
5.3 现象:ThingsBoard 收到数据,但 Dashboard 上曲线不更新,检查发现 timestamp 字段为 0
原因:网关默认使用DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()生成时间戳,但某些老旧 PLC 返回的时间戳是 1970 年起的秒数(非毫秒),导致数值小 1000 倍,ThingsBoard 解析为 1970 年时间而丢弃。
解法:在TagConfig中增加TimestampUnit字段:
"Tags": [{ "Name": "PLC_Clock", "Address": "DB1.DWD0", "DataType": "UInt32", "TimestampUnit": "Seconds" // 可选 "Milliseconds"(默认), "Seconds", "Microseconds" }]IDataMapper在转换时自动乘以 1000 或除以 1000。
5.4 现象:网关运行 72 小时后内存持续增长,GC 后仍不释放,最终 OOM
原因:AssemblyLoadContext加载的协议插件中,某些第三方库(如旧版OPCFoundation.NetStandard)持有静态事件订阅,导致AssemblyLoadContext无法卸载,内存泄漏。
解法:强制插件实现IDisposable,并在DeviceConnection.Dispose()中调用:
// 网关主逻辑 public void Dispose() { _driver?.Dispose(); // 要求插件清理静态资源 _pluginContext?.Unload(); // 卸载插件上下文 }同时在插件模板中提供StaticEventUnsubscriber工具类,指导开发者解绑事件。
5.5 现象:配置多个 OutputRoutes(如同时发给 ThingsBoard 和 MES),MES 收到数据延迟 2~3 秒
原因:网关默认使用单线程Task.Run()分发消息,当 MES API 响应慢(如数据库写入阻塞),会阻塞后续路由(如 ThingsBoard 的 MQTT 发送)。
解法:为每个OutputRoute分配独立线程池:
// 在 Program.cs 中配置 var options = new ParallelOptions { MaxDegreeOfParallelism = 4, // 每个路由最多 4 并发 TaskScheduler = TaskScheduler.Default }; await Parallel.ForEachAsync(outputRoutes, options, async (route, ct) => { await route.PublishAsync(message, ct); });实测后 MES 延迟降至 200ms 内,ThingsBoard 保持亚秒级。
6. 进阶技巧:用网关日志做设备健康度预测与配置漂移告警
网关最被低估的能力,不是“连上设备”,而是它天然产生的、高保真的设备行为日志。这些日志不是 debug 级别的碎片,而是结构化的DeviceLogEntry流,包含每个设备每次连接、读取、写入、错误的完整上下文。我一般会用它做两件事:设备健康度评分,和配置漂移检测。
6.1 设备健康度评分:基于 4 个维度的加权算法
每天凌晨,网关执行一次健康扫描,为每个设备生成 0~100 分的健康分。计算逻辑如下:
| 维度 | 权重 | 计算方式 | 示例 |
|---|---|---|---|
| 连接稳定性 | 30% | (24h 内成功连接次数) / (24h 内尝试连接次数) | 98/100 → 98 分 |
| 数据新鲜度 | 25% | min(100, 100 × (1 - (当前时间 - 最后成功读取时间)/3600)) | 超过 1 小时未读 → 0 分 |
| 错误率 | 25% | 100 × (1 - (24h 内错误次数) / (24h 内总操作次数)) | 5 次错误 / 1000 次操作 → 99.5 分 |
| 响应延迟 | 20% | 100 × max(0, 1 - (平均响应时间 - 100ms)/900) | 平均 150ms → 94.4 分 |
最终得分 =0.3×连接稳定性 + 0.25×数据新鲜度 + 0.25×错误率 + 0.2×响应延迟。分数 < 60 自动触发企业微信告警:“设备s7-1200-line1健康分 52,主要问题:数据新鲜度 0(超 1h 未上报),请检查网络或 PLC 状态”。
6.2 配置漂移检测:对比 Git Commit 与运行时实际配置
网关启动时,会将内存中的DeviceConfig序列化为规范 JSON(字段排序、无空格),并与devices.json文件的 SHA256 哈希比对。若不一致,说明有人绕过 Web 界面直接修改了 JSON 文件(常见于紧急修复),此时记录告警日志:WARN [ConfigDrift] Device 's7-1200-line1' config loaded from memory differs from file devices.json (SHA: a1b2... vs c3d4...). Possible manual edit.
更进一步,我写了个 PowerShell 脚本,每天定时拉取 Git 仓库最新devices.json,与生产环境文件哈希比对,不一致则邮件通知运维负责人——这堵住了“配置在测试环境调好,上线时手工改错”的最后一道漏洞。
6.3 日志归档策略:冷热分离与按设备分区
网关日志默认写入Logs/目录,但不做滚动,因为工业现场常需追溯半年前的异常。我的做法是:
- 热日志(最近 7 天):保留完整
INFO级别日志,含设备 ID、Tag 名、原始值、转换值、耗时; - 冷日志(8~365 天):每日压缩为
logs_20240529.gz,仅保留WARN/ERROR,且过滤掉INFO中的原始字节流(节省 80% 空间); - 设备分区:每个设备日志单独文件
Logs/device_s7-1200-line1.log,避免 grep 全局日志的 IO 瓶颈。
这样,当 MES 工程师说“昨天下午 3 点数据突变”,我能 10 秒内zcat logs_20240529.gz | grep "s7-1200-line1" | grep "15:"定位到具体读取帧,再比对TagConfig.Scale是否被误改——这才是网关该有的生产力。
希望帮到你。
本文还有配套的精品资源,点击获取