OneAPM 游戏引擎网络开发者的 64 做与不做 (二 A):协议与 API

OneAPM官方技术博客 · 2015年08月06日 · 636 次阅读

【编者按】在这个系列之前的文章 “游戏引擎网络开发者的 64 做与不做(一):客户端方面” 中,Sergey 介绍了游戏引擎添加网络支持时在客户端方面的注意点。本文,Sergey 则将结合实战,讲述协议与 API 上的注意点。

以下为译文

这篇博文将继续讲述关于为游戏引擎实现网络支持,当然这里同样会分析除下基于浏览器游戏以外的所有类型及平台。

作为系列的第一篇文章,这里将着重讨论不涉及协议的客户端应用程序网络开发。本系列文章包括:

  • Protocols and APIs
  • Protocols and APIs (continued)
  • Server-Side (Store-Process-and-Forward Architecture)
  • Server-Side (deployment, optimizations, and testing)
  • Great TCP-vs-UDP Debate
  • UDP
  • TCP
  • Security (TLS/SSL)
  • ……

##8a. 定制 Marshalling:请使用 “simple streaming” API

DIY marshalling 可以通过多种方式实现。一个简单且高效的方法是提供 “simple streaming” compose/parse 函数,例如 OutputMessage& compose_uint16(OutputMessage&, uint16_t) /uint16_t parse_uint16(Parser&) ——针对所有需要在网络上传输的数据类型。在这种情况下,OutputMessage 是一个类/结构,封装了一个消息的概念,在添加其他属性后就会增长,而 Parser 是通过一个输入消息创建的对象,它有一个指向输入消息的指针和一个针对当下解析发生地的偏移量。

Compose 和 parse 之间的不对称(Compose 是直接针对消息的,而 parse 需要创建分离的 Parser 对象)不是完全强制的,但是在实践中却是一个非常好的事情(特别是,其允许在消息中存储解析的内容,允许重复解析,对消息的解析形式不变等等)。通常来说,这个简单的方法同样适用于大规模环境,但是在游戏上却需要更多的努力来保持 composer 和 parser 之间的信息一致性。

一个 composing 可能像下面这样:

uint16_t abc, def;//initialized with some meaningful values
OutputMessage msg;
msg.compose_uint16(abc).compose_uint16(def);

对应的 parsing 的例子是这样:

InputMessage& msg;//initialized with a valid incoming message
Parser parser(msg);
uint16_t abc = parser.parse_uint16();
uint16_t def = parser.parse_uint16();

这种 “simple streaming” compose/parse API(以及基于它建立,例如下面讲的 IDL,和不同于 compose/parse API 基于明确的大小来处理的功能)的一个优点是使用什么格式并不重要——固定大小或者可变大小(即编码如 VLQ 和空值终止字符串编码是完全可行的)。另一方面,它的性能无与伦比(即使调用者提前确定消息的大小,它还有利于添加类似 void reserve(OutputMessage&,size_t max_sz);这样的功能)。

##8b. 定制 Marshalling:提供一些带有 IDL-to-code 编译器的 IDL

对于 compose/parse 一个简单提升是用某种声明的方式来描述消息(某种接口定义语言——IDL)并将它编译成 compose_uint16()/parse_uint16() 的序列。例子中,这种声明看起来像是一个 XML 声明。

<struct name=“XYZ“> <field name=“abc“ type=“uint16“ /> <field
    name=“def“ type=“uint16“ /> </struct> <message name=“ZZZ“>
    <field name=“abc“ type=“uint16“ /> <field name=“zzz“   type=“XYZ“
    /> </message>

之后则需要提供一个编译器,它读取上面的声明并产生类似下面的东西:

struct idl_struct_XYZ {
  uint16_t abc;
  uint16_t def;

  void compose(OutputMessage& msg) {
    msg.compose_uint16(abc);
    msg.compose_uint16(def);
  }
  void parse(Parser& parser) {
  abc = parser.parse_uint16();
    def = parser.parse_uint16();
  }
};

struct idl_message_ZZZ {
  uint16_t abc;
  idl_struct_XYZ zzz;

  void compose(OutputMessage& msg) {
    msg.compose_uint16(abc);
    zzz.compose(msg);
  }
  void parse(Parser& parser) {
    abc = parser.parse_uint16();
    zzz.parse(parser);
  }
};

实现这样一个编译器是非常简单的(具备一定经验的开发人员最多只需几天就可以完成;顺便说一句,使用 Python 这样的语言则更加容易——笔者只用了半天)。

需要注意的是,接口定义语言并不要求必须是 XML——例如,对于熟悉 YACC 的程序员,解析同样的例子,用 C 风格重写 IDL 不会很困难(再强调一次,整个编译器并不需要耗时数日——也就是说,如果已经使用过 YACC/Bison 和 Lex/Flex )。

struct XYZ {
  uint16 abc;
  uint16 def;
};

message struct ZZZ {
  uint16 abc;
  struct XYZ;
};

另一种实现 marshalling 的方式是通过 RPC 调用;在这种情况下,RPC 函数原型是一个 IDL。然而,应当指出的是阻塞式的 RPC 调用并不适合互联网应用(这个将在 Part IIb 的 #12 中详细讨论);另一方面,尽管条目 #13 不使用 Unity 3D 风格的无返回非阻塞 RPC 的出发点是好的,笔者仍然喜欢将结构体映射成消息,因为这样能更加清楚地解释正在发生的事情。

##8c. 第三方 Marshalling:使用平台和语言无关的格式

对于非 C 类的编程语言,marshalling 的问题并不在于 “是否 marshal”,而在于 “用什么去 marshalling”。理论上,任何序列化机制都可以做,但事实上平台和语言无关的序列化或者 marshalling 机制(例如 JSON)比指定平台和语言的(例如 Python pickle)要好的多。

##8d. 对于频繁内部交互的游戏使用二进制格式

对于数据格式,有一个强烈但并不是近期的趋势是使用基于文本的格式(例如 xml)胜过使用二进制格式(例如 VLQ 或 ASN.1 BER)。对于游戏来说,这个论点需要就情况而定。虽然文本格式能够简化调试并且提供更好的交互性,但是它们天生很大(即使在压缩之后通常也是如此),而且需要花费更多的处理时间,这将会在游戏火起来时给你沉重打击(无论是在流量还是服务器的 CPU 时间上)。笔者的经历是:对于游戏中高要求的交互式处理,使用二进制格式通常更加适合(尽管异常可能取决于特定的例如体积、频率的变化等)。

对于二进制格式,为了简化调试并提高交互性,用一个能够根据 IDL 分析消息并以文本格式打印的独立程序来实现是十分方便的。甚至更好的方式是用一个目的在于 logging/debugging 的库来做这件事。

##8e. 对于不频繁的外部交互使用文本格式

不同于内部交互游戏,外部交互例如支付通常是基于文本(XML)的,通常情况运行的不错。对于不频繁的外部交互,针对文本格式的所有参数变得不那么明显(由于罕见的原因),但是调试/互操作性变得更加重要。

##8f. 在抛弃之前请考虑下 ASN.1

ASN.1 是一种需要关注的二进制格式(即:严格来讲,ASN.1 也能通过 XER 生成和解析 XML)。它允许通用的 marshalling,有自己的 IDL,应用于通信领域(ASN.1 互联网上最常见的用途是作为 X.509 证书的基础格式)。而且乍一看,正是二进制 marshalling 所需要的。再一看,你可能会爱上它,或许也因为复杂的相关性而憎恨它,但是你不尝试的话,永远不知道。

就笔者认为,ASN.1 并不值得痴迷(它很笨重,而且类似 streaming 的 API 天生在性能上有大幅提高——至少,除非能把 ASN.1 编译成代码),但也不是在所有游戏中都这样。因此,开发者应该看看 ASN.1 和可用的函数库(尤其是在一个开源的 ASN.1 编译器 [asn 1 c]),再针对具体的项目,看它是否合适。

使用 asn1c 编译器,性能好的 ASN.1 更接近于上面描述的 streaming 解析,尽管笔者对 ASN.1 是否能够匹配 simple streaming 抱有疑问(大部分因为执行 ASN.1 解析需要显著增加更多配置);然而,如果有人做过基准测试,可以回复一下,因为在使用 asn1c 后差异并不明显。此外,如果大体上性能差异较小(甚至在 marshalling 中,2 倍的性能差异在整体性能中可能都不太明显),其他比如开发时间的考虑就变得更加重要。而且在这里, ASN.1 是否会是一个好的选择将取决于项目具体细节。一个需要注意的问题:当说到开发时间,游戏开发者的时间比网络引擎开发者的时间更重要,因此,需要考虑开发者更喜欢哪类 IDL——一种是上面所说的,或 ASN.1(顺便说下,如果他们更喜欢定制的简单 IDL,那么仍然可以在底层使用 ASN.1,提供从 IDL 到 ASN.1 的编译器,因为这并不复杂)。

概要:虽然个人真的不太喜欢 ASN.1,但它可能会有用(请根据上文自行判定)。

##8g. 记住 Little-Endian/Big-Endian 警告

Big-endian 是将高位字节存储在内存的低地址。相反,Little-endian 是将低位字节存储在内存的低地址。

当在 C/C++ 上实现 compose_()/parse_() 函数(处理多字节表达式),需要注意的是,相同的整数在不同的平台上表现出不同的字节序列。例如,在 “little-endian” 系统(尤其是 X86),(uint16_t) 1234 存储表示为 0xD2, 0x04,而在 “big-endian” 系统(如强大的 AIX 等),同样的 (uint16_t) 1234 表示为 0x04,0xD2。这就是为什么如果只写 “unit16_t x=1234;send(socket,&x,2);”,在 little-endian 和 big-endian 平台上发送的是不同的数据。

实际上,对于游戏来说,这并不是一个真正的问题。因为需要处理的绝大多数 CPU 是 Little-endian 的(X86 是 Little-endian,ARM 可以是 Little-endian,也可以是 Big-endian,IOS 和 Android 目前是 Little-endian)。然而,为了保证正确性,最好记住并选择使用下面一种方法:

逐字节的 marshal 数据(即:发送 first x>>8, 然后是 x&0xFF——这样无论是 Little-endian 还是 Big-endian,结果都是一样的)。
使用 #ifdef BIG_ENDIAN (或者 #ifdef __i386 等),在不同机器上会产生不同的版本。注:严格地说,Big-endian 宏不足以运行基于计算的 marshalling;在一些体系结构(尤其 SPARC)上,难以读出没有对齐的数据,所以无法运行。然而,ARMv7 和 CPU 的情况更是复杂:虽然技术上,不是所有指令都支持这个偏差,由于 marshalling 的代码编译器往往会用错位安全的指令生成代码,所以基于计算的分析可以运行;不过,目前笔者还是不会给 ARM 使用这个方法。
使用函数,如 htons() / ntohs(),注:这些函数生成所谓的 “网络字节排序”,这就是 Big-endian(就这样发生了)。
最后一个选项通常是文献资料中经常推荐的,但是,在实践应用中的效果并不明显:一方面,由于将所有的 marshalling 处理进行封装;第二个选项((#ifdef BIG_ENDIAN))也是个不错的选择(当在 99% 的目标机使用 Little-endian 时,可能会节省一些时间)。另一方面,不可能看到任何能够观察到的性能差异。更重要的是,要记住,确切的实现并没有多大关系。

个人而言,当关注性能的时候,笔者更喜欢下面的方法:有 “通用” 的逐字节版本(它可以不顾字节顺序随处运行,而且不依赖于读取未对齐数据的能力),然后为平台特性实现基于计算的专业化版本(例如 X86),举个例子:

uint16_t parse_uint16(byte*& ptr) { //assuming little-endian order on the wire
#if defined(__i386) || defined(__x86_64__) || defined(_M_IX86) || defined(_M_X64)
  uint16_t ret = *(uint16_t*)ptr;
  ptr += 2;
  return ret;
#else
  byte low = *ptr++;
  return low | ((uint16_t)(*ptr++)) <<8;
#endif
}

通过这种方式,将会获得一个可以工作在任何地方的可信赖版本(“#else” 以下),并且有一个基于平台兴趣的高性能版本。

至于其他的编程语言(例如 Java):只要底层的 CPU 仍然是 little-endian 或者 big-endian 的,诸如 Java 这样的语言不允许观察两者的不同,因此问题也就不存在了。

##8h. 记住 Buffer Overwrites and Buffer Overreads

当实现解析程序的时候,确保它们不易被异常数据包攻击(例如,异常数据包不能导致缓存溢出)。详细请参考 Part VIIb 中的 #57。另一个需要记住的是不仅仅只有 buffer overwrites 是危险的:buffer overreads(例如,对一个据称是由空终止字符串组成的数据包调用一个 strlen(),一旦那些字符很明显不是空终止字符)会导致 core dump(Windows 中的 0xC0000005 异常),很可能摧毁你的程序。

##9. 要有一个单独的网络层与一个定义良好的接口

无论对网络做些什么,它都应当有一个独立的库(在其它游戏引擎内部或相邻)来封装所需的所有网络相关。尽管目前这个库的功能很简单——不久,它可能会演变的很复杂。而且库应该与其它的引擎足够的分离。这就意味着 “不要把 3D 与网络混淆在一起;把它们分离的越远越好”。总之,网络库不应该依赖于图形库,反之亦然。注:对于那些认为没有人能写出一个与网络引擎紧密耦合的图形引擎的人——请看一下 Gecko/Mozilla,你会相当惊讶。

警告:网络库的接口需要根据应用的需求做适当的调整(切不可盲目模仿 TCP sockets 或者其它正在使用系统级 API)。在游戏应用中,任务通常是发送/接收信息(使用或者不使用保证交付),而且库所对应的 API 应该反映它。举一个很好(虽然不通用)的抽象实例是 Unity 3D:他们的网络 API 提供信息传递或无保证的状态同步,这两者对于实时游戏中的任务来说都是很好的抽象选择。

还有其它是(除了封装系统调用到你的抽象 API)属于网络层的吗?做这件事情不止一种方法,但是通常会包括所有的东西,它们会传输网络信息到主线程(看 Part I 中的 #1),并就地处理。同样的,,marshalling/unmarshalling(看上面的 #8)也属于网络层。

毫无疑问,任何系统级的网络调用只会出现在网络层,而且绝对不应该在其他地方使用。整个想法是封装网络层和提供整洁的关注分离,隔离应用程序级别与无关的通信细。

##10. 要理解底层到底是怎么回事

当开发网络引擎的时候,使用一些框架(例如 TCP sockets)看起来十分有诱惑力(至少乍看如此),它会自动做很多事情,不需要开发者关注。然而,如果想让玩家获得更好的体验,事情就变得棘手了。简而言之:尽管使用框架很省心,但是完全忽视它却并不好。在实践中它意味着只要团队超过 2 人,通常需要有一个专门的网络开发者——他知道框架底层是怎么回事。

此外,总体项目架构师必须知道至少大部分由互联网带来的局限(例如 IP 数据包有固有的非保证性,如何保证其准确交付,典型的往返时间等等),并且所有的团队成员必须理解网络是正在传输消息的,而这些消息很可能会被任意的延迟(有保证的消息传输)或者丢失(无保证的消息传输)。

可以总结为如下表格:

团队成员 技能
团队成员 有关库及底层机制的一切东西
总体项目架构师 通常的网络局限
所有团队成员 在网络上的消息,以及潜在的延误或潜在的丢失

##11.不要假设所有的用户都使用相同版本的 App(即提供一个方式去扩展游戏协议)

尽管程序会自动升级(包括网络库等),还是要记住那些还没有升级 APP 的用户。尽管每次应用启动时都会强制升级,仍然有用户在升级的那一刻正在使用互联网,也有一些找到了忽略升级的方法(忽略升级的原因很多,通常是不喜欢更新带来的改变)。处理此问题的两种常用的方法是:

  • 提供一种机制,让 App 开发者将 app 和一个 app 版本协议绑定,在服务器上检查它,让使用过期客户端的用户离开,强迫他们去升级。
  • 提供一种方式以优雅降级的形式处理协议之间的差异,不提供之前版本协议中没有的功能。

走第二条路是很困难的,但是却能给终端用户感到额外舒适(如果做的很细心)。一般来讲,需要在引擎中提供两种机制,使得 app 开发者能够根据需求作出选择(从长远来看,甚至在是一个 app 的生命周期中,他们往往两个都需要,)。

方法 2 的一个处理方式是基于这样一个观察,在一个差不多成熟的 app 中,大多数协议的变更都和在协议中添加新字段有关。这意味着可以在 marshalling 层提供一个通用函数,例如 end_of_parsing_reached(),这样 app 开发者就能在消息的末端添加新的字段,并使用下面代码来解析可能已经修改的消息。

if( parser.end_of_parsing_reached() )
  additional_field = 1;
else
  additional_field = parser.parse_int();

如果使用自己的 IDL(参见上面 #8b),它看起来应该是这样。

<struct name=“XYZ“>
   <field name=“abc“ type=“uint16“ />
  <field name=“def“ type=“uint16“ />
  <field name=“additional_field“ type=“uint16“ default=“1“ />
</struct>

当然,在 compose() / parse() 中会做相应的改变。

这个简单的方法,即在消息的末尾添加额外的字段,运行的比较不错,尽管需要游戏开发者弄清楚协议是如何扩展的。当然,不是所有的协议改变都能用这种方式处理,但如果 app 开发者能够用此方法处理 90% 以上的协议更新,并将强制更新的数量降低十倍,用户将会十分感激(或许不会——取决于更新带来的负累)。

## 未完待续···

显然,Part II 变得如此之大以至于必须将它切分。敬请关注——Part IIb,将会讲解 protocols and APIs 的一些更高级内容。

原文链接:Part IIa: Protocols and APIs of 64 Network DO’s and DON’Ts for Game Engine Developers

本文系 OneAPM 工程师编译整理。OneAPM 是应用性能管理领域的新兴领军企业,能帮助企业用户和开发者轻松实现:缓慢的程序代码和 SQL 语句的实时抓取。想阅读更多技术文章,请访问 OneAPM 官方博客

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册