
简介面向 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 都引用 CommonServer 额外引用 DataCommon 不引用任何业务项目。这种拆法听起来朴素但它是后面能不能顺利二次开发的前提——协议定义只有一份客户端和服务端不会各写一套枚举然后对不上号。项目输出类型职责主要依赖ChatCommon类库协议命令枚举、包头编解码、DTO、异常类型无纯 BCLChatData类库SQLite 建表、消息与用户仓储、Dapper 映射Dapper、Microsoft.Data.SqliteChatServer控制台 / Windows 服务监听、会话管理、路由转发、心跳扫描Common、DataChatClientWinForms登录注册、好友列表、聊天窗口、本地缓存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-windowsChatServer 设成net6.0这样服务端能跨平台丢到 Linux 容器里跑客户端仍然用 Windows 桌面。NuGet 依赖一共四个版本对齐到同一行避免 SQLite 原生库版本打架!-- ChatData/ChatData.csproj 片段 -- ItemGroup PackageReference IncludeDapper Version2.1.35 / PackageReference IncludeMicrosoft.Data.Sqlite Version8.0.0 / PackageReference IncludeNewtonsoft.Json Version13.0.3 / /ItemGroup数据库不需要手动装服务端第一次启动会调DbInitializer.EnsureCreated()在可执行文件同级目录建一个chat.db顺带插入两个测试账号。连接串写在appsettings.json里形如Data Sourcechat.db;CacheShared注意CacheShared这个参数多线程读的场景下少了它容易报「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) AcceptTcpClientAsyncIPAddress.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, len48有日志说明服务端收到了问题在推送没日志说明问题在客户端发包或服务端解包。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.Emptybyte(); 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用于请求响应配对比如登录请求带 seq1登录回包也带 seq1客户端靠它把回调和请求对上而不是靠命令号硬猜。提示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.Emptybyte() : 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 ActionChatTextDto(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 userIdvalue 该用户的连接会话 private readonly ConcurrentDictionaryint, 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 IReadOnlyListint 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 ListChatMessage PullUnread(int userId, int limit 200) { using var conn new SqliteConnection(_connStr); return conn.QueryChatMessage( 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 的设计在这里变成了副作用十条消息同时到十个线程同时开写事务互相等锁。解决办法有两层。第一层是连接串加CacheShared并开启 WAL 模式PRAGMA journal_modeWAL;读写不再互斥。第二层是在服务端加一个单消费者的写队列所有入库请求先进ChannelChatMessage由一个后台任务串行消费写入吞吐反而更稳。我一般两个都做WAL 兜底队列保证顺序。5.4 现象换台机器部署客户端一直连不上服务端日志干干净净先分清是「连不上」还是「连上了没反应」。前者客户端会报「目标机器积极拒绝」检查端口有没有被占用netstat -ano | findstr 9000、服务端有没有真的起来、防火墙有没有放行入站规则。后者一般是防火墙拦了或者监听地址写成了Loopback导致只有本机能连。还有一个容易忽略的点云服务器上的安全组是独立于系统防火墙的另一层两边都要开。我头一次部署时在系统防火墙里加完规则依然不通耗了半小时才想起安全组这种坑属于纯经验成本。5.5 现象中文消息发出去是乱码英文一切正常九成是序列化或编码没统一。System.Text.Json默认输出 UTF-8 字节Encoding.UTF8.GetString解开就没问题但如果中间有人用Encoding.Default转过一道手在中文系统上就会变成 GBK 解释 UTF-8 字节出来一堆问号。解决办法是全链路只认 UTF-8JSON 序列化出字节就直接塞进包体接收端JsonSerializer.DeserializeT(body)直接吃byte[]中间不要出现 string 和 byte[] 的来回转换。另外注意别给JsonSerializerOptions加UnsafeRelaxedJsonEscaping之外的奇怪设置中文会被转义成\u4f60这种形式虽然能正确还原但抓包时看着很累。6. 二次开发改造把单聊改成群聊与文件传输的动手顺序拿到这份代码想加功能最容易犯的错是从 UI 开始改——先在聊天窗口加个「群聊」标签页然后发现服务端不知道怎么转发。正确的顺序是自下而上协议 → 数据 → 服务端路由 → 客户端界面。以群聊为例需要动的地方一共四处按这个顺序改基本不会返工顺序位置具体改动备注1ChatCommon/Command.cs增加CreateGroup、GroupText、GroupTextAck命令号往后排别插队改旧值2ChatData新增groups、group_members两张表messages 表加to_id语义扩展或新增group_id3ChatServer路由时按 group_id 查成员列表逐个推离线成员写库上线后走 PullOffline4ChatClient群聊窗口复用 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 项目的协议层都会先把抓包工具和日志开关准备好再改第一行代码宁可多花十分钟也不想再经历一次「本地好好的一上线就乱码」的复盘。希望帮到你。本文还有配套的精品资源点击获取