简介:一份面向.NET Winform开发者与物联网入门者的MQTT通信示例源码,围绕MQTTnet库演示服务端与客户端之间的连接、订阅和消息收发,并给出将订阅消息持久化保存到文件的实现思路,适合用于局域网设备通信、上位机联调或课程实验参考。压缩包共67个文件,约494KB,包含cs源码、csproj项目文件、sln解决方案、dll依赖、exe与pdb运行调试文件,以及json配置和缓存等辅助内容,服务端与客户端分项目管理,结构紧凑便于直接打开编译运行。已有2133人学习下载。读者可据此理解MQTTnet在Winform中的接入方式,观察连接建立、主题订阅、消息回调与文件落盘的完整链路,并在此基础上改造成自己的通信模块或日志采集工具,减少从零搭建Demo的时间。
1. 从一条「看不见的历史消息」说起:这套 WinForm + MQTTnet 样例能解决什么问题
上周帮一个做设备监控的朋友查问题:上位机界面上温度曲线刷得飞起,可第二天想回看凌晨两点的波动,日志里只剩一行「收到数据」。消息在内存里走了一趟就没了,界面一关,全成黑匣子。这类「订阅得到、但留不下」的尴尬,本质是 MQTT 的发布订阅只保证消息实时可用,不负责替你保存。
这份 .rar 里的示例代码就是冲着这个痛点来的:一个 WinForm 窗体的 MQTT 服务端,加上可以同时起多个的客户端窗体;客户端订阅主题、收消息、把消息按天写进文件,服务端负责连接校验和主题转发。它不属于能直接上生产的成品,但把「服务端起起来 → 客户端连上 → 订阅主题 → 收到中文消息 → 保存到可复查的文件」这条链路完整跑通了,新手照着能复现,熟手能拿它当骨架往自己的采集软件里塞。适合做工业上位机、局域网消息中转、机台状态看板这类偏 Windows 桌面端的场景。
2. 项目骨架与运行路径:从解压到两个客户端互发消息
拿到压缩包先别急着点启动,中间有几个顺序问题,顺序错了就会卡在「连上了但没消息」这种模糊状态里。这一章把目录分工、依赖版本和启动顺序理清楚,后面拆代码才有落脚点。
2.1 解压后的目录分工:三个可执行程序各自管什么
样例按职责切成三块,通常是一个解决方案下挂三个工程。解压完先按这个结构对一遍,缺了哪个工程说明你只解压了一层:
MqttNetDemo/ ├── MqttNetDemo.Server/ # 服务端窗体,内置 MQTT Broker │ ├── Forms/MainForm.cs # 启动/停止服务、连接列表、日志窗格 │ ├── Services/MqttServerHost.cs # MQTTnet 服务端封装,事件都在这里 │ └── appsettings.json # 端口、账号、主题白名单(若已抽配置) ├── MqttNetDemo.Client/ # 客户端窗体,可多开 │ ├── Forms/ClientForm.cs # 连接参数、订阅、消息列表 │ ├── Services/MqttClientHost.cs # 客户端连接与收发封装 │ └── Services/MessageFileWriter.cs # 订阅消息落盘 └── MqttNetDemo.Shared/ # 两边共用的模型与常量 ├── MqttRecord.cs # 落盘用的消息记录 └── TopicNames.cs # 主题常量,避免两边拼串拼错服务端工程不是「再写一个客户端」,它内部真的跑了一个 Broker;客户端工程编译出来可以复制多份同时开,用不同的 ClientId 连同一个服务端,这样才有「发布订阅」的味道。Shared 工程看着可有可无,但主题字符串一旦两边各写一遍,早晚会出现一边发demo/temp、一边订demo/temperature的翻车现场,我一般会把主题常量集中放这里。
判断工程角色还有个笨办法:看哪个工程引用了服务端相关的类型,那个就是 Broker 侧;只有客户端类型的,就是订阅侧。有的版本把服务端和客户端合并在一个窗体里用 Tab 切换,逻辑上仍然是两套对象,拆代码时按对象分,不要按窗体分。
提示:如果目录里多出一个
.sln.DotSettings或.vs文件夹,那是编辑器缓存,和项目功能无关,删掉不影响运行。
2.2 环境与依赖:目标框架决定了你能抄哪一版 API
样例是 WinForm,所以目标框架只有两类选择:.NET Framework 4.7.2/4.8,或者.NET 6/8带-windows后缀。这两种的包管理方式和 API 写法都有差别,抄代码前先确认自己在哪一边,否则会遇到「网上的代码一行都编译不过」。
新式 SDK 工程的 csproj 大致长这样:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <!-- WinForm 必须带 -windows 后缀,否则 UseWindowsForms 不生效 --> <OutputType>WinExe</OutputType> <TargetFramework>net8.0-windows</TargetFramework> <UseWindowsForms>true</UseWindowsForms> <Nullable>enable</Nullable> <!-- 中文消息涉及编码,统一 UTF-8 省掉一堆乱码问题 --> <InvariantGlobalization>false</InvariantGlobalization> </PropertyGroup> <ItemGroup> <!-- 以你本机还原出来的实际版本为准,不要照抄别人的版本号 --> <PackageReference Include="MQTTnet" Version="填你还原后的版本" /> </ItemGroup> </Project>而老的 .NET Framework 工程走的是packages.config,装完包会在解决方案同级多出一个packages目录,App.config里还会插一段bindingRedirect。这段重定向不要手删,删了之后运行期加载程序集会报「未能加载文件或程序集」。
参数上还有两个容易忽略的点。Platform target建议统一Any CPU且不要勾「首选 32 位」,服务端持续收包时 32 位进程内存天花板很低;LangVersion如果停在默认值,新语法像record、switch表达式会用不了,把语言版本显式抬到对应版本会顺手很多。
注意:MQTTnet 在跨大版本升级时改过事件模型的写法——早期版本用
xxxHandler = ...这种委托属性赋值,后来改成+=事件订阅。样例代码和你的包版本对不上时,最先报错的一定是这几行,不要怀疑自己的环境。
2.3 启动顺序与第一轮自测:先 Broker 后客户端,再验证双向
顺序反过来也能连,但现象会很迷惑:客户端报连接超时,你以为是代码问题,其实是服务端还没监听端口。按下面的顺序走一遍,五分钟内能看到消息在两边跑:
- 先跑服务端工程,点「启动服务」,确认日志窗格里出现「监听 1883」之类的字样;
- 用命令行确认端口真的起来了:
netstat -ano | findstr 1883,有LISTENING才算成功; - 跑第一个客户端,ClientId 填
client-a,连上后订阅demo/#; - 再复制一份客户端目录跑第二个,ClientId 填
client-b,也订阅demo/#; - 在 A 的发布框里发一条
demo/room1/temp+25.6,B 的消息列表应该立刻出现; - 回头看服务端日志窗格,应该能看到连接、订阅、发布三行记录;
- 最后看客户端目录下的日志文件夹,确认这条消息已经落成一个文件。
# 端口没监听时,最快的排查是直接看谁占了 1883 netstat -ano | findstr 1883 # 拿到 PID 后查进程名,常见是上一次没退干净的自己 tasklist | findstr <PID>第三步里的 ClientId 是硬要求:同一个 Broker 下两个连接用相同 ClientId,后连的会把先连的踢掉,表现成「A 莫名其妙掉线」。自测阶段把client-a、client-b写死最省事。第五步用带层级的主题,是为了顺便验证通配符是否生效,如果 B 收不到但你订阅的是demo(少了/#),那就是层级没匹配上,不是网络问题。
3. 服务端拆解:一个进程内 Broker 的启动、校验与拦截
服务端这块代码量不大,但它是整条链路的地基。理解它的关键是三个事件:谁能连进来、连上之后干什么、发出去的消息要不要放行。把这三个回调抓住,剩下的都是窗体胶水。
3.1 服务端实例的创建:选项构造与事件挂载
启动逻辑一般封装在一个类里,窗体只负责调StartAsync和显示状态。核心代码是「先造选项、再造实例、最后挂事件」三步:
public sealed class MqttServerHost : IAsyncDisposable { private IMqttServer _server; public async Task StartAsync(int port = 1883) { var factory = new MqttFactory(); // 选项构造:端口、绑定地址、积压队列都在这里定 var options = new MqttServerOptionsBuilder() .WithDefaultEndpoint() // 启用默认 TCP 端点 .WithDefaultEndpointBoundIPAddress(IPAddress.Any) // 允许局域网其他机器连入 .WithDefaultEndpointPort(port) // 监听端口,冲突时改这里 .WithConnectionBacklog(200) // 半连接队列长度 .WithMaxPendingMessagesPerClient(1000) // 单客户端待发消息上限,防内存爆 .Build(); _server = factory.CreateMqttServer(options); // 四个事件:连接、断开、连接校验、发布拦截 _server.ClientConnectedAsync += OnClientConnectedAsync; _server.ClientDisconnectedAsync += OnClientDisconnectedAsync; _server.ValidatingConnectionAsync += OnValidatingConnectionAsync; _server.InterceptingPublishAsync += OnInterceptingPublishAsync; await _server.StartAsync(); } }WithDefaultEndpointBoundIPAddress(IPAddress.Any)这一行决定了别人能不能从另一台机器连过来。只在自己机器上测的话写IPAddress.Loopback更安全,但很多人在这一步漏掉,导致本机能连、同事连不上,然后开始怀疑防火墙——其实是绑到了回环地址。
WithMaxPendingMessagesPerClient是个保命参数。订阅端处理慢的时候,Broker 会替它缓存待投递消息,不设上限的话,一个卡死的客户端能把服务端内存吃到几个 G。常见做法是给个一两千的阈值,超了就让 Broker 按策略丢弃,宁可丢几条也不要整个进程被拖垮。
WithConnectionBacklog(200)影响的是瞬时并发握手,局域网几十个客户端根本用不满,但如果你的场景是几百台设备同时上电重连,这个值要往上抬,比如 500 到 1000。
3.2 连接校验:ClientId、账号密码与拒绝策略
默认情况下任何人都能连,样例里通常会在校验事件里做一层拦截,这也是最容易被跳过、后面又最想补回来的一段:
private Task OnValidatingConnectionAsync(ValidatingConnectionEventArgs e) { // 1) ClientId 不能为空,很多库私自生成,反而不好追踪 if (string.IsNullOrWhiteSpace(e.ClientId)) { e.ReasonCode = MqttConnectReasonCode.ClientIdentifierNotValid; return Task.CompletedTask; } // 2) 账号密码从配置读,示例里可硬编码,实际别这么干 var userOk = e.UserName == _settings.UserName; var pwdOk = e.Password == _settings.Password; if (!userOk || !pwdOk) { // 拒绝原因写清楚,客户端能拿到对应返回码,排查省一半时间 e.ReasonCode = MqttConnectReasonCode.BadUserNameOrPassword; Log($"[拒绝] {e.ClientId} 账号密码不匹配"); return Task.CompletedTask; } // 3) 通过校验,记一笔连接来源,方便后面查谁在猛连 Log($"[通过] {e.ClientId} 来自 {e.Endpoint}"); return Task.CompletedTask; }ReasonCode是这个回调里真正起作用的东西。不设置它,等于默认放行;设置成BadUserNameOrPassword,客户端那边ConnectAsync会直接抛异常,你能在客户端日志里看到明确的失败原因。这里最容易踩的坑是:只return不设ReasonCode,代码看着像做了校验,实际全放行了,血泪经验。
e.Endpoint在部分版本里可能为空,日志拼接时用?.ToString() ?? "unknown"兜一下,别在校验回调里抛空引用——这个回调抛异常会导致服务端直接拒绝连接,现象和密码错一模一样,很难查。
还有一种更细的玩法是按 ClientId 前缀分组,比如line1-开头的只允许订阅line1/#。这需要把校验事件和拦截发布事件配合起来做,样例里未必带,但扩展点就在这两个回调上。
3.3 发布拦截:主题白名单与转发放行
InterceptingPublishAsync是服务端唯一能在消息转发前「看一眼」的位置。它拿到的已经是解码后的ApplicationMessage,改主题、改负载、拒绝转发都在这里:
private Task OnInterceptingPublishAsync(InterceptingPublishEventArgs e) { var topic = e.ApplicationMessage.Topic; // 1) 系统主题以 $ 开头,一般是 Broker 内部用的,不允许客户端乱发 if (topic.StartsWith("$")) { e.ProcessPublish = false; Log($"[拦截] {e.ClientId} 试图发布系统主题 {topic}"); return Task.CompletedTask; } // 2) 主题白名单:只放行配置里列出的前缀 if (!_settings.AllowedPrefixes.Any(p => topic.StartsWith(p))) { e.ProcessPublish = false; Log($"[拦截] 主题不在白名单:{topic}"); return Task.CompletedTask; } // 3) 负载过大的消息直接掐掉,避免拖垮转发 var size = e.ApplicationMessage.PayloadSegment.Count; if (size > 64 * 1024) { e.ProcessPublish = false; Log($"[拦截] 负载 {size} 字节超限,主题 {topic}"); return Task.CompletedTask; } // 4) 放行,Broker 会把它投递给所有匹配的订阅者 e.ProcessPublish = true; Log($"[转发] {e.ClientId} -> {topic} ({size}B)"); return Task.CompletedTask; }ProcessPublish默认是true,所以「只做记录不做拦截」的时候可以不碰它。但一旦你写了白名单逻辑却忘了赋值false,过滤就等于没写,消息照样到处跑。
主题白名单建议用前缀数组而不是精确匹配。设备侧的 topic 一般是厂区/车间/设备/指标这种多层级,精确匹配等于每加一台设备就要改一次配置。用前缀demo/line1覆盖整个产线更实际,代价是粗粒度,误发到同前缀的其他主题拦不住。
负载大小用PayloadSegment.Count判断,别用Convert.ToBase64String之后再算长度,那会平白多一次内存分配,高频消息下这个开销不小。
注意:拦截发布只影响「转发」,不影响服务端自己把消息写进日志。想留一份完整信道记录的话,在
ProcessPublish判断之前先落一笔,顺序别搞反。
4. 客户端拆解:连接订阅、UI 回调与订阅消息落盘
客户端这块是三件事拼在一起:连上去、订阅到、存下来。前两件代码很短,坑主要集中在第三件的线程和文件上。很多样例把「落盘」写成在回调里直接File.AppendAllText,几百条消息就能让界面卡成幻灯片,这一节按能长期跑的写法拆。
4.1 连接与订阅:选项、事件与断线重连
连接部分要先建选项再建实例,事件要在ConnectAsync之前挂好,顺序反了会漏掉第一批消息:
public async Task ConnectAsync(string host, int port, string clientId) { var factory = new MqttFactory(); _client = factory.CreateMqttClient(); // 收到消息的回调,先挂上再连接,避免漏消息 _client.ApplicationMessageReceivedAsync += OnMessageReceivedAsync; _client.DisconnectedAsync += OnDisconnectedAsync; var options = new MqttClientOptionsBuilder() .WithTcpServer(host, port) // Broker 地址 .WithClientId(clientId) // 同一 Broker 下必须唯一 .WithCredentials("user", "pwd") // 与服务端校验保持一致 .WithCleanSession(false) // 断线重连后恢复订阅关系 .WithKeepAlivePeriod(TimeSpan.FromSeconds(30)) .WithTimeout(TimeSpan.FromSeconds(5)) .Build(); await _client.ConnectAsync(options); // 订阅用多层级通配符,# 必须放在最后一段 await _client.SubscribeAsync(new MqttTopicFilterBuilder() .WithTopic("demo/#") .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) .Build()); }WithCleanSession(false)是断线场景的关键。设成true时,每次重连 Broker 都会清掉这个 ClientId 的会话,之前订过的主题全部作废,重连后你不重新Subscribe就一条消息都收不到,现象是「明明连上了却安静如鸡」。设成false才能让 Broker 保留会话和离线消息。代价是 Broker 侧要为每个 ClientId 留状态,设备数量大的时候内存会涨。
WithKeepAlivePeriod给 30 秒比较中庸。太小会在弱网下频繁心跳超时,太大则掉线半天发现不了。如果做的是移动网络设备,我一般会调到 60 秒,同时把超时设成心跳的两倍。
重连不建议在DisconnectedAsync里无脑while(true)死循环重试,容易在 Broker 重启期间把客户端线程占满。常见做法是加退避:首次 1 秒,之后翻倍,上限 30 秒,连上后重置计数。样例里如果只是简单延时重试,直接拿来用问题不大,长时间跑要补上退避。
4.2 消息回到 UI 线程:Invoke 与批量刷新的取舍
ApplicationMessageReceivedAsync是在 IO 线程上执行的,直接往里写listView.Items.Add(...)会抛跨线程异常。更麻烦的是,就算你用Invoke包一层,高频消息下界面照样卡,因为每来一条就调度一次 UI 更新。
我一般用「队列 + 定时批量刷」的写法:
// UI 侧持有:线程安全队列 + 一个 100ms 的定时器 private readonly ConcurrentQueue<MqttRecord> _uiBuffer = new(); private Task OnMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs e) { var msg = e.ApplicationMessage; // PayloadSegment 才是真正的字节区间,别拿 Payload 再转一次 var text = Encoding.UTF8.GetString( msg.PayloadSegment.Array!, msg.PayloadSegment.Offset, msg.PayloadSegment.Count); var record = new MqttRecord { ReceivedAt = DateTime.Now, Topic = msg.Topic, Payload = text, Qos = (int)msg.QualityOfServiceLevel, Retained = msg.Retain }; // 1) 先进 UI 缓冲队列,不直接碰控件 _uiBuffer.Enqueue(record); // 2) 再进落盘队列,两步互不阻塞 _fileWriter?.Enqueue(record); return Task.CompletedTask; } // 定时器在 UI 线程上跑,一次最多取 200 条,防止单帧渲染过多 private void UiTimer_Tick(object sender, EventArgs e) { var batch = new List<MqttRecord>(200); while (batch.Count < 200 && _uiBuffer.TryDequeue(out var r)) { batch.Add(r); } if (batch.Count == 0) return; listView.BeginUpdate(); // 暂停重绘,几千条也不闪 foreach (var r in batch) { listView.Items.Add(new ListViewItem(new[] { r.ReceivedAt.ToString("HH:mm:ss.fff"), r.Topic, r.Payload })); } listView.EndUpdate(); // 列表无限增长会吃内存,超过 5000 行砍掉最老的 while (listView.Items.Count > 5000) { listView.Items.RemoveAt(0); } }这里的三个参数值得说清楚。PayloadSegment的Array、Offset、Count三元组决定了真正要解码的区间,某些版本Payload属性已标记过时,直接用它会在编译期给警告;batch.Count < 200是每帧上限,太小会追不上消息速率、积压越滚越大,太大则单帧渲染卡顿;listView.Items.Count > 5000是显示层的内存阀门,和落盘无关,界面上只留最近的一批就够看。
提示:
BeginUpdate/EndUpdate必须成对出现且包在 try/finally 里,中间任何一行抛异常,列表就会一直停在「不重绘」状态,看起来像界面假死。
如果窗体上还有曲线图控件,别在同一个定时器里刷新图表,图表重绘比列表贵一个量级,给它单独一个 500ms 的节流器会稳很多。
4.3 落盘实现:Channel + 按天分文件的写入器
落盘这一层要解决三个问题:不阻塞收消息的回调、文件不能一直被占用、消息量大了不能一个文件几十个 G。用Channel做生产者消费者,按天切目录、按行写 JSON,是这套样例里最值得照着抄的一段:
public sealed class MessageFileWriter : IAsyncDisposable { private readonly Channel<MqttRecord> _channel; private readonly string _rootDir; private readonly CancellationTokenSource _cts = new(); private Task? _loop; public MessageFileWriter(string rootDir, int capacity = 10000) { _rootDir = rootDir; // 有界队列:满了丢最老的,保证内存不会被无限撑开 _channel = Channel.CreateBounded<MqttRecord>(new BoundedChannelOptions(capacity) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false }); } public void Start() { _loop = Task.Run(WriteLoopAsync); } public void Enqueue(MqttRecord r) => _channel.Writer.TryWrite(r); private async Task WriteLoopAsync() { StreamWriter? writer = null; DateOnly currentDay = default; try { await foreach (var r in _channel.Reader.ReadAllAsync(_cts.Token)) { var day = DateOnly.FromDateTime(r.ReceivedAt); // 跨天就换文件:目录按 yyyyMMdd 分,文件名固定,方便按日期捞 if (writer is null || day != currentDay) { if (writer is not null) await writer.DisposeAsync(); currentDay = day; var dir = Path.Combine(_rootDir, day.ToString("yyyyMMdd")); Directory.CreateDirectory(dir); // 幂等,重复调用不报错 var path = Path.Combine(dir, "messages.jsonl"); // FileShare.Read 让别的进程能边写边读,方便排查时 tail var fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read, 64 * 1024, useAsync: true); writer = new StreamWriter(fs, new UTF8Encoding(false)) { AutoFlush = false }; } // 一行一条 JSON,方便后面用工具直接过滤 await writer.WriteLineAsync(r.ToJsonLine()); // 每 50 条落一次盘,兼顾吞吐和断电损失量 if (++_pending >= 50) { await writer.FlushAsync(); _pending = 0; } } } finally { if (writer is not null) await writer.DisposeAsync(); // 退出时兜底 flush } } private int _pending; public async ValueTask DisposeAsync() { _channel.Writer.TryComplete(); // 通知消费端没有新数据了 if (_loop is not null) await _loop; // 等剩余消息写完 _cts.Cancel(); _cts.Dispose(); } }BoundedChannelFullMode.DropOldest是这套写法的核心取舍:磁盘跟不上时,宁可丢最老的消息,也不让内存无限增长。如果你的场景一条都不能丢,就改成Wait,让Enqueue阻塞,代价是消息回调会变慢,进而影响 Broker 的投递节奏,得配合 QoS 一起评估。
new UTF8Encoding(false)里的false表示不写 BOM。带 BOM 的 UTF-8 文件在被别的程序按行解析时,第一行开头会多出三个不可见字符,导致 topic 匹配失败,这个坑我踩过一次,查了两小时。
FileShare.Read允许你在程序运行中用记事本或tail打开看,不加的话文件会被独占,双击就是「另一个程序正在使用此文件」。
_pending >= 50这个阈值是吞吐和可靠性的平衡点。全量AutoFlush = true会让每条消息都触发一次系统调用,高频下 CPU 全耗在 IO 上;完全不 flush 又会在进程被强杀时丢掉缓冲区里所有内容。50 条是个经验值,消息量小的话改成按时间 flush 更合适,比如每 2 秒一次。
如果一条消息里带中文,写完可以用下面的命令抽查,确认没有乱码再往下走:
# 看最后 3 条,确认中文和 topic 都正常 tail -n 3 logs/20250101/messages.jsonl5. 避坑排查:这套样例在真实环境里最容易翻车的五个点
样例代码在单机单客户端下几乎不会出问题,一旦放到两台机器、多个客户端、连续跑几天,毛病就集中冒出来了。下面五条都是按「现象 → 原因 → 解决」记的,遇到时按顺序对一遍,比盲改代码快。
5.1 现象:客户端显示已连接,订阅了却一条消息都收不到
原因:多数是主题层级没匹配上。MQTT 的通配符里+只匹配一层,#匹配多层且必须放在最后一段。订阅demo/room1收不到demo/room1/temp;订阅demo/#/temp这种写法本身就是非法的,Broker 会直接断开连接。还有一种情况是服务端的白名单拦截了发布,客户端看着连上了,但消息根本没被转发。
解决:先把订阅主题临时改成#(订阅全部),发一条消息看能不能收到,能收到就说明是通配符写错,逐层往回缩定位。再看服务端日志里有没有[拦截]记录,有的话是白名单配置的问题。另外注意主题大小写敏感,Demo/#和demo/#是两个主题。
5.2 现象:跑几分钟后界面卡死,或者弹出「Cross-thread operation not valid」
原因:消息回调跑在 IO 线程上,直接操作控件就会跨线程异常;而如果用Invoke逐条更新,消息一多 UI 线程就被塞满,表现成假死。
解决:回调里只入队,UI 更新交给定时器批量做,也就是 4.2 那套写法。已经写了Invoke的,检查是不是在循环里每条都Invoke一次,改成一次性传入整批数据。还有个隐蔽点是BeginUpdate没配对EndUpdate,控件停在暂停重绘状态,看着像卡死。
5.3 现象:中文消息变成问号或者一串乱码
原因:发布端和订阅端的编码不一致,或者中间经过了 Base64 转换又忘了还原。还有一种是把Payload当成字符串数组直接拼接,把字节区间搞错了。
解决:全链路统一 UTF-8,订阅端用Encoding.UTF8.GetString(PayloadSegment.Array, Offset, Count),不要用Encoding.Default(在中文系统上是 GBK,跨机器必乱)。落盘时用不带 BOM 的UTF8Encoding(false)。如果发布端确实只能发 GBK,那就在订阅端显式转码,别指望自动识别。
5.4 现象:两个客户端互相踢下线,日志里反复出现连接和断开
原因:两个客户端用了同一个 ClientId。Broker 认为这是同一个会话的重连,会把前一个连接断掉。样例里 ClientId 常写成固定字符串,复制一份目录跑第二个就撞了。
解决:ClientId 加上机器名或进程 ID 做后缀,比如client-{Environment.MachineName}-{Process.GetCurrentProcess().Id}。如果业务上确实需要固定 ClientId(比如设备编号),那就保证一个编号只跑一个实例,并且把CleanSession设成false,让重连能恢复订阅。
5.5 现象:落盘报错「路径中有非法字符」,或者进程被杀后文件是空的
原因:按 topic 生成文件名时,主题里的/、+、#在 Windows 路径里都是非法或特殊字符。文件为空则是因为缓冲区没 flush,进程被强杀或断电,内存里的几十条消息直接丢了。
解决:要么不按 topic 分文件(4.3 就是按天分、一行一条 JSON,topic 作为字段存在行里),要么先把 topic 做一次替换:topic.Replace('/', '_').Replace('+', 'p').Replace('#', 's'),同时限制文件名长度并过滤<>:"|?*。防丢数据这块,把 flush 阈值调小,退出时走DisposeAsync里那次兜底 flush,并且给窗体加个FormClosing钩子,等写入器排空再关。
注意:
FormClosing里如果直接await异步方法,容易和窗体销毁抢时序,稳妥做法是同步等待一小段时间(比如_writer.DisposeAsync().AsTask().Wait(3000)),超时就放弃剩余消息,别让用户关不掉窗口。
6. 二次开发改造:把这套样例变成能长期运行的小工具
样例跑通之后,真正花时间的不是加功能,而是决定「哪些不改造」。我的习惯是先划三条线:连接层、落盘层、界面层,改哪层就只动哪层,跨层改最容易把本来能跑的链路搞崩。
连接层最值得先动的是把服务端和客户端的参数抽到配置文件里。端口、账号、主题白名单、落盘目录这些,散在代码里意味着每次换现场都要重新编译,交付时非常难堪。抽成appsettings.json或者简单的 ini 都行,加载失败时给默认值并写一条日志,而不是直接抛异常退出——现场同事最怕的就是双击没反应。
落盘层的第一个改造点是加个「按主题分流」的开关。现在是全部写一个messages.jsonl,如果主题只有十几个,按一级前缀拆成多个文件反而更好查;但如果主题是动态生成的(比如带设备序列号),拆文件会导致文件数爆炸,这时候应该保持单文件 + 加索引导出。判断标准很简单:主题基数是否可控。我一般的做法是加一个SplitByTopicPrefix配置项,默认关,现场觉得需要再打开。
下面这张表是我通常会排的改造优先级,按投入产出比从高到低:
| 改造点 | 工作量 | 解决的实际问题 | 建议顺序 |
|---|---|---|---|
| 参数配置化 | 半天 | 换现场不用重编译 | 1 |
| 断线重连退避 | 半天 | Broker 重启后客户端全挂 | 2 |
| 落盘 flush 策略可配 | 1 小时 | 断电丢数据 / IO 打满 | 3 |
| 主题白名单 + 拦截日志 | 1 天 | 误发消息污染信道 | 4 |
| 消息查询界面 | 2 天 | 现场排查要开记事本 | 5 |
| 服务端做成后台服务 | 1 天 | 关窗体就断服务 | 6 |
验证改造有没有破坏原有链路,我有一套固定动作:起服务端 → 起两个客户端 → A 发一条中文 + 一条超长负载 → B 确认收到 → 掐掉服务端 → 等 30 秒 → 重启服务端 → 看 B 是否自动重连并且订阅还在 → 查当天日志文件里这两条都在。这套动作走完,基本能覆盖 90% 的回归问题。
第三个改造方向是把服务端从 WinForm 里剥出来。窗体进程一关,Broker 就没了,这在上位机场景里很致命。常见做法是把服务端逻辑搬到Windows 服务或者一个无界面的控制台程序里,WinForm 只留一个查看状态的面板。剥离时注意IAsyncDisposable的清理顺序:先StopAsync停止接收新连接,再等落盘队列排空,最后释放实例,顺序反了会丢掉最后一批消息。
界面层的改造建议往后放。消息列表加个虚拟模式(VirtualMode = true)比什么都管用,几万条也不卡;再加个按 topic 过滤的输入框,比导出到 Excel 再筛快得多。这些是体验优化,做不做都不影响链路正确性。
最后一条经验是关于版本管理的。样例代码最怕的是「跑起来就不管了」,三个月后现场出问题,你已经不记得当时改过哪几行。我从一个项目上吃过亏——客户现场的服务端行为异常,回去翻代码发现是有人手动改过端口常量,没提交。从那以后我每次动这类样例工程,都强制走一遍:建分支、每个改造点一个提交、提交信息写清楚改了哪个回调,交付时打一个 tag 并把 tag 名写进交付说明。麻烦是麻烦,但排障时能省下的是成倍的时间。
希望帮到你。
本文还有配套的精品资源,点击获取