在没做游戏之前,我主要的任务就是实现各种基于tcp、udp或者串口的通信协议。当我要设计一套基于tcp的网络游戏协议时,感觉应该很简单,以前各种国际标准的协议都实现过,自定义的协议还不手到擒来。然而事实打完我的脸告诉我,设计协议本身要比实现它难度大得多。
先说说什么是通信协议,两个不能共享数据的进程想要交换数据,需要两个条件:1.发送和接收数据的机制 2.封装和解析数据的规定。第一个条件就是物理层、链路层、网络层、传输层网络协议家族做的事情,第二个条件就是一套应用层通信协议。如果我们使用基于tcp的网络传输数据,那么要做的事情就简单到,只需要考虑逻辑了,因为底层是基于数据流的可靠的连接。最简单的协议是什么?我想,可能就是发送者要传递的信息只有两种状态,那么只需要连接和断开连接就够了,什么都不用发送。然而如果使用tcp的话,连接需要三次握手,断开连接需要四次握手,这个过程显然发送了大量的数据。所以说应用层面最简单的情况,应该是双方使用udp通信,一段时间内发送任意消息和不发送任何消息,两种状态,且不要求状态可靠。
好了,脑洞开完了,说下手游中的网络协议设计。从哪里开始呢?我们只考虑逻辑应用的需求,传输使用tcp协议,而tcp协议是数据流协议。那么第一个问题来了,如何确定数据流的头和尾?这个问题还要分情况讨论:
情况1.发送者和接收者保持tcp长连接,发送速率<=接收速率。那么理论上发送者只要在每个完整的消息开始指定消息长度就可以了,接收者可以正确的解析出每个消息。
情况2.发送者和接收者保持tcp长连接,发送速率>接收速率。这时候接收者不得不丢弃一些来不及处理的数据,可是一旦丢弃那么他再也无法知道消息的头在哪里了,所以每个消息头要有一段标记表示“这是一个消息的开头”。
情况3.发送者和接收者之间是tcp短连接。这意味着发送和接收的频率较低,而且发送者不能主动发送,只能由接收者请求之后发送,如http协议。这种情况下的消息处理可以认为每次发送的都是单独一条消息,理论上是最简单的。
手游里面情况3比较常见,手游有个特点就是手机网络容易丢失,在移动中不断丢失和重建。这直接导致了tcp短连接是不可靠连接,因为断网之后数据包在某一环丢失了,我们却不能像端游那样让客户端回档重新登录,而是让客户端重连重新请求。为了让客户端和服务器都能处理网络故障导致的丢包重发,我在协议层加入了协议序号。规则很简单,客户端序号从1开始,服务器序号从0开始,服务器每收到一条消息则序号+1,也就等于客户端的消息序号,返回消息给客户端,客户端确认消息序号一致。客户端下一条消息序号+1 。
这样如果客户端发送了消息,却没有收到响应,则超时,提示重发消息或放弃。
如果服务器没有收到丢失的消息,客户端重发和第一次发送效果一样,序号正常增长。
如果服务器已经收到消息,返回客户端的消息丢失,则服务器序号已经增加,收到客户端重发的消息序号会和服务器序号相等,服务器返回缓存的上一条消息即可。
如果网络异常,客户端1号消息发过来太慢,然后重发成功,1号消息又到达了服务器,这时候服务器认为消息过期,不会响应这条消息。
客户端要有一个发送队列,重发队列,接收队列。每发送一条消息就把这条消息从发送队列转移到重发队列,每收到一个响应包就去重发队列删除一条消息,加入接收队列。
其他情况都是bug。
现在我们看一下网络通信协议的基本组成:
[消息长度][校验码][协议号][序号][消息体]
事情没有完,这只是个开始。咱们回到游戏,玩家玩游戏的时候是多个玩家对一个服务器,那么明显服务器需要区分哪个消息是属于哪个玩家的,所以在协议号这一层后面需要加上playerId。
[消息长度][校验码][协议号][PlayerId][序号][消息体]
嗯,其实除了玩家,游戏的运营也在参与游戏,他们需要使用GM指令秘籍作弊,成为伪大R玩家。这时候咱们的协议又不够用了,我总不能给每个运营玩家一个特殊的playerid吧(我这里的playerid是动态生成的,玩家每次登录分配而得)。服务器除了和玩家通信,内部服务器进程之间也要通过协议通信。为了区分各种通信目的,我们需要在协议的消息头里面加上通道号。
[消息长度][校验码][通道号][协议号][PlayerId][序号][消息体]
我要解释一下,为什么先说的序号,却放消息头在如此靠后的位置。因为每个玩家都维持一个序号,玩家之间是没有关系的,序号是针对一个客户端对象和一个服务器对象的。现在咱们的消息格式看上去不错了,C/S之间,S/S之间,GM玩家,请求响应顺利,一条龙服务已然成型。不过现实总是要求的更多,考虑这种情况,客户端的一个操作请求导致了服务器上的多个数据更新,这时候我要在响应协议里加上所有变更的数据。如果类似的操作很多,就导致响应消息很复杂,服务器和客户端代码冗余易错,有的数据甚至不能加入到响应消息里面,客户端必须再请求另一条消息才行。比如,80%的操作可能导致任务完成或者成就完成,我总不能在这么多消息中都加上任务和成就的同步数据,如果没有完成,就变成了冗余数据。种种迹象表明,一问一答式的通信模型不能胜任复杂的消息同步。
但是上面的分析都是克服了很多历史问题得到的战果,总不能推倒重来,再次解决这些问题吧。机智如我,想到了一个两全的方案:增加一套协议号,作为服务器响应消息的子协议号,子协议可以*拼接。
[消息长度][校验码][通道号][协议号][PlayerId][序号][子协议号][子协议长度][子协议体][子协议号][子协议长度][子协议体]...
这样以来,通信层面的问题通过协议头的外层解决,逻辑层面的问题通过各个子协议解决。哈哈,我真是太机智了。
然而事情远未结束,客户端使用C#语言,服务器使用C++语言,内存布局不一样,对齐方式不一样,或许由于设备不同还会有大小端不一样的问题。为了解决实现层面的问题,也是经历了一波三折。时间关系我就不一一展开说了。客户端从一开始的同步接口改成异步接口,增加了超时重连,解决了断包拼接,手动解析改成了自动解析。服务器从主动关闭连接,到客户端主动关闭连接,解决了静态发送缓存bug,从主线程处理消息改成IOCP线程池直接处理消息,一问一答的通信模型改成一对多模型。最后,客户端和服务器都做了协议加密。可谓是一把屎一把尿做出来的系统啊。