最近的工作中用到了WebSocket协议,研究了下它的数据帧格式,发现其payload length的表示方式很适合用来优化以前用到的网络库。
简单来说就是把固定4字节表示包大小的方式,改为用可变字节数表示包大小,大部分包的大小可以用1个字节表示,可表示的大小范围为0 ~ 253字节,稍微大点的包用1+2字节表示,大小范围为0 ~ 64KB,再大的包才用1+3字节表示,大小范围为0 ~ 16MB。对于游戏来说,16MB的包大小足够用了。如果不够,也是有对策的。
先来看下WebSocket的数据帧格式。
WebSocket数据帧格式
RFC 6455 给出了 WebSocket 协议的详细规范,其中第5.1节说到,在WebSocket协议中,数据是使用一系列frame来传输的,随后5.2节详细介绍了frame的格式:
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-------+-+-------------+-------------------------------+ |F|R|R|R| opcode|M| Payload len | Extended payload length | |I|S|S|S| (4) |A| (7) | (16/64) | |N|V|V|V| |S| | (if payload len==126/127) | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + | Extended payload length continued, if payload len == 127 | + - - - - - - - - - - - - - - - +-------------------------------+ | |Masking-key, if MASK set to 1 | +-------------------------------+-------------------------------+ | Masking-key (continued) | Payload Data | +-------------------------------- - - - - - - - - - - - - - - - + : Payload Data continued ... : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued ... | +---------------------------------------------------------------+
我们只关注payload部分,RFC中写的比较清楚了,这里再复述一遍:
WebSocket数据帧中所承载的上层数据(payload data)的长度由payload len表示,payload len是不定长的,可能是7bits,也可能扩展到7 + 16 bits,也可能扩展到7 + 64 bits。
如上图,7 bits的payload len部分,可取值范围为0 ~ 127,其中:
- 0 ~ 125:表示payload len只占7 bits,此时其表示的数值就是payload data的长度。
- 126:126不是payload data的长度,而表示payload len随后的2字节( 16 bits )所表示的无符号整数,才是payload data的长度,范围0 ~ 64KB。此时,整个payload len占用7 + 16 bits。
- 127:同126,127也不是payload data的长度,而表示payload len随后的8字节(64 bits)表示的无符号整数,才是payload data的长度。但最高位必须是0(没有深究其原因),那么此时可表示的payload data的长度为0 ~ 2 ^ 63,基本可以认为是无限大了。整个payload len占用7 + 64 bits。
可以看到,当payload data比较小时(125字节以内),WebSocket只需要7个bit表示其长度。而7 + 64 bits可表示的数据长度就是天文数字了。
个人猜测是因为WebSocket是通用协议,所以尽可能不给单个数据帧能包含的数据长度设限。不过我们自己用的协议是私有协议,有明确的使用场景,可以根据自己的需求来修改具体的数值。
WebSocket和IncNet都是构建于TCP之上,都属于OSI模型的第七层(应用层)协议,两者是对等的,因此我们可以粗略地将WebSocket的frame和IncNet的packet划等号,只是叫法不一样而已。
现在再来看看IncNet中packet的格式:
IncNet的Packet格式
在《 拆解IncServer网络库 》一文中已经详细介绍过IncNet中packet的格式了,为了文章完整性,搬运过来:
Packet格式
IncNet的server模块和client模块之间的通信协议是以packet为单位的,上层逻辑可以定义不同种类的packet,每种packet的内容格式各不相同,收发packet的两端都按照定好的协议格式读写数据。
上图为一个packet的格式,先是4字节的packet大小,即整个packet(包括packet_size自身)的字节数,之后是两字节的packet_type(包类型),最后是packet的实际内容,其长度和格式因packet_type而异,由上层逻辑自行定义。
高频包优化
从packet的格式可以看出,每个packet的头部实际占用了6个字节之多。为了优化类似于位置同步、战斗相关逻辑收发的高频包,IncServer采取了一种连包优化策略,以减少packet的头部所浪费的空间。
准确的说这属于上层逻辑的优化,并不属于IncNet的底层机制。
IncServer定义了一种特殊的packet_type(ST_SNAPSHOT),其内容又是n个packet,只不过这些packet的packet_type只占用一个字节,而且不再有packet_size部分,从而减少了包头所占用的空间,如下图:
不过这种做法有一定的限制:
- packet_type只有1字节,因此最多支持256个高频包。
- 打乱了跟其他正常packet之间的顺序。因此时序相关的逻辑要么都用单字节包,要么都用双字节包。
- 因为不再有packet_size,所以前序packet的格式错误会导致后续所有packet的错误。
改进IncNet
可以看到,IncNet使用固定的4字节来表示每个packet的长度,4字节可表示的长度范围为0 ~ 4GB,而大部分游戏协议的数据量也就几十个字节而已,有点浪费。
而为了优化高频包,IncServer中采取的方式虽然消除了packet size所占的4字节,并且将2字节的packet type降低到1字节,但为此付出的代价也是不容忽视的:1. 打乱了与正常packet之间的顺序,2. 单个普通包内的高频包一错皆错。在实际开发过程中,这也确实带来了不便。
还有一点,这是上层逻辑的优化,已经不属于网络库本身了。
所谓的高频包,一般的大小有多少个字节呢?来看下技能协议ST_STATE_SKILL
function broadcast_skill( _obj, _skill_id, _x, _y, _z, _degree, _tx, _ty, _tz, _is_request, _target_id) local ar = g_ar ar:flush_before_send_one_byte( msg.ST_STATE_SKILL ) ar:write_ulong( _obj.ctrl_id_ ) ar:write_uint( _skill_id ) ar:write_pos_angle( _x, _y, _z, _degree ) ar:write_float( _tx ) ar:write_float( _ty ) ar:write_float( _tz ) ar:write_byte( _is_request ) ar:write_ulong( _target_id ) local invalid_ctrl if _is_request == 1 then invalid_ctrl = 0 else invalid_ctrl = _obj.core_ end g_playermng:c_broadcast_one_ar( ar, _obj.core_, invalid_ctrl ) end
数一数write的次数就知道,一共也才41个字节(假设long是4字节,且不考虑写write_pos_angle的内置优化)。
其实packet的大小跟高频不高频没啥必然联系,除了玩家序列化的packet比较大以外,大部分packet的大小其实也就几十最多上百字节。
想想WebSocket的7 bits可以表示多大来着?125字节,这完全足够覆盖绝大部分packet的大小了。因此,完全可以将WebSocket表示payload data大小的思路引入IncNet中。
仿照WebSocket,我们用8 bits即1 byte来表示packet size,不同的取值有不同的含义:
- 0 ~ 253:表示packet size,此时只有1个字节用来表示包大小,可表示范围为0 ~ 253字节。
- 254:表示随后的2个字节用来表示packet size,此时一共有3个字节用来表示包大小,可表示范围为0 ~ 2 ^ 16 = 64KB。
- 255:表示随后的3个字节用来表示packet size,此时一共有4个字节用来表示包大小,可表示范围为0 ~ 2 ^ 24 = 16MB。
然后情况会变成绝大部分packet中,只有1字节用来表示packet size,偶尔大点的包可能用到3字节,能用到4字节的可以说是几乎没有了。
什么?16MB还不够用,那就把253也当成一个特殊值,表示用随后的4个字节来表示packet size,范围0 ~ 4GB。你的数据都超过16MB了,多一个字节表示packet size也无所谓了吧。
那还要不要区分普通包和高频包呢?
我觉得区别可能不是很大。如果还是觉得用两字节表示高频包的packet类型太浪费了,想用一个字节,也有办法:
从表示packet size的第一个字节里拿出一个bit,为0表示这个包不是高频包,说明后续用了一个字节表示packet type,为1则表示这个包是高频包,后续用了两个字节表示packet type。
这样做的代价就是,单字节可表示的packet size范围少了一半,变成0 ~ 125了。如果不想这样,那就用一个新增的bit表示是否高频包。
这样做的优缺点
优点:
- 属于底层优化,业务层只管用即可,所有packet都受益。
- 普通包和高频包一视同仁,不再有时序上的问题。
- 某一个高频包出错,不会再导致后面的包也出错。
缺点:
- 对于高频包的优化力度没有之前的方式大。之前每个高频包只有额外的1字节表示packet type,改进之后还需要有至少一个字节表示packet大小。
如果说,缺点无法接受,那么只用WebSocket的思路来优化普通包也是可以的。