☰
C# Winform Socket JSON收发:TCP粘包与异步通信实践
2026/10/26 18:26:28 网站建设 项目流程

简介:这是一份面向C# Winform初学者及需要实现JSON与Socket通信的开发者整理的可运行示例包,围绕JSON与对象互转、Winform界面呈现和Socket消息收发展开,适合用来理解序列化、消息封装及客户端与服务端交互的基本流程。压缩包共33个文件、约66KB,以13个cs源码为主体,配合csproj项目文件、config配置、resx/resources资源、exe与pdb调试文件等,覆盖界面逻辑、通信处理、日志记录和配置读取等模块,体量轻便,导入后便于快速查看结构与运行调试。目前已有1876人学习下载。读者可从中获得完整的Winform演示工程、JSON转换辅助代码、Socket收发处理思路以及日志与配置文件组织方式,既能对照学习对象与JSON的映射关系,也能据此改造消息协议、补充异常处理和扩展实际通信场景。

1. 联调时界面白屏那一刻:这个 Winform Socket + JSON 样例能少走多少弯路

前阵子帮人看一个上位机小工具,点「连接」按钮之后窗体直接白屏,进程还活着,日志一行没有。拆开一看,问题既不在 Socket 也不在 JSON,而是把同步的Receive写在了 UI 线程里,加上收到的是半包数据,反序列化一抛异常,整个接收循环就断了。用 C# Winform 做设备通信,这条路几乎是必经之路。这个「C# Winform Sockets 收发 Json 样例」给的是一套能跑起来的最小骨架:一个服务端窗体、一个客户端窗体,走 TCP,报文统一用 JSON 序列化,收发逻辑与界面更新分开处理。适合刚接手上位机项目的开发者,也适合想把散落各处的通信代码收拢成一层的人。它不追求大而全,追求的是协议看得懂、代码改得动。

2. 工程骨架与技术选型:窗体、Socket、JSON 三者怎么各就各位

2.1 解决方案目录与职责划分

样例最容易犯的错,是把协议代码复制两份,服务端一份、客户端一份,某天加个字段两边对不上,联调一下午找不到原因。合理的拆法是把「报文长什么样」抽成独立程序集,两端都引用它,改一处两边同步。按这个思路,项目的目录大致是这样:

SocketJsonDemo/ ├── SocketJsonDemo.Server/ 服务端窗体 │ ├── FormServer.cs 监听、连接管理、日志展示 │ ├── FormServer.Designer.cs │ └── Program.cs ├── SocketJsonDemo.Client/ 客户端窗体 │ ├── FormClient.cs 连接、发送、接收 │ ├── FormClient.Designer.cs │ └── Program.cs ├── SocketJsonDemo.Shared/ 两端共用的协议层 │ ├── Models/MessageEnvelope.cs 报文信封 │ ├── Models/Payloads.cs 各业务负载 │ ├── Protocol/PacketCodec.cs 分包编解码 │ └── Protocol/JsonHelper.cs 序列化封装 └── SocketJsonDemo.sln

这个划分的关键在于 Shared 层不引用任何窗体代码,只依赖序列化库。服务端窗体负责TcpListener的启停和连接表维护,客户端窗体负责连接、发送和接收回调,真正的字节拼接和 JSON 解析都落在PacketCodec和JsonHelper里。好处很直接:想换个 UI 框架、想把这套通信层搬进控制台程序或者 Windows 服务,Shared 一行不用改。我在实际项目里会再加一个Logging目录,把收发原始报文按十六进制和文本双份落盘,出问题时不用靠猜。

窗体这边,服务端通常有两个核心控件:一个ListView显示当前连接(远端地址、连接时间、最后活跃时间),一个多行TextBox显示收发日志。客户端是「地址输入框 + 端口 + 连接按钮 + 发送框 + 接收日志」。控件的命名建议带上类型前缀,比如txtSend、lvConnections、btnConnect,后面跨线程更新时不容易写错。

2.2 序列化选型:Newtonsoft.Json 和 System.Text.Json 怎么取舍

JSON 这一层有两个主流选择,选错了后期会反复踩坑。把差异列出来更直观:

维度Newtonsoft.JsonSystem.Text.Json
引入方式NuGet 包,需随程序分发.NET Core 3.0+ 内置,框架自带
默认命名策略保持属性名原样默认转小驼峰
字段与私有成员支持,配[JsonProperty]即可默认不序列化字段,要开选项
循环引用提供ReferenceLoopHandling忽略策略默认直接抛异常
日期格式可控,支持自定义 Converter默认 ISO 8601,控制粒度较细
性能反射路径较慢,但有缓存源生成器模式下更快

样例这类场景,我一般直接上 Newtonsoft.Json。原因不是性能,而是「宽容」:设备侧或另一进程发来的 JSON 经常带注释、带多余字段、日期格式五花八门,Newtonsoft 默认忽略未知字段,配一个DateFormatString就能吃掉大部分格式差异。System.Text.Json 更严格,遇到不合规的报文会直接抛JsonException,在对接第三方时反而增加排查成本。

不过如果项目是 .NET 6 以上新起,且两端都由自己控制,System.Text.Json 配合源生成器是更干净的方案。无论选哪个,都建议在JsonHelper里把配置集中一次:

public static class JsonHelper { // 全局唯一的序列化配置,避免各处 new 出不同行为 private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings { // 报文里出现未知字段时不报错,方便协议向前兼容 MissingMemberHandling = MissingMemberHandling.Ignore, // 空值也写出来,接收端解析时字段结构稳定 NullValueHandling = NullValueHandling.Include, // 日期统一为可读格式,避免两端时区理解不一致 DateFormatString = "yyyy-MM-dd HH:mm:ss.fff", // 出现循环引用时忽略而不是抛异常 ReferenceLoopHandling = ReferenceLoopHandling.Ignore }; public static string Serialize(object obj) => JsonConvert.SerializeObject(obj, Settings); public static T Deserialize<T>(string json) => JsonConvert.DeserializeObject<T>(json, Settings); }

这里每个参数都有实际作用:MissingMemberHandling.Ignore保证老版本程序收到新字段不会崩;NullValueHandling.Include让接收端能明确区分「字段不存在」和「字段为 null」;DateFormatString是血泪经验,默认的 ISO 格式带T和时区,设备侧解析器经常不认。封装成静态类之后,全项目只有一处配置,改行为只改一次。

2.3 通信模型:同步阻塞、回调还是 async/await

Socket 收发的写法直接决定 UI 会不会卡。三种常见路径:最原始的是同步Receive,代码直观,但只要放在按钮事件里,界面立刻假死;第二种是BeginReceive/EndReceive的 APM 回调模型,老项目里很常见,回调跑在线程池线程上,必须自己处理跨线程更新;第三种是async/await包一层,可读性最好,现代项目优先选它。

样例为了照顾不同基础,通常在客户端用async/await,服务端用回调或Task.Run起接收循环。这里有个容易被忽略的点:Socket.ReceiveAsync返回的ValueTask<int>在 .NET 里要配合CancellationToken使用,否则关连接时接收线程会一直挂着。我一般的做法是给每个连接配一个CancellationTokenSource,断开时先Cancel()再Close(),顺序反了就会看到ObjectDisposedException刷屏。

另一个参数是Socket.NoDelay。TCP 默认开启 Nagle 算法,小包会攒着一起发,做指令类通信时表现为「点一下按钮,对面延迟几十毫秒才收到」。设置socket.NoDelay = true能关掉合并,代价是包数量变多,在局域网环境基本可以接受。

3. 协议与报文设计:JSON 消息怎么定格式、怎么解决粘包半包

3.1 报文信封:类型、序号、负载三段式

直接把业务对象序列化扔进 Socket 是最省事的做法,但很快会遇到两个问题:接收端不知道这条消息是什么类型,没法反序列化到具体类;出了问题没法把请求和响应配对。所以样例在业务对象外面套一层信封:

public class MessageEnvelope { // 消息类型,接收端据此决定负载反序列化成哪个类 public string Type { get; set; } // 请求序号,响应方原样带回,用于请求响应配对 public int Seq { get; set; } // 发送时间,用于排查延迟和乱序 public string Timestamp { get; set; } // 业务负载,保持为字符串,二次反序列化 public string Payload { get; set; } }

Type承担路由职责,常见取值是login、heartbeat、data、ack这类短字符串,别用中文或长句,日志里不好读。Seq解决的是「发出去三条指令,回来三条响应,哪条对哪条」的问题,客户端自增,服务端回填。Payload存的是业务对象再序列化一次的字符串,好处是信封结构永远稳定,业务字段怎么变都不影响外层解析;代价是多一次序列化开销,在指令级通信里完全可以忽略。

对应的业务负载定义在Payloads.cs里,用简单类即可:

public class LoginRequest { public string DeviceId { get; set; } public string Token { get; set; } } public class DataReport { public double Temperature { get; set; } public double Humidity { get; set; } public int Status { get; set; } }

字段名建议全项目统一风格,别一半 Pascal 一半 camel。JSON 本身大小写敏感,Temperature和temperature是两个字段,接收端反序列化时对不上就是默认值 0,这种问题不会有异常,只能靠日志发现。

3.2 长度前缀分包:解决 TCP 粘包的核心

TCP 是字节流,不保证一次Receive正好对应一条消息。发两条 200 字节的 JSON,接收端可能一次收到 400 字节,也可能先收 100 字节再收 300 字节。直接对收到的字节数组做Encoding.UTF8.GetString再Deserialize,十条里能崩八条。

通行的解法是「长度前缀」:每条消息前面加 4 字节,写明后面正文有多少字节。接收端先读 4 字节拿到长度,再攒够对应字节数才切出一条完整消息。发的时候这样打包:

public static byte[] Pack(string json) { byte[] body = Encoding.UTF8.GetBytes(json); byte[] lenBytes = BitConverter.GetBytes(body.Length); // 统一用大端,避免不同机器字节序不一致 if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes); byte[] buffer = new byte[4 + body.Length]; Buffer.BlockCopy(lenBytes, 0, buffer, 0, 4); Buffer.BlockCopy(body, 0, buffer, 4, body.Length); return buffer; }

BitConverter.GetBytes在 x86/x64 上返回小端序,如果两端都是自己写的程序其实无所谓,但只要有一端是嵌入式设备或别的语言实现,字节序不一致就会读出天文数字的长度。统一转成大端是最稳的约定。长度上限也要卡,我一般限制在 10MB 以内,超过直接判定为异常包并断开,防止对端发个错误长度把内存撑爆。

接收侧维护一个累积缓冲区,循环切包:

private readonly List<byte> _cache = new List<byte>(); public IEnumerable<string> Feed(byte[] chunk) { _cache.AddRange(chunk); while (true) { if (_cache.Count < 4) yield break; byte[] lenBytes = _cache.GetRange(0, 4).ToArray(); if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes); int bodyLen = BitConverter.ToInt32(lenBytes, 0); // 非法长度直接抛,由上层关闭连接 if (bodyLen <= 0 || bodyLen > 10 * 1024 * 1024) throw new InvalidDataException($"非法报文长度: {bodyLen}"); if (_cache.Count < 4 + bodyLen) yield break; byte[] body = _cache.GetRange(4, bodyLen).ToArray(); _cache.RemoveRange(0, 4 + bodyLen); yield return Encoding.UTF8.GetString(body); } }

这段代码的关键在yield break:数据不够就原样留在_cache里,等下一次Receive补齐,绝不提前解析。RemoveRange在切包后立刻裁掉已消费部分,否则缓冲区会无限增长,跑一天内存就上去了。用List<byte>是为了代码易懂,追求性能可以换MemoryStream或ArraySegment加偏移量,减少数组复制。

3.3 心跳、超时与重连的预留设计

短连接场景跑通收发就够,但设备通信通常是长连接,网络抖动、对端重启都会让连接变成「假活」:Socket.Connected返回 true,实际已经收不到数据。样例里一般会预留心跳位:客户端每隔 15 秒发一条Type = "heartbeat"的空负载消息,服务端收到后只刷新该连接的LastActiveTime,不回包。

服务端再起一个System.Windows.Forms.Timer,每 30 秒扫一遍连接表,把LastActiveTime超过 60 秒的连接关掉并从ListView移除。注意这是 Winform 的Timer,回调在 UI 线程上执行,操作控件安全,但别在里面做阻塞操作,否则界面会顿一下。要更精确可以用System.Threading.Timer,代价是需要自己Invoke回 UI 线程。

重连逻辑放在客户端,用一个状态机控制:Disconnected → Connecting → Connected → Disconnected。断线后延迟 3 秒重试,连续失败 5 次提示用户检查地址。这里有个坑,重连前必须把旧Socket的接收任务停掉,否则新旧两个接收循环同时往同一个日志框里写,日志会重复且顺序错乱。

4. 核心代码拆解:服务端监听、客户端连接与 JSON 收发全链路

4.1 服务端:监听、接入与连接表维护

服务端启动逻辑不复杂,TcpListener绑定端口后进入接受循环。核心是把「接受连接」和「处理连接」分成两个任务,接受循环只负责AcceptTcpClientAsync并把新连接登记进字典,处理逻辑丢给独立任务,这样新连接不会被旧连接的处理阻塞。

private TcpListener _listener; private readonly Dictionary<string, ClientSession> _sessions = new Dictionary<string, ClientSession>(); private async Task StartAsync(int port, CancellationToken token) { _listener = new TcpListener(IPAddress.Any, port); _listener.Start(); while (!token.IsCancellationRequested) { TcpClient client = await _listener.AcceptTcpClientAsync(); // 关键:每个连接独立任务,互不阻塞 _ = HandleClientAsync(client, token); } } private async Task HandleClientAsync(TcpClient client, CancellationToken token) { string key = client.Client.RemoteEndPoint.ToString(); var session = new ClientSession(client); _sessions[key] = session; UpdateConnectionList(); // 跨线程更新 UI,见 4.4 节 try { byte[] buffer = new byte[8192]; NetworkStream stream = client.GetStream(); while (!token.IsCancellationRequested) { int read = await stream.ReadAsync(buffer, 0, buffer.Length, token); if (read == 0) break; // 对端正常关闭 foreach (string json in session.Decoder.Feed(buffer.Take(read).ToArray())) Dispatch(session, json); } } catch (Exception ex) { Log($"连接 {key} 异常: {ex.Message}"); } finally { _sessions.Remove(key); client.Close(); UpdateConnectionList(); } }

ReadAsync返回 0 是 TCP 的正常关闭信号,一定要判断,否则会陷入空转死循环。_sessions用远端地址做 key 在局域网够用,正式项目建议用自增 ID,避免 NAT 环境下地址冲突。Dispatch里解析信封,根据Type找到对应处理函数,处理完把响应塞回session的发送队列,不要在接收循环里直接Write,否则发送慢会拖住接收。

服务端还有一个职责是广播。比如设备状态变化要通知所有在线客户端,遍历_sessions逐个发送即可。注意遍历时对字典加了写操作会抛InvalidOperationException,要么加锁,要么先ToArray()复制一份快照再遍历。

4.2 客户端:连接、发送与队列化

客户端这边我习惯把连接状态和发送逻辑都收在一个类里,窗体只调方法、订阅事件。发送走一个队列,避免 UI 线程直接写 Socket 造成卡顿:

public async Task SendAsync(MessageEnvelope envelope) { if (_client == null || !_client.Connected) throw new InvalidOperationException("连接未建立"); byte[] packet = PacketCodec.Pack(JsonHelper.Serialize(envelope)); // SemaphoreSlim 保证同一时刻只有一个写操作,防止包体交错 await _sendLock.WaitAsync(); try { await _client.GetStream().WriteAsync(packet, 0, packet.Length); } finally { _sendLock.Release(); } }

SemaphoreSlim这一层经常被省略,单线程点按钮时看不出问题,一旦有定时心跳和业务发送并发,两个WriteAsync交叉执行就可能把两个包拼成半截,接收端按长度前缀切包时直接错位。这种问题在日志里表现为「偶尔解析失败」,属于典型的玄学故障,加上锁就没事了。

接收侧和 3.2 节的Feed是同一套逻辑,包一层Task从NetworkStream连续读。窗体层通过事件拿到完整 JSON 字符串,再交给JsonHelper.Deserialize<MessageEnvelope>,根据Type分发。

4.3 接收分发与业务路由

Dispatch是纯逻辑,不碰 UI。按Type走switch,每个分支先把Payload反序列化成具体类,再调对应的处理方法。下面这段是服务端处理登录请求的写法:

private void Dispatch(ClientSession session, string json) { MessageEnvelope env; try { env = JsonHelper.Deserialize<MessageEnvelope>(json); } catch (JsonException ex) { // 报文格式错误,记录原文长度即可,避免日志爆量 Log($"报文解析失败({json.Length}字节): {ex.Message}"); return; } switch (env.Type) { case "login": var login = JsonHelper.Deserialize<LoginRequest>(env.Payload); bool ok = CheckDevice(login.DeviceId, login.Token); session.Send(new MessageEnvelope { Type = "login_ack", Seq = env.Seq, // 序号原样带回 Timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), Payload = JsonHelper.Serialize(new { success = ok }) }); break; case "heartbeat": session.LastActiveTime = DateTime.Now; break; default: Log($"未知消息类型: {env.Type}"); break; } }

异常捕获这段是必须的。JSON 解析失败在联调期非常频繁,不捕获会直接把接收任务打挂。捕获后只记日志不抛,让连接继续活着,后续报文还能正常处理。Seq原样带回是请求响应模型的基础,客户端靠它匹配回调。

4.4 跨线程更新 UI 与资源释放

Socket 回调跑在线程池线程上,直接改TextBox.Text会抛InvalidOperationException: 线程间操作无效。标准写法是判断InvokeRequired再BeginInvoke:

private void AppendLog(string text) { if (txtLog.InvokeRequired) { // BeginInvoke 异步投递,不阻塞通信线程 txtLog.BeginInvoke(new Action(() => AppendLog(text))); return; } txtLog.AppendText($"[{DateTime.Now:HH:mm:ss.fff}] {text}{Environment.NewLine}"); }

用BeginInvoke不用Invoke,是因为Invoke会等 UI 线程处理完才返回,高频率日志下通信线程会被 UI 拖慢,严重时表现为接收延迟越来越大。日志框还要设个上限,超过 5000 行就清掉最早的一批,否则跑一整天TextBox会卡到没法滚动。

资源释放放在窗体的FormClosing事件里,顺序是:先CancellationTokenSource.Cancel(),再遍历连接Close(),最后_listener.Stop()。反过来做的话,接收任务可能正在ReadAsync上等待,Socket 一关就抛ObjectDisposedException,日志里会留下一堆看起来像 bug 的红色异常。

5. 联调避坑:Socket + JSON 场景下最容易翻车的五个点

5.1 连接与线程层面的三个坑

现象一:点连接按钮后界面白屏,几秒后恢复或者直接无响应。原因是在 UI 线程上调用了同步的Receive或Connect,阻塞期间消息泵停止工作。解决办法是全部换成async/await版本,ConnectAsync、ReadAsync、WriteAsync都可以带CancellationToken。如果代码历史包袱重必须用同步 API,至少扔进Task.Run,回调里再Invoke回 UI 线程。

现象二:关窗时抛ObjectDisposedException,堆栈指向NetworkStream.Read。原因是窗体先关了 Socket,接收任务还在等待读,底层句柄失效。正确顺序是先取消令牌、await接收任务退出(加个超时兜底),再关 Socket。我一般会在ClientSession里存一个Task引用,Close()方法里await Task.WhenAny(_receiveTask, Task.Delay(1000)),等不到就强行关。

现象三:Socket.Connected是 true,但发出去的数据对端收不到。这是 TCP 的半开连接,对端断电或进程被杀,本端没有收到 FIN 包。判断连接是否可用不能看Connected属性,只能靠心跳加超时。服务端维护LastActiveTime,客户端定时发心跳,超过阈值就主动断开重连。这个机制越早加越好,等上线后才发现就得半夜爬起来查。

5.2 报文与序列化层面的两个坑

现象四:日志里能看到完整的 JSON 文本,但反序列化出来字段全是默认值。八成是字段名大小写不一致。System.Text.Json默认按小驼峰匹配,Newtonsoft 默认按原样匹配,两边配置不同就会出现「解析成功但数据为空」。这种问题不会有异常,属于黑匣子型故障。解决办法是两端统一用JsonHelper,并且第一次联调时就把一条完整报文打进日志,肉眼核对字段名。

现象五:中文内容偶尔变成乱码,重启后又正常。根因是编码不一致,发送端用Encoding.Default(在部分系统上是 GBK),接收端用UTF-8。样例里所有GetBytes和GetString都必须显式写Encoding.UTF8,不要用默认值。另外长度前缀算的是「字节数」不是「字符数」,一个中文字符占 3 字节,按字符数算长度会导致切包错位,这个坑我在两个项目里都见过。

6. 二次开发:把样例改成能承载业务的通信层

样例跑通只是起点,真要接到项目里,第一件事是把通信代码从窗体里抽出来,做成不依赖 Winform 的类库。我的做法是定义ISocketClient接口,暴露ConnectAsync、SendAsync、Disconnected事件和MessageReceived事件,窗体只订阅事件、调方法。这样同一套通信层可以在 Winform、控制台、Windows 服务里复用,测试时也能用假实现替换。

第二件事是加消息路由表,替代switch。用Dictionary<string, Action<MessageEnvelope>>注册处理器,启动时统一注册,新增消息类型只改注册处,不动分发逻辑:

private readonly Dictionary<string, Action<MessageEnvelope>> _handlers = new Dictionary<string, Action<MessageEnvelope>>(); public void Register(string type, Action<MessageEnvelope> handler) { _handlers[type] = handler; // 同类型覆盖,便于测试时替换 } private void Dispatch(string json) { var env = JsonHelper.Deserialize<MessageEnvelope>(json); if (_handlers.TryGetValue(env.Type, out var handler)) handler(env); else Log($"未注册的消息类型: {env.Type}"); }

第三件事是把收发原始报文落到文件,按天切分,格式用「时间 + 方向 + 十六进制 + 文本」。联调期这份日志比断点还管用,尤其是对方坚称「我发了」的时候,把日志甩过去比争论半天有效。

验证改造是否成功,我一般跑三个用例:连续发 1000 条小消息看是否丢包错位、发一条 1MB 的大消息看缓冲区是否够、拔网线 30 秒再插回来看重连是否自动恢复。三个都过,这层通信才算能用。从那以后我每次抽通信层,都强制先把这三个用例跑一遍再往下写业务,省下来的排查时间远多于写用例的时间。希望帮到你。

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

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

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

立即咨询