简介:这套SuperSocket示例源码面向.NET平台开发者,目标是帮助快速上手基于TCP/IP的Socket服务端与客户端开发,解决并发连接处理、自定义协议设计等常见问题。压缩包同时提供SuperSocketServer和SuperSocketClient两个完整示例项目,涵盖启动服务、监听连接、收发数据、执行命令及响应等环节,适合正在学习网络编程或准备构建高并发服务应用的工程师参考。资源共871个文件,以XML配置、DLL程序集、C#源码文件为主,另有NuGet包、配置文件、可执行文件及解决方案文件等,压缩包整体21.19MB,结构清晰便于按需查阅。目前已有1556人学习下载。通过阅读和改造示例代码,可理解自定义命令行协议、命令过滤器链、多线程并发处理等核心机制,并在此基础上扩展安全验证、加密解密等功能,是一套兼具教学与实战价值的Socket开发参考。
1. SuperSocket 示例源码:先跑通那对客户端和服务端,再谈协议设计
拿到示例源码,第一眼就是两个控制台项目:SuperSocketD.Server 和 SuperSocketD.Client。双击运行,服务端监听在 2012 端口,客户端敲一行命令回车,服务端把结果原样弹回来。就这么简单,但这个"简单"背后才是关键——SuperSocket 帮你处理了连接接受、协议分包、会话生命周期和数据分发,你要做的只是定义协议和写业务逻辑。这篇笔记把这套示例拆开讲,从启动流程、协议解析、客户端连接,到粘包错位和线程安全,适合第一次玩 SuperSocket 的人跟着跑一遍,也适合想把它塞进正式项目的熟手快速对照参数边界。
2. 服务器端落地:AppServer 启动流程、协议解析器选型与回调时机
2.1 从代码启动和配置文件启动:两种落地的边界
这套示例工程名是 SuperSocketD,服务端工程和客户端工程放在同一个解决方案里。服务端最常见的落地方式是代码启动,定义一个继承 AppServer 的类,初始化监听参数后直接 Setup 和 Start。
// ServerDemo/Program.cs 核心启动代码 static void Main(string[] args) { var server = new AppServer(); // 端口监听配置:Ip 传 "Any" 表示绑定所有可用网卡 var config = new ServerConfig { Port = 2012, Ip = "Any", MaxConnectionNumber = 1000, LogFactory = new ConsoleLogFactory() }; if (!server.Setup(config)) { Console.WriteLine("服务启动失败,请检查端口和配置。"); return; } if (!server.Start()) { Console.WriteLine("服务已启动但监听失败。"); return; } Console.WriteLine("服务已启动,端口:2012。回车停止服务。"); Console.ReadLine(); server.Stop(); }这段代码做了三件核心的事:构建 ServerConfig 配置对象、调用 Setup 初始化监听、调用 Start 开始接收连接。Setup 内部会创建监听端口、注册协议解析工厂、初始化会话工厂;Start 之后才有客户端能连进来。Stop 是清理端口和所有会话。
参数说明:Port 别用 1-1024 的保留端口,也尽量不要和本机其他服务抢端口;MaxConnectionNumber 是上限而不是预分配,设 1000 不代表启动就占 1000 个连接;LogFactory 用 Console 方便看演示,正式环境换成文件日志,否则控制台滚屏会拖慢效率。
配置文件方式在较早版本的 SuperSocket 示例里也常见,习惯在 App.config 里放一个 superSocket 节点:
<superSocket> <servers> <server name="ChatServer" port="2012" ip="Any" maxConnectionNumber="1000"> <listeners> <add ip="Any" port="2012" /> </listeners> </server> </servers> </superSocket>用配置文件的好处是改端口、改最大连接数不用重新编译,适合部署环节。坏处是配置项容易记错,比如listeners里端口和server的端口必须一致,否则启动日志会提示监听冲突。我一般会先用代码方式跑通示例,等要部署了再转配置文件方式,这样排查问题时少一个变量。
2.2 协议解析器选型:Terminator 结尾符协议与缓冲区切分
示例里自带的最简单协议是命令行协议,按\r\n换行切割。真实项目里更常用结尾符协议,因为分隔符自己可控,而且不受"一行"的语义限制。
// 在 AppServer 构造函数里指定协议解析器 public class DemoServer : AppServer<DemoSession, StringRequestInfo> { public DemoServer() : base(new TerminatorRequestFilterFactory("\r\n")) { } }TerminatorRequestFilterFactory会把收到的字节流按\r\n切分成一个个完整请求。这个工厂内部维护缓冲区,粘包进来时自动累积,半包进来时等待后续字节补全。很多人容易忽略:协议解析器不是只在"碰巧一个数据包正好是一次请求"的时候才工作,它会跨包处理。
选型建议:协议是用户手动敲命令的场景,用CommandLineRequestFilterFactory;机器对机器、客户端是代码固定格式发送,用TerminatorRequestFilterFactory更稳,因为分隔符完全自己控制;二进制协议、前面固定字节表示长度的,用FixedHeaderRequestFilterFactory或自定义IRequestFilter。
自定义 RequestInfo 的场景在第 4 章单独展开。这里先记住结论:示例默认的协议解析是"够演示但不够生产",你接手示例后第一件事往往是换协议工厂。
2.3 会话生命周期:OnSessionStarted、HandleException 与 SessionClosed
SuperSocket 的会话模型是:每个客户端连接对应一个 AppSession 实例,整个连接期间保持,你可以在它上面存状态。
public class DemoSession : AppSession<DemoSession, StringRequestInfo> { protected override void OnSessionStarted() { Console.WriteLine($"新连接进入,会话 ID:{SessionID}"); // 服务器主动推送一条欢迎语 Send("WELCOME TO DEMO SERVER\r\n"); } protected override void HandleUnknownRequest(StringRequestInfo requestInfo) { // 客户端发来一条没有对应命令的请求 Send($"UNKNOWN COMMAND: {requestInfo.Key}\r\n"); } public override void HandleException(Exception e) { Console.WriteLine($"会话异常:{e.Message}"); // 记录日志后可以让连接继续,也可以主动关闭 Close(CloseReason.ServerClosing); } }三个关键方法对应三个阶段:OnSessionStarted在 TCP 连接建立且协议过滤器完成初始化后触发,此时可以发欢迎消息,也可以把会话注册到全局管理器;HandleUnknownRequest在请求文本已经切好但找不到对应命令时触发,这里一定要给客户端回明确错误,否则客户端会一直等;HandleException在解析过程中出现异常时触发,注意别在异常处理里同步调用Send,可能再次抛异常。
还有一个容易踩的坑:OnSessionStarted里不要做耗时操作,比如查数据库。这个回调运行在接收数据的 IO 线程里,你卡在这里,服务器这一个处理线程就被拖住,连接多了之后会出现客户端连上来没反应的现象。
3. 客户端实战:连接管理、命令编码与异步接收的四个细节
3.1 连接、发送、接收三件事分开做
SuperSocket 示例的客户端通常是自己写的 TcpClient,不依赖服务端框架。这不奇怪,因为客户端要轻量,能用标准库就标准库。
// ClientDemo/TcpClientWrapper.cs 核心部分 public class TcpClientWrapper { private TcpClient _tcpClient; private NetworkStream _stream; private readonly object _writeLock = new object(); public bool Connect(string ip, int port, int timeoutMs = 3000) { _tcpClient = new TcpClient(); var task = _tcpClient.ConnectAsync(ip, port); return task.Wait(timeoutMs) && _tcpClient.Connected; } public void Send(string data) { var bytes = Encoding.UTF8.GetBytes(data); lock (_writeLock) { _stream.Write(bytes, 0, bytes.Length); _stream.Flush(); } } public string ReceiveLine() { // 读取一个 \r\n 结尾的行,这里简化成一次 Read var buffer = new byte[1024]; var bytesRead = _stream.Read(buffer, 0, buffer.Length); return Encoding.UTF8.GetString(buffer, 0, bytesRead); } }三个核心点:ConnectAsync(...).Wait(timeoutMs)是给连接操作加超时,防止地址不通时客户端卡死几秒,默认TcpClient.Connect可能等很久;Send加_writeLock是因为业务线程可能同时从多个地方发数据,而NetworkStream本身不是线程安全的;ReceiveLine这里简化了,它假设一次Read能读到一整行,这在局域网打几个字节没问题,真实场景必须处理"读到的比一行长"或"读到的比一行短",这就是第 5 章避坑里的粘包半包问题。
3.2 命令编码、协议格式与响应匹配
示例里的协议是文本行,真实项目往往是命令名 + 空格 + 参数,比如LOGIN alice 123456、GETTIME、SENDMSG bob hello。服务端按空格拆分第一个字段作为命令名,其余作为参数。
public void SendCommand(string key, params string[] args) { var sb = new StringBuilder(); sb.Append(key); foreach (var arg in args) { sb.Append(' '); sb.Append(arg); } sb.Append("\r\n"); var bytes = Encoding.UTF8.GetBytes(sb.ToString()); lock (_writeLock) { _stream.Write(bytes); _stream.Flush(); } }这里有一个常见误区:客户端和服务端的编码必须一致。示例默认用 UTF-8,如果你客户端用Encoding.Default(Windows 下可能是 GBK),服务端按 UTF-8 解析,中文用户名就会乱码,甚至因为多字节字符被切断导致协议解析错位。错位的典型表现是:服务端收到一条被切碎的字符,然后一直等待下一个\r\n,后面所有数据都变成"接不上"的垃圾消息。
响应匹配是另一个容易被忽略的点。客户端发命令后,服务器可能返回多条数据,比如登录成功后先返回AUTH_OK,紧接着收到一条其他用户发来的消息。如果客户端只是简单"发一条、读一条",就会把别人的消息当成自己这次命令的响应。常见做法是给请求带序号:
CLIENT -> SERVER: LOGIN alice 789 SERVER -> CLIENT: OK 789 "welcome" SERVER -> CLIENT: MSG 456 bob "hello"客户端维护一个序号 -> 回调的字典,收到响应后根据序号找到对应回调处理。示例源码通常不演示这个,但要做正式项目,最好先加上。
3.3 指数退避重连:断线后的状态恢复
服务端重启或者网络抖动,客户端就断了。示例客户端可能直接退出,正式客户端必须有重连逻辑。
public void RunWithReconnect() { const int maxRetry = 5; var retryCount = 0; while (retryCount < maxRetry) { if (Connect("127.0.0.1", 2012, 3000)) { Console.WriteLine("已连接"); retryCount = 0; // 进入正常收发循环,直到断开 while (IsConnected()) { ProcessNextMessage(); } } retryCount++; var delayMs = Math.Min(1000 * retryCount, 10000); Console.WriteLine($"连接断开,{delayMs}ms 后重试,次数:{retryCount}"); Thread.Sleep(delayMs); } }指数退避不是每次翻倍,这里用的是线性倍增长到上限:第一次断线等 1000ms,第二次 2000ms,第三次 4000ms,封顶 10000ms。这样服务端刚重启时客户端不会疯狂重连把端口打满。
还有一个状态机问题需要注意:断开重连后,客户端之前登录的身份已经丢失,必须重新登录。很多客户端只重连 TCP 不重连业务,结果服务端认为你是匿名连接,发指令全部返回AUTH_REQUIRED。经验做法是:每次重连成功后的第一条消息,必须是重新登录或恢复会话。
4. 命令注册与会话管理:广播、心跳与并发安全的做法
4.1 命令类注册机制与实际执行参数
客户端发过来一条指令,服务端怎么认出来并分发?示例里处理请求的推荐方式是命令类。每个命令类用[Command]特性标记,服务器收到请求后自动路由到对应命令类。
[Command("LOGIN")] public class LoginCommand : ICommand<DemoSession, StringRequestInfo> { public void ExecuteCommand(DemoSession session, StringRequestInfo requestInfo) { // requestInfo.Body 是去除命令名后的剩余参数段 var args = requestInfo.Body.Split(' '); if (args.Length >= 2) { session.UserName = args[0]; session.LastActiveTime = DateTime.Now; session.Send("AUTH_OK\r\n"); } else { session.Send("AUTH_FAIL parameter missing\r\n"); } } }命令注册机制是:AppServer启动时扫描当前程序集里所有实现了ICommand<,>且带[Command]特性的类,自动构建命令名 -> 命令实例字典。所以新加一个命令只需要两步:建类、加特性,不需要手动注册。
两个细节值得注意:requestInfo.Key是命令名,requestInfo.Body是命令名之后的所有字符串,比如LOGIN alice 123,Key 是 "LOGIN",Body 是 "alice 123";命令类里的同一个实例会被多线程并发调用,所以不要在命令类里放可变的共享字段,有计数器之类的全局状态要放到单独的线程安全静态类里。
4.2 会话字典与广播:并发遍历的安全性
多客户端场景下,核心需求是广播:一个客户端发消息,其他客户端都能收到。这就要依赖会话字典。
public class SessionManager { // 用 ConcurrentDictionary 而不是 Dictionary,因为收发线程会同时读写 private static readonly ConcurrentDictionary<string, DemoSession> _sessions = new ConcurrentDictionary<string, DemoSession>(); public static void Add(DemoSession session) { _sessions[session.SessionID] = session; } public static void Remove(string sessionId) { _sessions.TryRemove(sessionId, out _); } public static void Broadcast(string message, string excludeSessionId = null) { foreach (var kvp in _sessions) { if (excludeSessionId != null && kvp.Key == excludeSessionId) continue; try { kvp.Value.Send(message); } catch (Exception ex) { // 连接已断开时 Send 会抛异常,这里要吞掉并清理 Console.WriteLine($"发送失败,清理会话 {kvp.Key}:{ex.Message}"); Remove(kvp.Key); } } } }三个细节:不用Dictionary加锁固然可以,但ConcurrentDictionary的读写性能在大多数场景足够,且代码更少;遍历时如果连接已经断开,Send里会抛异常,所以 Send 必须包一层 try-catch,否则广播一条消息能带崩整个服务;SessionID是框架自动生成的 GUID 字符串,多网卡环境也不会重复,字典 key 建议用SessionID而不是用户名,因为用户名可能重复登录。
4.3 心跳保活与假死连接处理
连接假死是 TCP 的经典问题:客户端断电、网线松动、长时间无数据,TCP 层并不一定知道对方已经消失。SuperSocket 本身不主动清理静默连接,需要自己实现心跳。
// 全局定时器,每隔 30 秒检查一次所有会话 _heartbeatTimer = new Timer(CheckAliveSessions, null, 30000, 30000); private void CheckAliveSessions(object state) { var threshold = DateTime.Now.AddSeconds(-120); foreach (var kvp in SessionManager.AllSessions()) { var session = kvp.Value; if (session.LastActiveTime < threshold) { Console.WriteLine($"会话 {session.SessionID} 心跳超时,主动关闭。"); session.Close(CloseReason.TimeOut); } } }需要更新LastActiveTime的地方有两个:OnDataReceived回调里,以及收到PONG时。注意不要在 session 属性上做无锁 DateTime 写,多线程下可以用Volatile.Write或者直接加锁。
提示:心跳间隔设 30 秒时,超时阈值建议设 120 秒,留出 3 到 4 个周期容忍网络抖动,避免短暂延迟误杀正常连接。
5. 避坑指南:端口占用、粘包错位与线程安全
这章把跑示例时最常见的几个坑列全,每条都是现象到原因再到解决,照着排查能省半天时间。
5.1 现象:服务启动失败,日志提示 address in use
原因:端口被其他进程占用。示例默认 2012 端口,如果本机跑过别的服务占了端口,或上一次服务没停止干净,TCP 端口处于 TIME_WAIT 状态就会绑定失败。
解决:先确认占用,再决定换端口还是杀进程。
# Windows netstat -ano | findstr :2012 taskkill /PID <进程号> /F # Linux lsof -i :2012 kill -9 <PID>如果是 TIME_WAIT 状态且不想杀进程,在 ServerConfig 里显式打开地址重用,或者换一个高端口比如 9012。我最常用的是换端口,避开常规服务区。
5.2 现象:连续发送多条消息,服务端收到一条合并后的大消息
原因:TCP 粘包。发送端两次Send的数据被操作系统合并成一个 TCP 段,服务端一次Read读到的字节流包含两条完整请求。示例里简单的ReadLine会把两条消息拼在一起,解析错位。
解决:不要在客户端绕过分隔符,也不要在服务端用"读一次算一条消息"。自己写客户端时,接收循环要做缓冲区累加:
private readonly List<byte> _buffer = new List<byte>(); public IList<string> ProcessReceivedBytes(byte[] data) { _buffer.AddRange(data); var lines = new List<string>(); while (true) { var index = FindDelimiter(_buffer, "\r\n"); if (index < 0) break; var lineBytes = _buffer.GetRange(0, index).ToArray(); _buffer.RemoveRange(0, index + 2); lines.Add(Encoding.UTF8.GetString(lineBytes)); } return lines; }这段代码的关键是while (true)循环——一次收到 5 条完整消息,必须全部切出来,而不是只取第一条。缓冲区里只剩半条时,不切,继续等下一批字节拼接。
5.3 现象:UI 程序里报"跨线程操作无效"
原因:客户端 UI 程序里,接收线程读到数据后直接更新 TextBox,而控件属于 UI 线程,从非 UI 线程访问就会抛异常。SuperSocket 服务端的Send内部有同步机制不会报这个错,但你自己写的回调里直接碰 UI 控件就会翻车。
解决:把收到消息丢回 UI 线程再更新。WinForms 用Control.BeginInvoke,WPF 用Dispatcher.BeginInvoke:
void OnMessageReceived(string message) { // 这里是接收线程 listBoxMessages.BeginInvoke(new Action(() => { listBoxMessages.Items.Add(message); })); }如果你用 WPF,就把listBoxMessages换成对应的控件和Dispatcher。注意用BeginInvoke异步版本,别用同步Invoke,否则 UI 线程卡住时接收线程也会被拖住,最终缓冲区堆积。
5.4 现象:中文消息乱码,偶发会话被强制关闭
原因:服务端 UTF-8、客户端 GBK,或反过来。中文在 GBK 下 2 字节、UTF-8 下 3 字节,如果协议过滤器按字节切分,字符切碎后\r\n就错位了。切出来的"完整行"用错误编码解析就是乱码,甚至解析出错误命令名。
解决:全链路统一编码。客户端发送、服务端解析、日志打印三处全部 UTF-8:
var text = Encoding.UTF8.GetString(buffer, 0, validBytes);不要用Encoding.Default,不要用Encoding.GetEncoding("GB2312")。如果协议上必须 GBK,那么服务端 Session 初始化、客户端发送、服务端解析三处都要一致。这是排错时最玄学的问题之一,看起来像随机崩溃,其实是编码错位。
5.5 现象:连接数一多,内存暴涨、CPU 100%
原因:两个参数没配好——接收缓冲区大小和最大连接数。示例默认的缓冲区可能偏大,1000 个连接不显,5000 个连接就明显了。
注意:缓冲区不是越大越好。单连接内存开销 = 接收缓冲区 + 发送缓冲区 + session 对象,10000 连接下每条多 4KB 就是 40MB。
解决:根据单条消息最大长度定缓冲区。比如协议里单条最大 4KB,接收缓冲区设 8KB 足够,不要默认 64KB。在 ServerConfig 里显式设置:
var config = new ServerConfig { Port = 2012, MaxConnectionNumber = 5000, ReceiveBufferSize = 8192, SendBufferSize = 8192, };MaxConnectionNumber设成 10000 不代表机器能扛 10000,那是上限值。实际能扛多少看单连接内存开销和系统句柄限制,压测时先从 500 开始往上加,盯着任务管理器看内存和句柄数。
6. 进阶验证:用模拟客户端把服务端压到 1000 连接
前面的避坑是针对已知问题的防御,未知问题还得靠压测暴露。最后讲一个我拿到任何 SuperSocket 服务端示例都会做的动作:不手工开客户端,而是写一个并发模拟器,把服务端往极限上推一把。
6.1 模拟压测脚本与判定指标
const int clientCount = 1000; const int messagesPerClient = 10; var tasks = new List<Task>(); for (var i = 0; i < clientCount; i++) { var clientId = i; tasks.Add(Task.Run(async () => { using var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", 2012); await using var stream = client.GetStream(); for (var j = 0; j < messagesPerClient; j++) { var msg = $"MSG client{clientId} hello{j}\r\n"; var bytes = Encoding.UTF8.GetBytes(msg); await stream.WriteAsync(bytes); await stream.FlushAsync(); var response = await ReadLineAsync(stream); if (!response.StartsWith("ACK")) { Console.WriteLine($"客户端 {clientId} 收到异常响应:{response}"); } } })); } await Task.WhenAll(tasks);clientCount = 1000表示并发开 1000 个 TCP 连接,每个连接发 10 条消息。Task.Run保证并发,不是顺序执行。ReadLineAsync逐字节读到\r\n为止,能真实检验服务端的粘包切分是否正确。判断服务端健康看两个指标:压测结束后进程内存是否回落,居高不下说明会话清理不干净;日志里有没有成片的SocketException或Connection reset by peer,有则说明MaxConnectionNumber偏小或缓冲区不匹配。
6.2 随机间隔更接近真实场景
把固定循环改成随机间隔 10-100ms 再发下一条,能暴露更多时序问题,比如半包、心跳超时误杀、会话字典并发读写。真实场景的连接不会整齐地同时上同时下,随机间隔能打乱节奏,逼出同步代码里隐藏的竞态。
从那以后我每次拿到 SuperSocket 示例,都强制先跑一遍 1000 连接压测再动手改逻辑。改协议解析器、会话状态、心跳参数、命令路由,任何一个环节错了,压测都会先于业务测试暴露问题。希望帮到你。
本文还有配套的精品资源,点击获取