1. 为什么选择Socket:先理清通讯方案背后的逻辑
1.1 上位机通讯的主流选项对比
做C#上位机开发的人,大概率都遇到过这样的场景:设备端(PLC、仪表、视觉系统、甚至另一台工控机)需要和你写的上位机软件通讯,数据量从几个字节的状态字到几百MB的日志文件都有。这时候第一反应通常是"用什么通讯方式"。
拿我自己的项目经验来说,C#里能选的方案大概有这几类:
- HttpClient / WebApi:适合请求-响应模式,服务端主动推送麻烦,长连接支持不好,做实时性要求高的控制类通讯会很别扭;
- NamedPipe / 共享内存:只能本机通讯,跨机器就废了;
- TCPClient类(TcpListener/TcpClient):封装程度高,写起来快,但遇到粘包、半包、断线重连这些场景,封装反而成了限制,你得去剥洋葱一样把底层Socket翻出来;
- 原生Socket:代码量确实大一些,但通讯过程的所有环节都在自己手里,超时、重连、文件传输、半包缓存,每一处都能精确控制。
这个项目我选了原生Socket,原因很直接:要做的两件事——断线重连和远程文件发送——都不是简单的"连上发数据"能搞定的。断线重连意味着我要处理网络异常后的状态恢复,文件发送意味着数据量可能很大、需要分块、需要进度追踪和完整性校验。这些东西用TcpClient的Stream读写也能做,但一旦加了业务逻辑,底层Socket的灵活性和可控性就体现出价值了。
1.2 一套合理的通讯协议是稳定性的地基
很多新手在写Socket程序时有个习惯:直接发字符串,用Encoding.UTF8.GetBytes转一下就往网络流里塞,对端收到再转回来。这种写法在数据量小、交互不频繁的Demo里能跑,但放到真实项目里就是埋雷——你根本不知道对端这次Read到的数据到底是一个完整的消息,还是半条消息加下一条消息的开头。
网络传输有两个很经典的问题:粘包(多次Send的数据被合并成一次收到)和半包(一次Send的数据被拆成了多次收到)。这是TCP的流式特性决定的,和数据包的大小、网络拥塞状况都有关系,不是你调大缓冲区就能规避的。
所以通讯协议必须自己设计。我在这类项目里固定使用一套消息帧格式,不管传输的是控制指令还是文件数据,都走同一套封包解包逻辑:
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 帧头标记 | 2 | 固定0xAA 0x55,用于识别消息起点 |
| 包体长度 | 4 | 整个包体(含命令字、序列号、数据)的字节数,使用网络字节序 |
| 命令字 | 2 | 区分消息类型:心跳、控制指令、文件开始、文件数据、文件结束、错误报告等 |
| 序列号 | 4 | 每条消息唯一编号,用于重发、确认和乱序处理 |
| 包体数据 | 不定 | 业务数据,长度由包体长度字段决定 |
| 校验码 | 2 | CRC16,对包体做校验,防止数据损坏 |
这套结构的核心思想是:接收方只做一件事——不断从Socket缓冲区读原始字节流,然后根据帧头标记和长度字段,从流里"切"出一个个完整的消息帧。只要协议定了,后面的断线重连和文件发送都在这套框架里展开。
1.3 消息帧结构之外的三个约定
协议不只是字节格式,还包括一些业务层面的约定。我在动手写代码之前先定了三条规矩:
第一,心跳包独立于业务数据。心跳用固定的小包,不带业务负载,只有命令字、序列号和时间戳。之所以单独定义,是为了让心跳包不被大文件数据块"挤"到后面——如果心跳和文件数据共用一条发送通道,文件一传,心跳就滞后,对端会误判你掉线。
第二,发送和接收都要做超时控制。Socket的Send和Receive操作不能无限等下去。比如文件数据块,发送方每块超时5秒没发完就重发;接收方每块超过10秒没等到,就认定链路异常触发重连流程。
第三,业务层和网络层分开。网络层只管收发字节、拆包组包、维护连接状态;业务层只管处理"收到一条完整消息后该干嘛"。这个分离在后续维护时价值极大——改文件传输逻辑的时候完全不用碰Socket的收发线程。
我把这三条先放在前面说,是因为后面的所有代码实现都建立在它们之上。跳过这套设计直接写代码的,通常都在联调阶段被粘包和超时问题反复折磨。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 服务器端的骨架:监听、会话管理与回调分发
2.1 TCPListener的事故现场:监听循环不能这么写
服务器端的第一个坑,就是监听循环的写法。很多人第一次写的版本是这样:
csharp复制TcpListener listener = new TcpListener(IPAddress.Any, 9000);
listener.Start();
while (true)
{
TcpClient client = listener.AcceptTcpClient();
// 处理客户端
}
这段代码的问题在于:AcceptTcpClient是阻塞的,如果放在主线程里,整个程序就卡死在等待连接上了。实际项目里,监听必须放在独立线程,而且Accept操作要支持在程序退出时被中断。
我推荐的写法是用异步接受 + 信号控制退出:
csharp复制private TcpListener _listener;
private CancellationTokenSource _cts = new CancellationTokenSource();
private Task _acceptLoopTask;
public void Start(int port)
{
_listener = new TcpListener(IPAddress.Any, port);
_listener.Start();
_acceptLoopTask = Task.Run(() => AcceptLoopAsync(_cts.Token));
}
private async Task AcceptLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
TcpClient client = await _listener.AcceptTcpClientAsync();
if (client != null)
{
// 创建会话对象,交给会话管理器统一跟踪
var session = new ClientSession(client, this);
_sessions.TryAdd(session.Id, session);
session.Start();
}
}
catch (OperationCanceledException)
{
break; // 正常退出
}
catch (SocketException ex)
{
// 记录日志,继续等待下一个客户端
}
}
}
用AcceptTcpClientAsync的好处是,在等待连接期间,当前线程不会白白占着一个线程池线程干睡觉,而且配合CancellationToken可以优雅关闭——程序退出时调用_cts.Cancel(),AcceptLoop会自己跳出来。
2.2 客户端会话:每个连接是一个状态机
服务器端不会只有一个客户端,一个客户端也不一定只做一件事。每个Accept连接我都封装成一个ClientSession对象,这个对象里面包含:
- Socket状态:当前连接是否可用、最后一次收发时间;
- 收发线程:独立的接收线程循环,持续读取数据并拆包;发送线程不做死循环,但要做并发发送的锁控制;
- 会话状态字段:Connected、AuthenticationPending、Disconnected这样几个状态。
会话管理用一个ConcurrentDictionary<string, ClientSession>存放,key是会话ID。收到的每条消息都要带上会话ID,这样业务层做回调分发时,能明确知道这条消息来自哪个连接。
这里有个我很坚持的做法:每个会话的接收循环用while (socketConnected)来驱动,而不是用Thread.Sleep去轮询。正常情况下,接收线程应该一直阻塞在Receive调用上,数据到了就处理,没到就继续等。真正的掉线检测是心跳超时之后才做的事情,而不是接收线程本身去判断——接收线程阻塞在Receive上,断开时会自己抛异常。
2.3 收发线程分开:接收是阻塞的,发送是加锁的
服务器端Session的接收和发送,我建议用两个不同的机制来处理:
接收端:独立的循环线程(或async循环),永久阻塞在socket.Receive上。收到的原始字节统一扔给拆包器,拆出完整的消息再回调上层。
发送端:不能多个线程同时调用Socket.Send,否则会产生字节交错。所有要发送的数据进一个队列,由发送线程统一取出并调用Send;或者用一个SemaphoreSlim给Send操作加锁。
我用的是后者,因为上位机的场景发送频率通常不高,加锁比维护一个发送队列更简单直接。核心代码大概是:
csharp复制private SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1);
private readonly byte[] _sendBuffer = new byte[8192];
public bool SendMessage(byte[] frame)
{
if (!IsConnected) return false;
_sendLock.Wait();
try
{
int offset = 0;
while (offset < frame.Length)
{
int chunkSize = Math.Min(_sendBuffer.Length, frame.Length - offset);
_socket.Send(frame, offset, chunkSize, SocketFlags.None);
offset += chunkSize;
}
return true;
}
catch (SocketException)
{
MarkDisconnected();
return false;
}
finally
{
_sendLock.Release();
}
}
注意那个Math.Min,是防止一次Send过大数据导致底层缓冲区问题。这个细节在你后面发文件的时候会非常明显,几百MB的文件如果一次性传给Send,大概率会出错。
3. 客户端实现要点:连接、心跳与数据收发
3.1 写一个不会卡死UI线程的SocketClient
客户端这边,我封装了一个SocketClient类,核心接口就四个:ConnectAsync、Disconnect、SendMessage、OnMessageReceived。客户端的架构比服务器端简单,但有一个重要差异:客户端要支持"主动连接"和"被动断开重连",所以它的连接状态变化必须能通过事件通知UI层。
连接这块,我强烈建议用await风格而不是Thread.Sleep等待。UI线程上的WPF/WinForm程序,如果直接调用阻塞式Connect,界面会卡死;如果用ConnectAsync,就完全不碰UI线程了:
csharp复制public async Task<bool> ConnectAsync(string ip, int port, int timeoutMs = 3000)
{
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
var connectTask = socket.ConnectAsync(new IPEndPoint(IPAddress.Parse(ip), port));
// 超时控制:超过timeoutMs还没连上,判定失败
var completed = await Task.WhenAny(connectTask, Task.Delay(timeoutMs));
if (completed != connectTask)
{
socket.Close();
return false;
}
await connectTask;
_socket = socket;
IsConnected = true;
_ = Task.Run(() => ReceiveLoopAsync());
return true;
}
这里的Task.WhenAny比用Socket.Poll或者设置SendTimeout更直观,超时判定精确到毫秒,而且不会误伤正常的慢速连接。
3.2 手动分包:唯一可靠的拆包姿势
前面提到过粘包和半包。现在具体说解决方式。接收循环里维护一个MemoryStream作为接收缓存:
csharp复制private MemoryStream _recvBuffer = new MemoryStream();
private readonly byte[] _tempBuffer = new byte[4096];
private void ReceiveLoop()
{
while (IsConnected)
{
int received = _socket.Receive(_tempBuffer, 0, _tempBuffer.Length, SocketFlags.None);
if (received == 0) break; // 对端正常关闭
int offset = 0;
while (offset < received)
{
// 先把新数据写入缓存
_recvBuffer.Write(_tempBuffer, offset, received - offset);
offset = received - offset >= 0 ? received : offset;
// 关键:从缓存中尝试取出完整帧
if (TryExtractFrame(_recvBuffer, out byte[] frame))
{
ProcessFrame(frame);
}
}
}
}
TryExtractFrame的逻辑就是按照第一节定义的帧格式来:先查帧头0xAA 0x55,再读4字节长度,如果缓存中的数据量已经达到完整帧大小,就截取这一段返回,剩余数据继续留存在缓存里等待下一次Receive。
这就是"流式数据"的正确处理方式:不管你底层一次收到了多少字节,解析层永远只认帧边界。这个逻辑写对之后,粘包半包问题就彻底消失了。
有个细节容易忽略:TryExtractFrame取出完整帧之后,如果缓存里还有剩余数据(可能是下一条消息的头部),缓存必须保留,不能清空。当年我在这里踩过一次坑,清了缓冲导致下一条消息的帧头直接丢了,对端发两条紧挨着的消息就错乱了。
3.3 心跳包:发送Tick、超时计数与状态联动
心跳在整个断线重连机制里的位置非常关键。我的设计是这样的:
- 发送频率:每2秒发一次心跳;
- 超时判死:连续3次(6秒)没收到任何数据(不止是心跳,任何数据都算),判定链路死亡,触发断开和重连;
- 判定依据:不是用单独的计数器,而是用"最后一次收到数据的时间"。每次Receive循环收到任意字节,就更新
_lastReceiveTime。
心跳发送用System.Timers.Timer,每2秒触发:
csharp复制private void HeartbeatTimer_Elapsed(object sender, ElapsedEventArgs e)
{
if (!IsConnected) return;
if ((DateTime.Now - _lastReceiveTime).TotalSeconds > 6)
{
// 连续超时,判定掉线
ForceDisconnect("心跳超时");
StartReconnect();
return;
}
var heartbeat = BuildFrame(CommandType.Heartbeat, null);
SendMessage(heartbeat);
}
为什么要"任何数据都算",而不是要求必须回心跳?因为真实场景里,服务器可能在批量推送消息,客户端收到消息的同时恰好没来得及回心跳。如果严格限定"只能心跳更新计时",就会出现误判掉线的情况。网络通讯中,能收到对端的任何数据,都说明链路是通的。
4. 断线重连:看似简单的小功能,里面有大学问
4.1 重连状态机:别用裸循环跑重连
网上很多教程会把重连写成这样:
csharp复制while (!connected)
{
Thread.Sleep(1000);
Connect();
}
这个写法最大的问题是:它没有任何"状态"的概念,重连过程中用户发了一条消息、程序要退出、服务器暂时不可达要加大等待间隔——这些事情都没法优雅处理。控制权完全陷在那个while循环里。
我自己的实现是一个四状态状态机:Disconnected、Connecting、Connected、Reconnecting。状态迁移的逻辑是:
- 初始进Disconnected;
- 用户点连接或启动时自动进入Connecting,调用ConnectAsync;
- 连接成功进入Connected,启动心跳和接收循环;
- 接收循环异常退出或心跳超时,进入Reconnecting;
- Reconnecting状态下,定时发起连接尝试,成功后回Connected,失败继续保持Reconnecting,并逐次加大重试间隔。
用状态机的好处是:任何时刻要知道当前连接处于什么状态,只需读一个状态属性,UI上显示连接状态、用户操作按钮的可用性、断线后的业务逻辑(比如缓存未发送的数据),全都在这个状态机上做分支。
4.2 指数退避重连,避免"风暴"
重连间隔不能是固定值。服务器宕机恢复需要时间,如果客户端每1秒疯狂重连,服务器一恢复就要面对几十个客户端的连番轰炸。正确的做法是用指数退避:
csharp复制private int _reconnectAttempt = 0;
private TimeSpan GetReconnectDelay()
{
_reconnectAttempt = Math.Min(_reconnectAttempt + 1, 6);
int delaySeconds = (int)Math.Pow(2, _reconnectAttempt - 1); // 1,2,4,8,16,32
return TimeSpan.FromSeconds(Math.Min(delaySeconds, 30)); // 封顶30秒
}
重连成功后,_reconnectAttempt要清零。这个机制的合理性在于:网络刚刚出问题的时候,大概率是临时抖动,快速重连能第一时间恢复;如果连续几次都失败,说明问题不小,这时候把间隔拉大,既能减少无谓的消耗,也能防止日志被刷屏。
重连过程的日志一定要记录。每次重连尝试都打一条:时间、尝试次数、下一次重试的间隔。这个日志在排查现场问题的时候,价值远大于代码本身——它能还原整个断线过程的时间线。
4.3 重连之后的三件事:订阅恢复、数据补偿、UI通知
很多人把断线重连理解成"把Socket重新连上就完事",其实远远不够。重连成功只代表TCP链路通了。链路断了这么久,业务层面有几个重要的问题需要处理:
第一,恢复服务器端订阅。如果客户端之前订阅了某类实时数据(视觉检测结果、设备状态、温度曲线),重连后服务器不会知道你还是那个客户端。TCP断开的瞬间,服务器端的Session对象就销毁了,订阅信息也跟着没了。所以客户端重连成功后要主动上报"我是哪个客户端、我要订阅哪些数据"。我一般用一个专用的Register消息,在连接成功后立即发送。
第二,补偿断线期间的数据。上位机场景中,服务器可能在断线期间缓存了一批数据(比如设备报警记录)。客户端重连后,服务器应该把离线期间的数据补发过来。这个需求的实现方式是在客户端的Register消息里带上"上次收到的最后一条消息序列号",服务器据此把序列号之后的数据重新发一遍。这就是我在协议里设计序列号字段的核心目的。
第三,UI状态更新。这一条看着简单,但最容易做错——重连成功的事件回调如果直接去更新WPF控件,会抛跨线程异常。我通常把ConnectStateChanged定义成事件,UI层订阅后在Dispatcher.Invoke里更新界面:
csharp复制public event EventHandler<ConnectionStateChangedEventArgs> StateChanged;
// 在UI层订阅
_client.StateChanged += (s, e) =>
{
Dispatcher.Invoke(() =>
{
BtnConnect.IsEnabled = e.CurrentState == ConnectState.Disconnected;
StatusText.Text = $"连接状态:{e.CurrentState}";
});
};
4.4 主动断开的处理:区分"用户不想连了"和"意外掉线"
重连逻辑容易误伤一种场景:用户主动点了"断开连接"。如果这个操作触发了重连状态机,就会出现一个很尴尬的场面——用户明明点了断开,程序过两秒又自动连上了。
所以必须在Disconnect操作里设置一个_manuallyDisconnected标志位:
csharp复制public void Disconnect()
{
_manuallyDisconnected = true;
IsConnected = false;
_socket?.Close();
}
只有在_manuallyDisconnected == false的情况下,接收循环异常或心跳超时才允许进入Reconnecting状态。用户手动断开后,状态机应该进入Disconnected而不是Reconnecting。
这点看起来是常识,但在我接触过的项目里,至少有三四次是工程师没做这个区分,导致业务上的"断开"和"自动重连"互相打架。
5. 远程文件发送:从"能传"到"传得稳"
5.1 为什么不能直接"发字节":文件发送的协议设计
文件发送这个功能,如果用最简单的方式实现——把文件读成byte[],用一条消息发过去——也不是完全不行,但只适用于几十KB的小文件。一旦文件到几十MB、几百MB,问题就来了:
- 内存占用:整个文件读进内存,几百MB直接内存飙升;
- 发送阻塞:一次Send几百MB的字节数组,底层会拆成很多小包慢慢发,期间发送线程被占死,心跳发不出去,对端会判定你掉线;
- 断点重传没法做:传了一半网络断了,重新传就得整个重来。
所以文件发送必须设计成分块传输。我在原有的帧协议基础上,定义了三种文件相关的帧:
| 帧类型 | 命令字 | 包体内容 | 用途 |
|---|---|---|---|
| FileHeader | 0x11 | 文件名(定长256),文件总长度(8字节),总块数(4字节),MD5(16字节) | 告诉对端要开始传文件,附带元信息 |
| FileData | 0x12 | 块序号(4字节),本块数据长度(4字节),块数据 | 传输文件的一个分块 |
| FileEnd | 0x13 | 已接收总块数(4字节) | 接收方用于确认完整性 |
分块大小我选了8192字节。这个值不是随便定的:太小的块会导致帧头、长度、校验码这些额外开销占比变大,传输效率下降;太大的块虽然每块的额外开销占比小,但如果网络质量一般,一个块出错重传的成本就很高。8KB对绝大多数网络环境都是一个性能与稳定性的好平衡点。
5.2 发送端的实现:FileStream边读边发
发送端用FileStream边读边发,而不是一次性Read整个文件:
csharp复制public async Task<bool> SendFileAsync(string filePath, CancellationToken ct)
{
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
long fileLength = fs.Length;
int totalBlocks = (int)((fileLength + CHUNK_SIZE - 1) / CHUNK_SIZE);
// 1. 发送文件头
byte[] header = BuildFileHeader(Path.GetFileName(filePath), fileLength, totalBlocks);
SendMessage(header);
// 2. 分块发送
byte[] chunk = new byte[CHUNK_SIZE];
int blockIndex = 0;
int bytesRead;
while ((bytesRead = await fs.ReadAsync(chunk, 0, CHUNK_SIZE)) > 0)
{
ct.ThrowIfCancellationRequested();
byte[] dataFrame = BuildFileDataFrame(blockIndex, chunk, bytesRead);
await Task.Run(() => SendMessage(dataFrame)); // 避免大量Send阻塞UI
blockIndex++;
// 释放引用,让GC能及时回收,不会因为大文件导致内存暴涨
dataFrame = null;
}
// 3. 发送文件结束帧
SendMessage(BuildFrame(CommandType.FileEnd, BitConverter.GetBytes(blockIndex)));
return true;
}
分块发送时进度上报也很重要。我定义了FileProgressChanged事件,每发完一定数量的块就触发一次,UI层显示"已发送xx MB / xx MB"。注意这里不要每块都触发事件,高频事件会拖垮UI线程,我通常每发完128块才上报一次,换算下来大概每1MB报一次进度。
5.3 接收端的落盘:临时文件加校验
接收端的处理流程要谨慎一些,因为直接覆盖正式文件的话,一旦传输出错,原有的好文件也被毁了。我的做法是:
先写到临时文件,全部接收完并且校验通过后,再替换正式文件:
csharp复制private FileStream _fileStream;
private string _tempFilePath;
private int _receivedBlocks = 0;
private long _expectedLength;
private void OnFileHeader(byte[] data)
{
_expectedLength = BitConverter.ToInt64(data, 0);
_tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".tmp");
_fileStream = new FileStream(_tempFilePath, FileMode.Create, FileAccess.Write);
_receivedBlocks = 0;
}
private void OnFileData(byte[] data)
{
int blockIndex = BitConverter.ToInt32(data, 0);
int dataLength = BitConverter.ToInt32(data, 4);
_fileStream.Seek(blockIndex * CHUNK_SIZE, SeekOrigin.Begin);
_fileStream.Write(data, 8, dataLength);
_receivedBlocks++;
}
这里有个很容易出错的细节:块序号对应的文件偏移,必须用Seek明确指定,而不是顺序写。因为网络传输过程中,块不一定是按顺序到达的(虽然TCP保证字节顺序,但上层消息如果有重传逻辑,后到的块可能比先到的块序号小)。乱序的情况下,按顺序Write会把数据写到错误的位置,造成文件损坏。
接收完所有块之后,做两件事:
- 计算MD5:对收到的临时文件算一次MD5,与FileHeader里的MD5对比;
- 校验通过才替换:
csharp复制string newPath = Path.Combine(_saveDirectory, _fileName);
if (File.Exists(newPath))
{
File.Delete(newPath);
}
File.Move(_tempFilePath, newPath);
5.4 文件发送与心跳的共存:发送队列的优先级
前面提到过,发送大量文件数据时不能把心跳挤到后面。具体实现时,我给发送队列加了一个简单的优先级:
csharp复制private ConcurrentQueue<SendItem> _normalQueue = new();
private ConcurrentQueue<SendItem> _priorityQueue = new();
public void SendPriority(SendItem item) => _priorityQueue.Enqueue(item);
public void SendNormal(SendItem item) => _normalQueue.Enqueue(item);
发送线程每次取数据,优先从_priorityQueue取,心跳就放进优先级队列;文件数据块放普通队列。这样即使在传大文件的过程中,心跳也能得到2秒级以内的响应,不会因为传文件而触发误判掉线。
另外,文件块数据的发送,不要用第一节那种全量加锁的Send方式。8192字节的块是可以在一次Send内发完的,但为了保证和心跳互不阻塞,我会把文件数据的发送也丢到发送线程统一处理,主线程只负责向队列投递数据。这个结构调整完之后,传文件的同时UI操作、心跳、控制命令互不干扰,实测非常稳定。
6. 联调阶段的坑与最后一公里的优化
6.1 五个真实踩过的坑,按破坏力排序
这节的内容都是我在实际联调中一步步踩出来的,按对项目的影响程度从大到小列出来:
第一坑:Socket异常后没关紧资源,句柄泄漏。
网络异常断开后,如果你没把Socket对象彻底Shutdown和Close,底层连接资源不会立刻释放。每个异常连接都会占一个句柄,项目跑一天,句柄数蹭蹭往上涨,最终连新的连接都accept不了。正确做法是:
csharp复制try { _socket.Shutdown(SocketShutdown.Both); } catch { }
try { _socket.Close(); } catch { }
_socket = null;
第二坑:同步操作把UI卡死了。
客户端连接、发送文件时如果用了同步的Socket.Send或Connect,在WPF/WinForm程序里就是UI假死的元凶。这个坑在建项目初期最容易犯,尤其是从控制台Demo代码改到带界面的程序时。解决方法前面已经说过了,一律用异步或丢到后台线程。
CUDA(不是,这里不谈CUDA)——说回正题,C#里的Task.Run配合async/await就够用了,不用额外引入什么复杂框架。
第三坑:MD5校验后才发现文件名字没处理干净。
Windows环境下,从协议里收到的文件名可能包含盘符、非法字符、甚至..\这样的路径穿越字符。如果直接拼接到保存路径:
csharp复制string unsafePath = Path.Combine(_saveDirectory, _fileName);
一旦文件名是..\..\Windows\system32\xxx,文件会被写出保存目录。这个安全漏洞在实际项目中必须堵死。我的处理方式是:文件名只允许字母、数字、下划线、点、中划线,其他字符一律替换为下划线,并且用Path.GetFileName先做一次规范化。
第四坑:异常日志没打上下文,排查全靠猜。
项目上线后,你不可能每时每刻盯着调试器。所有异常日志必须带上时间、连接状态、会话ID、最近一次收发时间。否则就凭日志里的"NulLReferenceException"根本没法定位问题。
第五坑:接收数据时没处理"0个字节"的情况。
Socket.Receive返回0表示对端已经关闭了连接(有序关闭),这个情况和抛出SocketException是不同的。如果代码里没有单独处理这个分支,接收线程会一直空转,CPU占用率飙到100%并且永不退出。
6.2 最后再分享几条实战建议
基于这个项目的完整开发过程,有三条建议是我觉得最有复用价值的:
第一条,先把协议文档写出来再写代码。 我吃过急急忙忙开写、后来协议对不齐的亏。协议文档不需要长,但帧格式、命令字、超时时间、重连参数这几项必须写清楚。哪怕代码改了一版,只要协议文档同步更新,维护成本就会被限制在一个非常可控的范围内。
第二条,一定做个模拟端。 联调时,跨机器跑两个程序很不直观。我通常会在客户端项目里带一个模拟服务器模式,用同一套协议在本机自测,这样断线重连、心跳超时、文件发送这些场景,可以在开发机上随时复现,不用每次都在真机上反复折腾。
第三条,断线重连和文件传输这两个功能,测试方法不是"跑一次通过",而是"反复断网100次还能恢复"。 联调阶段我会直接拔网线、停服务器进程、人为把Socket关掉,观察重连逻辑是否每次都可靠恢复。项目稳定与否,很大程度取决于这种暴力测试覆盖了多少次异常场景。
C# Socket这套东西,写起来不算难,难的是把异常场景全部处理到位。断线重连和远程文件发送这两块,正好是这类场景最集中的地方。把这个项目完整跑通之后,再去写其他基于TCP的业务,底层通讯的部分就可以完全复用,只需要改协议层和业务层,开发效率会有非常明显的提升。
