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

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

如下为译文编程

这篇博文将继续讲述关于为游戏引擎实现网络支持,固然这里一样会分析除下基于浏览器游戏之外的全部类型及平台。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声明。app

<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官方技术博客

相关文章
相关标签/搜索