简介:MQTT客户端C#版是一份面向物联网开发者与C#学习者的实战项目源码,基于M2Mqtt.Net.dll实现MQTT协议通信,可用于智能家居、工业自动化、远程监测等场景的上位机开发。资源包共35个文件,约223KB,以cs源码、dll类库、resx资源、exe可执行程序、config配置及sln解决方案为主,涵盖窗体设计、程序入口、属性配置等完整工程结构,便于直接编译运行与二次开发。项目围绕连接管理、主题订阅、消息发布、心跳保活与异常重连等核心环节展开,读者可借此理解发布/订阅模型、QoS服务质量级别以及CONNECT、PUBLISH等报文的实际运用。目前已有4171人学习下载,适合希望掌握C#与MQTT库集成方式、构建图形化上位机并深入理解协议工作原理的开发者参考借鉴。
1. MQTT客户端C#版:从设备接入到消息闭环,一条能跑通的落地路径
设备端用 C# 写 MQTT 客户端,最常见的场景是工控上位机、边缘网关、产线数据采集盒这类 Windows 或 Linux 上的 .NET 程序。它们要干的事很朴素:把 PLC 寄存器、传感器读数、设备状态,稳定地发到 Broker,同时订阅控制指令做反向操作。难点从来不在“连上”,而在断线重连不丢消息、QoS 选得对、主题设计不打架、长时间跑内存不涨。这篇笔记按“选库 → 建连 → 收发 → 保活 → 排错 → 进阶”的顺序,把 MQTT客户端C#版 这条链路拆成能直接抄的代码和参数。适合已经会用 C# 但第一次认真做 MQTT 长连接的开发者,也适合被“跑三天就掉线”折磨过的老手对照排查。
2. 选库与建连:MQTTnet 和原生 MQTT 到底怎么选
2.1 三个候选库的取舍逻辑
C# 生态里做 MQTT 客户端,绕不开三个选择:MQTTnet、M2Mqtt(老牌 GnatMQ 系)、以及基于 System.Net.Sockets 自己撸协议。自己撸只适合教学,生产环境不建议,因为 MQTT 3.1.1 和 5.0 的报文编码、剩余长度变长整数、QoS 2 的四次握手,任何一处写错都会变成偶发玄学问题。
MQTTnet 是当前 .NET 平台最活跃的实现,支持 MQTT 5.0、TLS、WebSocket、自动重连,API 是异步的,和 .NET 的 async/await 契合度高。M2Mqtt 胜在轻、老项目多,但维护节奏慢,MQTT 5.0 支持弱。我的建议很直接:新项目一律 MQTTnet,老项目如果已经用 M2Mqtt 且没坏,不必为了换而换。
选型时看四个硬指标:是否支持 MQTT 5.0(要用共享订阅、消息过期、原因码就得要)、是否内置重连、是否支持 TLS 1.2 以上、是否能在 .NET Framework 4.6.2 和 .NET 6/8 上跑。MQTTnet 四项都过。
2.2 用 MQTTnet 建一个最小可用连接
先装包,命令行一条:
dotnet add package MQTTnet然后是最小连接代码,注意这里用的是 MQTTnet 4.x 的工厂写法:
using MQTTnet; using MQTTnet.Client; var factory = new MqttFactory(); var client = factory.CreateMqttClient(); var options = new MqttClientOptionsBuilder() .WithTcpServer("broker.example.local", 1883) // Broker 地址和端口 .WithClientId($"gateway-{Guid.NewGuid():N}") // 客户端ID,必须唯一 .WithCredentials("device01", "******") // 用户名密码 .WithCleanSession(true) // 清理会话,见下方说明 .WithKeepAlivePeriod(TimeSpan.FromSeconds(30)) // 保活心跳 .WithTimeout(TimeSpan.FromSeconds(10)) // 连接超时 .Build(); var result = await client.ConnectAsync(options, CancellationToken.None); if (result.ResultCode != MqttClientConnectResultCode.Success) { Console.WriteLine($"连接失败: {result.ResultCode}"); }逻辑说明:WithClientId必须全局唯一,两个客户端用同一个 ID 会互相踢下线,这是现场最常见的“莫名掉线”原因。WithCleanSession(true)表示不保留会话,断线后 Broker 不帮你存订阅和未确认消息;如果要 QoS 1/2 且不能丢,应设为 false 并配合固定 ClientId。WithKeepAlivePeriod设 30 秒是保守值,Broker 通常按 1.5 倍判定超时,即 45 秒没收到任何报文就断开。
参数说明:端口 1883 是明文,8883 是 TLS,8083/8084 是 WebSocket 常用端口。超时 10 秒适合局域网,跨公网建议 15 到 20 秒。凭据不要硬编码,用环境变量或配置中心注入。
2.3 重连策略:别用 while(true) 硬怼
断线重连是 MQTT 客户端的命门。MQTTnet 提供DisconnectedAsync事件,正确做法是在事件里做带退避的重连,而不是在业务循环里轮询。
client.DisconnectedAsync += async e => { Console.WriteLine($"断开原因: {e.Reason}"); var delay = TimeSpan.FromSeconds(2); while (!await TryReconnect(client, options)) { await Task.Delay(delay); // 指数退避,上限 60 秒,避免风暴式重连 delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 60)); } }; async Task<bool> TryReconnect(IMqttClient c, MqttClientOptions o) { try { var r = await c.ConnectAsync(o, CancellationToken.None); return r.ResultCode == MqttClientConnectResultCode.Success; } catch { return false; } }逻辑说明:退避从 2 秒开始翻倍到 60 秒封顶,防止 Broker 刚重启就被成百上千个客户端同时打满。e.Reason要打日志,区分是网络断、认证失败还是被踢,认证失败重连一万次也没用,应该告警而不是死循环。
参数说明:初始延迟 2 秒适合设备量小于 500 的场景,设备量大时初始值调到 5 到 10 秒。上限 60 秒是经验值,再长会影响指令实时性。
3. 收发消息:QoS、主题设计和批量上报的实操
3.1 QoS 0/1/2 到底怎么选
QoS 是 MQTT 最容易被误用的参数。QoS 0 是“发出去就不管”,可能丢;QoS 1 是“至少一次”,可能重复;QoS 2 是“恰好一次”,开销最大,四次握手。
现场经验:周期性遥测数据(温度、转速)用 QoS 0,丢一两个点无所谓,下一周期就补上了;控制指令、报警、计费类用 QoS 1,重复可以通过业务幂等去重;QoS 2 除非协议强制,否则别用,吞吐会掉一半以上,而且很多 Broker 对 QoS 2 的支持并不完整。
订阅时的 QoS 和发布时的 QoS 是两回事。最终生效的是两者中较小的那个。你发布用 QoS 1,订阅用 QoS 0,实际就是 QoS 0,这个坑很多人踩。
3.2 主题设计:层级、通配符和共享订阅
主题设计要在项目第一天定死,后期改主题等于全链路返工。推荐结构:{产品线}/{设备类型}/{设备ID}/{数据类别},例如factory-a/plc/line01-07/telemetry。
订阅通配符只有两个:+匹配单层,#匹配多层且必须在末尾。factory-a/plc/+/telemetry能匹配所有 PLC 的遥测,factory-a/#匹配 factory-a 下所有。
共享订阅是 MQTT 5.0 的能力,写法是$share/{组名}/{主题},多个订阅者用同一个组名,Broker 会把消息轮询分发,用于多实例消费同一批数据做负载均衡。注意共享订阅需要 Broker 支持,EMQX、HiveMQ 支持,部分轻量 Broker 不支持。
3.3 发布与订阅的完整代码
// 订阅 var subOptions = factory.CreateSubscribeOptionsBuilder() .WithTopicFilter(f => f .WithTopic("factory-a/plc/+/telemetry") .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)) .Build(); client.ApplicationMessageReceivedAsync += e => { var topic = e.ApplicationMessage.Topic; var payload = System.Text.Encoding.UTF8.GetString( e.ApplicationMessage.PayloadSegment); Console.WriteLine($"[{topic}] {payload}"); return Task.CompletedTask; }; await client.SubscribeAsync(subOptions); // 发布 var payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(new { ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), temp = 36.5, rpm = 1480 }); var msg = new MqttApplicationMessageBuilder() .WithTopic("factory-a/plc/line01-07/telemetry") .WithPayload(payload) .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtMostOnce) .WithRetainFlag(false) // 周期数据不保留 .Build(); await client.PublishAsync(msg);逻辑说明:ApplicationMessageReceivedAsync是回调,里面不要做耗时操作,否则会阻塞网络线程。正确做法是把 payload 丢进Channel或BlockingCollection,由后台消费者处理。PayloadSegment是只读内存,转字符串前确认编码,工业设备常用 GBK,用 UTF8 会乱码。
参数说明:WithRetainFlag(true)用于状态类主题,Broker 会保留最后一条,新订阅者立刻收到当前值,适合设备在线状态;周期遥测设 false,否则 Broker 存一堆过期数据。WithQualityOfServiceLevel按 3.1 节的规则选。
3.4 批量上报与背压控制
设备端高频采集时,逐条PublishAsync会打爆网络。常见做法是攒批:本地队列攒 50 条或 200 毫秒,合并成一个 JSON 数组发一条。但要注意背压——如果发布速度持续大于网络吞吐,队列会无限涨直到 OOM。
用Channel做有界队列,满了就丢最旧或阻塞采集:
var channel = Channel.CreateBounded<Telemetry>(new BoundedChannelOptions(10000) { FullMode = BoundedChannelFullMode.DropOldest // 满了丢最旧 });逻辑说明:DropOldest适合遥测,丢老数据保新数据;如果是报警数据,应该用Wait模式阻塞生产者,宁可慢也不能丢。容量 10000 是经验值,按每秒产生条数乘以最大容忍断网秒数估算。
4. 保活、TLS 与遗嘱消息:长连接稳定的三个开关
4.1 KeepAlive 与 PINGREQ 的真实行为
KeepAlive 不是“每隔 N 秒发一次心跳”,而是“客户端承诺在 N 秒内至少发一次报文”。只要你在周期内发了任何 PUBLISH,就不用额外发 PINGREQ。MQTTnet 内部会自动管理,你只需要设对值。
设太短,心跳流量大;设太长,断线发现慢。局域网 30 到 60 秒,跨公网 60 到 120 秒。注意 Broker 端也有一个最大 KeepAlive 限制,客户端设的值超过 Broker 上限会被拒绝或强制下调。
4.2 TLS 配置:证书、版本和常见握手失败
生产环境必须上 TLS。MQTTnet 配置:
var tlsOptions = new MqttClientTlsOptionsBuilder() .UseTls(true) .WithSslProtocols(System.Security.Authentication.SslProtocols.Tls12) .WithAllowUntrustedCertificates(false) // 生产必须 false .WithIgnoreCertificateChainErrors(false) .WithIgnoreCertificateRevocationErrors(false) .Build(); var options = new MqttClientOptionsBuilder() .WithTcpServer("broker.example.local", 8883) .WithTlsOptions(tlsOptions) .Build();逻辑说明:WithAllowUntrustedCertificates(true)只在自签证书调试时用,生产开了等于裸奔。握手失败九成是三个原因:证书链不全(中间证书没装)、SNI 没带对、系统时间不对导致证书过期判定错误。
参数说明:TLS 1.2 是底线,TLS 1.3 在 .NET 5+ 可用,但部分老 Broker 不支持。吊销检查在离线内网会拖慢握手,内网可关,公网必须开。
4.3 遗嘱消息:设备掉线的最后一道通知
遗嘱消息(Will)是客户端在 CONNECT 时报给 Broker 的,一旦客户端异常断开,Broker 代为发布。用于设备离线告警。
var options = new MqttClientOptionsBuilder() .WithWillTopic("factory-a/plc/line01-07/status") .WithWillPayload(System.Text.Encoding.UTF8.GetBytes("offline")) .WithWillQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) .WithWillRetain(true) .Build();逻辑说明:正常断开(调用 DisconnectAsync)不会触发遗嘱,只有异常断开才触发。所以设备上线后要主动发一条online到同一主题并 retain,形成“上线主动报、下线靠遗嘱”的闭环。WithWillRetain(true)让新订阅者立刻知道当前状态。
5. 避坑与排查:现场最耗时的五类问题
5.1 现象:跑几小时就掉线,日志只有“连接已关闭”
原因:ClientId 重复,两个进程用了同一个 ID,Broker 按后到踢先到,互相踢形成循环。或者 KeepAlive 设得比 Broker 上限大,被 Broker 主动断。
解决:ClientId 用设备序列号加进程标识保证唯一;把 KeepAlive 调到 Broker 上限以内;在DisconnectedAsync里打印e.Reason和e.ReasonString,MQTT 5.0 会给出具体原因码。
5.2 现象:QoS 1 消息重复消费,业务重复执行
原因:QoS 1 语义就是“至少一次”,网络抖动导致 PUBACK 丢失,发送方重发,接收方就收到两条。
解决:业务层做幂等,用消息里的唯一 ID(比如设备ID加时间戳加序号)去重;或者把关键操作设计成幂等,比如“设置为 30 度”重复执行无害,而“累加 1”就会出错。
5.3 现象:中文乱码,数字正常
原因:payload 编码不一致。发送方用 UTF-8,接收方按 GBK 解,或者反过来。
解决:全链路统一 UTF-8,在协议文档里写死;如果对接老设备必须用 GBK,在接收回调里按主题区分编码,别全局一刀切。
5.4 现象:内存持续上涨,几天后 OOM
原因:ApplicationMessageReceivedAsync里做了耗时操作阻塞网络线程,消息堆积;或者订阅了#收到海量无关消息;或者发布队列无界。
解决:回调里只入队不处理;订阅主题精确到层级,别用#一把梭;发布队列用有界Channel;定期用dotnet-counters看 GC 和线程数。
5.5 现象:TLS 握手偶尔失败,重试就好
原因:证书吊销检查(OCSP)超时,或者系统时间被 NTP 校正瞬间跳变。
解决:内网关闭吊销检查;确保 NTP 同步稳定;把握手失败也纳入重连退避,别立即重试。
6. 进阶:用 MQTT 5.0 特性把客户端做得更省心
MQTT 5.0 相比 3.1.1 多了几个对设备端很实用的能力,用好了能省掉不少自研逻辑。
第一个是消息过期。发布时设MessageExpiryInterval,Broker 在过期后丢弃消息。对于控制指令特别有用——设备离线时攒了一堆“开灯”指令,上线后全执行一遍是灾难,设 30 秒过期就只剩最近的有效指令。
var msg = new MqttApplicationMessageBuilder() .WithTopic("factory-a/plc/line01-07/cmd") .WithPayload("{\"action\":\"start\"}") .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) .WithMessageExpiryInterval(30) // 30 秒后过期 .Build();第二个是原因码。MQTT 5.0 的 PUBACK、SUBACK、DISCONNECT 都带原因码,排错时不用猜。比如订阅被拒会返回0x97(配额超限),连接被拒返回0x87(未授权),比 3.1.1 的“连接被拒绝”有用得多。
第三个是用户属性。可以在消息头里带元数据,比如设备型号、固件版本、采集精度,不用塞进 payload,接收方按需读取。
.WithUserProperty("fw", "1.4.2") .WithUserProperty("unit", "celsius")第四个是请求响应模式。用ResponseTopic和CorrelationData实现 RPC 式调用,设备收到指令后往指定主题回结果,比自定义一套请求 ID 机制规范。
验证方法上,我习惯用两个手段:一是本地起一个 Broker(比如用容器跑一个),用命令行工具订阅#看全量报文,确认主题和 payload 符合预期;二是写一个压测脚本,模拟 500 个客户端同时连接、断线、重连,观察 Broker 和客户端的内存曲线。压测时把 KeepAlive 调小、网络延迟注入打开,能提前暴露大部分重连和背压问题。
最后说个我自己的习惯:任何 MQTT 客户端上线前,必须先在断网环境下跑一遍——拔网线 5 分钟再插上,看消息是否按预期补发或丢弃,看重连退避是否生效,看遗嘱是否正确触发。这一步能挡掉八成现场事故。希望帮到你。
本文还有配套的精品资源,点击获取