« 程序员一年究竟能有多少代码产量? | 返回首页 | 基于TCP数据流的压缩 »

貌似合理的网络包协议

最近有个小项目,很快的开始,似乎也能很快的完工。就一个不大不小的游戏,2d 的,图象引擎是成熟的,然后我就这这段日子对 lua 的热情,用 lua 把原来写的 C++ 图象引擎做了个封装。用起来感觉良好,UI 部分封装的也不错。游戏逻辑用 lua 驱动貌似很方便。一度幻想着哪天把它开源出去,没准可以成为 lua 开发 2d 游戏的准标准。想想可能性不大,纯当意淫了。

小项目比较能锻炼队伍,所以我做为基本封装就跟新同事上课了。看着别人做程序心里痒痒的,做不出来急急的。恨不得什么都自己代劳。

其中一个同事的工作安排是网络协议的封装,争取用 lua 封装的好用一些。

老实说,我对网络编程的实战经验非常有限,总共不超过 1 万行代码。写的可以运行的可以称之为程序的东西不超过 5 个。其中一部分是基于 UDP 协议的,而这次这个小项目打算用 TCP 协议。我不禁想起大话的通讯协议,首先一个字节的 type 和接下来两个字节的数据包长度信息。

当初,我对这个协议颇有微辞。因为 tcp 是一个流协议,如果统一用数据包长度来分包,再由 type 来 dispatch 逻辑的话,最合理的应该是先把长度信息放在前面,让最底层的代码把数据流分割成数据包,然后就可以把整个数据包交给网络无关的层次去处理。

这次设计协议的时候,希望可以用 lua 脚本来描述每个协议,用 C++ 代码将网络数据包解析成 lua 的一个 table,这样,lua 就可以很方便的处理网络传送过来的数据了。

非阻塞模式下,socket api 处理 TCP 协议,只有在读单个字节时才能看成一个原子操作。这样当我们的数据包长度大于一个字节时,都不能默认它是一次可以读到的。所以,处理网络数据流的过程一般用一个状态机来实现。这个状态机可以实现的很简单,长度+类型+内容这种格式,我们可以看成状态机的三个状态。 程序的主逻辑里,我们只需要不停的调用状态机的处理过程,就可以完成对网络数据的处理。

我按这个思路设计程序的时候却发现,长度完全是个纯粹的冗余信息。因为,如果我们用脚本定义好了每种协议的格式,那么长度已经可以根据协议的 type 推导出来。大部分的协议格式是由定长的元素构成,最终它本身也是定长的。有些元素是不定长的,例如 string 和 array 。那么整个的长度还是可以根据上下文获得。至于处理后一种,无非是增加了状态机处理程序的一点点难度而已。

这样,我不再在数据包头放上包的长度。

Comments

Isn't that causing too many system calls?

楼下的错误在于,即使你加了长度,对于判断数据是否正确也没有任何帮助.

偶然发现这篇文章,有一点不明的是,如果封包不带长度,那服务端遇到伪造的错误封包该如何处理数据流呢?一路错下去直到异常为止?

每个消息应该有个长度信息来定界,不然的话,服务端更新,给客户端发了一些未知type的消息,客户端就蒙了,或者客户端给服务端发一些未知type的消息,把服务端整蒙

支持加长度,同时支持加在最前面.原因:简单,分层清晰.

理念不一样,云风也只是想尝试一下而已.再说了,lua对于C++代码而言,只是个超级纯虚的对象,将内部的转换封装到一个异步stream上,没有多大的复杂度.不过不太符合习惯。不爽.而且可能对lua的编写习惯可能有要求.

详细信息详细信息显现

同意云风的观点,记得以前我在看大话客户端的lua源代码的时候也发现,大部分的协议都不需要长度这个值,只有在判断新旧版本的协议时用长度来判断。用脚本驱动时完全可以忽略长度,只要脚本收到的是type和Data就足够了,剩下的是lua的事情。不过在处理含有多个不定长的string时会麻烦些。感觉讨论有点失焦了,不是一回事。

我要用多核芯片(16-core),在linux系统下实现ssl vpn,不知大家有和见解,谢谢!

不知道cloud说的把lenght放在type之后,从实现上来说,有什么不好的地方,仅仅是增加解析代码的工作量?还是使用script起来不方便?从代码的执行效率上来看,又有多大的差异?我不是很清楚你的用什么样的方法得到数据缓冲区的,如果你是用raw socket方法来得到数据缓冲区的话,在其中上自己定义的lenght当然是冗余了。

如何充分发挥多核CPU的作用也是挺麻烦的, 我曾经考虑过服务器的并发结构, 但结果都不太理想.

要增强服务器的性能,我想主要是两条思路,一是分布式,二是并行计算.分布式是让多个CPU完成多个任务,并行计算是让多个CPU完成同一个任务.

从分布式的思路出发,我们可以把服务器上的各种应用分置到不同线程,例如通讯,广播,怪物,聊天交友等,这种方式比较容易实现,但调整的余地比较小.我曾设想是否可以把聊天交友等都做成服务,放到别的机器,整个服务器是基于服务构建的.

从并发的思路出发,...,考虑过一些方法都不太完善,就不讲了.

很希望开个专题讨论一下
(我没有接触过大型服务器的设计,加上理论基础薄弱,所以对并发的很多东西完全不明白,我了解过OpenMP和MPI编程,但是好象行不通)

把数据流拆分成不同类型的数据也是可以放在底层做的,它们也和逻辑无关。如果网络消息都是靠脚本解析,这个就更加重要。因为脚本一般在无意义的字节流处理上比较弱。

我的理解网络层只负责通讯,另做一个命令解释器负责命令的分派.这个命令解释器可以支持Filter的模式,方便游戏应用的扩展.

版本兼容是一个问题,并不是每个应用都需要兼容老版本的协议。而且也并不一定需要前导长度码做消息切分。而且长度信息本身还有版本问题,例如到底是用一个字节,两个字节还是更多。

为了兼容新的版本还有别的方法,比如可以用如下方法:

定义一个 type 是扩展用,然后跟一个扩展版本号,一个长度信息。用于老 client 跳过下个数据包。

根据各种不同的应用当然有不同的解决方案。我的意思并不是长度信息在任何时候都是无意义的,只是它不应该是一个标准,在所有场合都有存在的价值。

虽然tcp是流式的,但是实际应用中的模式绝大部分都是消息式的,所以消息切分这个能力应该是必须提供的。不引入前导长度码做消息切分的话,遇到消息版本兼容问题就无从解决了。

什么是方便? 当程序能够正确工作,并且可以被封装,无须关心其实现的话,对使用人来说同样方便。如果真的是必要的,就应该有一个统一的协议。或者 TCP 协议本身就这样定义。

ps. 这里讨论的问题跟recv的返回值无关。这个值对于逻辑层毫无意义。这里要提一下,我见过某通讯领域的一个项目居然把 recv 的返回值作为包的长度,还定义成服务接口供第三方使用。当因为某种原因本该在一次 recv 到的数据被分成了多次接收到时,服务器会认为其是错误信息被丢弃掉。最寒的是,当我们找过去要求他们修改bug时,答复是,TCP 协议定义的有问题 (._.!)

长度是需要的
recv获得物理包的长度虽然是知道的 但是逻辑上的长度是和物理包长度概念不同的, 逻辑长度不但可以让你确定你的包实际长度(也就是做的长度准确性校验)而且比起去掉这个长度省这两个字节方便了许多,省这辆个字节是没有必要的.

"等待数据完整,数据CRC检查,解压缩,解密等等" 只是一个状态机,可以由底层实现,不需要放在逻辑里。

ps. crc 校验不应该由这一层完成。

长度是绝对需要的,呵呵。否则就缺少了网络底层这一说法。你的游戏处理逻辑还要做等待数据完整,数据CRC检查,解压缩,解密等等。。不麻烦么?

好热闹哦。
类型的结构本身就包含了长度信息,长度是不需要的。关于大家讨论的焦点是在抽象,设计概念上。谈谈我的想法:
1。tcp保证了网络层,实际上我们做的东西都是应用层。
2。关于设计概念,把数据流整理成数据元素,这就够了。对于我们做的应用来说,这个就是底层,我觉得很合适。

关于冗余,很多情况设计上留有冗余是为了效率。如果要到极端效率的方法,最好是client到server保留这个冗余,server到client去掉这个冗余。毕竟有个确定长度的过程,无论是有长度还是没长度,有长度会在封包的时候确定这个长度然后加进去,没长度会在接包的时候去查这个长度。
这样设计会觉得很别扭,但我觉得有他的合理性,如果单单从效率上来看。

我觉得对象的解析放在应用层做反而味道不好,因为这和逻辑无关。只要固定了支持的数据类型,怎么改协议都不用再改网络层的代码,应用层的复杂度也降低了。

to Cloud:
恩, 我把其当作 C Struct 看是我错了. 但你的做法网络层还是违背了Open-Close 原则, 味道不好了.

to KxjIron:
string 是一种类型, 就像 array , union 一样。我们不能用 C 中静态的 struct 的观念来看这个问题。变长于否并不增加网络处理层对这些对象类型的处理难度。就好象 ItemInfoStruct 中要增加一个 string CreatorName 在描述 ItemInfoStruct 的定义中增加一行就够了。

to zlong:
处理 type 和 length 时,无论谁在前,针对特定的定义,编码的难度都不大。但是 type 在前会让设计变的糟糕。如果每个包都有 length 的话,如果我把 length 信息放在最前,就可以在接到网络包的时候不必知道包是怎么被分发的。dispatch 的过程就可以被提出来。

偶然看到这篇讨论
大话是怎么处理消息的我不清楚,对于
——————————
大话的封包处理代码因为 type 在 length 之前无谓的增加了处理复杂度;
——————————
我却不这么认为,除非是大话的处理方式本身不合适。
只需要定义一个消息头结构:
struct MSG_HEAD {
unsigned short usType;
unsigned short usSize;
};
反正接收到的消息如果小于sizeof(MSG_HEAD)就可以肯定这个消息包一定还没接收完整;
否则就可以根据usSize判断是否收到完整的一个消息包并提交处理
根本无所谓type在前在后的

ok,仔细看了并想了一下,IP协议中已经有长度信息,UDP里再加一个长度信息,确实是冗余,这一点,云风是对的,是我看得不够认真。关于udp的这个冗余长度,我现在的想法:可能确实是udp本身使用起来比较方便吧。

我明白了你们的意思, 问题出在协议层上, 在我的设计里协议层是放在应用层的, 是被淡化掉的, 而不是放在网络层.
网络层并不负责对具体游戏逻辑的包结构进行解析. 如果按照分布式方法做的话, 网络层只负责从网络接收消息包, 根据 len/type, 转发到对应的管道中. 游戏逻辑层被管道消息驱动, 在这里进行解包并处理. 发送同理, 应用层会合成了 len/type 发送到相应的管道, 如果是到网络层, 网络层会负责转发出去. 这是一个淡化掉协议描述的方式, 只要服务器 msg 结构与客户端一致即可, 无需变动你们所说的协议层. 协议设计就是 msg 结构设计.
------
to Atry:
应用层并没有依赖协议层, 或者说游戏协议层并没有和网络层耦合.

我举个具体的例子ItemInfoStruct, 它是可变长的, 起初只有物品名称是变长的,其后策划又想加入一个变长的锻造者名称, 难道又要去动网络层吗?

其实序列化成对象可以不需要长度信息的。因为对象信息本身就包含了长度信息,除非是字符串。MFC包装的用来序列化的CSocket类就没有长度信息。
从设计上说,加入长度信息的优点在于可以把分割封包和协议解析这两个步骤分开。
但这其实必要不大,因为,不比切了封包再整个交给协议层,而可以让协议层直接根据对象信息来读取。

而不论要不要先切割封包,应用层要依赖协议层这都是必然的,并不会说不切封包就增大了耦合。

用asn.1编码不是挺好的吗,它本质上也是一个tag/length/value结构,应该比C的plain结构体更零活吧。

路过,发现大家讨论的比较热烈。傻傻的问一句云风,你们打算实现的东西是否可以用asn.1编码实现?asn编码也是一种tag/length/value结构,可以让应用层开发人员聚焦在协议接口上,而不用担心内部结构的变更,似乎这跟你的意图是一致的吧

这不是网络层的和应用层耦合的问题,我认为把网络包里的字节流解析成可以直接用的数据类型可以放在网络层做而非应用层。这一步做到了,包的长度信息自然就成了冗余信息。

我与 analyst, wonna 的观点一致, 这是一个设计概念的问题, 网络层的和应用层的耦合,将来的功能扩展会让你头痛不已。特别在多人合作的环境下,写码更重要的是概念抽象,我指的写码的难度不是个人写码,而是他人写码需要付出额外的思考, 以功能设计为抽象模型, 是我最深恶痛绝的。

udp 包头的长度信息是个冗余信息,这是我的观点。如果因为我说的不是权威的话,可以去查 TCP/IP Vol.1 上面也是这么说的。

至于udp 协议的校验,用校验码就够了。

接着讨论,呵呵。

to 云风:

udp的首部含有udp的长度信息,基于udp的应用层协议设计,确实可以自己不带长度信息,这是因为udp是“数据报”协议(注意这个“报”字),呵呵,也就是说,udp每次传送的都是发送方的一个完整数据包,要么不传送,要传送就传送一个完整的数据包,它不象tcp,tcp是流协议,它一次可能只传一字节,也可能把整个完整的数据包都传完,甚至可能将若干个数据包合在一起传送,但udp不会发生这种情况,它只会传“一个”且是“完整”的包。呵呵。既然传送的是完整的包,那我通过recvfrom时,这个函数本身就会告诉我一个当前udp包的长度这样一个信息,所以基于udp的应用层协议设计完全可以不要长度信息,我是指在应用层不要,而不是udp本身不要。udp首部的长度信息是必须的,这关系到udp包的传送逻辑控制,比如:作丢弃、校验码等判断时都可能会用到这个udp的长度信息。

to wonna : tcp/ip 那几本经典 我自然看过,你误解了我的意思,我的意思是使用 udp 协议通讯时不需要再自己附加长度信息上去。至于 udp 协议为什么要加上冗余的长度信息,我的理解是为了某种实现上的便利。比如用页面切换的方式把待发的数据包加上 ip 头。而且因为对齐的关系,处理的数据不按 4 字节对齐也不太好。

大话的封包处理代码因为 type 在 length 之前无谓的增加了处理复杂度;而 ip 协议本身长度正是在第一字节的:4bit 的版本和4bit的首部长度。有这个字节就可以确定 ip 包头的长度,然后 ip 包本身的长度放在哪里就无关紧要了。

to analyst, 我用格式文件只是为了说明问题,跟你说的 metadata 一回事。都已经用脚本了,没有人会傻到另外创造一套格式描述语言的,我用的 lua 自然直接去利用 lua table 的 key/value来解析最简单。

同样我也不认为取消包长度信息用metadata 取代增加了多少设计的复杂度。无非就是用 type 去对应一个 client/server 共同认同的结构,从结构的 metadata 来知道下面该如何解析数据,同样是一个结构处理完毕后才分发到应用层。

我的基本观点,与analyst相似:网络层就是负责处理网络层的东西,而数据包的具体解析交给应用层去自己作。通常可见的情况是:网络层根据包长度字段取出一个个完整的数据包,将这个完整的数据包丢给上层的网络包处理逻辑。

当然,不加长度字段,也可以取出一个个完整的包,但我个人认为这样就远没有加个长度字段来得方便了。

事实上我们从来都是把长度放在type之前的,长度属于网络层,而type属于协议解析层,网络层完全不需要知道type。至于有没有长度就是关系到一个实现复杂度的问题了,原本只要在网络层处理一次的异步操作,现在要在解析层里处理N次,而且找不到更好的理由去掉长度字段,却无端的增加了复杂性。
用格式描述文件来解析协议还是比较原始的,更好的方案是用程序的metadata信息来自动解析,这样代码里的类型定义就是协议,不需要再另外写格式文件了。

引用云风的话:
--------------------------------
如果只是简单的把流变成包,那么加入长度是可以简化设计的。但这个时候就没有必要把长度放在 type 的后面了,而应该每个包最前面就放在长度信息,这也是我对大话的封包设计最为不屑的地方。
--------------------------------

针对于以上云风的观点,我表示赞同,在应用层协议的设计中,长度信息确实经常是作为第一出现的信息的。但,如何来说呢,大话这样设计你觉得不爽,我想这更多的只能算是你的个人感受吧,无所谓合理不合理的。在IP协议的首部信息中,它的第一部分内容也不是长度信息,而是IP协议的版本号信息,随之才是长度信息。所以,这里争执的问题,我觉得更多的是个人感受,无所谓合理不合理,我认为两者都可接受,都合理。也所以,针对这样的讨论,我一般是不会持续争下去的,因为我认为两者都可接受,呵呵。

就这个问题继续与云风讨论,呵呵:
---------------
udp 协议自然不用自己再放长度信息,ip包头上就有。
---------------
请查一下TCP/IP详解第一卷第一扉页,上面有IP、UDP、TCP的首部示意露台,上面明明白白写着32~47共16位为UDP长度。

至于你说的在脚本中,不用长度可能更为方便,这一点,我没有作过脚本,所以可能没有什么根据来说这样好不好。但如果仅从协议设计本身来说,应用层确实是一般要自带包长度的,我指TCP协议,因为它是“流”协议。

数据结构的版本问题跟要不要统一的长度信息没什么关系。如果是为了版本间容错,那么可以把结构描述本身在握手时传过去,或者为经常更新的结构做容错处理。

扯淡,数据结构版本更新了怎么办

如果只是简单的把流变成包,那么加入长度是可以简化设计的。但这个时候就没有必要把长度放在 type 的后面了,而应该每个包最前面就放在长度信息,这也是我对大话的封包设计最为不屑的地方。

而现在我们项目是用 lua 来处理网络包。取到 type 后自然就可以推导出包的长度。所谓的解析, 只是从简单的划分成一个个包进一步转换成把数据流到逐个数据元素的对应,这一步也很适合底层来做。

例如给 type login 一个格式描述,

pakeage login {
string username;
string password;
};

我们只需要在接到网络包时来解析这个格式描述文件就够了。

脚本层的 dispatch 函数 do_login (pack) 里可以用 pack.username 和 pack.password 来获得数据去处理。这个函数依然是到获取了完整的数据后才处理的。

至于封包正确性的校验同样可以去做。

没有长度当然也是可以的。但是这其中就出现一个问题,原本数据包接收和解析是两个层次的问题,底层网络模块接受一个完整数据包然后交给上层解析就可以了。但是现在你把网络模块退化成了一个异步stream接口,对解析层来说必须要面对异步IO这种非常不友好的接口,增加了解析层的实现负担,如果你的解析层是要用户手工写的话,那么你这个设计就是一个十分糟糕的设计。
至于从性能上考虑,2个字节完全是可以忽略不计,如果真的很计较这2个字节,那么用协议压缩要远好过省一个长度字段。

加入長度是合理的, 這樣網絡包就存在了自描述特性, 而不需要根據上層的程序根據type類型去解釋了.同時, 增加長度字端還有助於判斷包的正確性, 加快數據包的解析速度

我以前也有过包装socket,我感觉tcp协议是一个流协议,逻辑上不用看成一个个的包,而是序列化成对象来做比较好,包头信息就是对象信息

udp 协议自然不用自己再放长度信息,ip包头上就有。

用 C 去处理 TCP 的数据流的时候,大多数情况放长度信息是合理的,程序会相对简洁。但是如果有脚本或模板的描述后,这个东西就没有需要了。比如用 lua ,应用层肯定希望数据包交到应用层后是一个 lua 的 table ,把网络包里的数据元素都转换成相应的类型;而不是只提供逐字节读取 buffer 中数据的 api 。这种情况下,长度信息在解析过程中就是不必要的了。

至于稍微增加的编码难度,应该是写代码的人的感觉吧。比如对一个有序数组检索,有人认为2分查找比循序查找难写,如果数组不大,觉得 2 分查找也没啥大的优势,一律都改成 for 循环了;其实,一个 while 循环加上 if else if 又能复杂到哪去呢?

恨不得什么都自己代劳。
------------------------
作为一个项目管理者,这种想法是非常要不得的,他应该努力培养自己的团队成员去独立完成。切切。

这样,我不再在数据包头放上包的长度。
----------------------------
之所以IP协议、TCP协议以及UDP协议,它们的头部信息中都包含相应的长度信息,最主要的目的还是为了实现消息分层机制。如果你不放长度了,那也就是说你的网络底层是与你的上层逻辑绑定的了(我个人认为“消息类型”这个层次已经是应用层而不是网络层了)。


为了省2字节, 增加写码的难度, 有必要吗?

Post a comment

非这个主题相关的留言请到:留言本