ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# Socket聊天源码实战:粘包、心跳、离线消息与群聊改造

C# Socket聊天源码实战:粘包、心跳、离线消息与群聊改造

简介:面向 C# 入门与进阶学习者的聊天软件源码实例,围绕仿 QQ 的桌面聊天场景,演示本地登录、文字与图片消息收发等基础功能,适合想通过完整小项目理解网络编程与桌面 UI 协作方式的开发者练手。压缩包共 21 个文件、约 36KB,以 6 个 .cs 源码文件和 3 个 .exe 为主,配合 .resx 与 .resources 资源文件、.sln 和 .csproj 工程文件及 .settings 配置,构成一个可直接用 Visual Studio 打开的 WinForms 客户端工程。读者可梳理 Socket 通信、多线程任务划分、ADO.NET 用户验证、图片压缩与 Base64 传输等环节的代码组织方式,也可借设计器文件理解事件驱动界面与消息收发的衔接;工程体量小巧,便于逐文件阅读与二次改造。已有 186 人浏览学习,适合作课程设计或自学 C# 网络编程的参考样例。

1. 一个能跑的 C# 聊天软件源码:从双击登录到消息送达要经过几层

把一份 C# 聊天软件源码拖进 Visual Studio,按下 F5,登录窗口弹出来,输入账号密码,转圈,然后红字提示超时——这是我第一次接手聊天类需求时的真实开局。当时我以为是数据库没连上,折腾了两个小时才发现服务端根本没启动。后来才明白,这类项目真正的难点从来不是登录界面那几个控件,而是消息怎么从 A 的输入框稳定走到 B 的气泡里,中间还要过粘包、心跳、离线、并发写库这几道关。

这套源码处理的正是这件事。WinForms 客户端 + 原生 Socket 服务端 + SQLite 落库,分层清晰,单聊、好友列表、在线状态、离线消息、图片与文件传输都有对应实现,代码量不大但链路完整。适合三类人:想搞懂 TCP 编程到底在写什么的在校开发者、要给内部管理系统补一个 IM 模块的兼职同学,以及需要一份能直接改的课程设计底子的朋友。它不追求高并发,但把「一条消息怎么不丢、不乱、不假在线」讲明白了。

2. 技术选型与目录结构:WinForms 客户端 + Socket 服务端 + SQLite 的四段拆分

2.1 四段式目录与各项目职责边界

整个解决方案拆成四个项目,依赖方向是单向的:Client 和 Server 都引用 Common,Server 额外引用 Data,Common 不引用任何业务项目。这种拆法听起来朴素,但它是后面能不能顺利二次开发的前提——协议定义只有一份,客户端和服务端不会各写一套枚举然后对不上号。

项目输出类型职责主要依赖
ChatCommon类库协议命令枚举、包头编解码、DTO、异常类型无(纯 BCL)
ChatData类库SQLite 建表、消息与用户仓储、Dapper 映射Dapper、Microsoft.Data.Sqlite
ChatServer控制台 / Windows 服务监听、会话管理、路由转发、心跳扫描Common、Data
ChatClientWinForms登录注册、好友列表、聊天窗口、本地缓存Common、Newtonsoft.Json

实际目录大致长这样,第一次打开先认这几个位置,后面改代码基本不会迷路:

ChatSolution/ ├─ ChatCommon/ │ ├─ Protocol/PacketCodec.cs # 12 字节包头 + JSON 载荷 │ ├─ Protocol/Command.cs # 命令号枚举,改功能先改这里 │ └─ Dto/ # LoginDto / ChatTextDto / FileHeaderDto ├─ ChatData/ │ ├─ DbInitializer.cs # 首次运行建表 + 种子账号 │ └─ Repositories/MessageRepository.cs ├─ ChatServer/ │ ├─ Program.cs # 启动入口 │ ├─ Net/ClientSession.cs # 一条连接的收发放大器 │ └─ Services/SessionManager.cs # 在线表 └─ ChatClient/ ├─ Forms/LoginForm.cs ├─ Forms/ChatForm.cs └─ Net/ChatConnection.cs # 客户端侧连接封装

Common 里千万别塞 UI 或数据库相关的东西。我见过有人图省事把 SqliteConnection 写进 Common,结果客户端编译时被迫带上一堆服务端依赖,发布包直接胖了一圈,这种返工最不划算。

2.2 环境与依赖:.NET 版本、NuGet 包、数据库文件位置

源码同时保留了两种目标框架的 csproj 配置,.NET Framework 4.8 和 .NET 6 各一份,选一个改就行,别两个都开——WinForms 设计器有时会在多目标下抽风。常见做法是把 ChatClient 设成net6.0-windows,ChatServer 设成net6.0,这样服务端能跨平台丢到 Linux 容器里跑,客户端仍然用 Windows 桌面。

NuGet 依赖一共四个,版本对齐到同一行,避免 SQLite 原生库版本打架:

<!-- ChatData/ChatData.csproj 片段 --> <ItemGroup> <PackageReference Include="Dapper" Version="2.1.35" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> </ItemGroup>

数据库不需要手动装,服务端第一次启动会调DbInitializer.EnsureCreated(),在可执行文件同级目录建一个chat.db,顺带插入两个测试账号。连接串写在appsettings.json里,形如Data Source=chat.db;Cache=Shared,注意Cache=Shared这个参数,多线程读的场景下少了它容易报「database is locked」,后面排查章节还会提到。

服务端启动就是十行代码的事,但有两处参数值得改:

// ChatServer/Program.cs var cfg = Config.Load("appsettings.json"); // 端口、连接串、心跳超时都在这 var listener = new TcpListener(IPAddress.Any, cfg.Port); // Any = 监听所有网卡 listener.Start(backlog: 128); // 排队队列长度,内网 128 足够 Console.WriteLine($"服务端已启动,监听 {cfg.Port}"); var server = new ChatServerRuntime(listener); await server.RunAsync(); // 内部是 while(true) AcceptTcpClientAsync

IPAddress.Any表示监听本机所有网卡,如果只想本机调试可以换成IPAddress.Loopback,安全性更好但也意味着同事连不上你的机器。backlog是内核排队的上限,不是并发连接上限,写小了在压测时会出现连接被拒的假象,我第一次遇到时还以为是防火墙。

2.3 第一次跑起来:先起服务端再起两个客户端

启动顺序不能反。客户端一上来就会尝试连接,服务端没起来会看到「目标机器积极拒绝」,这个报错信息很明确,但新手容易误判成端口写错。

# 1. 还原依赖 dotnet restore ChatSolution.sln # 2. 先编译,确保四个项目都能过 dotnet build ChatSolution.sln -c Debug # 3. 起服务端(保持这个窗口不关,日志都在这看) dotnet run --project ChatServer/ChatServer.csproj # 4. 另开一个终端,起第一个客户端 dotnet run --project ChatClient/ChatClient.csproj # 5. 再开一个终端,起第二个客户端(同一台机器上跑两个实例) dotnet run --project ChatClient/ChatClient.csproj

两个客户端用种子账号分别登录,A 给 B 发一句「在吗」,B 的窗口应该在 100 毫秒内弹出气泡。如果 A 显示发送成功但 B 没收到,先看服务端控制台有没有打印路由日志——正常情况下每条消息都会打一行route 1001 -> 1002, len=48,有日志说明服务端收到了,问题在推送;没日志说明问题在客户端发包或服务端解包。

3. 通信协议拆解:一条消息从输入框到对方气泡的完整链路

3.1 包结构设计:12 字节包头 + JSON 载荷

TCP 是字节流,没有消息边界。很多人第一次写 Socket 聊天会直接stream.Write(Encoding.UTF8.GetBytes(json)),本地测两个人聊天一切正常,一上线就偶发解析失败,这就是典型的粘包翻车。解决办法是自定义长度前缀,这套源码用的是固定 12 字节包头。

// ChatCommon/Protocol/PacketCodec.cs public enum Command : byte { Login = 1, LoginAck = 2, ChatText = 10, ChatTextAck = 11, Heartbeat = 20, HeartbeatAck = 21, PullOffline = 30, PullOfflineAck = 31, FileHeader = 40, FileChunk = 41 } public static class PacketCodec { public const int HeaderSize = 12; // 2 + 1 + 1 + 4 + 4 public const int MaxBody = 1024 * 512; // 单包上限 512KB,防止恶意大包 public static readonly byte[] Magic = { 0xCA, 0x7C }; public static byte[] Encode(Command cmd, uint seq, byte[] body) { body ??= Array.Empty<byte>(); var buffer = new byte[HeaderSize + body.Length]; Magic.CopyTo(buffer, 0); // [0..1] 魔数,快速校验 buffer[2] = 1; // [2] 协议版本,改结构时自增 buffer[3] = (byte)cmd; // [3] 命令号 BinaryPrimitives.WriteInt32BigEndian(buffer.AsSpan(4, 4), body.Length); // [4..7] 包体长度 BinaryPrimitives.WriteUInt32BigEndian(buffer.AsSpan(8, 4), seq); // [8..11] 序号 body.CopyTo(buffer, HeaderSize); return buffer; } }

魔数的作用是尽早发现「这根本不是我们的包」,比在 JSON 解析阶段抛异常好定位得多。版本号目前没用上,留着是为了将来协议升级时服务端能同时兼容新旧客户端。序号seq用于请求响应配对,比如登录请求带 seq=1,登录回包也带 seq=1,客户端靠它把回调和请求对上,而不是靠命令号硬猜。

提示:BinaryPrimitives在 .NET Framework 4.8 下不可用。如果项目必须留在 4.8,用BitConverter.GetBytes加手动反转,或者引System.Memory包。这里统一用大端序,是因为调试工具里看抓包更符合直觉,别中途换字节序,否则前后端能对上一整天。

3.2 发送端:Send 的线程安全队列与半包问题

发送看起来简单,实际上是这套代码里最容易埋雷的地方。聊天窗口里 UI 线程在发消息,心跳定时器在另一个线程发心跳,文件传输又开了一条任务在发分片,三路同时调WriteAsync,字节流就会交织在一起,接收方读到的是半截包加半截包。

// ChatServer/Net/ClientSession.cs(客户端同理) private readonly SemaphoreSlim _sendLock = new(1, 1); public async Task SendAsync(Command cmd, uint seq, object payload, CancellationToken token) { var body = JsonSerializer.SerializeToUtf8Bytes(payload); var frame = PacketCodec.Encode(cmd, seq, body); await _sendLock.WaitAsync(token); // 同一连接不允许两路字节交错 try { await _stream.WriteAsync(frame, 0, frame.Length, token); await _stream.FlushAsync(token); } finally { _sendLock.Release(); // 异常也要放锁,否则整条连接卡死 } }

用的是SemaphoreSlim而不是lock,因为里面有await,普通 lock 不能跨 await 持有。这里有个细节:序列化放在锁外面,只有写入放在锁内,这样大包的 JSON 序列化不会挡住别的线程发心跳。序列化器用System.Text.Json的SerializeToUtf8Bytes直接出字节,省掉一次 string 到 byte[] 的分配,消息量大时这点优化还是挺明显的。

3.3 接收端:循环读满包头再读包体

接收端必须遵守一个铁律:先读满 12 字节包头,解析出长度,再读满指定长度的包体。NetworkStream.ReadAsync的返回值是「本次读到了多少」,不是「你要多少它给多少」,所以必须循环。

private async Task ReceiveLoopAsync(CancellationToken token) { var header = new byte[PacketCodec.HeaderSize]; while (!token.IsCancellationRequested) { await ReadExactAsync(header, token); // 一定要读满 if (header[0] != 0xCA || header[1] != 0x7C) throw new ProtocolException("包头魔数不匹配,可能连错了端口"); var cmd = (Command)header[3]; var len = BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(4, 4)); if (len < 0 || len > PacketCodec.MaxBody) throw new ProtocolException($"包体长度异常: {len}"); var body = len == 0 ? Array.Empty<byte>() : new byte[len]; if (len > 0) await ReadExactAsync(body, token); // 再读满包体 _ = HandleAsync(cmd, body, token); // 业务处理不阻塞收包 } } private async Task ReadExactAsync(byte[] buf, CancellationToken token) { int offset = 0; while (offset < buf.Length) { int n = await _stream.ReadAsync(buf.AsMemory(offset), token); if (n == 0) throw new IOException("对端已关闭连接"); // 0 表示优雅关闭 offset += n; } }

HandleAsync前面加_ =是有意为之:业务处理里可能要写数据库、要转发给另一个连接,这些操作耗时不定,如果 await 在这里,收包线程就被业务阻塞了,对方连发十条消息会明显感觉到延迟。代价是并发处理带来的时序问题,所以消息入库这一步又加了队列串行化,具体在下一章讲。

3.4 消息上屏:跨线程更新控件的正确姿势

收包循环跑在线程池线程上,而 WinForms 控件只允许创建它的线程访问。直接label.Text = "xxx"会抛InvalidOperationException: 线程间操作无效,这是每个写 WinForms 网络程序的人都会踩一次的坑。

// ChatClient/Forms/ChatForm.cs private void OnTextReceived(ChatTextDto dto) { if (IsDisposed) return; // 窗口已关,直接丢弃,防止 BeginInvoke 抛异常 if (InvokeRequired) { BeginInvoke(new Action<ChatTextDto>(OnTextReceived), dto); // 切回 UI 线程再执行一次 return; } var bubble = new MessageBubble(dto.From, dto.Content, dto.SendTime, isSelf: false); flowPanel.Controls.Add(bubble); flowPanel.ScrollControlIntoView(bubble); // 自动滚到底 }

用BeginInvoke而不是Invoke,原因是Invoke会同步等待 UI 线程处理完,如果在 UI 线程里恰好在等某个网络操作,两边互等就死锁了。IsDisposed这个判断别省,关闭窗口瞬间到达的消息很常见,少了它偶尔会在退出时崩一次,用户感知就是「这软件关不掉」,很难查。

4. 服务端会话与消息落地:在线表、心跳、离线消息三件套

4.1 会话容器:ConcurrentDictionary 与登录态绑定

服务端要维护「谁在线」,本质上是一张 userId 到连接对象的映射表。用ConcurrentDictionary而不是Dictionary加锁,是因为读多写少且读写频率都很高,无锁读取能省不少开销。

// ChatServer/Services/SessionManager.cs public sealed class SessionManager { // key = userId,value = 该用户的连接会话 private readonly ConcurrentDictionary<int, ClientSession> _sessions = new(); public bool TryAdd(ClientSession session) => _sessions.TryAdd(session.UserId, session); public ClientSession? Find(int userId) => _sessions.TryGetValue(userId, out var s) ? s : null; public IReadOnlyList<int> OnlineUsers => _sessions.Keys.ToList(); public async Task RemoveAsync(int userId, string reason) { if (_sessions.TryRemove(userId, out var session)) { await session.CloseAsync(reason); // 关 socket,触发对端走重连逻辑 Console.WriteLine($"[离线] uid={userId}, 原因={reason}"); } } }

注意TryAdd的返回值必须检查。同一个账号在两台机器登录时,第二次TryAdd会返回 false,常见做法有两种:踢掉旧连接(顶号)或者直接拒绝新登录。这套源码默认是拒绝,并在回包里带上「该账号已在别处登录」;如果你要做微信那种多端同时在线,就得改成字典 value 换成会话列表,路由时按设备类型分发,改动量不大但涉及所有推送路径。

4.2 心跳与假在线清理

TCP 连接断开分两种:优雅关闭(FIN)和异常断开(拔网线、笔记本合盖、手机切飞行模式)。前者ReadAsync会返回 0,代码能立刻感知;后者什么信号都没有,连接在字典里一直挂着,别人给你发消息显示「已发送」,实际石沉大海,这就是假在线。

// 客户端:每 20 秒发一次心跳 _heartbeatTimer = new Timer(async _ => { try { await _conn.SendAsync(Command.Heartbeat, NextSeq(), new { ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }, _cts.Token); } catch (Exception ex) { Log.Warn($"心跳失败: {ex.Message}"); } }, null, dueTime: 0, period: 20_000); // 服务端:每 30 秒扫一遍在线表,超过 60 秒没有任何包的会话直接清掉 private async Task SweepAsync() { var now = DateTime.UtcNow; foreach (var s in _sessions.Values) { if (now - s.LastActive > TimeSpan.FromSeconds(60)) await _sessions.RemoveAsync(s.UserId, "心跳超时"); } }

参数关系要算清楚:客户端发心跳周期 20 秒,服务端判定超时 60 秒,也就是允许连续丢两次心跳。这个比例我一般是「心跳周期 × 3 = 超时阈值」,太紧会在弱网环境误踢,太松假在线时间过长。心跳包里不放业务数据,只更新LastActive,服务端收到后回一个HeartbeatAck,客户端靠它顺带检测上行是否还通——有些网络环境下上行断了但下行还能收,只靠服务端踢会延迟很久。

4.3 消息落库与离线拉取(SQLite + Dapper)

消息表结构比较克制,够用且索引清晰:

-- ChatData/Scripts/schema.sql CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, -- GUID,客户端生成,天然幂等 from_id INTEGER NOT NULL, to_id INTEGER NOT NULL, content TEXT NOT NULL, msg_type INTEGER NOT NULL DEFAULT 0, -- 0 文本 1 图片 2 文件 send_time INTEGER NOT NULL, -- Unix 毫秒 is_read INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS ix_msg_to_unread ON messages(to_id, is_read, send_time);

id用客户端生成的 GUID 而不是自增主键,是为了解决重复发送:网络抖动时客户端可能重发同一条消息,服务端插入时靠主键冲突直接忽略,天然幂等,省掉一套去重逻辑。

// ChatData/Repositories/MessageRepository.cs public void Insert(ChatMessage m) { using var conn = new SqliteConnection(_connStr); conn.Execute( @"INSERT OR IGNORE INTO messages(id, from_id, to_id, content, msg_type, send_time, is_read) VALUES(@Id, @FromId, @ToId, @Content, @MsgType, @SendTime, 0)", m); } public List<ChatMessage> PullUnread(int userId, int limit = 200) { using var conn = new SqliteConnection(_connStr); return conn.Query<ChatMessage>( @"SELECT * FROM messages WHERE to_id = @userId AND is_read = 0 ORDER BY send_time ASC LIMIT @limit", new { userId, limit }).ToList(); }

INSERT OR IGNORE配合主键就是幂等写入,比先 SELECT 再 INSERT 少一次往返,也避开了并发下的竞态窗口。离线拉取带上LIMIT 200是必须的,一个用户离线一周可能积压几千条,一次性全推过去客户端会卡住好几秒,常见做法是分页拉,拉完一批再请求下一批,界面上先渲染最近 50 条。

5. 排查与避坑:粘包、UI 卡死、连接假在线这几个坑我踩过

5.1 现象:本地双人聊天正常,三人以上偶发消息内容错乱甚至解析异常

原因几乎可以锁定为粘包处理不当。要么接收端用了「读一次就当一包」,要么发送端多线程共用一个流没加锁。TCP 只保证字节有序,不保证边界,两个包紧挨着到达时一次ReadAsync可能把 1.5 个包读回来。

解决办法是把接收逻辑统一收进ReadExactAsync,严格「读满包头 → 算长度 → 读满包体」。同时给发送加SemaphoreSlim,任何写操作必须持锁。改完之后建议用压测脚本连发 1000 条 1KB 消息验证,别只靠手点。

5.2 现象:第一次收到别人消息,客户端直接崩溃退出,日志里是「线程间操作无效」

原因是收包回调跑在线程池线程,直接操作了 WinForms 控件。这类异常在调试器里会中断,但 Release 下如果没挂全局异常处理器就是静默退出,用户只看到软件消失,堪称黑匣子。

解决就是上一章那段InvokeRequired判断加BeginInvoke。另外建议在Program.Main里挂上Application.ThreadException和AppDomain.CurrentDomain.UnhandledException,把堆栈写进本地日志文件,不然线上出问题只能靠猜。

5.3 现象:跑一段时间后开始报database is locked,重启服务端又好了

原因是多个连接的业务处理并发写 SQLite,而 SQLite 同一时刻只允许一个写事务。_ = HandleAsync(...)那个不 await 的设计在这里变成了副作用:十条消息同时到,十个线程同时开写事务,互相等锁。

解决办法有两层。第一层是连接串加Cache=Shared并开启 WAL 模式(PRAGMA journal_mode=WAL;),读写不再互斥。第二层是在服务端加一个单消费者的写队列,所有入库请求先进Channel<ChatMessage>,由一个后台任务串行消费,写入吞吐反而更稳。我一般两个都做,WAL 兜底,队列保证顺序。

5.4 现象:换台机器部署,客户端一直连不上,服务端日志干干净净

先分清是「连不上」还是「连上了没反应」。前者客户端会报「目标机器积极拒绝」,检查端口有没有被占用(netstat -ano | findstr 9000)、服务端有没有真的起来、防火墙有没有放行入站规则。后者一般是防火墙拦了或者监听地址写成了Loopback,导致只有本机能连。

还有一个容易忽略的点:云服务器上的安全组是独立于系统防火墙的另一层,两边都要开。我头一次部署时在系统防火墙里加完规则依然不通,耗了半小时才想起安全组,这种坑属于纯经验成本。

5.5 现象:中文消息发出去是乱码,英文一切正常

九成是序列化或编码没统一。System.Text.Json默认输出 UTF-8 字节,Encoding.UTF8.GetString解开就没问题;但如果中间有人用Encoding.Default转过一道手,在中文系统上就会变成 GBK 解释 UTF-8 字节,出来一堆问号。

解决办法是全链路只认 UTF-8:JSON 序列化出字节就直接塞进包体,接收端JsonSerializer.Deserialize<T>(body)直接吃byte[],中间不要出现 string 和 byte[] 的来回转换。另外注意别给JsonSerializerOptions加UnsafeRelaxedJsonEscaping之外的奇怪设置,中文会被转义成\u4f60这种形式,虽然能正确还原,但抓包时看着很累。

6. 二次开发改造:把单聊改成群聊与文件传输的动手顺序

拿到这份代码想加功能,最容易犯的错是从 UI 开始改——先在聊天窗口加个「群聊」标签页,然后发现服务端不知道怎么转发。正确的顺序是自下而上:协议 → 数据 → 服务端路由 → 客户端界面。

以群聊为例,需要动的地方一共四处,按这个顺序改基本不会返工:

顺序位置具体改动备注
1ChatCommon/Command.cs增加CreateGroup、GroupText、GroupTextAck命令号往后排,别插队改旧值
2ChatData新增groups、group_members两张表messages 表加to_id语义扩展或新增group_id
3ChatServer路由时按 group_id 查成员列表,逐个推离线成员写库,上线后走 PullOffline
4ChatClient群聊窗口复用 MessageBubble,头部显示群名发送前先本地回显,收到 ack 再改状态

第 2 步的取舍值得说一下。messages.to_id在单聊里是用户 ID,群聊要么复用它(正数用户负数群),要么新增group_id字段。我倾向后者,虽然要多写一个分支,但半年后回来看代码不会一脸懵,也不用担心用户 ID 出现负数导致越界。数据库迁移用ALTER TABLE messages ADD COLUMN group_id INTEGER NULL;,老数据全为 NULL,下面这段查询就能同时兼容单聊和群聊:

-- 拉取某人所有未读:单聊按 to_id,群聊按 group_id 关联成员表 SELECT m.* FROM messages m LEFT JOIN group_members g ON m.group_id = g.group_id WHERE m.is_read = 0 AND (m.to_id = @uid OR (m.group_id IS NOT NULL AND g.user_id = @uid)) ORDER BY m.send_time ASC LIMIT 200;

文件传输那块,源码里已经预留了FileHeader和FileChunk两个命令,走的是同一条 TCP 连接分片发。改造时唯一要小心的是别让文件分片把聊天消息饿死——一个大文件几百个 64KB 分片连续写入,期间用户发的文字消息会排在后面。我的做法是给发送队列分优先级,ChatText插到队头,FileChunk排队尾,或者干脆给文件单开一条连接。这一点在源码里没有强制约束,你得自己补。

验证方式也别只靠手动点。改完协议后,我会写一个极简压测客户端,模拟 50 个连接持续发消息 5 分钟,观察三件事:服务端内存有没有持续上涨(会话没清)、数据库文件增长是否线性(有没有重复写)、客户端 UI 是否仍然可交互(有没有阻塞在主线程)。这三条过了,功能基本算稳。

从那以后我每次动这类 Socket 项目的协议层,都会先把抓包工具和日志开关准备好再改第一行代码,宁可多花十分钟,也不想再经历一次「本地好好的,一上线就乱码」的复盘。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进