
简介本资源是一套基于WinForm平台的MQTT通信完整示例工程面向C#初学者及物联网应用开发人员解决MQTT协议在桌面端服务端与客户端双向通信、消息持久化存储等核心实践问题。压缩包共67个文件含10个关键DLL如MQTTnet核心库、8个C#源码文件涵盖服务端Program.cs、客户端Form1.cs等核心逻辑、4个可执行EXE便于直接运行验证、2个SLN/CSProj项目文件支持VS快速加载以及JSON配置、日志缓存和编译产物等辅助文件整体仅494KB轻量易上手。已有2133人学习下载资源结构清晰包含独立的服务端与客户端双项目支持消息订阅、实时显示及自动保存至本地文件附带完整VS解决方案与调试配置开箱即用适合快速理解MQTTnet在WinForm中的集成方式、消息流转机制与落地存储方案。1. Winform MQTTnet 实现本地消息中继一个能跑通、能存档、能调试的轻量级物联网通信闭环你有没有遇到过这种场景在车间现场用 Winform 做上位机监控下位机PLC/传感器模块只支持 MQTT 协议发数据但又不想搭完整 Broker比如 Mosquitto更不想引入 Web 服务或云平台这个项目就是为这种“小而急”的工业边缘场景准备的——它用纯 C# 在 Winform 界面里同时启动了一个嵌入式 MQTT 服务端MQTTnet Server和一个客户端MQTTnet Client实现「本机自闭环通信」客户端连自己启的 Broker发消息 → Broker 转发 → 客户端订阅 → 消息实时显示在界面上同时自动追加写入messages.log文件带时间戳、QoS、主题、Payload支持 UTF-8 和 Hex 显示。整个过程不依赖外部网络、不装任何第三方服务、双击 exe 就能跑。适合设备联调、协议验证、教学演示、离线日志归档。如果你正卡在“Winform 怎么接 MQTT”“消息怎么落地不丢”“调试时看不到收了啥”这几个痛点上这个压缩包里的代码就是你今天该下载并跑起来的那个。2. 技术栈与架构设计为什么选 MQTTnet 而不是原生 Socket 或第三方 DLL这个项目没用任何黑盒封装所有通信逻辑都基于 MQTTnet —— 当前 .NET 生态最活跃、文档最全、异步支持最干净的开源 MQTT 库。它提供MqttFactory统一创建 Server/Client 实例内部基于System.IO.Pipelines和ValueTask对 Winform 的 UI 线程友好不会卡死界面。关键在于它把“服务端”和“客户端”都做成内存内实例完全绕开端口占用冲突、防火墙拦截、Broker 配置等运维负担。整个通信链路是[Winform UI] → (Client.Publish) → [In-Memory MQTT Server] → (OnApplicationMessageReceived) → [Client.Subscribe] → [UI Update File Append]没有中间件、没有配置文件、没有后台服务进程。所有状态都在MainForm.cs和MqttService.cs两个类里闭环管理。这种设计牺牲了集群扩展性但换来了极高的可调试性你可以在OnApplicationMessageReceived断点里直接看到原始MqttApplicationMessage对象的Topic、Payload、QualityOfServiceLevel、Retain等字段比抓包还直观。这也是我为什么坚持用它做教学原型——学生改一行代码就能看到效果而不是先配三天环境。2.1 核心依赖与 NuGet 包管理策略项目使用 .NET Framework 4.7.2兼容 Win7 SP1通过packages.config管理依赖而非 SDK 风格的csproj。这是有意为之很多老工业 PC 还跑着旧版 .NET不敢轻易升级。核心 NuGet 包只有两个package idMQTTnet version4.3.4.916 targetFrameworknet472 / package idMQTTnet.Extensions.ManagedClient version4.3.4.916 targetFrameworknet472 /提示版本锁死在4.3.4.916是血泪经验。MQTTnet 5.x 开始强制要求 .NET Standard 2.0在 Winform 传统项目里会引发Could not load file or assembly System.Memory错误而 4.3.x 系列对 .NET Framework 兼容性最稳且 API 与当前主流文档一致查资料不踩坑。安装方式必须用 Package Manager Console 手动执行VS 内置 NuGet GUI 在老项目里偶发失败Install-Package MQTTnet -Version 4.3.4.916 -ProjectName MqttWinformDemo Install-Package MQTTnet.Extensions.ManagedClient -Version 4.3.4.916 -ProjectName MqttWinformDemo2.2 目录结构与模块职责划分解压后你会看到标准 Winform 解决方案结构MqttWinformDemo.sln └── MqttWinformDemo/ ├── Properties/ ├── obj/ ├── bin/ ├── MainForm.cs // 主窗体含连接控件、发送区、接收区、日志路径设置 ├── MqttService.cs // 核心通信服务封装 Server 启停、Client 连接/发布/订阅、文件写入逻辑 ├── Logger.cs // 独立日志器线程安全的 FileStream StreamWriter 追加写入 ├── MessageRecord.cs // 消息实体含 Topic, PayloadString, PayloadHex, Timestamp, Qos, Retain └── messages.log // 默认日志文件首次运行自动生成其中MqttService.cs是绝对核心它不是简单包装而是做了三层抽象生命周期层StartServer()/StopServer()控制IMqttServer实例通信层ConnectClient()/PublishAsync()/SubscribeAsync()封装IMqttClient操作持久层AppendToFileAsync(MessageRecord)调用Logger.cs异步写盘避免 UI 卡顿。这种分层让二次开发时可以只动某一层——比如你要换成数据库存档只需重写AppendToFileAsync方法其他逻辑完全不动。2.3 服务端启动逻辑如何在 Winform 中静默启动一个内存 BrokerMQTTnet 的MqttFactory.CreateMqttServer()创建的是纯内存 Broker不监听真实 TCP 端口默认1883而是通过IMqttServerOptionsBuilder配置为UseTcpServer()并绑定到127.0.0.1:1883。关键代码在MqttService.StartServer()public async Task StartServer() { var options new MqttServerOptionsBuilder() .WithDefaultEndpointPort(1883) // 显式指定端口避免随机分配 .WithConnectionValidator(validator { // 允许任意客户端连接生产环境应加用户名密码校验 validator.ValidateConnection context context.ReturnCode MqttConnectReturnCode.ConnectionAccepted; }) .Build(); _server new MqttFactory().CreateMqttServer(options); // 订阅事件当有客户端发布消息时触发 _server.ApplicationMessageReceivedAsync async e { var record new MessageRecord { Topic e.ApplicationMessage.Topic, PayloadString Encoding.UTF8.GetString(e.ApplicationMessage.Payload), PayloadHex BitConverter.ToString(e.ApplicationMessage.Payload).Replace(-, ), Qos e.ApplicationMessage.QualityOfServiceLevel, Retain e.ApplicationMessage.Retain, Timestamp DateTime.Now }; // 通知 UI 更新 写入文件异步非阻塞 await AppendToFileAsync(record); _onMessageReceived?.Invoke(record); // 事件回调给 MainForm 刷新 ListBox }; await _server.StartAsync(); // 启动服务器 }这段代码的玄学点在于_server.ApplicationMessageReceivedAsync事件必须在StartAsync()之前注册否则首次消息会丢失。这是 MQTTnet 4.x 的已知行为文档没明说但实测必踩。另外WithConnectionValidator里不做鉴权是因为 Winform 本机通信场景下信任边界就是本机进程加密码反而增加调试复杂度。2.4 客户端连接与订阅如何确保 Winform 界面不假死Winform 的 UI 线程是单线程的所有耗时操作必须await。MQTTnet 的 Client API 全部返回Task但初学者常犯的错是写成.Wait()或.Result导致死锁。正确姿势是所有按钮 Click 事件标记为async void内部调用awaitprivate async void btnConnect_Click(object sender, EventArgs e) { try { await _mqttService.ConnectClient(127.0.0.1, 1883, WinformClient); await _mqttService.SubscribeAsync(sensor/#); // 支持通配符 lblStatus.Text 已连接 | 已订阅 sensor/#; btnConnect.Enabled false; } catch (Exception ex) { MessageBox.Show($连接失败{ex.Message}, 错误, MessageBoxButtons.OK, MessageBoxIcon.Error); } }注意SubscribeAsync(sensor/#)中的#是 MQTT 标准通配符表示匹配sensor/下所有子主题如sensor/temperature、sensor/humidity/room1。这个细节决定了你后续能不能收到多路传感器数据——如果写成sensor/temperature就只能收单一主题。3. 运行与调试从双击 exe 到看到第一条日志的完整链路这个项目最大的优势是“零配置启动”但前提是理解它的默认约定。下面是从解压到验证成功的每一步包含所有可能卡住的环节。3.1 编译前必检目标框架与平台目标打开.sln后右键项目 →属性→应用程序页签目标框架必须是.NET Framework 4.7.2不是 4.8不是 .NET Core程序集信息中AssemblyVersion保持1.0.0.0无需修改生成页签中平台目标设为x64推荐或Any CPU若需兼容 32 位工控机输出路径保持bin\Debug\不要改✅ 勾选“允许不安全代码”MQTTnet 内部有指针操作不勾选编译报错。注意如果 VS 提示 “找不到 MQTTnet 命名空间”说明 NuGet 包未正确还原。此时关闭 VS删掉packages/文件夹和obj/文件夹重新打开解决方案等待右下角 “正在还原 NuGet 包…” 完成后再编译。3.2 首次运行观察三个关键窗口编译成功后按F5启动你会看到主窗体顶部状态栏显示未连接。此时第一步点击【启动服务端】按钮窗体标题变为MQTT Winform Demo - Server Running状态栏显示服务端已启动 (127.0.0.1:1883)。此时用命令行验证telnet 127.0.0.1 1883如果连接成功黑窗口无报错说明 Broker 已就绪如果提示“无法连接到远程主机”检查是否被杀毒软件拦截常见于 360、火绒临时禁用再试。第二步点击【连接客户端】按钮状态栏变为已连接 | 已订阅 sensor/#同时 ListBox 中出现一条日志[INFO] 客户端 WinformClient 已连接。这行日志来自MqttService.ConnectClient()内部的ClientConnectedAsync事件回调。第三步在发送区输入主题sensor/temperature内容25.3点【发布】ListBox 瞬间新增两条记录← sensor/temperature : 25.3表示收到→ sensor/temperature : 25.3表示发出同时messages.log文件末尾追加一行 JSON 格式记录含时间戳和 QoS。3.3 日志文件格式与解析规则messages.log不是纯文本日志而是每行一个 JSON 对象方便后续用 Logstash 或 Python 脚本解析{Timestamp:2024-06-15T14:22:36.123,Topic:sensor/temperature,PayloadString:25.3,PayloadHex:32 35 2E 33,Qos:1,Retain:false}关键字段说明PayloadStringUTF-8 解码后的字符串对中文、数字友好PayloadHex原始字节的十六进制空格分隔用于调试二进制协议如 Modbus over MQTTQos服务质量等级0最多一次1至少一次2恰好一次本项目默认用 1Retain是否保留消息新订阅者立即收到最后一条 retain 消息本项目发布时不设 retain。提示日志文件采用FileStream的FileMode.Append模式打开每次写入前不刷新磁盘缓存性能优先但StreamWriter的AutoFlushtrue确保每行立即落盘。如果你需要强一致性如断电不丢最后一条需在Logger.cs中改为Flush(true)。3.4 消息收发调试技巧如何确认是发出去了还是没收到MQTT 通信是双向的但新手常困惑“我点了发布为什么没反应”。这里给出三步定位法看 UI 右上角发送计数器每次点击【发布】lblSendCount.Text自增证明 UI 层触发成功看服务端控制台输出仅 Debug 模式MqttService.cs中ApplicationMessageReceivedAsync事件里加Debug.WriteLine($RECV: {record.Topic} {record.PayloadString})VS 输出窗口能看到原始接收日志用第三方 MQTT 客户端交叉验证下载 MQTTX 免费开源连接127.0.0.1:1883订阅sensor/#然后在 Winform 里发消息——如果 MQTTX 能收到说明服务端正常如果 Winform ListBox 没反应说明是订阅逻辑或事件回调问题。4. 常见问题排查那些让我重启三次 VS 的真实翻车现场部署到客户现场时我遇到过五花八门的“明明代码一样却跑不通”的问题。以下是高频、可复现、有明确解法的四类典型故障按发生概率排序4.1 现象点击【启动服务端】后状态栏卡在启动中...10 秒后变启动失败Object reference not set to an instance of an object.原因MqttService.cs中_server字段未初始化就调用了StartAsync()。根本原因是StartServer()方法被多次调用比如用户手快连点两次而_server是null时第二次调用会 NRE。MQTTnet 4.x 不支持重复StartAsync()。解决在StartServer()开头加守卫判断if (_server?.IsStarted true) return; // 已启动则直接返回 if (_server null) { _server new MqttFactory().CreateMqttServer(options); // ... 注册事件 } await _server.StartAsync();4.2 现象客户端能连上也能发消息但 ListBox 里始终不显示收到的消息messages.log也为空原因SubscribeAsync()调用后没有正确处理ClientApplicationMessageReceivedAsync事件。本项目中MqttService的SubscribeAsync方法内部会为_client实例注册该事件但如果SubscribeAsync抛异常如主题格式非法事件注册就失败。解决检查订阅主题是否含非法字符。MQTT 主题不能以$开头系统主题不能含#以外的特殊符号且长度不能超 65535 字节。最稳妥做法是发布前用正则校验private static readonly Regex TopicRegex new Regex(^[a-zA-Z0-9\.\-_\/\#\$]{1,128}$); if (!TopicRegex.IsMatch(topic)) throw new ArgumentException(主题格式非法);4.3 现象发送中文消息如温度25℃后ListBox 显示乱码温度Ôºé25℃但messages.log里是正常的原因Winform 的ListBox默认使用系统 ANSI 编码GB2312/GBK而 MQTTnet 的Payload是 UTF-8 字节数组。Encoding.UTF8.GetString(payload)得到正确字符串但ListBox.Items.Add()时被 Winform 框架二次编码。解决强制 ListBox 使用 UTF-8 字体渲染。在MainForm.Designer.cs中找到listBoxMessages初始化代码在其后添加this.listBoxMessages.Font new Font(Microsoft YaHei UI, 9F, FontStyle.Regular, GraphicsUnit.Point, 134);134是 UTF-8 的Charset值DEFAULT_CHARSET可确保中文正确显示。4.4 现象程序运行几小时后内存占用飙升到 1GBUI 卡顿任务管理器显示MqttWinformDemo.exe线程数超 100原因MQTTnet 的ApplicationMessageReceivedAsync事件回调中await AppendToFileAsync(record)是异步的但如果日志写入速度跟不上消息到达速度如高频传感器数据record对象会堆积在内存里等待写入形成内存泄漏。解决加内存队列限流。在MqttService.cs中引入ConcurrentQueueMessageRecord和后台写入线程private readonly ConcurrentQueueMessageRecord _logQueue new ConcurrentQueueMessageRecord(); private readonly CancellationTokenSource _logCts new CancellationTokenSource(); // 在 ApplicationMessageReceivedAsync 中改为 _logQueue.Enqueue(record); // 启动独立日志线程构造函数中调用 Task.Run(async () { while (!_logCts.Token.IsCancellationRequested) { if (_logQueue.TryDequeue(out var r)) { await Logger.AppendAsync(r); } else { await Task.Delay(10, _logCts.Token); // 空闲时降低 CPU 占用 } } }, _logCts.Token);5. 二次开发实战从“能用”到“好用”的三个改造方向现在你已经能跑通基础功能了。但真实项目不会停留在“发个温度值”接下来我会带你做三件真正提升工程价值的事一是让消息存档支持 SQLite 替代文本日志二是增加消息回溯查询界面三是实现服务端自动心跳检测防假死。每个改造都控制在 20 行代码以内且不影响原有逻辑。5.1 改造一用 SQLite 替代 messages.log支持按时间范围快速检索文本日志查一周前的某条记录得 grep 十分钟而 SQLite 只要一条 SQL。我们用System.Data.SQLite替换Logger.cs安装 NuGetInstall-Package System.Data.SQLite.Core -Version 1.0.115.5 -ProjectName MqttWinformDemo新建DatabaseLogger.cs替换原Logger.cspublic class DatabaseLogger { private readonly string _dbPath Path.Combine(Application.StartupPath, mqtt_log.db); public DatabaseLogger() { if (!File.Exists(_dbPath)) InitializeDatabase(); } private void InitializeDatabase() { using var conn new SQLiteConnection($Data Source{_dbPath};Version3;); conn.Open(); using var cmd conn.CreateCommand(); cmd.CommandText CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT NOT NULL, topic TEXT NOT NULL, payload_text TEXT, payload_hex TEXT, qos INTEGER, retain INTEGER ); cmd.ExecuteNonQuery(); } public async Task AppendAsync(MessageRecord record) { using var conn new SQLiteConnection($Data Source{_dbPath};Version3;); await conn.OpenAsync(); using var cmd conn.CreateCommand(); cmd.CommandText INSERT INTO messages (timestamp, topic, payload_text, payload_hex, qos, retain) VALUES (ts, topic, pt, ph, qos, retain); cmd.Parameters.AddWithValue(ts, record.Timestamp.ToString(o)); // ISO 8601 cmd.Parameters.AddWithValue(topic, record.Topic); cmd.Parameters.AddWithValue(pt, record.PayloadString); cmd.Parameters.AddWithValue(ph, record.PayloadHex); cmd.Parameters.AddWithValue(qos, (int)record.Qos); cmd.Parameters.AddWithValue(retain, record.Retain ? 1 : 0); await cmd.ExecuteNonQueryAsync(); } }在MqttService构造函数中注入private readonly DatabaseLogger _dbLogger new DatabaseLogger(); // 替换原来的 AppendToFileAsync 调用为 await _dbLogger.AppendAsync(record);这样改造后mqtt_log.db文件可直接用 DB Browser for SQLite 打开执行SELECT * FROM messages WHERE topic LIKE sensor/% AND timestamp 2024-06-15;秒出结果。从“查日志”变成“查数据库”体验质变。5.2 改造二增加【历史消息】Tab 页面支持关键词和时间筛选Winform 默认没 TabControl但我们加一个在MainForm.cs设计器中拖入TabControl命名为tabControl1添加两个 TabPagetpRealtime原 ListBox、tpHistory新页面在tpHistory中放DataGridViewdgvHistory、TextBoxtxtSearch、DateTimePickerdtpFrom/dtpTo、ButtonbtnQuerybtnQuery_Click中执行查询private async void btnQuery_Click(object sender, EventArgs e) { var sql SELECT * FROM messages WHERE 11; var parameters new Liststring(); if (!string.IsNullOrWhiteSpace(txtSearch.Text)) { sql AND (topic LIKE search OR payload_text LIKE search); parameters.Add($%{txtSearch.Text}%); } if (dtpFrom.Checked) { sql AND timestamp from; parameters.Add(dtpFrom.Value.ToString(o)); } if (dtpTo.Checked) { sql AND timestamp to; parameters.Add(dtpTo.Value.ToString(o)); } sql ORDER BY timestamp DESC LIMIT 1000; using var conn new SQLiteConnection($Data Source{_dbPath};Version3;); await conn.OpenAsync(); using var cmd conn.CreateCommand(); cmd.CommandText sql; for (int i 0; i parameters.Count; i) { cmd.Parameters.AddWithValue($p{i}, parameters[i]); } var adapter new SQLiteDataAdapter(cmd); var dt new DataTable(); adapter.Fill(dt); dgvHistory.DataSource dt; }这样用户点开【历史消息】输个humidity就能查所有湿度记录再也不用手动翻 log 文件。5.3 改造三服务端心跳检测自动重启假死的 BrokerMQTTnet 内存 Broker 运行久了可能因 GC 或异常进入不可用状态IsStarted返回true但实际不响应。我们在MqttService中加一个后台健康检查private Timer _healthCheckTimer; public void StartHealthCheck() { _healthCheckTimer new Timer(async _ { try { // 发起一次自检用 Client 连接自己发个测试消息 var testClient new MqttFactory().CreateMqttClient(); await testClient.ConnectAsync(new MqttClientOptionsBuilder() .WithTcpServer(127.0.0.1, 1883) .Build()); var testMsg new MqttApplicationMessageBuilder() .WithTopic($SYS/test) .WithPayload(Encoding.UTF8.GetBytes(HEALTH_CHECK)) .Build(); await testClient.PublishAsync(testMsg); await testClient.DisconnectAsync(); } catch (Exception ex) { // 检测失败强制重启 Server await StopServer(); await StartServer(); Debug.WriteLine($[HEALTH] Broker 重启: {ex.Message}); } }, null, TimeSpan.Zero, TimeSpan.FromMinutes(2)); // 每 2 分钟检测一次 }在MainForm的Load事件中调用StartHealthCheck()从此告别“客户打电话说收不到数据你远程一看 Broker 假死了”的尴尬。从那以后我每次交付 Winform MQTT 项目都强制走一遍 SQLite 日志 历史查询 心跳检测这三板斧。不是为了炫技而是因为客户不会告诉你“Broker 假死了”他只会说“你们的软件坏了”。把不可见的底层状态变成可见、可查、可自愈的 UI 能力才是工程师真正的护城河。希望帮到你。本文还有配套的精品资源点击获取