☰
基于MQTTnet的WinForms MQTT服务端与客户端搭建及消息持久化
2026/10/15 14:43:34 网站建设 项目流程

简介:面向C# Winform开发者的MQTT通信示例,基于MQTTnet库实现服务端与客户端完整交互,并演示将订阅消息保存到本地文件。资源共67个文件,主要包含cs源码、csproj工程文件、json配置、dll依赖库及可执行exe,压缩包约494KB;其中dll为MQTTnet运行库,pdb用于调试,json与cache为工程配置缓存,txt文件可用于查看消息记录,类型搭配完整,方便直接运行、调试或二次修改。示例拆分为服务端与客户端两个独立项目,分别覆盖服务端启动监听、客户端连接与发布订阅、消息持久化落盘等环节;同时展示Winform界面如何调用后台逻辑,给出从界面操作到消息接收、文件保存的完整处理链路,帮助理解MQTT协议在桌面端的实际落地方式。适合初学MQTT协议,或需要在桌面应用中集成消息通知、数据采集与离线记录功能的开发者对照学习。已有2132人学习下载,代码组织规范,目录结构清晰,可直接对照学习,也可将核心通信逻辑移植到实际项目,作为工具类应用的基础模块复用。

1. MQTT通信为什么要自己搭服务端:从WinForms到MQTTnet的选型理由

一个在桌面端做设备数据采集的开发者,大概率会被一个问题卡住:MQTT 的消息总线到底是买一个现成的 broker,还是自己在应用里嵌一个?现成的 broker(比如 EMQX、Mosquitto)功能强,但部署和运维都是额外负担;自己搭,又怕协议细节处理不到位。这套基于 MQTTnet 的 WinForms 示例代码解决的就是后者:用 NuGet 里一个库同时扛起服务端和客户端,在 Windows 桌面进程里完成订阅、转发,并把收到的消息原样写入文件。适合要在内网里快速搭一套轻量消息通道、又不想引入独立中间件的人。MQTT 这种发布/订阅模型的通信方式,在这套代码里会完整地走一遍,包括连接握手、心跳保活、主题匹配和消息落盘,跑通之后你就能明白 MQTT 在自己的业务里到底适合放在哪个位置。

2. MQTTnet的通信机制:服务端与客户端握手、报文与回调

2.1 先理解MQTT最小的那套概念

MQTT 不是把消息直接塞给对端,而是通过一个中转站——broker 来转发。服务端就是 broker,客户端是发布者和订阅者。消息按主题(Topic)分类,发布者往某个主题发一条报文,订阅者只要注册了同样的主题就能收到。这个过程里有三个参数直接影响行为:QoS(服务质量)、Retain(遗嘱保留)、CleanSession(会话清理)。

QoS 分 0、1、2 三档。QoS 0 是尽力而为,发出去不管对方收没收到;QoS 1 保证至少到达一次,但可能重复;QoS 2 保证恰好一次,代价是握手开销大。在本地回环和局域网里,QoS 0 的丢包率非常低,但如果你要长期跑、中间经过无线网络,至少用 QoS 1。这里有个很多初学者忽略的知识点:QoS 是发布者和订阅者各管各的,发布时设置的 QoS 决定从发布者到服务端的可靠性,订阅时设置的 QoS 决定从服务端到订阅者的可靠性,两边取的是较弱的一档。

Retain 参数表示消息是否被服务端保留。如果发布者发布一条 Retain 消息,之后任何新客户端订阅这个主题,都会立即收到这条旧消息。这在设备状态上报场景里很有用:新设备连上来,马上就知道当前状态,而不是等下次上报。CleanSession 则决定服务端是否保存客户端的订阅关系和离线消息。设为 true,客户端重连后一切重来;设为 false,离线期间的消息会在重连后补投。在 WinForms 这种长期驻留的桌面进程里,一般设为 false 更稳。

2.2 MQTTnet的MqttServer与MqttClient:API与事件对照

MQTTnet 的版本比较多,我以 3.x 为例,这套示例代码在 4.x 下也兼容大部分 API。服务端的核心类是 MqttServer,通过 MqttServerOptionsBuilder 配置端口、允许匿名、消息日志等。客户端的核心类是 MqttClient,用 MqttClientOptionsBuilder 配置地址、端口、客户端ID。

两者的关系看一张简单的对照表:

角色核心类关键方法关键事件
服务端MqttServerStartAsync, StopAsyncClientConnected, ClientDisconnected, InterceptingPublish
客户端MqttClientConnectAsync, SubscribeAsync, PublishAsyncConnectedAsync, DisconnectedAsync, ApplicationMessageReceived

服务端的 InterceptingPublish 事件会在每条消息进入转发流程之前触发,这是个非常关键的扩展点:你可以在这里做鉴权、计数,也可以直接把消息内容拷贝一份存文件。客户端的 ApplicationMessageReceived 则是订阅消息最终到达的地方,示例代码就是在这一层做持久化的。

还有一点值得注意:MQTTnet 的回调全部是异步的。在 WinForms 里直接用 async/await 没有线程安全问题,但如果回调里要操作 UI 控件,就必须通过 Invoke 或者 SynchronizationContext 切回 UI 线程。很多人在调试时发现消息到了但界面不刷新,基本都是这个问题。

2.3 本机回环、局域网与公网:三种场景的差异

服务端跑在本机,客户端也连本机,走的是 TCP 回环地址 127.0.0.1,速度最快,基本不会丢包,适合调试和单机自测。局域网场景下,服务端要监听 0.0.0.0 而不是 127.0.0.1,否则外部设备根本连不上。这个是最容易被忽视的坑,很多人明明在同一局域网却连不上服务端,打开监听地址一看,写的是 localhost。

公网场景就复杂了,涉及端口映射、TLS、鉴权认证等等。WinForms 这套示例基本不推荐直接暴露公网,因为桌面进程没有专门的并发保护,公网流量一上来很容易把进程拖垮。如果确实要做公网接入,前面应该再挂一层正式的 broker,桌面端只作为客户端接入。

3. 用WinForms搭建服务端与客户端:工程结构、连接与收发代码

3.1 创建工程与NuGet引用

先建一个 .NET Framework 4.8 或 .NET 6 的 WinForms 工程。MQTTnet 3.x 的最低目标框架是 .NET Framework 4.6.2 和 .NET Core 3.1,所以老项目也能接上。在 NuGet 里搜索 MQTTnet,装 3.1.2 或 4.3.x 都行。注意 4.x 的 API 命名空间没变,但部分方法签名有调整,如果遇到编译不过,优先看方法参数是 MqttApplicationMessage 还是 MqttPublishMessage。

dotnet add package MQTTnet --version 4.3.6.1032

命令行加包,或者直接在 Visual Studio 的 NuGet 管理器里点选,结果一样。版本号可以按自己环境调整,不需要追新。装完之后,WinForms 工程里会自动出现 MQTTnet 的引用。

3.2 服务端启动:监听端口与连接回调

接着写服务端。在 MainForm 的加载事件里创建 MqttServer,配置端口 1883,允许匿名连接。

// 服务端核心代码段 var optionsBuilder = new MqttServerOptionsBuilder() .WithDefaultEndpoint() .WithDefaultEndpointPort(1883) .WithConnectionBacklog(100); _mqttServer = new MqttFactory().CreateMqttServer(optionsBuilder.Build()); _mqttServer.ClientConnectedAsync += async e => { // e.ClientId 是客户端连接时用的 ID UpdateLog($"客户端连接: {e.ClientId}"); await Task.CompletedTask; }; _mqttServer.InterceptingPublishAsync += async e => { // e.ApplicationMessage.Topic / PayloadSegment SaveMessageToFile(e.ApplicationMessage.Topic, e.ApplicationMessage.PayloadSegment.ToArray()); await Task.CompletedTask; }; await _mqttServer.StartAsync();

这段代码里 WithDefaultEndpoint 是让服务端监听所有网卡地址(0.0.0.0),如果只希望本机访问,改成 WithDefaultEndpointBoundIPAddress 指定 127.0.0.1。WithConnectionBacklog(100) 设置的是 TCP 连接队列长度,局域网内几十个设备绰绰有余。UpdateLog 是我们自己封装的 UI 日志方法,内部用 Control.BeginInvoke 更新 ListBox 或 TextBox。SaveMessageToFile 是后面要展开的持久化方法。

3.3 客户端连接:地址、端口、客户端ID与遗嘱

客户端需要一个全局唯一的 ClientId,如果同一 ID 的客户端重复连接,服务端会把旧连接踢掉。这在调试的时候要留意:上一次调试进程没退出,新的连接就会把旧的顶掉。

// 客户端连接代码段 var clientOptions = new MqttClientOptionsBuilder() .WithTcpServer("127.0.0.1", 1883) .WithClientId($"winform-client-{Guid.NewGuid():N}") .WithCleanSession(false) .WithKeepAlivePeriod(TimeSpan.FromSeconds(30)) .WithWillTopic("device/status") .WithWillPayload("offline") .WithWillRetain(true) .Build(); _mqttClient = new MqttFactory().CreateMqttClient(); _mqttClient.ConnectedAsync += async e => { await _mqttClient.SubscribeAsync("cmd/control"); UpdateLog("连接成功,已订阅 cmd/control"); }; _mqttClient.ApplicationMessageReceivedAsync += async e => { var topic = e.ApplicationMessage.Topic; var payload = Encoding.UTF8.GetString(e.ApplicationMessage.PayloadSegment); UpdateLog($"收到 [ {topic} ] => {payload}"); await Task.CompletedTask; }; await _mqttClient.ConnectAsync(clientOptions);

WithCleanSession(false) 配合服务端保存会话,客户端短暂掉线重连后,掉线期间的消息还能补投。WithKeepAlivePeriod 是心跳间隔,单位是秒,服务端在 1.5 倍这个时间内没收到心跳就判定客户端离线。遗嘱消息(Will)的作用是:当服务端感知到客户端异常断开时,代替客户端向指定主题发一条消息,这样其他订阅者立刻知道这台设备掉线了。

3.4 发布消息与订阅主题的代码路径

发布消息用 MqttClient 的 PublishAsync,参数是 MqttApplicationMessage。这里注意主题不要带结尾斜杠,不要有空格,消息体可以是字节数组,不一定是字符串。

// 发布消息代码段 var msg = new MqttApplicationMessageBuilder() .WithTopic("sensor/temperature") .WithPayload("25.6") .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) .WithRetainFlag(false) .Build(); await _mqttClient.PublishAsync(msg);

同样的库既能当服务端又能当客户端,很多人会问:为什么不用同一个进程同时干两件事?可以,但没必要。在示例代码里,服务端和客户端其实是两个独立的 WinForms 窗口,可以同时启动,也可以开两个实例。调试的时候建议分两个进程跑,这样断点、日志都清楚,不会互相干扰。要是放同一个进程里,回调上下文混在一起,出了消息丢失或者 UI 卡死的问题,判断归属就很费劲。

4. 订阅消息持久化:文件保存逻辑、覆盖策略与参数设置

4.1 在ClientReceived事件里做文件追加写入

前面章节里我提到 SaveMessageToFile 这个方法,现在把它完整写出来。核心思路是:每收到一条消息,就以追加方式写入文本文件,一行一条完整记录,字段之间用竖线分隔。

// 文件持久化代码段 private readonly object _fileLock = new object(); private void SaveMessageToFile(string topic, byte[] payload) { var text = Encoding.UTF8.GetString(payload); var now = DateTime.Now; var fileName = Path.Combine(Application.StartupPath, "messages.log"); var line = $"{now:yyyy-MM-dd HH:mm:ss.fff}|{topic}|{text}"; lock (_fileLock) { File.AppendAllText(fileName, line + Environment.NewLine, Encoding.UTF8); } }

File.AppendAllText 本身会打开文件、写入、关闭,这在低频场景下没问题,但消息一秒钟来几百条,频繁开关文件句柄就是性能瓶颈。更稳妥的做法是持有一个 StreamWriter,开启 AutoFlush 或者定时 Flush。lock 是必须的,因为 MQTTnet 的回调可能来自多个线程,并发写同一个文件会报“文件被占用”或者写坏数据。

参数说明:时间格式带毫秒,方便后续按时间线回放;topic 放在中间段,就算 payload 里也有竖线也能通过首尾字段定位;编码统一用 UTF-8,避免中文乱码。

4.2 文件命名、滚动策略与防止磁盘写满

固定写一个 messages.log 会越写越大,时间长了打开都费劲。实际项目里建议按天滚动文件,文件名带日期。

// 按天滚动的文件路径计算 private string GetDailyFileName() { var dir = Path.Combine(Application.StartupPath, "mqtt_logs"); if (!Directory.Exists(dir)) { Directory.CreateDirectory(dir); } return Path.Combine(dir, $"mqtt_{DateTime.Now:yyyyMMdd}.log"); }

再把 SaveMessageToFile 里的文件名替换成 GetDailyFileName(),每天零点之后新消息自然写入新文件。再进一步可以加一个保留天数,定期删除 N 天前的文件。在 WinForms 里可以用一个 Timer,每天凌晨跑一次清理。

另一个考虑是 payload 可能含二进制数据。文本日志不适合直接存,可以改成存 Base64。Base64 会把大小膨胀约三分之一,但至少能无损还原。示例代码先按文本处理,够用就行,真的遇到二进制建议用单独的二进制文件流。

4.3 QoS与CleanSession对持久化的影响

这里要把概念和持久化串起来。很多人发现保存的文件里消息少了,第一反应是丢消息,其实是 QoS 和会话清理的配置问题。

QoS 0 下,消息从发布者到服务端不保证可靠,服务端到订阅者也是尽力而为。如果客户端当时正好在处理别的消息,服务端可能直接丢弃这条。QoS 1 就不一样,服务端收到 QoS 1 消息后会回 PUBACK,客户端没收到 PUBACK 就重发,服务端转发给订阅者时同理。所以做持久化的场景,发布端和订阅端都至少设 QoS 1,才能保证保存文件里的消息差不了太多。

CleanSession 的影响更隐蔽。如果客户端以 CleanSession=true 连接,服务端不保存会话上下文,消息在客户端离线期间直接丢失,重连后也不补投。要保证离线期间的消息不丢,必须 CleanSession=false,同时服务端要能存储离线消息。MQTTnet 的服务端默认支持内存存储,重启服务端后内存里的会话就清了,这是另一个层面的限制。

下面这个表总结参数与丢消息之间的对应关系:

参数组合离线消息运行中消息对持久化的影响
QoS0 + CleanSession=true丢可能丢不建议用于日志持久化
QoS1 + CleanSession=false不丢基本不丢推荐,但服务端重启仍会丢
QoS2 + CleanSession=false不丢最多一次最稳,吞吐量下降

5. 避坑与排查:连接断开、消息丢失与文件写坏的五个常见问题

5.1 服务端重复启动导致端口占用

现象:第一次运行一切正常,第二次运行报 SocketException,“地址已被占用”。 原因:上一个服务端进程没有被完全释放,或者前面的调试实例还在跑。 解决:在服务端启动之前先判断 _mqttServer 是否为 null,非空则先 StopAsync 再重新创建;调试时确保上一个实例退出后再启动。WinForms 程序如果关了窗口但进程还在后台,直接到任务管理器结束进程。

if (_mqttServer != null) { await _mqttServer.StopAsync(); _mqttServer.Dispose(); }

5.2 客户端订阅不生效,收不到服务端转发

现象:订阅返回成功,但客户端就是收不到消息。 原因:大概率是主题写错了。MQTT 主题区分大小写,且支持通配符。订阅主题“sensor/#”能收到“sensor/temperature”,但收不到“sensor-temperature”。还有一个原因是发布和订阅用客户端连接的不同,比如你发布了“sensor/temperature”,订阅的是“sensor/temperature/”多了个结尾斜杠,两者不匹配。 解决:先在服务端日志里打印每条消息的 Topic,和客户端订阅时填的主题做逐字节对比,不要凭肉眼猜。

5.3 消息重复和丢失之间的配置错位

现象:有时候保存的文件里同一条消息出现两次,有时候又少了一条。 原因:QoS 1 本身允许重复。客户端重连后,如果服务端还有未确认的消息,会重新投递;如果发布端在没收到 PUBACK 时也重发,就会出现重复。丢失则大概率是 QoS 0 或者 CleanSession 配置不对。 解决:日志场景下,重复可以容忍,丢失不能容忍,所以优先保证 QoS 1 + CleanSession=false。如果对重复敏感,就在消息体里加一个自增序号或 GUID,保存时去重。

5.4 文件写入时出现IOException

现象:程序运行一会儿后,磁盘上出现 0 字节文件,或者写入报“文件正由另一进程使用”。 原因:设置了 AutoFlush=false 的 StreamWriter 长时间没 flush,进程异常退出导致缓冲丢失;或者两条线程同时打开同一个文件。 解决:统一用一个静态锁对象保护写入,不要每次打开新的 StreamWriter。用 File.AppendAllText 时锁的重要性在 4.1 小节已经说过,不再赘述。文件如果被别的进程(比如文本编辑器)打开,也会引发权限冲突。

5.5 网络波动后客户端不会自动重连

现象:路由器重启,或者无线网络抖动,客户端掉线后界面还显示在线,但消息已经不通了。 原因:MQTTnet 的 DisconnectedAsync 事件触发了,但没有写自动重连逻辑。WinForms 里大部分人只在 ConnectedAsync 里做订阅,断线后就没人管后续了。 解决:在 DisconnectedAsync 里加一个重试循环,判断是否手动关闭连接,如果不是就延时 3 秒重连,直到成功。

_mqttClient.DisconnectedAsync += async e => { if (_manualStop) return; for (var retry = 0; retry < 5; retry++) { await Task.Delay(3000); try { await _mqttClient.ConnectAsync(_clientOptions); break; } catch (Exception ex) { UpdateLog($"重连失败 {retry + 1} 次: {ex.Message}"); } } };

这段代码有两个注重点:_manualStop 是窗体关闭时置为 true 的标志,避免程序退出时还反复重连;重试间隔用 3 秒是折中,太快会加重服务端压力,太慢则体验很差。

6. 进阶验证:用第二客户端做消息核对与断线重连压力测试

验证这套示例代码是否正确,不建议直接在 WinForms 界面里看,建议用另一个 MQTT 客户端工具做对照。把工具连接到同一个服务端,订阅和 WinForms 客户端相同的主题,然后并排观察两边收到的消息。

先做消息完整性核对。用发布端连续发 1000 条带序号的消息,比如 payload 分别是 msg-0000 到 msg-0999,然后统计 WinForms 保存的文件里有多少条。如果一条不少,说明 QoS 和 CleanSession 的配置在局域网下是可靠的。如果少了,优先检查发布端和订阅端的 QoS 是否都是 1。

# 用命令行工具快速发布带序号的消息 for i in $(seq -w 0 999); do mosquitto_pub -h 127.0.0.1 -t "test/seq" -m "msg-$i" -q 1 done

注意这条命令里的 -h 指向服务端地址,-q 1 对应 QoS 1。跑完之后,在日志目录里执行 wc -l 看行数,和 1000 对比。如果差了,把发布端和订阅端的 QoS 都改为 2 再测一轮,大概率能对上。

再验证离线补投。把 WinForms 客户端退出,用工具发几条消息,再启动客户端,CleanSession=false 时会收到离线期间的消息。这里有一个关键点:客户端连接的时候如果没有订阅主题,服务端不会存离线消息;必须确保连接之后先订阅,再等补投。

最后做断线重连测试。可以直接拉掉网线或者用防火墙规则断掉 1883 端口,观察客户端的重连逻辑是否恢复。注意看服务端日志里是不是出现了 ClientDisconnected 和 ClientConnected 两条记录,以及重连事件里日志输出是否堵塞 UI。

一个容易被忽略的习惯是:每次改完参数重启服务端之前,先停掉客户端,再停服务端。这样能避免服务端处理一半的时候客户端去连,把旧的残留会话和新的会话搅在一起,产生一些很难排查的时序问题。从那以后我每次搭这类示例项目,都会强制走一遍“发布带序号消息—核对文件条数—模拟断网—观察重连”的流程,确认无误后再交给同事,这真的避免了很多后面才知道的坑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询