遊戲引擎網絡開發者的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官方技術博客

相關文章
相關標籤/搜索