手游框架设计<三>
网游协议开发
1. 使用中间语言定义协议
在过去的项目中,协议定义通常采用如下方式:
struct MSG_XX_MOVE {
int x,y;
int vx,vy;
int ax,ay;
};
前后端均使用 C++ 开发,并共同包含这份文件。然而,这种方式存在明显不足。C++ 在描述协议方面不够简洁,难以方便地表达可选情形。而且,一旦在类中加入各种方法(如流处理等),代码会迅速变得庞大。更严重的是,每次修改协议,代码编译时间会变得极长。
若采用中间格式来定义协议,情况将大为简化,例如使用 Protocol Buffers(protobuf):
message MSG_XX_MOVE {
required int32 x = 1;
required int32 y = 2;
optional int32 vx = 3;
optional int32 vy = 4;
}
为使协议更加规范清晰,我们规定协议分为两种模型:请求 - 应答和推送(服务器主动通知),这通常能满足游戏的需求。同时,规定协议后缀名如下:
XX_MOVE_Request:用于请求数据。XX_MOVE_Response:用于接收数据。XX_MOVE_Notify:用于推送数据。
其中,请求必定会有应答返回,而推送则是服务器主动发包。此外,Response 必定包含 ReturnCode,用于告知业务上的情况。以下是一个聊天协议的示例:
message CharMsgRequest {
required int32 recvId;
required string content;
}
message CharMsgResponse {
enum ErrorCode {
Error = 0;
Success = 1;
}
required ErrorCode returnCode = 1;
optional int32 recvId = 2;
}
message CharMsgNotify {
required int32 senderId;
required string content;
}
通过这样的设计,无需过多解释,就能清晰了解整个聊天流程。借助 protobuf,我们可以高效地设计协议,从而提升开发生产力。
2. 自动编码、解码
在某些项目中,数据包需要手动进行流处理,例如:
-- 编码
stream.writeInt(10)
stream.writeString("hi")
云风的 pbc 库可以实现自动编码和解码,示例代码如下:
-- 编码
pbc.encode("CharMsgRequest", {recvId = 10, content = "hi"})
-- 解码
local buf = getBuf()
local data = pbc.decode("CharMsgResponse", buf)
print(data.returnCode)
将编码和解码封装在网络层,使用起来会更加便捷。
3. 事件机制
在游戏开发中,我们期望各个功能模块尽可能独立,事件机制是实现这一目标的有效手段。例如,排行榜系统希望点击榜中角色时能够跳转到详情界面,此时只需向系统派发跳转详情消息,系统会将事件传递给感兴趣的对象并进行相应处理。
与直接调用相比,事件机制具有以下优点:
- 松耦合:无需依赖详情系统,只需派发事件即可。
- 简洁:详情系统只需专注于提供服务。
- 灵活:其他系统可以对事件进行监听和拦截,使系统更具弹性。
4. 网络层
网络层的核心功能包括高效、低延迟、省流量、安全地与服务器通信,以及方便调试。游戏的流畅度在很大程度上与协议设计相关,而网络层的性能有时是次要因素。以下是一个粗略的设计,协议使用 protobuf 描述:
4.1 高效
在协议设计上,我们提供合并数据包的机制。网络包定义如下:
message NetworkPackage {
required int32 msgCount = 1;
repeated GameMsg msg = 2;
}
一次发包时,应尽可能将多个要发送的数据合并。通过上述定义,我们可以知道数据包中包含多少条消息,进而实现自动合并数据,减少传送次数,提升用户体验。
4.2 低延迟
网络性能在很大程度上与使用方式有关。对于对时服务、位置同步等对延迟敏感的功能,使用 UDP 比 TCP 效果更好。此外,关闭 Nagle 算法可以有效降低延迟。
4.3 省流量
对网络包进行压缩编码可以显著减小其体积,结合合并消息包的方式,能够节省大量数据量。
4.4 安全
一般情况下,使用 OpenSSL 进行加密就足以满足安全需求。
4.5 方便调试
我们希望网络层便于调试,并且能够像沙盒一样使用假数据。为此,我们可以设计一个“假服务器”,它可以像真实服务器一样接受和返回数据,但所有操作都在本地进行,返回的数据为假数据。“假服务器”的好处在于方便调试,特别是在项目早期,服务器功能不完善时,使用“假服务器”可以加快开发速度,减少 bug 数量,许多 bug 可以在本地被及时发现。
5. 利用协程
通过协程,我们可以采用与传统异步方式不同的编程模式,甚至在代码中难以看出是异步操作。作者第一次接触协程是在使用 Lua(Lua 5.1)时,当时非常兴奋并重新编写了框架,将协程封装起来供使用者调用。但后来发现 Lua 对协程的支持存在问题,当在 Lua 的 C 函数中调用 yield 后,无法再调用 resume(原因是 C 函数的调用栈帧已被释放)。不过,现在的 Lua 版本已经有了较好的支持。以下是使用回调和协程实现异步操作的区别:
回调方式:
player.login(function()
print("I'm login")
end)
协程方式:
player.login()
print("I'm login")
在上述示例中,当调用 player.login() 时,网络层以非阻塞方式完成发包。协程实际上是对回调进行了包装,当接收到数据包并调用回调函数时,会恢复到 yield 处继续执行,就像没有发生等待一样。作者建议尽量将协程隐藏在底层,例如网络层,这样可以使代码更加自然优雅。
6. 游戏世界的高层同步
此概念源自《游戏编程精粹 7》,有兴趣深入了解的同学可以自行查阅相关资料。其基本思想是通过配置元数据来实现某些玩家属性的自动同步,从而简化逻辑。以下是一个英雄强化协议的示例:
message TrainHeroRequest {
required int32 heroId = 1;
}
message TrainHeroResponse {
enum ErrCode {
Success = 0;
NoItem = 1;
NotEnoughMoney = 2;
NoEmptyTrainRoom = 3;
Fail = 4;
}
required ErrCode returnCode = 1 [default = Success];
optional GameCoin consume = 2;
}
在上述协议中,服务器会告知客户端实际花费的金额,客户端据此扣除相应金额。在手游中,这种简单的设计是合理的。但在情况较为复杂时,最好采用自动同步的方式,无需专门编写协议通知客户端进行同步。
自动同步的优缺点如下:
- 优点:节省了大量重复的数据同步定义,完善后不易出错。
- 缺点:跟踪较为困难(可通过为每次属性变动添加
reason并自动记录日志来解决),实现自动同步需要环境支持服务器推送消息。许多手游基于 HTTP 协议,实现这种同步推送不太方便,需要另外建立长连接。此外,开发一套同步代码也需要花费一定的精力。