
简介这是一份基于 Winform 与 MQTTnet 库的 MQTT 通信示例代码包面向使用 C# 进行 Windows 桌面应用开发的工程师及物联网通信初学者旨在演示如何搭建 MQTT 服务端和客户端并将订阅到的消息保存到文件。整体资源共 67 个文件压缩包大小仅 494KB包含 C# 源码、项目配置、编译生成的 exe/dll、Winform 窗体设计文件以及调试符号等目录结构清晰适合快速阅读与复用。服务端与客户端拆分为两个独立工程服务端负责启动 MQTT 服务并监听消息客户端则演示连接、订阅主题并把接收到的消息写入本地文件整个调用链路完整代码逻辑清晰可直接在 Visual Studio 中打开调试或二次改造。该示例目前已累计吸引2129人学习下载适合正在做设备数据采集、消息推送或本地消息暂存的开发者参考稍加调整就能嵌入实际物联网项目。 做上位机开发这几年我最大的感受是很多看似简单的通讯需求真正落地时总会冒出一些奇怪的细节问题。前阵子接到一个任务要在本机跑一个轻量级MQTT调试工具既要能当服务端又要有客户端能订阅主题最好还能把收到的消息保存到本地文件。翻了一圈现成工具要么太重要么收钱最后还是决定用Winform加MQTTnet自己写一个。工程不大但把服务端和客户端放到一个解决方案里串起来再带上文件落盘踩了一遍坑之后我把整个示例代码整理成了压缩包也就是标题里那个“示例代码.rar”。这篇文章就把整个实现思路和关键代码拆开讲清楚。1. 项目背景与整体设计1.1 为什么在Winform里同时做服务端和客户端MQTT协议本身是发布/订阅模型由于它足够轻量MQTT协议详解里最常见的使用方式是把数据从传感器或设备发布到中心Broker再由订阅方消费。传统方案是单独部署一个Mosquitto或EMQX然后客户端去连它。但很多时候我们只是想在电脑上快速验证某个主题的消息内容或者给硬件设备做一个本地上报测试工具这时候再装一个独立Broker就有点杀鸡用牛刀了。MQTTnet这个库特别的一点是它同时提供了服务端和客户端两个方向的API我们完全可以在一个Winform进程里把自己的程序变成一台迷你MQTT服务器也就是标题里说的“服务端和客户端之间的通信”。这样你既可以用它对接外部设备也可以在工具内部实现消息回环测试。每次写这类工具我都习惯把服务端和客户端拆成两个独立模块但放在同一个解决方案里。这样既能独立调试又能相互联调后续要拆出去做独立服务也方便。1.2 这个示例能覆盖的核心能力综合来看这个示例代码主要能做四件事第一启动一个MqttServer监听指定端口第二启动一个MqttClient连接任意MQTT服务端支持主题订阅第三把收到的消息在Winform界面上实时显示第四把收到的消息按时间写入本地文件方便事后回放和分析。除此之外还包括了简单的连接校验、客户端列表查看和客户端下线处理。对做物联网网关、硬件调试工具或者刚开始学MQTT的开发者来说这套代码已经是一个能直接改着用的底座。1.3 为什么选MQTTnet我也对比过其他C#常用的MQTT库。M2Mqtt很早就不再维护了而且只支持客户端做服务端还要另想办法。MQTTnet则一直有更新支持MQTT 3.1.1和5.0异步API用起来很顺手。下面是当时我整理的选型对比库服务端支持客户端支持维护状态适用场景MQTTnet支持支持活跃服务端/客户端都需要适合Winform工具M2Mqtt不支持支持停止维护旧项目兼容MQTTnet.Extensions.ManagedClient不支持支持活跃只需要客户端且要自动重连时从表里也能看出来只有MQTTnet能在一个包内同时解决服务端和客户端的问题所以在Winform里做轻量级MQTT服务器搭建它是很合适的选择。2. 环境准备与工程搭建2.1 目标框架与版本锁定工程我基于.NET Framework 4.7.2配合MQTTnet 4.2.1.781。MQTTnet 4.x支持netstandard2.0所以老的WPF、Winform项目都能用。有一点必须提前提醒MQTTnet 3.x和4.x的API差异非常大网上一搜一大堆代码是3.x的老写法直接复制到4.x根本编译不过。所以一定要在csproj或packages.config里锁定版本号避免哪天顺手点了“更新NuGet包”整个工程突然红成一片。如果你用.NET 6/8也可以但异步事件回调的写法要注意SynchronizationContext这一块后面单独说。2.2 NuGet安装步骤创建好Winform项目后打开“程序包管理器控制台”执行Install-Package MQTTnet -Version 4.2.1.781安装完成后项目引用里会多出MQTTnet。注意不要顺手安装MQTTnet.AspNetCore那是给ASP.NET Core用的Winform里用不上。刚开始我被NuGet搜索页面的排序坑过装成了带Aspnet的包结果很多类型找不到。如果你所在的网络环境NuGet源不太好可以切换成国内镜像或者下载好nupkg包后离线安装。2.3 解决方案目录结构示例代码压缩包里我分成了三个部分Common - Models/MessageRecord.cs // 消息模型 MqttServerDemo - Services/MqttServerService.cs // 服务端封装 - FormMain.cs // 服务端控制界面 MqttClientDemo - Services/MqttClientService.cs // 客户端封装 - FormMain.cs // 客户端控制界面两个Winform工程相互独立但引用了同一个Common项目。这样解耦后服务端和客户端各自可以独立调试公用模型类也不用复制多份。3. 服务端实现细节3.1 启动一个最小的MqttServerMQTTnet的MqttFactory把所有创建逻辑都收口了创建服务端、客户端都用它。启动服务端的最小代码如下var mqttFactory new MqttFactory(); var mqttServer mqttFactory.CreateMqttServer(); var options new MqttServerOptionsBuilder() .WithDefaultEndpoint() .WithDefaultEndpointPort(1883) .Build(); await mqttServer.StartAsync(options);关键点有两个。WithDefaultEndpoint会让服务端监听本机所有网卡如果你只希望本机访问可以用WithEndpointBoundIPAddress(127.0.0.1)。端口1883是MQTT默认端口如果被占用可以换1884等端口客户端连接时填一致就行。我还习惯把StartAsync外面包一层try-catch一旦端口被占用能给出友好提示而不是直接崩溃。3.2 服务端事件回调与消息处理服务端事件是理解MQTT服务端工作流程的关键。你既要处理客户端连接状态也要处理发布上来的消息。我用得比较多的三个事件mqttServer.ValidatingConnectionAsync e { // 在这里校验用户名密码、ClientId等 return Task.CompletedTask; }; mqttServer.ClientConnectedAsync e { // 客户端连接成功 return Task.CompletedTask; }; mqttServer.ApplicationMessageReceivedAsync e { // 收到客户端发布的消息 return Task.CompletedTask; };注意这些事件回调都是FuncEventArgs, Task类型所以一定要返回Task。如果写成async void虽然也能跑但等异常发生时会很难排查。服务端本身只是转发消息但你可以在ApplicationMessageReceivedAsync里打印日志方便看到是哪个客户端发布了什么主题。3.3 连接校验与客户端管理如果不想让陌生人乱连可以在ValidatingConnectionAsync里校验账号mqttServer.ValidatingConnectionAsync e { if (e.UserName ! admin || e.Password ! 123456) { e.ReasonCode MqttConnectReasonCode.BadUserNameOrPassword; return Task.CompletedTask; } return Task.CompletedTask; };这样客户端连接时就必须传入用户名密码否则直接拒绝。服务端还提供了GetClientsAsync方法可以拿到当前所有客户端连接信息我把它绑定到一个ListView上连接、断开事件里刷新列表这样谁在线谁掉线一目了然。这一块在排查设备反复上下线时特别有用。4. 客户端实现细节4.1 客户端连接配置客户端的代码同样从MqttFactory开始var mqttFactory new MqttFactory(); var mqttClient mqttFactory.CreateMqttClient(); var options new MqttClientOptionsBuilder() .WithTcpServer(127.0.0.1, 1883) .WithClientId(Guid.NewGuid().ToString(N)) .WithCleanSession() .WithProtocolVersion(MqttProtocolVersion.V311) .Build(); var result await mqttClient.ConnectAsync(options);ClientId是MQTT里最容易被忽略的坑。如果两台设备用了同一个ClientId后连接的会把先连接的踢下线。开发调试时建议直接用Guid生成一个保证全局唯一。WithCleanSession表示断开时清空会话适合临时工具如果你的程序需要离线消息那就必须用持久会话同时把CleanSession设为false。4.2 订阅主题与QoS建立连接后再订阅主题var topicFilter new MqttTopicFilterBuilder() .WithTopic(demo/#) .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) .Build(); await mqttClient.SubscribeAsync(new MqttTopicFilter[] { topicFilter });这里有两个细节一是必须连接成功后再订阅否则订阅动作会失败二是QoS不能乱选。QoS0最多分发一次可能会丢消息QoS1至少一次会重复QoS2恰好一次最严格但开销大。本地调试建议QoS1既能测出大部分问题又不会引入太多复杂性。主题的层级分隔符用“/”#是匹配任意多级是匹配单级。“demo/#”能接收到“demo/a”和“demo/a/b”但不能接收“demo”。这一块理解清楚了基本就掌握了MQTT怎么连接和订阅。4.3 接收消息并安全更新Winform控件MQTTnet事件回调默认在异步线程池上执行直接在里面操作TextBox会抛“线程间操作无效”。正确做法是用BeginInvoke切换到UI线程mqttClient.ApplicationMessageReceivedAsync e { var payload Encoding.UTF8.GetString(e.ApplicationMessage.PayloadSegment); BeginInvoke(new Action(() { txtReceived.AppendText(${DateTime.Now:HH:mm:ss} [{e.ApplicationMessage.Topic}] {payload}\r\n); })); return Task.CompletedTask; };我见过很多人在这里用Invoke而不是BeginInvoke一旦UI线程正在繁忙等待Invoke就可能造成死锁。所以客户端收消息后更新列表我统一用BeginInvoke。如果消息体是二进制可以转换成Base64字符串显示避免乱码。5. 订阅消息保存到文件5.1 消息记录结构设计要想把订阅到的消息持久化第一步是定好记录格式。我在Common里放了一个MessageRecord类public class MessageRecord { public DateTime Timestamp { get; set; } public string Topic { get; set; } public string Payload { get; set; } }文件按天拆分文件名类似mqtt_20250115.log。每行记录一个消息格式是“时间 [主题] 内容”。这样一来后续就算要按主题过滤用正则或Excel都能快速处理。5.2 异步写文件不阻塞UI线程直接在消息回调里调File.AppendAllText会频繁打开关闭文件消息一多性能就崩。我采用的方案是用ConcurrentQueue当缓冲区再加一个后台写入任务每次取出一批记录后批量追加private ConcurrentQueueMessageRecord _messageQueue new ConcurrentQueueMessageRecord(); private CancellationTokenSource _cts new CancellationTokenSource(); private async Task ProcessMessageQueueAsync() { while (!_cts.IsCancellationRequested) { var lines new Liststring(); while (_messageQueue.TryDequeue(out var record)) { lines.Add(${record.Timestamp:yyyy-MM-dd HH:mm:ss} [{record.Topic}] {record.Payload}); } if (lines.Count 0) { var filePath Path.Combine(Application.StartupPath, Logs, $mqtt_{DateTime.Now:yyyyMMdd}.log); Directory.CreateDirectory(Path.GetDirectoryName(filePath)); await File.AppendAllLinesAsync(filePath, lines); } await Task.Delay(200, _cts.Token); } }这段代码把“写文件”从UI线程中摘了出来每200ms批量处理一次。实测下来每秒几百条消息也能稳定落盘。如果你要处理更极端的量可以把StreamWriter长期打开再配合SemaphoreSlim控制并发但示例这里已经够用。5.3 文件大小轮转与写入冲突同一文件被多次写入时理论上多线程并发会造成冲突。上面用队列单消费线程已经避开了绝大部分冲突。我还加了一个简单的大小轮转逻辑每次写入前检查文件长度超过1MB就在原文件名后面加序号生成新文件避免单个日志文件无限膨胀private string GetAvailablePath(string basePath) { var file new FileInfo(basePath); if (!file.Exists || file.Length 1024 * 1024) { return basePath; } var suffix 1; while (File.Exists(basePath.Replace(.log, $_{suffix}.log))) { suffix; } return basePath.Replace(.log, $_{suffix}.log); }当然如果消息量特别大文件能增长到GB级别建议还是上SQLite或时序数据库。文本文件只适合作调试日志不适合做长期存储这一点要有心理预期。6. 双端联调与整体验证6.1 联调流程示例代码里服务端和客户端是两个独立工程。运行顺序一般是先运行MqttServerDemo点击“启动服务”看到监听日志。再运行MqttClientDemo填写127.0.0.1和1883端口点击“连接”。在客户端界面输入要订阅的主题比如demo/#点击订阅。在服务端界面或第三方MQTT客户端发布一条demo/test主题的消息。客户端界面上立即显示消息内容同时Logs目录下生成当天日志文件。因为MQTTnet的服务端和客户端都可以在同一个进程内发布消息所以为了模拟真实的“外部设备”我通常再用一个Console客户端去发布这样能验证跨进程通信是否正常。6.2 日志验证与消息回放文件落盘后可以用记事本打开日志查看格式是否正确。我习惯在日志里附带服务端转发时间这样能用来判断链路时延。实际测试中用MQTTnet服务端在同一台机器上转发消息延迟基本在毫秒级肉眼根本看不出差异。如果你需要做消息回放只要读取日志文件按行解析回原来的主题和报文重新发布到服务端即可这个功能再往后扩展就能变成一个简单的协议调试器。6.3 扩展到自己的业务这套代码绝不是一个只能看不能用的demo。我曾经基于它扩展成了一个“设备上报管家”服务端接收设备数据客户端订阅后把数据解析成业务模型再存到数据库。整个系统的通讯底座没变只是在消息回调里加了对应的解析逻辑。Winform界面美化虽然不在本文范围内但大家可以在确保线程安全的前提下给窗体加一些深色主题、列表悬停效果并不会影响底层通信。7. 常见问题与排查技巧7.1 连接不上、超时或被拒绝遇到这种情况先按顺序检查服务端是否已经启动端口是否被占用netstat -ano | findstr 1883。本机连接用127.0.0.1跨机器连接用服务端所在机器的局域网IP。Winform服务端如果绑定了127.0.0.1外部设备是连接不进来的。防火墙是否放行自选端口Windows通常会在首次运行时弹窗选允许即可。如果服务端配置了用户名密码客户端连接选项里也要带上。7.2 界面卡死、假死消息一多就无响应九成是因为在消息回调里直接操作UI或者在UI线程里同步等待异步方法。记住两条规则回调里只用BeginInvoke异步方法一律await不要用.Result或.Wait()。还有一点容易被忽略如果服务端和客户端在同一个Winform程序里共用一个SynchronizationContext两个事件回调交叉时要小心同一个文件被两个写入逻辑同时使用。我的做法是把服务端日志和客户端日志拆成两个文件彻底避免锁竞争。7.3 消息丢失、重复、客户端被挤下线这三个问题通常和ClientId、QoS、订阅时机有关。ClientId重复会导致后连的客户端把先连的踢掉调试时务必用唯一ID。QoS设为0时消息可能丢失如果业务不允许至少用QoS1并在订阅前确保连接已完成。还有就是要理解“上一次订阅会话”的概念如果客户端掉线没有持久会话重连后要重新订阅否则收不到消息。示例代码里我在连接成功后自动订阅预置主题就是为了避开这个坑。7.4 MQTTnet版本升级带来的兼容性坑这是目前问得最多的问题。从3.x到4.x很多类名和事件签名都变了网上旧代码会出现找不到MqttServerOptionsBuilder、ApplicationMessageReceivedAsync参数类型不对、事件回调要求返回Task等问题。如果不想折腾建议直接使用示例对应的4.2.1.781版本并把版号锁死。等熟悉API之后再考虑是否升级到4.3.x或5.x没必要一开始就和编译错误较劲。最后再说一点个人体会。这个示例工程我最初只是想快速验证设备连接没想到后来成了我电脑里使用频率最高的工具之一。每次遇到客户说“设备消息发不过来”我都会先把它跑起来用自带的客户端订阅主题三分钟就能定位是网络问题还是设备协议问题。代码本身不复杂但把这几个模块串起来的思路以后做任何通信工具都可以复用。建议你拿到代码后先跑通一遍再把服务端端口、认证方式、日志路径改成自己项目需要的很快就能变成属于你自己的调试利器。本文还有配套的精品资源点击获取