ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Laya网络游戏开发:Socket通信机制与C#异步服务器实践

Laya网络游戏开发:Socket通信机制与C#异步服务器实践 搞过Laya网络游戏的人都知道客户端和服务器之间的通信绕不开Socket。真正上手之后会发现引擎自带的API只是冰山一角从粘包拆包到C#回调处理再到最头疼的端口被占用每个环节都能磨掉你半天时间。这篇东西不聊空话直接把我实际踩过的坑和验证过的写法整理出来从Laya客户端到C#服务器端一步步拆开讲清楚。1. Laya 的网络通信方案辨析与选型策略1.1 WebSocket 与 Socket 的本质差异很多刚开始做Laya项目的人会纠结一个问题到底用WebSocket还是用Socket这两个东西名字相近底层逻辑却完全不同。WebSocket是建立在HTTP之上的应用层协议它的握手过程就是一次HTTP Upgrade请求之后双方通过帧Frame来交换数据。浏览器原生支持Laya引擎也封装了现成的Laya.Socket类注意这里虽然名字叫Socket但connectByUrl连接的是WebSocket服务端。WebSocket天然带消息边界因为它基于帧传输每个帧有明确的长度字段和FIN标志位所以不会出现粘包问题这是它最大的优势。而传统意义上的TCP Socket是传输层协议没有消息边界只有字节流。你用connect(host, port)方式连接的才是真正的TCP Socket。TCP不需要HTTP那套握手过程连接建立更快数据格式完全由自己定义传输效率理论上更高但随之而来的是需要自己处理粘包、拆包、半包等一系列问题。实际项目中我的建议是分场景考虑维度WebSocketTCP Socket浏览器兼容全支持不支持原生浏览器不能用秒开连接速度需HTTP握手略慢直接三次握手更快消息边界自带无粘包需要自定义协议处理服务器实现Node.js、C#、Java均可同上适合场景H5小游戏、微信小游戏、跨平台原生App包、对延迟敏感的对战类游戏如果你做的是发布到微信小游戏或者网页端的项目那基本就是WebSocket没跑但如果是原生App壳加Laya的套路或者服务器本身就有一堆自研C# Socket服务那直接用TCP Socket更顺手。Laya的Socket类两种模式都支持平时开发可以用WebSocket模式调试上生产再切TCP也可以。1.2 什么时候选 Socket 而不是 WebSocket这里说个我自己的经历。之前做一个实时对战项目一开始图省事全用的WebSocket后来压测发现当在线人数爬到几千的时候服务器端Frame解析和掩码解密的CPU占用明显偏高单帧处理时延不稳定偶尔飘到200ms以上。后来换成了自定义TCP协议同样的服务器配置CPU占用降了差不多30%时延稳定在50ms以内。不是说WebSocket不好它兼容性和调试便利性确实没得挑。但如果你的游戏逻辑本身对实时性要求很高比如格斗、弹幕、实时竞技这类而且你有能力完全控制客户端和服务器端的代码那自定义TCP协议一定是更好的选择。另外如果你打算用C#写游戏服务器那原生Socket处理高性能并发比HttpListener包装出来的WebSocket要更顺手因为IO模型完全由你掌控。还需要注意一个点Laya引擎在WebGL模式下如果是纯网页环境其实没法使用真正的TCP Socket它只能走WebSocket这是浏览器的安全策略决定的。所以Laya中的connectByUrl和connect两种方式适用的运行环境是有区别的。做跨端方案的时候这一点要先确定清楚。1.3 网络通信前的必备准备在写任何网络代码之前先要梳理清楚这几个问题包体格式是什么比如4字节包头消息长度加消息体还是2字节命令字加4字节长度加消息体字节序用什么我习惯用大端序Big Endian也就是网络字节序这样C#和TypeScript两边的解析逻辑最简单。服务器是单机部署还是多服架构多服架构下连接管理、断线重连的目标服务器怎么分配心跳机制是什么频率超时时间多长这些直接决定了服务器能不能及时清理死连接。这些不确定的话后面写代码全是返工。我见过太多人上来就写socket.connect结果协议格式一变半个网络模块都推倒重来。先定协议再写代码顺序不能反。2. Laya客户端Socket实战从连接到收发数据2.1 初始化连接与事件监听的正确姿势Laya的Socket类用起来其实不复杂但事件监听的时机很关键。先看一段标准的初始化代码import Socket Laya.Socket; import Byte Laya.Byte; class GameSocket { private socket: Socket; private recvBuffer: Byte; private connected: boolean false; constructor() { this.socket new Socket(); // 统一使用大端序。 this.socket.endian Byte.BIG_ENDIAN; // 事件监听要在 connect 之前挂好。 this.socket.on(Socket.EVENT_CONNECT, this, this.onConnectHandler); this.socket.on(Socket.EVENT_MESSAGE, this, this.onMessageHandler); this.socket.on(Socket.EVENT_CLOSE, this, this.onCloseHandler); this.socket.on(Socket.EVENT_ERROR, this, this.onErrorHandler); } public connect(host: string, port: number): void { // 如果上一次连接没有完全关闭这里直接 connect 会出问题。 if (this.socket.connected) { this.socket.close(); } this.socket.connect(host, port); } private onConnectHandler(): void { this.connected true; console.log(连接成功); // 连接成功后立刻开始发送心跳之类的业务数据。 this.sendHeartBeat(); } private onMessageHandler(msg: any): void { // msg 是 Byte 类型的对象。 this.handleMessage(msg as Byte); } private onCloseHandler(): void { this.connected false; console.log(连接关闭); } private onErrorHandler(e: any): void { this.connected false; console.error(连接错误, e); } }有几个容易忽略的坑我一个个说。第一endian一定要在connect之前设置好。如果你先建立连接再改字节序已经收到的数据解析就会错位而且这个错误特别隐蔽因为Laya的Byte对象在读取时会根据已设置的绝对字序工作新版Laya使用的是DataView支持setInt32这类方法但endian同样要提前定好。第二EVENT_CLOSE事件在服务器端主动断开和客户端调用close时都会触发所以在断线重连逻辑里要加防抖不然重连逻辑会连发。第三EVENT_ERROR事件的参数在部分版本里是undefined不要依赖它去定位错误代码排查问题要靠socket.close之后的回调状态。2.2 发送与接收Byte对象和DataView的配合Laya中收发数据统一走Byte对象。发送时你往Byte里写入数据然后调用socket.send(byte)或者直接socket.send(data)传入二进制数据。接收时EVENT_MESSAGE回调参数是一个装好数据的Byte对象直接读就行。发送端的典型代码是这样的public sendMessage(cmd: number, body: Object): void { if (!this.socket || !this.connected) { console.warn(socket未连接); return; } let bytes new Byte(); bytes.endian Byte.BIG_ENDIAN; // 假设协议格式是4字节消息长度 4字节cmd body字节流。 let bodyBuffer this.encodeBody(body); let totalLen 4 4 bodyBuffer.length; bytes.writeInt32(totalLen); bytes.writeInt32(cmd); bytes.writeArrayBuffer(bodyBuffer); this.socket.send(bytes); }这里就是前面提到的分包核心。TCP和WebSocket在传输层是不管消息边界的所以你在发送端必须自己用长度字段来标识一条消息的开始和结束。为什么长度要放在最前面因为接收端拿到字节流之后首先读4个字节得到长度值才知道后面这个完整消息体有多少字节需要缓冲才能正确切割出完整的一条消息。这个思路在后面C#服务器端同样适用。接收端的处理就相对麻烦一点因为你拿到的Byte不一定是完整的一条消息可能粘了多条也可能只有半条。底层的处理逻辑是先把数据累积到一个收包缓冲区然后循环检查长度字段判断能不能按长度切出一个完整的包来private recvBuffer: Byte new Byte(); private recvLength: number 0; private handleMessage(msg: Byte): void { // 把收到的数据追加到 recvBuffer 中。 msg.pos 0; while (msg.bytesAvailable 0) { let temp new Byte(); temp.endian Byte.BIG_ENDIAN; msg.readArrayBuffer(temp, msg.bytesAvailable); this.recvLength temp.length; // 这里实际项目会把 temp.buffer 拼接到 this.recvBuffer 后面。 } // 然后循环拆包。 while (this.recvLength 4) { let view new DataView(this.recvBuffer.buffer); let bodyLen view.getInt32(0, false); // false 表示大端序。 if (this.recvLength 4 bodyLen) { break; // 还不完整继续等。 } // 切出一条完整消息进行处理。 this.processMessage(this.recvBuffer, 4, bodyLen); // 剩余数据前移继续下一次拆包。 } }这个拆包循环的逻辑是固定的套路核心思想就是“先读长度再判断是否足够够就切不够就等”。我见过很多人在这一步偷懒直接假定一次收到完整包这在局域网内问题不大一旦上公网逻辑百分百出错。2.3 粘包和半包问题的本质所谓粘包就是发送方连续发了两个包接收方一次收到了两个包的数据。半包则是发送的一个包被切成了两次到达。在WebSocket协议里因为协议的帧结构天然解决了消息边界所以不会有这个问题。但在TCP Socket模式下这两个是必须处理的。这里还可以延伸出一个细节要不要在协议里加一个协议版本号或者命令字我建议加。比如4字节消息长度L、2字节协议版本、2字节命令字、L-4字节的消息体。这样服务器端在拆包的时候可以顺手校验协议版本不匹配的直接丢弃并记录日志。这个对正式环境定位线上问题很有用因为你永远不知道线上客户端会有什么旧版本没升级。踩过几次坑之后我的习惯是做一个统一的编码器解码器模块客户端也好、C#服务器也好都用同一套协议定义。编码解码逻辑单独抽出来不要散落在业务代码里。后面如果协议升级只需要改一个地方。这个模块尽量用纯函数写法输入字节流输出消息对象不依赖任何全局状态测试也好写。3. C#服务器端异步接收模式详解3.1 为什么必须用异步而不是同步写C#的TCP服务器第一道选择题就是异步还是同步。同步模式下一个监听线程只能服务于一个客户端连接你有1000个客户端就得开1000个线程这显然是不现实的。而C#的异步Socket模型基于IO完成端口一个线程可以同时管理成千上万个连接线程在等待IO时会被挂起不占用CPU。从代码结构上看同步阻塞模式下常见的问题就是“假死”。你调用了clientSocket.Receive(buffer)如果客户端一直不发数据这个线程就卡死在Receive调用上后续代码全都不执行了。一旦某个客户端掉线时没有正确关闭连接服务器端的资源就会被白白占住。所以核心实践就是服务器端全部走异步回调。BeginAccept负责接受新连接BeginReceive负责读取数据。每一个连接维护自己的接收缓冲区通过SocketAsyncEventArgs或者StateObject保存上下文。下面这个模式是我用下来比较稳的一套流程。3.2 从 BeginAccept 到 BeginReceive 的闭环C#异步Socket的标准流程是这样的public class TcpServer { private Socket _listenSocket; private int _port; public void Start(int port) { _port port; _listenSocket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); // 设置端口复用解决重启时的 TIME_WAIT 问题。 _listenSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _listenSocket.Bind(new IPEndPoint(IPAddress.Any, port)); _listenSocket.Listen(100); Console.WriteLine($服务器启动监听端口 {port}); // 第一次调用 BeginAccept。 _listenSocket.BeginAccept(AcceptCallback, _listenSocket); } private void AcceptCallback(IAsyncResult ar) { Socket listenSocket (Socket)ar.AsyncState; try { Socket clientSocket listenSocket.EndAccept(ar); var state new StateObject(); state.WorkSocket clientSocket; state.Buffer new byte[4096]; // 在接受完这个连接之后立刻继续接收下一个连接。 // 这一点必须放在 EndAccept 之后第一时间做不然新增连接会排队。 listenSocket.BeginAccept(AcceptCallback, listenSocket); // 开始异步接收客户端数据。 clientSocket.BeginReceive(state.Buffer, 0, state.Buffer.Length, SocketFlags.None, ReceiveCallback, state); } catch (SocketException ex) { // 监听Socket被关闭时EndAccept会抛错这里捕获后正常退出。 Console.WriteLine($Accept error: {ex.Message}); } } private void ReceiveCallback(IAsyncResult ar) { StateObject state (StateObject)ar.AsyncState; Socket clientSocket state.WorkSocket; try { int bytesRead clientSocket.EndReceive(ar); if (bytesRead 0) { // 处理收到的消息。 ProcessData(state, bytesRead); // 核心接收完一段数据后马上再次调用 BeginReceive。 // 这样才能形成持续的接收回调循环。 clientSocket.BeginReceive(state.Buffer, 0, state.Buffer.Length, SocketFlags.None, ReceiveCallback, state); } else { // 对方关闭连接。 clientSocket.Shutdown(SocketShutdown.Both); clientSocket.Close(); } } catch (SocketException ex) { // 捕获后清理连接资源。 Console.WriteLine($Receive error: {ex.Message}); clientSocket.Close(); } } private void ProcessData(StateObject state, int bytesRead) { // 实际项目中这里是拆包逻辑。 // 注意 state.Buffer 只有 bytesRead 长度的数据是有效的。 } }StateObject是一个自定义的上下文类用来在多次回调之间保存Socket和缓冲区等状态。最关键的是在ReceiveCallback处理完一次EndReceive之后必须马上再次调用BeginReceive。这是异步Socket机制能够持续收数据的底层逻辑。中断了这个循环连接就僵掉了。还有一个细节很多新手会漏EndAccept和BeginAccept的调用顺序。代码里我特意把BeginAccept放在了EndAccept之后执行这样下一个客户端的连接请求就能立刻被处理不用等当前连接的初始化完成。这个顺序在多客户端场景下非常重要否则第二个客户端想来连接在监听队列里的等待时间会变长。3.3 服务器端的拆包缓冲设计上面C#代码中的ProcessData是处理收到的字节数据的入口。服务器端的拆包逻辑和客户端是镜像的。由于每次BeginReceive拿到的数据长度不固定所以需要把收到的数据累积到自己的包缓冲区里再按协议长度切出消息。开头的搜索词里有一个特别典型的报错failed to create server shutdown socket on address [localhost] and port [802]。这个错误信息其实不是出在业务服务器上而是某些框架在创建独立的关停监听端口时失败。原因很直接——那个端口已经被占用或者还没被释放。这个我后面第4节会专门讲但在这里先提醒一点如果你的服务器代码里某个Socket被误操作置为复用但没有设ReuseAddress那在Windows上重启时默认会有60秒左右的TIME_WAIT时间这时再Bind同一个端口就会报“只允许使用一次”的错误。解决办法就是在Bind之前把ReuseAddress设为true。_listenSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);这四个单词不算复杂但加不加这一行在服务器开发中就是顺手和被迫重启的区别。开发调试阶段服务器总要一遍遍地重启不加这个选项每重启一次可能就等半分钟才能再绑上端口太浪费时间了。3.4 并发连接管理与资源释放细节C#服务器端多连接管理核心是维护一个连接对象的集合或字典。常用的做法是ConcurrentDictionarystring, StateObject _onlineClients new ConcurrentDictionarystring, StateObject(); string clientId clientSocket.RemoteEndPoint.ToString(); _onlineClients.TryAdd(clientId, state);当客户端断开连接时从字典中移除_onlineClients.TryRemove(clientId, out _)。这个字典里的连接状态在业务层用处很大比如服务器要主动给某个玩家推送消息就可以根据玩家ID找到对应的StateObject再拿到Socket调BeginSend。注意清理的顺序先移除字典条目再关闭Socket。如果顺序倒了会出现在线列表里还有该玩家但Socket已经没法发数据的中间状态。另外clientSocket.Close()和clientSocket.Dispose()的区别要弄清。Close()会关闭Socket并释放非托管资源之后一般不需要再Dispose。但如果你在测试中发现连接资源没有被释放可以额外调用一下Dispose()不过大部分情况下这个属于玄学层面的排查手段真正的资源泄漏往往出在业务层忘记移除引用Socket本身倒不必太担心。4. Socket 常见故障排查与稳定性调优4.1 端口被占用问题的完整解法“通常每个套接字地址协议/网络地址/端口只允许使用一次”这个错误我估计很多人都见过。这句报错的英文版是Only one usage of each socket address (protocol/network address/port) is normally permitted。它发生在Bind或者Connect的时候具体诱因有几种端口未释放上一个进程绑定过同一个端口进程结束后端口没有立即回到可用状态。两个进程抢同一个端口启动了两份服务器副本后启动的那份自然Bind失败。TIME_WAIT状态主动关闭连接的一方通常是服务器自己调了Close端口可能进入TIME_WAIT状态默认持续2个MSL在Windows上约60秒期间不能被重新Bind。HTTP端口冲突有时候你写了别的服务占用了同一个端口自己不知道。排查流程我推荐先用netstat -ano | findstr 端口号找到占用端口的进程PID再用tasklist | findstr PID看是哪个进程占着。如果在开发机上发现是自己上一次启动的服务残留直接taskkill /PID xxx /F杀掉就行。如果是开发环境频繁重启导致 TIME_WAIT 问题或者干脆换一个测试端口。下面是把ReuseAddress的完整用法和注意事项列出来// 正确用法Bind 之前设置。 Socket s new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); s.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); s.Bind(new IPEndPoint(IPAddress.Any, 8020)); s.Listen(100);这里再说一句ReuseAddress解决的是“端口被TIME_WAIT状态占用”的问题它不代表你可以在同一时刻让两个Socket监听同一个端口。真要两个进程共享一个端口需要ReusePortWindows上现在也支持但一般不推荐。4.2 心跳保活与死连接清理TCP层的KeepAlive默认是两小时探测一次对游戏服务器来说这个时间太长了。客户端掉线或者进入弱网状态后服务器在最多两小时的时间里不会发现连接已经死了这期间连接对象一直占着内存和文件描述符。所以业务层必须自己做心跳这也是所有游戏服务器的标配。客户端每5到10秒发送一个心跳包服务器收到心跳包后刷新该连接的最后活跃时间。后台用一个独立线程定时扫描所有连接的活跃时间超过超时阈值比如30秒的连接直接关闭。这里的超时阈值至少要大于心跳间隔的两倍留足网络抖动导致的延迟余量。如果心跳包间隔5秒超时设为15到20秒比较合理。DateTime lastActiveTime DateTime.Now; void HeartBeatCheck(object state) { foreach (var client in _onlineClients) { if ((DateTime.Now - client.Value.LastActiveTime).TotalSeconds 30) { // 超时关闭连接。 client.Value.WorkSocket.Close(); _onlineClients.TryRemove(client.Key, out _); } } }心跳包本身要尽量精简一般就是一个固定命令字加一个空消息体不要带上多余的业务数据。有些团队喜欢把心跳和精准时间戳同步放在一起做但那是另一套逻辑了建议心跳包保持纯粹别搞混合协议不然出问题的时候定位很麻烦。断线重连这块客户端的策略我推荐指数退避第一次失败等1秒重试第二次等2秒第三次等4秒最大间隔不超过30秒。同时在App前后台切换时注意重新检测网络状态触发重连。Laya的socket.connect在断线后是可以重新调用的但前提是旧连接已经触发了EVENT_CLOSE所以重连逻辑里要先判断connected状态避免重复建立连接。4.3 windows socket error 与 shutdown 顺序windows socket error: 通常每个套接字地址(协议/网络地址/端口)只允许使用一次还有个容易触发的情境服务器在监听时主动调用了Shutdown(SocketShutdown.Both)而不是Close()。Shutdown只停止收发数据并不释放端口资源之后没有正确关闭底层句柄就会导致端口一直处于占用状态。正确关闭TCP连接的顺序是调用Shutdown(SocketShutdown.Send)告诉对端不再发送数据。继续接收完对端发送过来的剩余数据。调用Close()释放Socket资源。不过在实际项目中游戏服务器一般不用这么优雅直接Close()就够了。只是要注意在ReceiveCallback和AcceptCallback捕获到的异常里关闭逻辑要放在finally或者正确的catch块中。我遇到过有人直接在异常语句后写关闭代码结果因为异常类型不对跳过了导致连接泄漏。最稳妥的管理方式还是在某个专门的连接清理类中统一处理不要每个回调都写一遍关闭逻辑。4.4 网络异常与半关闭状态的判断客户端在未正常关闭的情况下断电、切网服务器端此时收不到任何FIN包连接一直处于ESTABLISHED状态。TCP的半关闭状态很难察觉这也是为什么心跳不能省的根本原因。实际开发中判断连接是否真的有效除了心跳超时之外还可以在服务器发送数据时观察Send返回值或者捕获异常。C#的BeginSend回调中如果EndSend抛出SocketException并且错误码是ConnectionReset(10054)说明对端连接已经不复存在这时就该从在线列表移除这个连接。10054这个错误码在很多网络日志里出现频率很高初学者看到ConnectionReset容易慌其实它不过就是说明对端把连接关了数据没送到。就像你给一个已经关机的人发消息系统告诉你对方不在服务区很正常。心态放平按断开流程处理就好。5. 稳定运行的核心经验结合我这几个项目的实际维护经历最后说几点非常基础但又容易被忽略的心得也是之后维护任何一个Socket系统时我都会先确认的事项。第一字节序必须统一。Laya端用大端序C#端BitConverter默认用的是小端序如果不做转换解析出来的整数完全不是同一个数。C#要处理大端序最直接的方法是用BinaryPrimitives.ReadInt32BigEndian.NET Core 2.1或者手动把字节数组倒序再转int。这个坑太经典了两个端各写各的联调时却要花好几个小时才找到原因。第二异常回调不等于断线。Laya的EVENT_ERROR触发后连接不一定已经关闭你需要主动close()之后再走重连逻辑。反过来服务器端的SocketException也有各种错误码不一定都是致命错误只有确认无法恢复时才需要清理资源。第三协议设计要向前兼容。给消息加版本号字段哪怕现在只有一个版本也建议加。游戏上线后总是有玩家没来得及更新客户端旧客户端连上来发旧版本协议服务器可以根据版本号判断是否兼容避免整个通讯机制被不兼容数据搞挂。最后要说的是Socket编程不仅仅是API层面的连接收发。它更像是一场客户端和服务器端之间的舞蹈——两个端必须严格遵循同一套节奏在同一套二进制规则下协作。多花点时间把协议定义清楚把边界条件想全面真正跑起来的时候它反而会成为整个项目里最稳定、最不需要操心的部分。
返回列表