网络游戏协议以及分发模块的设计

时间:2022-06-01 21:22:56

应用层消息的格式

无论是做何种网络应用,必须要解决的问题之一就是应用层从字节流中拆分出消息的问题,也就是对于 TCP 这种字节流协议,接收方应用层能够从字节流中识别发送方传输的消息。解决此问题有几种方法:

  1. 使用特殊字符或者字符串作为消息的边界,应用层解析收到的字节流时,遇见此字符或者字符串则认为收到一个完整的消息
  2. 为每个消息定义一个长度,应用层收到指定长度的字节流则认为收到了一个完整的消息
  3. 对于短连接,发送方发送完一个消息后就关闭连接

方法二比较简单,也比较常用,完全能够满足我们的需要。那么我们的消息格式看起来如下:

 
 
  1. --------------
  2. | len | data |
  3. --------------

在游戏开发中,我们常说的“分布式游戏服务器”(游戏逻辑分散在不同的服务中,各个服务利用 Socket 进行通信)主要是为了区分最传统的服务器架构,即“单服架构”(几乎所有游戏逻辑均被放置在一台服务器中)。在分布式游戏服务器架构中,消息可能在游戏服务器的一组服务中传递,消息的具体传递方式由服务器的具体架构决定,但无论何种架构,每个消息都必须明确将被发送到的目的服务。

 
 
  1. client <---> server A <---> server B

client 发送的消息,可能被发送到 server A 处理也可能发送到 server B 处理。确定消息目的服务有多种做法,有一些做法要么依赖于特定架构,要么实现和维护过于繁琐。这里提供一个比较优雅的实现方式,不依赖于特定的服务器架构,实现也非常简单:

  1. 在消息中增加消息目的服务标识,表明消息的目的地
  2. 每个服务均存在一个路由例程,用于根据消息的目的地进行消息的路由

那么现在消息格式看起来应该是这样的:

 
 
  1. ----------------------------
  2. | len | destination | data |
  3. ----------------------------

特定服务收到消息之后,需要进行消息的分发,将消息分发到特定的处理函数上去。为了分发消息,消息必须存在一个服务器全局唯一的标识,通常来说我们会使用消息 ID 来标识一个消息,当然有一些应用使用消息的名字来标识消息,不过针对网络游戏来说,选用整形 ID 来标识一个消息是一个不坏的选择:

 
 
  1. -----------------------------------------
  2. | len | destination | message_id | data |
  3. -----------------------------------------

这就是一个最简单的消息格式了。现在从更抽象的层次来讨论消息格式的定义问题,一个消息被两部分人关心,一个是框架开发者,一个是逻辑开发者,框架开发者需要实现消息的发送和路由而逻辑开发者则主要关心消息的具体内容,因此我将消息定义为三个组成部分:消息分隔标识(separator)、消息头(header)、消息体(body)

 
 
  1. -----------------------------------------------
  2. | separator | header | body |
  3. -----------------------------------------------
  4. | len | destination | message_id | data |
  5. -----------------------------------------------

消息定义与模块职责:

  1. 网络模块负责接收和发送字节流,它完全不关心发送和接收的字节流的具体含义(甚至于消息头都不关心)。它的责任是实现网络通讯。网络模块会在发送消息的时候设定消息分隔标识(separator)并在接收消息的时候通过消息分隔标识在字节流中获取到一个完整的消息。
  2. 一旦一个完整的消息被获取,网络模块就会把消息交付给分发模块,分发模块关心消息头(header),它从消息头中获取信息,并最终将消息送至对应的消息处理函数处理。在消息发送时,分发模块有责任填充消息头并投递消息到网络模块。
  3. 消息被正确分发的结果是消息处理函数被调用,这时候,消息体(也就是逻辑数据)被逻辑模块使用。逻辑模块需要发消息时,总是先构建好一个消息体并将其传递给消息分发模块。

前面说到的 destination、message_id 组合为 header 只是一种最简单的形式,实际上针对于特定的场合,为了分发消息,常常需要在 header 中添加增加其他的字段,例如:玩家 ID(被用于将消息被分发给特定玩家)。这样消息头需要能够扩展,这里给出了一种消息格式:

 
 
  1. ---------------------------------------------------------------------------------------
  2. | separator | header | body |
  3. ---------------------------------------------------------------------------------------
  4. | len | header len | destination | message_id | optional header data ... | data |
  5. ---------------------------------------------------------------------------------------

这只是一种做法,在满足消息头能够扩展的前提下,可按实际情况设计。

消息的权限

我们在消息头使用 destination 标识分发的目的地时,我们可能面临一个问题:
假定 server A 和 server B 间会使用一个消息 X 通讯且消息 X 仅在 server A 和 server B 之间使用,这时候如果 client 伪造了消息 X,server A 必须辨别并丢弃此消息。换而言之,我们必须检测消息是否可以接收。

结构化数据的编码

对于消息中的 data 来说(一般 header 也是),它是通过编码的结构化数据。而这个过程通常不会是想象中那么简单,常见的做法有:

  1. data 作为一个结构体对象的内存数据,这样做的优点是简单,也存在一些问题,例如:结构体内存布局的问题,endianness 的问题,安全的问题(如 SQL 注入)。如果不存在这些问题(例如 endianness 的问题常常在不同平台下才会出现),那此方法也不为一种可选的解决方案,但是一旦出现这些问题,那么通常很难把问题封装在底层解决,而必须暴露给逻辑程序员,要求他们在每个消息处理的逻辑上格外小心。
    直接使用结构体内存数据的另外一个问题就是很难很好的支持变长消息,这可以很好理解。
  2. 使用第三方的库,例如:protobuf,它能够把问题都解决并且提供一套简单的接口。

当然有一些游戏选择自己实现一套结构化数据编码和解码的方案,也是一种选择。

实现也很重要

由于消息的定义、处理是整个网络游戏最基础的工作之一,因此一旦在实现上稍不注意就会给上层逻辑开发者带来诸多的痛苦,在实现的时候务必保证接口尽可能的保证简洁,如果使用了 protobuf 这样的库还可以使用其反射等功能进一步简化接口。