简介:面向C#与.NET 4.5开发者的MQTT客户端源码学习包,适合物联网设备通信、消息推送及需要理解发布/订阅模式的初中级开发者参考。压缩包共278个文件、约2.03MB,以59个cs源码、9个csproj工程文件、5个sln解决方案、dll库文件、config配置、exe程序及png图片为主,另含html与txt说明材料,便于直接打开工程梳理客户端连接、订阅发布、事件回调、异常处理、QoS等级和心跳保活等实现脉络。已有599人学习下载,可作为对照Paho.MQTT.NET用法、排查连接与消息收发问题的参考资料,也能帮助读者理解MQTT在低带宽、不稳定网络下的消息传递机制,并借鉴其工程组织与库文件引用方式。
1. 一份 .NET 4.5 的 C# MQTT 源码,值不值得翻
上个月接手一个老工控上位机,目标框架锁死在 .NET Framework 4.5,NuGet 上新版客户端库一律要求 netstandard2.0,装上去就是一堆黄叹号。最后救场的是一份 C# MQTT 源码包:纯 C# 实现的 MQTT 3.1.1 客户端,自带库文件,直接引用 dll 或把工程拖进解决方案都能编。它解决的不是「发一条消息」这种需求,而是「协议行为要我自己能改」的需求——自定义 ClientId 拼装规则、遗嘱消息、离线队列、QoS 降级策略,这些在封装好的库里要么改不动,要么得读它几千行源码。
适合两类人:一类是被老框架绑住、只能在 .NET 4.5 上做物联网接入的从业者;一类是想把 MQTT 报文掰开看清楚的开发者,拿它当协议参考实现比啃规范快得多。如果你只想三行代码连上 broker 发个温湿度,用成熟库更省事,这份源码对你的价值有限。
2. 拆开这份源码:三个分层与一条报文流水线
拿到一份源码,我第一件事不是编译,是看目录。目录结构基本决定了它的抽象边界在哪、哪些地方你敢改、哪些地方一改就全崩。这份 C# MQTT 源码的分层比较传统,传输、编解码、会话、对外 API 是分开的,没有把 Socket 调用散落在业务回调里——这点很关键,很多教学向的实现图省事,直接在Publish方法里socket.Send,一旦要做重连或者异步队列就得推倒重来。
2.1 分层职责与工程边界
按我拆下来的实际结构,大致是四层,工程名可能不同,但职责划分是清晰的:
| 层级 | 典型命名 | 职责 | 改动风险 |
|---|---|---|---|
| 传输层 | Transport / Net | Socket 建连、收发字节流、超时控制 | 低,可整体替换 |
| 编解码层 | Packet / Codec | 固定头解析、剩余长度、各报文序列化 | 中,改错就是全链路崩 |
| 会话层 | Session / Client | PacketId 分配、QoS 状态机、重发队列 | 高,涉及状态一致性 |
| 应用层 API | MqttClient | 连接、订阅、发布、事件回调 | 低,主要改签名 |
传输层通常只暴露两个方法:Send(byte[])和「读满 N 个字节」。别小看这个约束,它把 TCP 的粘包问题关在了最里面一层,上层拿到的永远是完整报文。编解码层是纯函数式的,输入字节数组输出对象,反过来也一样,没有状态,所以最好写单元测试——我一般会拿一段真实抓包数据丢进去跑,能过就说明编码没问题。
会话层是最容易翻车的地方。QoS 1 的重发、QoS 2 的四次握手、PacketId 回收,全在这一层。这份源码里 PacketId 用的是一个自增计数器,1 到 65535 循环,跳过 0。这个做法在低并发下没问题,但如果你在同一时刻有大量未确认的 QoS 1 消息,循环一圈回来可能撞上还没收到 PUBACK 的旧 Id,那时候 broker 会当成重复消息丢弃或者触发协议错误。
提示:判断一份 MQTT 源码值不值得学,看它有没有独立的会话层就够了。把 PacketId 管理和 Socket 混在一起的实现,读起来省事,用起来全是坑。
应用层 API 的设计能看出作者的使用场景。如果Connect是同步阻塞的,说明它是给控制台工具或者后台服务写的;如果返回Task并且带CancellationToken,那就是奔着长期运行的服务去的。这份源码在 .NET 4.5 的条件下用了async/await,说明作者至少是照着服务端场景设计的。
2.2 十四种报文在代码里的落点
MQTT 3.1.1 一共 14 种报文类型,固定头第一个字节的高 4 位就是类型编号:
| 编号 | 报文 | 方向 | 代码里的典型位置 |
|---|---|---|---|
| 1 | CONNECT | C→S | ConnectPacket.Encode |
| 2 | CONNACK | S→C | ConnAckPacket.Decode |
| 3 | PUBLISH | 双向 | PublishPacket |
| 4 | PUBACK | 双向 | 会话层 QoS1 状态机 |
| 5 | PUBREC | 双向 | 会话层 QoS2 上半程 |
| 6 | PUBREL | 双向 | 会话层 QoS2 下半程 |
| 7 | PUBCOMP | 双向 | 会话层 QoS2 收尾 |
| 8 | SUBSCRIBE | C→S | SubscribePacket.Encode |
| 9 | SUBACK | S→C | 返回码含 0x80 表示失败 |
| 10 | UNSUBSCRIBE | C→S | — |
| 11 | UNSUBACK | S→C | — |
| 12 | PINGREQ | C→S | 心跳定时器 |
| 13 | PINGRESP | S→C | 刷新最后收包时间 |
| 14 | DISCONNECT | C→S | 正常关闭时发 |
固定头第一个字节的低 4 位是标志位,只有 PUBLISH、SUBSCRIBE、UNSUBSCRIBE、PUBREL 四种报文用得上,其余必须为 0。SUBSCRIBE 的标志位固定是0x02,这是规范硬性要求,写成 0 有些 broker 会直接断连,而且断连理由给得很含糊——这种「玄学断连」八成是标志位的事。
PUBLISH 的标志位含义是:bit3 是 DUP(重复投递标记),bit2-1 是 QoS 等级,bit0 是 RETAIN。解码时如果直接拿header & 0x0F当 QoS 用,就会把 DUP 和 RETAIN 一起算进去,QoS 变成 8 或者 9,后面所有分支都走不通。
// 解析 PUBLISH 固定头:bit3=DUP, bit2..1=QoS, bit0=RETAIN private static void ParsePublishFlags(byte firstByte, out bool dup, out int qos, out bool retain) { dup = (firstByte & 0x08) != 0; qos = (firstByte >> 1) & 0x03; // 必须右移后掩码,不能直接用 0x0F retain = (firstByte & 0x01) != 0; if (qos == 3) throw new MqttProtocolException("QoS 不能为 3,报文非法"); }这里的逻辑说明:firstByte是固定头第一字节,类型编号在高 4 位已经由调用方判断过,这里只取低 4 位。qos先右移一位把 RETAIN 挤掉,再用0x03取两位,顺序不能反。qos == 3是规范里明确禁止的组合,遇到就抛异常,别硬着头皮往下走,因为后续的 PacketId 位置会全部错位,读出来的 Topic 是乱码,排查起来能磨掉一下午。
参数上唯一需要注意的是dup:QoS 0 的报文也可能带 DUP 标记,但规范说 QoS 0 不能重发,所以这个标记在 QoS 0 时应当忽略,不要拿它做去重判断。
2.3 连接建立的时序与返回码
Connect调用的完整链路是:DNS 解析 → TCP 三次握手 → 发 CONNECT → 等 CONNACK。这四步里,第二和第四都有可能卡住,所以两个超时要分开设。这份源码里 TCP 连接超时默认 10 秒,CONNACK 等待超时默认 5 秒,我在弱网环境下会把后者调到 15 秒,因为有些 broker 在鉴权走外部 HTTP 服务时,返回会很慢。
CONNACK 只有两个字节的载荷:第一个是连接确认标志(bit0 是 SessionPresent),第二个是返回码。返回码的含义必须背下来,否则出错时你只能看日志猜:
| 返回码 | 含义 | 通常原因 |
|---|---|---|
| 0x00 | 接受 | 正常 |
| 0x01 | 协议版本不支持 | 协议级别写了 3 或 5 |
| 0x02 | ClientId 被拒绝 | Id 太长或含非法字符 |
| 0x03 | 服务不可用 | broker 内部异常 |
| 0x04 | 用户名或密码错误 | 凭据问题 |
| 0x05 | 未授权 | ACL 拒绝 |
SessionPresent这个位只有在CleanSession = false时才有意义。它等于 1 表示 broker 还留着上次的会话,离线消息和订阅关系还在;等于 0 表示会话是新建的,你之前订阅的主题得重新订一遍。很多实现把这一步漏了,结果重连之后收不到消息,还以为是 broker 的问题。
2.4 为什么锁定 .NET 4.5 而不是新框架
.NET 4.5 是 2012 年的产物,但它有两个东西刚好够用:async/await和Task。前者让收包循环不用写回调地狱,后者让超时控制可以用Task.WhenAny拼出来。缺的是Span<T>、ValueTask、ArrayPool<T>,所以这份源码在字节处理上大量用byte[]加偏移量,会有一些额外的数组分配。
如果你的项目能升到 .NET Framework 4.6.2 以上,可以改的地方不少:把NetworkStream.ReadAsync换成带Memory<byte>的重载,把报文头的临时数组改成栈上分配或者池化。但要动之前先想清楚,改完的收益是每次收发少几次 GC,如果你的消息频率是每秒几十条,这个优化根本感知不到;每秒几万条才值得动。
反过来,如果要从 .NET 4.5 往下降(有些 XP 时代的工控机还在跑 4.0),那async/await就没了,得改成BeginReceive/EndReceive的 APM 模式,或者干脆用独立线程阻塞读。我一般选后者,代码可读性好得多,一条收包线程、一个BlockingCollection派发,逻辑清楚。
3. 把它跑起来:从编译到第一条 CONNACK
源码能读和能跑是两回事。这一章按顺序把环境、broker、连接、收发走一遍,每一步都给出可对照的检查点,跑不通的时候你就知道卡在哪一环。
3.1 环境准备与目标框架的选择
先确认三件事:Visual Studio 版本、目标框架、以及源码里的工程文件格式。老工程可能是.csproj的老格式(没有 SDK 属性),用新版 VS 打开会提示升级,升不升要看团队——升级之后别人用旧版 VS 就打不开了。
编译顺序上,先编库工程,再编示例工程。如果源码包里带的是已编译好的 dll,那你要确认它的目标框架和你的主工程一致,AnyCPU和x86混用会出现「BadImageFormatException」,这个错在运行时报,编译期一点提示都没有,属于典型的浪费时间的坑。
一个自查清单,编译前过一遍:
- 目标框架是否为
.NET Framework 4.5或更高(不要选.NET Core,语法和 API 都对不上); - 平台目标是否统一,工控机上的老设备驱动一般是 x86;
- 是否引用了
System.Net.Sockets、System.Threading.Tasks; - 有没有用到 C# 6 以上的语法(字符串插值
$""在 VS2015 之前的编译器上不支持)。
第 4 条容易被忽略。有些源码里写了nameof()或者?.空传播运算符,这些是 C# 6 的特性,如果你的编译器版本低,直接报语法错误。解决办法要么升编译器,要么手动改写成老语法,不复杂但费时间。
3.2 本地起一个 broker 做靶子
没有 broker 什么都验证不了。测试环境我一般用两种:一种是轻量级的单文件 broker,解压即用,适合本机调试;另一种是带管理界面的,能看到连接数、订阅树、消息堆积,适合排查「到底发出去没有」。
配置文件里要关注的项不多,主要是三个:
# broker 配置片段(通用写法,字段名各实现略有差异) listener 1883 0.0.0.0 # 明文端口,内网测试用 allow_anonymous true # 测试期先放开,上线前必须关 max_keepalive 120 # 服务端允许的最大心跳,0 表示不限制参数说明:listener的第二个参数是绑定地址,写127.0.0.1只能本机连,写0.0.0.0局域网可连,测试期建议先本机,避免被同事的客户端抢了 ClientId。allow_anonymous打开是为了排除鉴权干扰,等你确认协议层通了再关掉,重新测一遍鉴权路径。max_keepalive这个参数很多实现有默认值,如果你的客户端 KeepAlive 设得比它大,broker 会在 CONNACK 里协商,协商失败就断连。
起好之后用命令行工具验一下端口通不通:
# 确认端口在监听(Windows) netstat -ano | findstr :1883 # 简单连通性测试,能收到 0 字节或连接成功即说明 TCP 层通了 telnet 127.0.0.1 1883netstat看到LISTENING就说明 broker 起来了。这一步别跳过,我好几次都是客户端代码改了半天,最后发现是 broker 根本没启动,纯属自己跟自己较劲。
3.3 第一条 CONNECT:参数怎么填才不会被拒
CONNECT 是整个协议里字段最多的报文,也是最容易填错的一个。它的结构是:固定头 + 可变头(协议名、协议级别、连接标志、KeepAlive)+ 载荷(ClientId、遗嘱主题、遗嘱消息、用户名、密码)。
// 组装 CONNECT 报文(MQTT 3.1.1) private static byte[] BuildConnectPacket( string clientId, string userName, string password, ushort keepAliveSeconds, bool cleanSession) { var body = new List<byte>(); WriteUtf8String(body, "MQTT"); // 协议名,固定 4 字节 body.Add(0x04); // 协议级别:4 = 3.1.1,3 = 3.1 byte connectFlags = 0x00; if (cleanSession) connectFlags |= 0x02; // bit1 if (!string.IsNullOrEmpty(userName)) connectFlags |= 0x80; // bit7 if (!string.IsNullOrEmpty(password)) connectFlags |= 0x40; // bit6 body.Add(connectFlags); body.Add((byte)(keepAliveSeconds >> 8)); // KeepAlive 高字节 body.Add((byte)(keepAliveSeconds & 0xFF)); // KeepAlive 低字节 WriteUtf8String(body, clientId); if (!string.IsNullOrEmpty(userName)) WriteUtf8String(body, userName); if (!string.IsNullOrEmpty(password)) WriteUtf8String(body, password); return WrapWithFixedHeader(0x10, body); // 0x10 = CONNECT } // UTF-8 字符串:2 字节大端长度 + 字节内容(长度按字节算,不是字符数) private static void WriteUtf8String(List<byte> buffer, string value) { var bytes = Encoding.UTF8.GetBytes(value ?? string.Empty); if (bytes.Length > 65535) throw new ArgumentException("字符串超过 65535 字节,MQTT 不允许"); buffer.Add((byte)(bytes.Length >> 8)); buffer.Add((byte)(bytes.Length & 0xFF)); buffer.AddRange(bytes); }逻辑说明:WrapWithFixedHeader负责把报文类型和剩余长度拼到最前面,后面 4.1 节会展开。connectFlags的位序千万别记错——bit1 是 CleanSession,bit2 是遗嘱标志,bit3-4 是遗嘱 QoS,bit5 是遗嘱保留,bit6 是密码,bit7 是用户名。这里只用了最简单的用户名密码组合。
参数上最容易出错的是clientId。broker 对它的限制五花八门,有的限 23 字节,有的允许 65535,有的要求必须包含特定前缀。稳妥做法是用「设备类型 + 序列号」的短格式,控制在 20 字节以内,别塞中文,别塞特殊符号。keepAliveSeconds设 60 是通用值,设 0 表示关闭心跳,但在 NAT 环境下关闭心跳等于给自己埋雷,连接会被中间设备静默回收。
提示:密码字段在 MQTT 3.1.1 里是二进制数据,不是字符串。如果你的实现用
WriteUtf8String写密码,遇到包含 0x00 的密钥就会截断。这份源码里用的是独立的WriteBinary方法,改的时候别顺手合并。
3.4 订阅与发布:QoS 分支在代码里长什么样
订阅和发布是日常用得最多的两个操作,也是最容易在 QoS 上出问题的地方。
订阅时,一个 SUBSCRIBE 报文可以带多个主题过滤器,每个后面跟一个字节的 QoS 请求值。broker 回的 SUBACK 里按顺序给出每个主题的授予 QoS,可能是 0、1、2,也可能是 0x80 表示失败。
// 订阅多个主题:topicFilters 与 qosLevels 一一对应 public ushort Subscribe(string[] topicFilters, byte[] qosLevels) { if (topicFilters.Length != qosLevels.Length) throw new ArgumentException("主题与 QoS 数量必须一致"); ushort packetId = _packetIdProvider.Next(); var body = new List<byte>(); body.Add((byte)(packetId >> 8)); body.Add((byte)(packetId & 0xFF)); for (int i = 0; i < topicFilters.Length; i++) { WriteUtf8String(body, topicFilters[i]); body.Add(qosLevels[i]); // 只能是 0/1/2,其他值 broker 会拒绝 } // SUBSCRIBE 固定头低 4 位必须是 0x02 var packet = WrapWithFixedHeader(0x82, body); _transport.Send(packet); return packetId; // 后续用它在 SUBACK 里配对 }逻辑说明:0x82里的 8 是报文类型(SUBSCRIBE),低 4 位0x02是规范强制要求的保留位。packetId用来把异步返回的 SUBACK 和这次请求对应起来,所以调用方要保存它。qosLevels里的值不能超过 2,有些实现不校验,发出去之后 broker 直接断连,日志里只有一句「协议错误」。
发布侧的分支更多,QoS 0 发完就不管,QoS 1 要等 PUBACK,QoS 2 要走 PUBREC/PUBREL/PUBCOMP 四步。
public void Publish(string topic, byte[] payload, int qos, bool retain) { var body = new List<byte>(); WriteUtf8String(body, topic); ushort packetId = 0; if (qos > 0) { packetId = _packetIdProvider.Next(); body.Add((byte)(packetId >> 8)); body.Add((byte)(packetId & 0xFF)); } body.AddRange(payload); // 固定头:类型 3 左移 4 位 | QoS 左移 1 位 | RETAIN byte header = (byte)(0x30 | (qos << 1) | (retain ? 1 : 0)); _transport.Send(WrapWithFixedHeader(header, body)); if (qos == 1) _inflight[qos1Key(packetId)] = DateTime.UtcNow; // 登记待确认 }逻辑说明:0x30是 PUBLISH 的类型位,qos << 1把等级放到 bit2-1,retain放 bit0。QoS 大于 0 时必须在 Topic 之后插入 PacketId,这个位置是固定的,少写两个字节后面全乱。_inflight字典记录未确认的消息,重发定时器会扫它。
参数上,retain的含义是「让 broker 保存这条消息作为该主题的保留消息」,新订阅者一连上就能收到。用它做设备状态上报很合适,但要注意:保留消息只能有一条,后发的覆盖先发的;清除保留消息的办法是往同一主题发一条空载荷且 retain 为 true 的消息。
4. 读懂核心实现:剩余长度、心跳与状态机
协议实现里真正难的不是字段拼接,是三件事:变长编码、超时判定、状态一致性。这三块读懂了,剩下的都是体力活。
4.1 剩余长度:1 到 4 字节的变长编码
MQTT 的剩余长度用的是 base-128 变长编码,每个字节低 7 位存数据、最高位表示「还有后续」。这个设计让 127 字节以内的报文只占 1 字节长度,最大能表示 268435455 字节(约 256MB)。
编码实现:
// 把剩余长度写入 buffer,返回写入的字节数(1~4) public static int EncodeRemainingLength(int length, byte[] buffer, int offset) { if (length < 0 || length > 268435455) throw new ArgumentOutOfRangeException(nameof(length)); int count = 0; do { byte digit = (byte)(length % 128); length /= 128; if (length > 0) digit |= 0x80; // 还有后续字节,置高位 buffer[offset + count] = digit; count++; } while (length > 0); return count; }逻辑说明:先取低 7 位,再右移 7 位(等价于除以 128),如果还有剩余就把最高位置 1。循环最多跑 4 次,因为 268435455 正好是 4 个字节能表示的上限。
解码实现要防两个问题:一是恶意报文声称自己很长,导致预分配巨大数组;二是长度字节超过 4 个还在置高位,属于畸形报文。
public static int DecodeRemainingLength(byte[] buffer, int offset, out int bytesRead) { int multiplier = 1, value = 0, i = 0; byte digit; do { if (offset + i >= buffer.Length) throw new MqttProtocolException("剩余长度字节不完整"); digit = buffer[offset + i]; value += (digit & 0x7F) * multiplier; multiplier *= 128; i++; if (i > 4) throw new MqttProtocolException("剩余长度超过 4 字节"); if (value > 268435455) throw new MqttProtocolException("剩余长度越界"); } while ((digit & 0x80) != 0); bytesRead = i; return value; }逻辑说明:multiplier从 1 开始每次乘 128,对应权重。bytesRead是输出参数,告诉调用方后面跟了几个长度字节,读载荷时要从offset + bytesRead开始。两个异常分支必须有,尤其是i > 4——见过一些实现只判断while条件,遇到全 0xFF 的畸形数据能跑几十次循环,虽然最终会越界,但中间的行为不可控。
参数上,offset指的是固定头第一字节之后的位置。如果一次性把整个报文读进内存,这里直接传 1 就行;如果是流式读,要保证buffer至少有 5 个字节可读,否则可能读不全长度字段。
4.2 心跳:KeepAlive 与 1.5 倍超时
KeepAlive 的语义是「客户端承诺在这个时间间隔内至少发一次报文」。如果没有任何业务数据要发,就发 PINGREQ。服务端的判定是 1.5 倍 KeepAlive,超了就断开连接。
客户端侧的定时器设计要点:发送间隔要小于 KeepAlive,一般取 0.75 倍;判定死链要大于 KeepAlive,一般取 1.5 倍。
private void StartKeepAlive(ushort keepAliveSeconds) { if (keepAliveSeconds == 0) return; // 0 表示关闭心跳 var sendInterval = TimeSpan.FromSeconds(Math.Max(1, keepAliveSeconds * 0.75)); _pingTimer = new Timer(_ => { try { var idle = DateTime.UtcNow - _lastInboundUtc; if (idle.TotalSeconds > keepAliveSeconds * 1.5) { ForceReconnect("心跳超时,最后收包距今 " + idle.TotalSeconds.ToString("F0") + " 秒"); return; } SendPingReq(); // 只发,不等回包,回包会在读循环里刷新时间 } catch (Exception ex) { Log.Error("心跳定时器异常", ex); } }, null, sendInterval, sendInterval); }逻辑说明:定时器周期取 0.75 倍 KeepAlive,这样即使有一次丢包,下一次还能补上。_lastInboundUtc在收到任意报文时刷新,包括 PINGRESP 和业务消息,所以高频收发场景下基本不会触发超时分支。ForceReconnect里必须先停定时器再重建连接,否则会出现两个定时器同时跑,重连风暴就是这么来的。
参数上,Timer的回调是在线程池线程上执行的,必须 try-catch 包住,未捕获的异常会直接把进程干掉——.NET Framework 4.5 里这个行为是默认的,不像后来的版本会吞掉。另外Timer本身不持有强引用,如果它只被局部变量引用,可能被 GC 回收,导致心跳莫名其妙停了。这份源码里用字段持有了它,改的时候别改成局部变量。
4.3 会话与 CleanSession:离线消息为什么会丢
CleanSession = true时,broker 在断开后立刻清空会话,订阅关系和离线消息全没了。CleanSession = false时,会话保留,但前提是 ClientId 每次相同。
这里有个反直觉的点:即使CleanSession = false,QoS 0 的消息也不会离线保存。broker 只对 QoS 1 和 QoS 2 的消息做离线存储,而且量和时长受 broker 配置限制(很多实现默认只存 1000 条、24 小时)。
| 配置组合 | 断开后订阅还在吗 | 离线消息会补吗 | 适用场景 |
|---|---|---|---|
| CleanSession=true, QoS0 | 否 | 否 | 临时调试、传感器实时数据 |
| CleanSession=true, QoS1 | 否 | 否 | 不关心历史的一次性任务 |
| CleanSession=false, QoS0 | 是 | 否 | 长期订阅但能容忍丢包 |
| CleanSession=false, QoS1 | 是 | 是(受 broker 限制) | 指令下发、告警推送 |
会话层的实现里,SessionPresent的处理逻辑要写对:如果返回 0 但你设了CleanSession = false,说明 broker 那边会话已经过期了,你必须在连接成功后重新订阅所有主题,否则永远收不到消息。这个补订阅的动作很多实现忘了写,表现出来就是「程序重启后前几分钟正常,过一会儿就没消息了」——因为第一次连的是新会话,订阅成功了,第二次重连会话没了,没人补订阅。
4.4 线程模型:收包循环与回调派发
收包必须是一个独立的循环,不能放在业务线程里。TCP 是字节流,一次Read可能只返回半个报文,也可能返回两个半报文,你必须自己按「固定头 + 剩余长度 + 载荷」的格式切。
private async Task ReadLoopAsync(CancellationToken token) { var headerBuf = new byte[1]; var lenBuf = new byte[5]; while (!token.IsCancellationRequested) { // 1. 读固定头第一字节(报文类型 + 标志位) await ReadExactAsync(headerBuf, 0, 1, token).ConfigureAwait(false); // 2. 逐字节读剩余长度,最多 4 字节 int remaining = await ReadRemainingLengthFromStreamAsync(lenBuf, token).ConfigureAwait(false); // 3. 按剩余长度读满载荷 var payload = new byte[remaining]; if (remaining > 0) await ReadExactAsync(payload, 0, remaining, token).ConfigureAwait(false); _lastInboundUtc = DateTime.UtcNow; try { DispatchPacket(headerBuf[0], payload); } catch (Exception ex) { Log.Error("报文处理异常,已丢弃", ex); // 单条报文异常不能中断读循环 } } }逻辑说明:ReadExactAsync是关键,它必须循环调用底层读取直到读满指定字节数,返回不足就是连接断了。ReadRemainingLengthFromStreamAsync要一个字节一个字节读,因为长度字段本身是变长的,不能预读 4 个字节——预读会把后面的载荷吃掉,导致后续解析全错位。DispatchPacket外面包 try-catch,是为了防止某条畸形报文把整个连接搞崩。
参数上,CancellationToken要从上层传下来,Dispose时取消它,读循环才能退出。如果不传 token,靠关闭 socket 来中断读操作,在某些网络状态下会卡住几十秒。
回调派发的策略有两种:直接在读循环里同步执行,或者丢到队列里由业务线程处理。前者简单但危险——回调里一旦做了耗时操作(写数据库、调 HTTP 接口),后面的报文全都堵住,心跳也可能被延误。这份源码用的是BlockingCollection加独立消费线程,队列长度可以设上限,满了就丢弃 QoS 0、保留 QoS 1/2,这是个合理的降级策略。
5. 避坑与排查:五条血泪记录
这一章全是实际踩过的,按「现象 → 原因 → 解决」写,遇到类似的直接对号入座。
5.1 连上几秒就被踢:KeepAlive 的单位与 1.5 倍
现象:客户端日志显示 CONNACK 成功后大约 90 秒,连接被 broker 断开,重连后重复同样的过程。
原因:KeepAlive 设的是 60 秒,但定时器用的是 60 秒发一次 PINGREQ,加上网络抖动和线程调度延迟,实际发送间隔常常超过 60 秒,broker 按 1.5 倍判定(90 秒)就断开了。更隐蔽的一种情况是把 KeepAlive 当成了秒数却按毫秒传,Timer的周期参数写成了 60 而不是 60000。
解决:发送间隔取 0.75 倍 KeepAlive,且判定死链用 1.5 倍;把 KeepAlive 值和定时器周期的实际值打日志,重连时对照看。顺便检查一下发 PINGREQ 的时候是不是被业务发送的锁挡住了。
5.2 中文主题发出去变乱码:长度按字节算
现象:订阅设备/温度/1号这种含中文的主题,broker 端看到的是一串问号或乱码,订阅无效。
原因:UTF-8 字符串前缀的 2 字节长度写成了value.Length(字符数),而不是 UTF-8 编码后的字节数。一个中文字符占 3 个字节,长度少写了,broker 就按错误的边界去截,后面的字段全部错位。
解决:统一用Encoding.UTF8.GetBytes(value).Length作为长度前缀,并且写入时也用同一份字节数组,不要算一次、编码一次。工程里凡是出现string.Length当字节长度用的地方,逐个排查。
5.3 QoS 1 消息被重复消费:PUBACK 发晚了
现象:同一条指令被执行两次,日志里能看到两个相同的 PacketId。
原因:QoS 1 的语义是「至少一次」,broker 在没收到 PUBACK 之前会重发。如果业务处理耗时较长,而 PUBACK 是在业务处理完之后才发的,重发窗口内 broker 就会再推一次。这份源码默认就是「处理完再确认」,在慢业务下必然重复。
解决:要么在收到消息的第一时间发 PUBACK,业务逻辑异步处理(接受「可能丢」的风险);要么业务侧做幂等,用 PacketId 或者消息体里的业务流水号去重。我一般两个都做,PUBACK 提前发,去重表保留 5 分钟。
5.4 大报文发送卡住:Nagle 算法与发送缓冲区
现象:小消息收发正常,一旦发送几 MB 的固件分片,Send调用就长时间不返回,最后超时。
原因:TCP 默认开启 Nagle 算法,小包会攒着一起发;同时发送缓冲区有限,对端读得慢就会反压。MQTT 本身没有分片机制,一个大 PUBLISH 就是一个大 TCP 流,中间任何一个环节慢都会堵住。
解决:把 socket 的NoDelay设为 true,禁用 Nagle;同时对业务侧做分片,单条消息控制在 64KB 以内,自己定义分片头和序号。SendTimeout也要设,不然会无限等待。
5.5 两个进程互相踢下线:ClientId 重复
现象:两个客户端用同样的配置,日志里交替出现「连接成功」和「连接被断开」。
原因:ClientId 重复,broker 的处理是后连的顶掉先连的。这在多实例部署、或者本地调试时忘了改配置的情况下极常见。
解决:ClientId 里拼上机器名或者进程 ID,Environment.MachineName + "-" + Process.GetCurrentProcess().Id,简单有效。部署到容器环境的话,用环境变量注入一个唯一标识,别用随机数——随机数会导致CleanSession = false的会话永远对不上。
6. 接进自己的工程:三个改造点与一套报文核对法
源码能跑通只是起点,真正要用还得改。我一般做三件事:把库工程改成内部 NuGet 或者直接引用项目(方便断点)、把日志接到自己的日志框架、把连接管理抽成一个带状态机的封装类。前两件是体力活,第三件才是价值所在——一个能自动重连、能续订阅、能在断网恢复后补发 QoS 1 消息的封装,才是生产可用的。
改造时的优先级我按这张表排:
| 改造点 | 必要性 | 工作量 | 不改的后果 |
|---|---|---|---|
| 断线重连 + 重新订阅 | 必须 | 中 | 网络一抖就永久失联 |
| QoS 1 待确认队列持久化 | 高 | 中 | 进程崩溃丢指令 |
| 收包循环异常隔离 | 必须 | 低 | 一条畸形报文搞崩进程 |
| 消息分片 | 视场景 | 高 | 大报文发不出去 |
| 零拷贝优化 | 低 | 中 | 高频下 GC 压力大 |
验证环节,我不太信「跑通了就行」,一般会用抓包工具对着看。核对顺序是:先看 CONNECT 的协议名是不是4D 51 54 54("MQTT" 的 ASCII),再看协议级别是不是04,然后看剩余长度编码对不对(小于 128 时只占 1 字节,第一字节最高位必须是 0)。PUBLISH 报文重点看固定头的低 4 位,QoS 1 且 retain 为 false 时应该是0x32。SUBSCRIBE 的固定头必须是0x82。这几个字节对上了,剩下的字段错不到哪去。
一个具体技巧,专门对付「读不全」这类问题:在ReadExactAsync里加一行计数日志,把每次底层Read实际返回的字节数打出来。正常情况你会看到1, 1, 4, 20这种碎片的读数,说明确实存在半包;如果永远是「一次读满」,那反而要警惕——可能是你的缓冲区设得太大,掩盖了分包问题,换个 MTU 小的网络环境就露馅了。我吃过这个亏,本机测试一切正常,扔到 4G 网络下第一条消息就解析失败,日志里全是「剩余长度字节不完整」。
最后一件事,拿到源码包之后别急着改。先原样编译、原样跑通、原样连一次 broker,把 CONNECT 和 CONNACK 的字节抓下来存一份当作基线。后面不管你怎么改,只要基线还能复现,出问题就一定是改动引入的;基线都复现不了,说明环境有问题,别在代码里找。从那以后我每次接这类协议源码,第一步都是先固化一份基线报文,再动第一行代码。
希望这份拆解能帮你少走几个弯路。
本文还有配套的精品资源,点击获取