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

发表于2016-09-01
评论0 1.4k浏览
  摘要:纵观过去10年的游戏领域,单机向网络发展已成为一个非常大的趋势。然而,为游戏添加网络支持的过程中往往存在着大量挑战,这里将为大家揭示游戏引擎网络开发者的64个做与不做。
  【编者按】在这个系列之前的文章“游戏引擎网络开发者的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)
  ……
  定制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);这样的功能)。
   定制Marshalling:提供一些带有IDL-to-code编译器的IDL
  对于 compose/parse 一个简单提升是用某种声明的方式来描述消息(某种接口定义语言——IDL)并将它编译成compose_uint16()/parse_uint16()的序列。例子中,这种声明看起来像是一个XML声明。


<struct name="“XYZ“"> "“abc“" type="“uint16“"> "“def“" type="“uint16“"> struct> "“ZZZ“">
    "“abc“" type="“uint16“"> "“zzz“" type="“XYZ“">
  之后则需要提供一个编译器,它读取上面的声明并产生类似下面的东西:



























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的出发点是好的,笔者仍然喜欢将结构体映射成消息,因为这样能更加清楚地解释正在发生的事情。
  第三方Marshalling:使用平台和语言无关的格式
  对于非C类的编程语言,marshalling 的问题并不在于“是否marshal”,而在于“用什么去marshalling”。理论上,任何序列化机制都可以做,但事实上平台和语言无关的序列化或者marshalling 机制(例如JSON)比指定平台和语言的(例如Python pickle)要好的多。
  对于频繁内部交互的游戏使用二进制格式
  对于数据格式,有一个强烈但并不是近期的趋势是使用基于文本的格式(例如xml)胜过使用二进制格式(例如VLQ 或 ASN.1 BER)。对于游戏来说,这个论点需要就情况而定。虽然文本格式能够简化调试并且提供更好的交互性,但是它们天生很大(即使在压缩之后通常也是如此),而且需要花费更多的处理时间,这将会在游戏火起来时给你沉重打击(无论是在流量还是服务器的CPU时间上)。笔者的经历是:对于游戏中高要求的交互式处理,使用二进制格式通常更加适合(尽管异常可能取决于特定的例如体积、频率的变化等)。
  对于二进制格式,为了简化调试并提高交互性,用一个能够根据IDL分析消息并以文本格式打印的独立程序来实现是十分方便的。甚至更好的方式是用一个目的在于logging/debugging 的库来做这件事。
  对于不频繁的外部交互使用文本格式
  不同于内部交互游戏,外部交互例如支付通常是基于文本(XML)的,通常情况运行的不错。对于不频繁的外部交互,针对文本格式的所有参数变得不那么明显(由于罕见的原因),但是调试/互操作性变得更加重要。
  在抛弃之前请考虑下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,但它可能会有用(请根据上文自行判定)。
  记住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这样的语言不允许观察两者的不同,因此问题也就不存在了。
  记住Buffer Overwrites and Buffer Overreads
  当实现解析程序的时候,确保它们不易被异常数据包攻击(例如,异常数据包不能导致缓存溢出)。详细请参考Part VIIb中的#57。另一个需要记住的是不仅仅只有buffer overwrites 是危险的:buffer overreads (例如,对一个据称是由空终止字符串组成的数据包调用一个strlen(),一旦那些字符很明显不是空终止字符)会导致core dump(Windows中的0xC0000005 异常),很可能摧毁你的程序。
  要有一个单独的网络层与一个定义良好的接口
  无论对网络做些什么,它都应当有一个独立的库(在其它游戏引擎内部或相邻)来封装所需的所有网络相关。尽管目前这个库的功能很简单——不久,它可能会演变的很复杂。而且库应该与其它的引擎足够的分离。这就意味着“不要把3D与网络混淆在一起;把它们分离的越远越好”。总之,网络库不应该依赖于图形库,反之亦然。注:对于那些认为没有人能写出一个与网络引擎紧密耦合的图形引擎的人——请看一下Gecko/Mozilla,你会相当惊讶。
  警告:网络库的接口需要根据应用的需求做适当的调整(切不可盲目模仿TCP sockets 或者其它正在使用系统级API)。在游戏应用中,任务通常是发送/接收信息(使用或者不使用保证交付),而且库所对应的API应该反映它。举一个很好(虽然不通用)的抽象实例是Unity 3D:他们的网络API提供信息传递或无保证的状态同步,这两者对于实时游戏中的任务来说都是很好的抽象选择。
  还有其它是(除了封装系统调用到你的抽象API)属于网络层的吗?做这件事情不止一种方法,但是通常会包括所有的东西,它们会传输网络信息到主线程(看Part I中的#1),并就地处理。同样的,,marshalling/unmarshalling(看上面的#8)也属于网络层。
  毫无疑问,任何系统级的网络调用只会出现在网络层,而且绝对不应该在其他地方使用。整个想法是封装网络层和提供整洁的关注分离,隔离应用程序级别与无关的通信细。
  要理解底层到底是怎么回事
  当开发网络引擎的时候,使用一些框架(例如TCP sockets)看起来十分有诱惑力(至少乍看如此),它会自动做很多事情,不需要开发者关注。然而,如果想让玩家获得更好的体验,事情就变得棘手了。简而言之:尽管使用框架很省心,但是完全忽视它却并不好。在实践中它意味着只要团队超过2人,通常需要有一个专门的网络开发者——他知道框架底层是怎么回事。
  此外,总体项目架构师必须知道至少大部分由互联网带来的局限(例如IP数据包有固有的非保证性,如何保证其准确交付,典型的往返时间等等),并且所有的团队成员必须理解网络是正在传输消息的,而这些消息很可能会被任意的延迟(有保证的消息传输)或者丢失(无保证的消息传输)。
  可以总结为如下表格:
团队成员技能
团队成员有关库及底层机制的一切东西
总体项目架构师通常的网络局限
所有团队成员在网络上的消息,以及潜在的延误或潜在的丢失
  不要假设所有的用户都使用相同版本的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“">
  "“abc“" type="“uint16“">
  "“def“" type="“uint16“">
  "“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工程师 审校/仲浩)

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引