最近的工作中用到了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的思路来优化普通包也是可以的。