简介:这一压缩包汇集了Flash游戏客户端与配套服务器源码,并以PPT讲解双方交互的数据包格式,适合正在学习网络编程、TCP通信及select I/O多路复用模型的开发者参考,尤其对想要理解传统浏览器端游戏如何与后端服务交互的读者十分实用。包内共15个文件,以C++源码(cpp/h)、编译中间文件(obj/pdb)及可执行程序(exe)为主,另有工程配置与说明文档;其中服务器控制台程序负责处理客户端请求、同步游戏状态,并演示了如何用select同时管理多个套接字,而Flash游戏(如flash_car2)则作为客户端展示实时交互过程。整体649KB,结构紧凑,便于快速查看关键代码与运行效果。已有235人学习,对于想理解在线游戏服务器架构、数据包设计或select模型实战应用的人,这是一份可运行的参考案例,能帮助你将课堂理论与具体实现对应起来。
1. 一个老Flash联机Demo:TCP长连接与select模型的完整组合
Flash游戏在今天的浏览器里已经不多见,但联机玩法的通讯逻辑并没有过时:客户端用ActionScript 3的Socket建立TCP长连接,服务器用C++的select模型同时管理几十上百个连接,消息按自定义二进制协议收发。这份「flash游戏以及服务器」就是这样一个最小可跑的联机Demo——包含登录、房间列表、移动同步三条业务链路,服务端代码量不大,却把socket阻塞、粘包、fd_set重设、客户端掉线清理这些底层问题都实际跑了一遍。适合两类人:一类是接手老Flash项目、需要把服务器逻辑抽出来改造成新通讯层的人;另一类是刚接触TCP服务端编程、想用最直接的方式理解多连接管理的新手。翻车与踩坑我在拆的过程中都遇到了,照着下面章节一步步走,能少走很多弯路。
2. 整体链路与技术选型:从AS3客户端到C++ select服务器
2.1 项目定位:登录、房间、移动同步三条链路
这份Demo的角色不是仿真大作,而是Flash联机游戏里最常见的通讯原型。客户端启动后先发登录请求,服务器验证账号后返回角色信息和房间列表;玩家加入某个房间后,客户端每移动一次就发一条同步消息给服务器;服务器再把这条移动广播给同房间其他人。整个服务器要处理的事情可以拆成三类:连接管理、协议解析、业务分发。
连接管理负责accept新连接、recv数据、关闭异常连接;协议解析负责从TCP字节流里切出完整消息;业务分发则根据消息ID调用登录、房间、移动对应的处理函数。这三层在代码里是分开的,net目录管连接,proto目录管解析,biz目录管业务。这样的分层对后来做二次开发很友好——想改成epoll就改net层,想加消息类型就改proto和biz层,彼此不纠缠。
技术栈上,客户端是ActionScript 3.0,用内置的Socket类做TCP通讯,消息体用ByteArray序列化;服务端是C++,用标准socket接口加select做并发。这套组合是早期Flash联机游戏的主流标配,没有引入任何第三方框架,依赖极少,拿到源码后直接就能编译。
2.2 服务端为什么选select模型而不是其他方案
服务端并发方案常见的有阻塞多线程、select模型、epoll、IOCP。这个Demo选select有几个现实原因。第一,老Flash联机项目大多部署在Windows服务器上,select模型在Windows和Linux都能编译,代码不需要平台分支;IOCP在Windows上性能好但门槛高,epoll则只能在Linux用。第二,房间规模不大,几十个连接属于select的舒适区,它默认能管理的fd上限是1024,对当前业务绰绰有余。第三,单线程模型对调试非常友好,数据竞争少,新人看代码不会一头雾水。
相比直接套libevent或Boost.Asio,手写select的好处是每一步都透明。fd_set里填充了哪些文件描述符,select返回后哪些socket可读,数据读完缓冲区还剩多少,全部清晰可见。等连接数真的大于500,再往epoll迁移也不迟,因为业务分发和连接管理是分离的,迁移成本主要在net层。
提示:select模型的小并发定位要心里有数。它适合单个房间几十人、整个服几百人这类场景,不适合万人同服。认清场景再看代码,很多设计决策就顺理成章。
连接管理上有个细节值得注意:select是水平触发,只要fd上有数据没读完,下次select依然会返回可读。所以接收缓冲区一定要及时消费,否则主循环会被某个不活跃的连接反复唤醒。这个Demo的做法是先用非阻塞socket加200毫秒超时,单次recv最多读2048字节,剩下的留在缓冲区下一轮再继续读。
2.3 目录结构与编译顺序
拿到源码后先看目录结构,我拆的时候把核心文件单独拎了出来。服务端代码大致按下面这个方式组织:
server/ main.cpp # 入口,初始化监听socket并进入select循环 net/ select_server.cpp # select主循环、accept新连接 conn.cpp # 单个客户端的fd、缓冲区、状态管理 proto/ message.h # 消息结构定义 dispatcher.cpp # 按消息ID分发到业务函数 biz/ login.cpp # 登录验证与角色信息返回 room.cpp # 房间列表、加入房间、移动广播 utils/ buffer.cpp # 收包缓冲区的粘包半包处理 flash_client/ GameSocket.as # 封装Socket连接、发送、接收解析 LoginView.as # 登录界面逻辑 RoomView.as # 房间内移动同步逻辑看代码的顺序建议是conn.cpp、select_server.cpp、buffer.cpp、dispatcher.cpp。先把单个客户端的数据结构看明白,再理解主循环怎么管理一堆客户端,最后看缓冲区如何处理TCP的流式数据。这套顺序比从头文件开始看高效得多。
编译方面,服务端用g++直接编就能过,命令行大致是这样:
g++ -o flash_server main.cpp net/select_server.cpp net/conn.cpp \ proto/dispatcher.cpp biz/login.cpp biz/room.cpp utils/buffer.cpp \ -lpthread -O2不需要额外链接第三方库,-lpthread是为后面扩展心跳线程预留的。编译完成直接运行,监听端口默认9000。客户端用Flash Builder或对应的AS3 IDE打开flash_client目录,把GameSocket里的连接地址改成服务器IP就能联调。
3. 协议层设计:客户端和服务器之间怎么说话
3.1 自定义二进制协议:消息头加消息体
TCP是流式协议,数据像水管里的水一样没有边界。要区分“一条一条的消息”,必须在协议层做封包。这份Demo用的是最常见的二进制协议:每条消息由一个固定长度的头和可变长度的体组成。头里放消息ID和消息体长度,服务器拿到数据后先读头,再按长度读体。
消息头定义在message.h里,核心是一个紧凑结构体:
#pragma pack(push, 1) struct MessageHeader { uint16_t msg_id; // 消息ID,比如1=登录,2=加入房间,3=移动 uint32_t body_len; // 消息体字节数,最大可支持4GB }; #pragma pack(pop)#pragma pack(push, 1)的作用是取消结构体的内存对齐,让msg_id和body_len紧密排列。如果编译器默认对齐到4字节,sizeof(MessageHeader)会从6变成8,客户端和服务端计算的包头长度就对不上,直接导致粘包解析全部错位。这个细节很坑,很多人第一次联调失败就栽在这里。
消息ID用uint16_t,上限65535条消息类型,对游戏项目足够用。body_len用uint32_t意味着单条消息理论上可以有4GB,但实践里建议限制到64KB以内。原因是select模型的接收缓冲区通常只有几KB,一条超大消息要分多次recv才能攒齐,业务处理时间也会拉长,容易被别的玩家感知到卡顿。
3.2 Flash客户端Socket封装:AS3里的连接与发送
客户端这边,ActionScript 3的Socket类封装了TCP连接的细节。常见做法是写一个GameSocket类,把connect、send、数据事件监听都包一层,业务界面就不需要直接碰Socket API。下面是一个最小可用的封装:
package { import flash.net.Socket; import flash.events.Event; import flash.events.ProgressEvent; import flash.utils.ByteArray; public class GameSocket { private var sock:Socket; public function connect(host:String, port:int):void { sock = new Socket(); sock.addEventListener(Event.CONNECT, onConnect); sock.addEventListener(ProgressEvent.SOCKET_DATA, onData); sock.connect(host, port); } public function sendMessage(msgId:int, body:ByteArray):void { var out:ByteArray = new ByteArray(); out.writeShort(msgId); out.writeInt(body.length); out.writeBytes(body); sock.writeBytes(out); sock.flush(); } private function onData(e:ProgressEvent):void { // 数据到达后由解析函数按包头切分 } } }sendMessage的写法与服务端包头严格对应:writeShort写入2字节的消息ID,writeInt写入4字节的body长度,最后写入请求体。这里要特别强调writeInt,AS3的ByteArray.writeInt固定写4字节,与C++的uint32_t小端序一致,不需要额外转换。
Flash的Socket是异步模型,connect之后不能立刻write,必须等Event.CONNECT事件触发。很多初学AS3的人在这里犯过忘记监听CONNECT事件就直接发数据的错误,服务器收不到任何东西,还以为是网络问题。另外,发送完数据要调用flush(),否则数据停留在Flash运行时缓冲区里,不会真正落到TCP连接上。
3.3 粘包与半包处理:缓冲区里的拼图游戏
TCP没有消息边界,100条消息发送后,服务器可能一次recv收到好几条,也可能只收到一条消息的前半截。前者叫粘包,后者叫半包。处理方式是同一套:服务器为每个连接维护一个接收缓冲区,数据来了先追加到缓冲区,然后循环尝试从缓冲区头部解析消息。
buffer.cpp里的解析逻辑是这个Demo的核心之一,简化后大致长这样:
int parse_buffer(Client* cli) { while (cli->recv_len >= (int)sizeof(MessageHeader)) { MessageHeader* h = (MessageHeader*)cli->recv_buf; uint32_t need = h->body_len; if (cli->recv_len < (int)sizeof(MessageHeader) + (int)need) { break; // 半包:总数据还不够,等下一轮recv } // 凑齐了一条完整消息,交给分发器 dispatch(h->msg_id, cli->recv_buf + sizeof(MessageHeader), need); // 把剩余数据搬到缓冲区头部,继续循环 size_t left = cli->recv_len - sizeof(MessageHeader) - need; memmove(cli->recv_buf, cli->recv_buf + sizeof(MessageHeader) + need, left); cli->recv_len = left; } return 0; }这段代码的逻辑要点是:第一次进入循环时先判断缓冲区是否够一个包头;不够就直接break,等select下一轮继续读;够就解析出body_len,再判断包头加消息体的总长度是否已经就绪;如果消息体还没收全,同样是break;收全了就调用dispatch处理,然后用memmove把剩余数据搬到缓冲区起点。整个过程像拼图,一次循环处理一条完整消息,一条不浪费。
memmove在这里不能用memcpy代替。因为源地址和目标地址重叠,memcpy的行为是未定义的,只有memmove能正确处理重叠拷贝。这个位置如果写错,数据错位会表现得极其诡异——可能某条消息正常,下一条消息的msg_id变成上一段body的残留。
还有一个容易被忽略的问题:缓冲区长度限制。Demo里BUFFER_SIZE默认是8KB,如果一条消息体超过了这个值,或者客户端疯狂发包把缓冲区塞满,解析代码会陷入死循环。常见的做法是在追加数据前做一次容量检查,超过限制直接关闭连接,防止被恶意客户端撑爆内存。
4. 核心模块拆解:select模型下,一个消息从进入到返回的完整链路
4.1 select主循环:fd_set的填充、轮询、重设
服务端的主循环位于select_server.cpp,所有连接的可读事件都从这里分发。这个循环做的事情可以概括为三步:把要监听的所有fd塞进fd_set,调用select等待事件,遍历fd_set找出可读的socket。代码核心结构如下:
void server_loop(int listen_fd) { fd_set readfds; while (!g_stop) { FD_ZERO(&readfds); // 1. 清空集合 FD_SET(listen_fd, &readfds); // 2. 加入监听fd int max_fd = listen_fd; for (int i = 0; i < MAX_CLIENTS; ++i) { if (g_clients[i].fd > 0) { FD_SET(g_clients[i].fd, &readfds); if (g_clients[i].fd > max_fd) { max_fd = g_clients[i].fd; } } } struct timeval tv; tv.tv_sec = 0; tv.tv_usec = 200000; // 200ms超时 int ret = select(max_fd + 1, &readfds, NULL, NULL, &tv); if (ret < 0) { continue; // 被信号打断,重新填充 } if (FD_ISSET(listen_fd, &readfds)) { accept_new_client(); // 有新连接 } for (int i = 0; i < MAX_CLIENTS; ++i) { if (g_clients[i].fd > 0 && FD_ISSET(g_clients[i].fd, &readfds)) { handle_client_read(i); // 该客户端有数据到达 } } } }每次循环必须重新执行FD_ZERO和FD_SET,这个顺序不能反过来。select返回后会改写fd_set,只保留有事件的fd,下一次循环如果直接用旧的fd_set,没事件到达的fd就会全部消失,表现为连接陆续断线。这是select模型最容易犯的错误,几乎每个手写过select的人都在这上面翻过车。
readfds之外的三个参数分别是写集合wr_fds、异常集合ex_fds和超时时间。这份Demo只监听了可读事件,写操作通过发送缓冲区和send返回值来判断,没有单独用写集合。对于小并发游戏场景,把send做成阻塞调用通常不会出问题,因为玩家操作频率低,数据量小;如果是实时战斗类游戏,需要另开发送线程或用非阻塞send加上写事件监听。
关于select的第一个参数,Linux下要求传入“所有fd中最大值加1”。max_fd在每次循环里重新计算,就是为了保证传入的值准确。Windows的select不校验这个参数,但为了跨平台代码一致性,Demo仍然取了max_fd + 1。时间tv设为200毫秒,一方面避免select永久阻塞导致无法处理定时任务,另一方面不至于空转浪费CPU。
4.2 新连接接入:客户端对象是怎么被管理的
监听fd可读时,说明有一个新客户端发起了TCP握手,需要调用accept把它接入。accept之后要做的事比想象中多一些,不只是拿到一个fd就完事。下面这段是Demo里的接入逻辑:
void accept_new_client() { struct sockaddr_in addr; socklen_t addr_len = sizeof(addr); int client_fd = accept(listen_fd, (struct sockaddr*)&addr, &addr_len); if (client_fd < 0) { return; } // 从全局客户端数组里找一个空位 int idx = find_free_slot(); if (idx == -1) { close(client_fd); // 连接数已满,直接断开 return; } g_clients[idx].fd = client_fd; g_clients[idx].recv_len = 0; g_clients[idx].last_active_time = time(NULL); g_clients[idx].state = ST_CONNECTED; // 设置为非阻塞模式,避免recv阻塞主循环 set_nonblocking(client_fd); }客户端管理用的是固定数组,MAX_CLIENTS默认64。数组的好处是下标即ID,查找快,没有链表的内存碎片问题;坏处是64是硬上限,连接满了只能拒绝。select模型本来就限制在1024以内,固定数组是合理的简化方案,二次开发时如果连接数预期更大,可以把数组改成vector加空闲链表。
设置非阻塞模式很重要。如果保持阻塞模式,某个客户端recv时数据没到,线程会卡在recv里,整个select循环停摆,其他所有连接全部假死。非阻塞模式下recv没数据时立即返回-1并设置EWOULDBLOCK错误码,主循环可以继续处理别的事件,这是多连接服务端的基本素养。
4.3 消息读取与业务分发:从字节流到业务逻辑
handle_client_read承担了读取数据、追加缓冲区、触发解析三个步骤。每次最多读2048字节,避免单次读取阻塞太久:
void handle_client_read(int idx) { Client* cli = &g_clients[idx]; char tmp[2048]; int n = recv(cli->fd, tmp, sizeof(tmp), 0); if (n <= 0) { // n=0对端关闭,n<0非阻塞且无数据,两者都要清理连接 close_client(idx); return; } if (cli->recv_len + n > BUFFER_SIZE) { close_client(idx); // 缓冲区溢出,协议异常或客户端恶意发包 return; } memcpy(cli->recv_buf + cli->recv_len, tmp, n); cli->recv_len += n; cli->last_active_time = time(NULL); parse_buffer(cli); // 尝试从缓冲区解析出完整消息 }recv的返回值需要区分三种情况:大于0表示实际读取到n个字节;等于0表示对端正常关闭连接,此时要立刻close_client,否则这个fd会一直被select标记为可读,主循环空转;小于0要检查错误码,非阻塞模式下的EWOULDBLOCK不算异常,但其他错误码同样要走close_client。初版代码经常只处理了小于0的情况,结果对端关闭后fd反复可读,CPU占用率被拉高到不正常水平。
parse_buffer解析出完整消息后,会调用dispatch函数。dispatch本质上是一张消息ID到处理函数的跳转表,用switch实现:
void dispatch(uint16_t msg_id, const char* data, uint32_t len) { switch (msg_id) { case MSG_LOGIN: handle_login(g_current_client, data, len); break; case MSG_JOIN_ROOM: handle_join_room(g_current_client, data, len); break; case MSG_MOVE: handle_move(g_current_client, data, len); break; case MSG_PING: handle_ping(g_current_client, data, len); break; default: // 不认识的消息ID,记日志即可,别关闭连接 printf("unknown msg_id=%u\n", msg_id); break; } }g_current_client是一个全局指针,dispatch前由上层设置好当前正在处理哪个客户端。之所以没有把它作为参数直接传下来,是为了让biz层的登录、房间函数签名保持简洁——它们只需要关心数据和长度,不需要关心连接管理细节。这个取舍在Demo的场景里成立,但如果同一份代码将来迁移到多线程,这个全局变量要立刻改掉,改成参数传递。
4.4 心跳与超时清理:避免僵尸连接占着名额
TCP连接本身不会主动通知服务器“客户端已经死了”,客户端程序直接关掉电脑或拔掉网线,服务器看到的还是established状态。要检测这种僵尸连接,游戏服务器普遍采用心跳机制:客户端每隔5秒发一条PING,服务器记录last_active_time,如果10秒内没收到任何数据就判定超时。
主循环在select超时返回后的空闲时机检查所有客户端的活跃时间:
void check_timeout() { time_t now = time(NULL); for (int i = 0; i < MAX_CLIENTS; ++i) { if (g_clients[i].fd > 0 && g_clients[i].state != ST_LOGINED) { continue; // 未登录的连接不参与心跳检查,直接由登录超时管理 } if (g_clients[i].fd > 0 && now - g_clients[i].last_active_time > KICK_TIMEOUT) { printf("kick timeout client fd=%d\n", g_clients[i].fd); close_client(i); } } }心跳的好处不只是清理僵尸连接,还能让服务器掌握玩家真实在线状态。玩家突然断网时,服务器最多在10秒内就能释放这个连接占用的fd和缓冲区,否则连接数会被慢慢耗尽,新玩家无法登录。Flash客户端侧配合一个Timer,每5秒调一次sendMessage发MSG_PING,这是最简单又最有效的保活手段。
5. 避坑指南:Flash联调中踩过的五条血泪经验
5.1 recv返回0没有立即清理,CPU被打满
现象:几个客户端退出后,服务器进程CPU占用率一直维持在30%以上,日志里没有任何异常。排查时发现有个客户端的fd一直处于可读状态,select每次返回都触发handle_client_read。
原因:初版代码在handle_client_read里只判断了n小于0才清理连接。对端正常关闭连接时,recv返回的是0而不是-1,0被当成正常情况跳过,fd留在fd_set里。因为TCP关闭后fd永久可读,select每200毫秒返回一次,一次空转,循环反复唤醒,CPU自然被拉高。
解决:把清理条件改成n <= 0,无论是0还是负数都走close_client。这个修复是这份Demo里最值得记住的一行代码。
5.2 select返回后没有重设fd_set,新连接一接就断
现象:服务启动后前几个连接工作正常,再过一会儿新连接的TCP握手都完成了,但登录消息发过去一直没有回应,过几秒连接被系统关闭。
原因:select会修改传入的fd_set,只保留有事件发生的fd。如果循环体开始处没有FD_ZERO和FD_SET重建集合,下一次select监听的fd数量会越来越少,最终只剩listen_fd。新连接的fd虽然被accept了,但它从来没有被加入后续的fd_set,所以它的数据没人读。
解决:每次循环必须无条件重建fd_set,顺序是FD_ZERO、加入listen_fd、遍历所有活跃客户端加入其fd。这个坑的排查方法也很直接:在select返回后把当前可读的fd都打出来,和预期对比。
5.3 粘包只解析了一部分,第二条消息永远报错
现象:客户端连续发送两条消息,服务器收到的第一条正常处理,第二条的msg_id看起来像乱码,或者body_len大得离谱,之后这个连接所有消息都解析失败。
原因:第一次recv可能同时收到两条消息的数据,缓冲区里有消息A和消息B的一部分。解析代码在处理完消息A后,只把游标向前移动了消息A的长度,B的数据还残留在缓冲区。如果不做memmove,或者只移动游标不移动数据,下一次解析的起始位置就会错位。
解决:统一用“处理一条、搬移一次”的做法,解析成功后把剩余数据memmove到缓冲区头部。搬移后recv_len要同步更新,否则缓冲区头部会遗留脏数据,干扰下一次解析。这个问题的玄学之处在于它不总是发生,偶尔出现一次,最容易被误判为网络问题。
5.4 浏览器运行SWF连不上本机服务器,安全沙箱拦截
现象:用Flash调试工具直接运行SWF,连接服务器正常;但把SWF放到网页里用浏览器打开,服务器日志里完全没有TCP连接记录。一开始怀疑是端口被防火墙拦了,换端口也一样。
原因:Flash Player的浏览器插件对socket连接有安全沙箱限制。客户端连接某个服务器端口之前,会先请求该端口上的安全策略文件;如果服务端没有返回合法的crossdomain.xml策略,连接会在应用层被直接拒绝。本地调试工具没有沙箱,所以表现不一致。
解决:在服务器监听逻辑里处理策略请求。常见做法是在客户端连接后先读一段数据,判断是不是“ ”,如果是就直接返回策略文件内容再关闭连接。这份Demo里的处理方式比较简略,二次开发时建议把策略逻辑单独拆一个文件,避免污染正常协议解析。
5.5 业务线程直接send,和select主循环抢发送缓冲
现象:为了提升房间广播效率,把发送逻辑放到了一个独立线程里,结果偶尔出现某条广播消息丢失、极少数情况崩溃。日志里没有明显报错,崩溃位置还不固定。
原因:两个线程同时对一个socket调用send,可能交错写入同一段数据,导致协议内容错乱。更严重的是,C++标准库的send本身是线程安全的,但发送缓冲区不是;一个线程正在写包头,另一个线程插进来写了一半的body,对端解析就会失败。
解决:统一由select主循环所在的线程负责send,业务线程只把待发送消息塞进一个互斥锁保护的队列。主循环每次select返回后检查队列,统一发送。这个改动单独看不大,但它让整个服务端从“多线程各干各的”变成“单一负责制”,排查问题容易得多。
6. 二次开发:从“能跑”到“能上线”,我建议这么改
6.1 给协议加版本号和握手,为后续兼容留后路
原版协议的MessageHeader只有msg_id和body_len,客户端连上来直接发登录消息。这种方式在Demo阶段没问题,但到了真实项目里,服务器要面对多个不同版本的客户端,老版本没做过登录就升级了新版本。我会把协议头改成这样:
#pragma pack(push, 1) struct MessageHeader { uint16_t version; // 协议版本号,收到不支持的版本直接拒绝 uint16_t msg_id; uint32_t body_len; }; #pragma pack(pop)改动之后,客户端连接成功后先发一条握手消息,带上自己声明的版本号。服务器校验版本号合法后,才允许继续发登录消息。如果版本不匹配,直接返回错误包并关闭连接。这段逻辑加在dispatch之前,对业务代码完全无感。代价是协议头从6字节变成8字节,粘包解析的sizeof(MessageHeader)会自动适配,不需要改buffer.cpp的解析框架。
6.2 用一个Python压测脚本验证改动的正确性
开发服务器最怕“改完本地能跑,一上线就翻车”。我给你一个自己用了很久的习惯:每次改完协议,先写一个轻量压测脚本,模拟20个客户端同时连接、乱序发消息,看服务器是否稳定。Python标准库的socket足够干这个事:
import socket import struct import threading import time def make_msg(msg_id: int, body: bytes) -> bytes: # 前缀:2字节msg_id + 4字节body_len return struct.pack("<HI", msg_id, len(body)) + body def client_worker(idx: int): s = socket.create_connection(("127.0.0.1", 9000), timeout=3) s.sendall(make_msg(1, b"user_%d" % idx)) # 登录 time.sleep(0.1) s.sendall(make_msg(3, b"move_x=100")) # 移动 time.sleep(0.5) s.close() threads = [threading.Thread(target=client_worker, args=(i,)) for i in range(20)] for t in threads: t.start() for t in threads: t.join() print("20 clients done")参数说明:struct.pack的格式串"<HI"表示小端序、2字节无符号短整型加4字节无符号整型,正好对应C++端的MessageHeader。登录消息的msg_id=1,移动消息的msg_id=3,需要和dispatch里的case保持一致。压测时观察服务器日志里是否出现异常断连、消息解析失败之类的输出,如果连续跑三轮都干净,再往代码库合。
当然后续要真正上线,还需要改很多:日志系统、配置项、房间过期清理、断线重连,这些都在这份Demo的基础上逐个加。但核心的通讯骨架不用动,这就是当初把协议、连接、业务拆开的价值。
那次改版本号的时候,我写完压测脚本直接跑了,发现连续几次都有几条消息解析失败,排查到最后是客户端SendMessage里writeShort和writeInt的字节序没对齐。从那以后,我每次改协议都强制自己先把抓包窗口打开,跑一遍联调脚本确认完整来回,再合代码。希望帮到你。
本文还有配套的精品资源,点击获取