【編者按】在這個系列之前的文章「游戲引擎網絡開發(fā)者的 64 做與不做(一):客戶端方面」中碘箍,Sergey 介紹了游戲引擎添加網絡支持時在客戶端方面的注意點衅胀。本文,Sergey 則將結合實戰(zhàn)泼舱,講述協(xié)議與 API 上的注意點援雇。
以下為譯文
這篇博文將繼續(xù)講述關于為游戲引擎實現(xiàn)網絡支持,當然這里同樣會分析除下基于瀏覽器游戲以外的所有類型及平臺时迫。
作為系列的第一篇文章颅停,這里將著重討論不涉及協(xié)議的客戶端應用程序網絡開發(fā)。本系列文章包括:
- 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 可以通過多種方式實現(xiàn)掠拳。一個簡單且高效的方法是提供「simple streaming」compose/parse 函數(shù)癞揉,例如 OutputMessage& compose_uint16(OutputMessage&, uint16_t) /uint16_t parse_uint16(Parser&) ——針對所有需要在網絡上傳輸?shù)臄?shù)據(jù)類型。在這種情況下溺欧,OutputMessage 是一個類/結構喊熟,封裝了一個消息的概念,在添加其他屬性后就會增長姐刁,而Parser 是通過一個輸入消息創(chuàng)建的對象芥牌,它有一個指向輸入消息的指針和一個針對當下解析發(fā)生地的偏移量。
Compose 和 parse 之間的不對稱(Compose 是直接針對消息的聂使,而 parse 需要創(chuàng)建分離的 Parser 對象)不是完全強制的壁拉,但是在實踐中卻是一個非常好的事情(特別是,其允許在消息中存儲解析的內容柏靶,允許重復解析弃理,對消息的解析形式不變等等)。通常來說宿礁,這個簡單的方法同樣適用于大規(guī)模環(huán)境案铺,但是在游戲上卻需要更多的努力來保持 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 基于明確的大小來處理的功能)的一個優(yōu)點是使用什么格式并不重要——固定大小或者可變大斜仕小(即編碼如 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);
}
};
實現(xiàn)這樣一個編譯器是非常簡單的(具備一定經驗的開發(fā)人員最多只需幾天就可以完成;順便說一句磁携,使用 Python 這樣的語言則更加容易——筆者只用了半天)褒侧。
需要注意的是,接口定義語言并不要求必須是XML——例如谊迄,對于熟悉 YACC 的程序員闷供,解析同樣的例子,用 C 風格重寫 IDL 不會很困難(再強調一次统诺,整個編譯器并不需要耗時數(shù)日——也就是說歪脏,如果已經使用過 YACC/Bison 和 Lex/Flex )。
struct XYZ {
uint16 abc;
uint16 def;
};
message struct ZZZ {
uint16 abc;
struct XYZ;
};
另一種實現(xiàn) marshalling 的方式是通過 RPC 調用粮呢;在這種情況下婿失,RPC 函數(shù)原型是一個 IDL。然而鬼贱,應當指出的是阻塞式的 RPC 調用并不適合互聯(lián)網應用(這個將在 Part IIb 的 #12 中詳細討論)移怯;另一方面,盡管條目 #13 不使用 Unity 3D 風格的無返回非阻塞 RPC 的出發(fā)點是好的这难,筆者仍然喜歡將結構體映射成消息,因為這樣能更加清楚地解釋正在發(fā)生的事情葡秒。
8c. 第三方 Marshalling:使用平臺和語言無關的格式
對于非 C 類的編程語言姻乓,marshalling 的問題并不在于「是否marshal」,而在于「用什么去 marshalling」眯牧。理論上蹋岩,任何序列化機制都可以做,但事實上平臺和語言無關的序列化或者 marshalling 機制(例如 JSON)比指定平臺和語言的(例如 Python pickle)要好的多学少。
8d. 對于頻繁內部交互的游戲使用二進制格式
對于數(shù)據(jù)格式剪个,有一個強烈但并不是近期的趨勢是使用基于文本的格式(例如 xml)勝過使用二進制格式(例如 VLQ 或 ASN.1 BER)。對于游戲來說版确,這個論點需要就情況而定扣囊。雖然文本格式能夠簡化調試并且提供更好的交互性乎折,但是它們天生很大(即使在壓縮之后通常也是如此),而且需要花費更多的處理時間侵歇,這將會在游戲火起來時給你沉重打擊(無論是在流量還是服務器的 CPU 時間上)骂澄。筆者的經歷是:對于游戲中高要求的交互式處理,使用二進制格式通常更加適合(盡管異程杪牵可能取決于特定的例如體積坟冲、頻率的變化等)。
對于二進制格式溃蔫,為了簡化調試并提高交互性健提,用一個能夠根據(jù) IDL 分析消息并以文本格式打印的獨立程序來實現(xiàn)是十分方便的。甚至更好的方式是用一個目的在于 logging/debugging 的庫來做這件事伟叛。
8e. 對于不頻繁的外部交互使用文本格式
不同于內部交互游戲私痹,外部交互例如支付通常是基于文本(XML)的,通常情況運行的不錯痪伦。對于不頻繁的外部交互侄榴,針對文本格式的所有參數(shù)變得不那么明顯(由于罕見的原因),但是調試/互操作性變得更加重要网沾。
8f. 在拋棄之前請考慮下 ASN.1
ASN.1 是一種需要關注的二進制格式(即:嚴格來講癞蚕,ASN.1 也能通過 XER 生成和解析 XML)。它允許通用的 marshalling辉哥,有自己的 IDL桦山,應用于通信領域(ASN.1 互聯(lián)網上最常見的用途是作為X.509證書的基礎格式)。而且乍一看醋旦,正是二進制 marshalling 所需要的恒水。再一看,你可能會愛上它饲齐,或許也因為復雜的相關性而憎恨它钉凌,但是你不嘗試的話,永遠不知道捂人。
就筆者認為御雕,ASN.1 并不值得癡迷(它很笨重,而且類似 streaming 的 API 天生在性能上有大幅提高——至少滥搭,除非能把 ASN.1 編譯成代碼)酸纲,但也不是在所有游戲中都這樣。因此瑟匆,開發(fā)者應該看看 ASN.1 和可用的函數(shù)庫(尤其是在一個開源的 ASN.1 編譯器[asn 1 c])闽坡,再針對具體的項目,看它是否合適。
使用 asn1c 編譯器疾嗅,性能好的 ASN.1 更接近于上面描述的 streaming 解析外厂,盡管筆者對 ASN.1 是否能夠匹配 simple streaming 抱有疑問(大部分因為執(zhí)行 ASN.1 解析需要顯著增加更多配置);然而宪迟,如果有人做過基準測試酣衷,可以回復一下,因為在使用 asn1c 后差異并不明顯次泽。此外穿仪,如果大體上性能差異較小(甚至在 marshalling 中意荤,2 倍的性能差異在整體性能中可能都不太明顯)啊片,其他比如開發(fā)時間的考慮就變得更加重要。而且在這里玖像, ASN.1 是否會是一個好的選擇將取決于項目具體細節(jié)紫谷。一個需要注意的問題:當說到開發(fā)時間,游戲開發(fā)者的時間比網絡引擎開發(fā)者的時間更重要捐寥,因此笤昨,需要考慮開發(fā)者更喜歡哪類 IDL——一種是上面所說的,或 ASN.1(順便說下握恳,如果他們更喜歡定制的簡單 IDL瞒窒,那么仍然可以在底層使用 ASN.1,提供從 IDL 到 ASN.1 的編譯器乡洼,因為這并不復雜)崇裁。
概要:雖然個人真的不太喜歡 ASN.1,但它可能會有用(請根據(jù)上文自行判定)束昵。
8g. 記住 Little-Endian/Big-Endian 警告
Big-endian 是將高位字節(jié)存儲在內存的低地址拔稳。相反,Little-endian 是將低位字節(jié)存儲在內存的低地址锹雏。
當在 C/C++ 上實現(xiàn) compose_()/parse_()函數(shù)(處理多字節(jié)表達式)巴比,需要注意的是,相同的整數(shù)在不同的平臺上表現(xiàn)出不同的字節(jié)序列礁遵。例如匿辩,在「little-endian」系統(tǒng)(尤其是 X86),(uint16_t)1234 存儲表示為 0xD2, 0x04榛丢,而在「big-endian」系統(tǒng)(如強大的 AIX 等),同樣的(uint16_t)1234 表示為 0x04,0xD2挺庞。這就是為什么如果只寫「unit16_t x=1234晰赞;send(socket,&x,2);」,在 little-endian 和 big-endian 平臺上發(fā)送的是不同的數(shù)據(jù)。
實際上掖鱼,對于游戲來說然走,這并不是一個真正的問題。因為需要處理的絕大多數(shù) CPU 是Little-endian 的(X86是Little-endian戏挡,ARM 可以是 Little-endian芍瑞,也可以是 Big-endian,IOS 和 Android 目前是 Little-endian)褐墅。然而拆檬,為了保證正確性,最好記住并選擇使用下面一種方法:
逐字節(jié)的 marshal 數(shù)據(jù)(即:發(fā)送 first x>>8, 然后是 x&0xFF ——這樣無論是 Little-endian 還是 Big-endian妥凳,結果都是一樣的)竟贯。
使用 #ifdef BIG_ENDIAN (或者 #ifdef __i386 等),在不同機器上會產生不同的版本逝钥。注:嚴格地說屑那,Big-endian 宏不足以運行基于計算的 marshalling;在一些體系結構(尤其 SPARC)上艘款,難以讀出沒有對齊的數(shù)據(jù)持际,所以無法運行。然而哗咆,ARMv7 和 CPU 的情況更是復雜:雖然技術上蜘欲,不是所有指令都支持這個偏差,由于 marshalling 的代碼編譯器往往會用錯位安全的指令生成代碼岳枷,所以基于計算的分析可以運行芒填;不過,目前筆者還是不會給 ARM 使用這個方法空繁。
使用函數(shù)殿衰,如 htons() / ntohs(),注:這些函數(shù)生成所謂的“網絡字節(jié)排序”盛泡,這就是 Big-endian(就這樣發(fā)生了)闷祥。
最后一個選項通常是文獻資料中經常推薦的,但是傲诵,在實踐應用中的效果并不明顯:一方面凯砍,由于將所有的 marshalling 處理進行封裝;第二個選項((#ifdef BIG_ENDIAN))也是個不錯的選擇(當在 99% 的目標機使用 Little-endian 時拴竹,可能會節(jié)省一些時間)悟衩。另一方面,不可能看到任何能夠觀察到的性能差異栓拜。更重要的是座泳,要記住惠昔,確切的實現(xiàn)并沒有多大關系。
個人而言挑势,當關注性能的時候镇防,筆者更喜歡下面的方法:有“通用” 的逐字節(jié)版本(它可以不顧字節(jié)順序隨處運行,而且不依賴于讀取未對齊數(shù)據(jù)的能力)潮饱,然后為平臺特性實現(xiàn)基于計算的專業(yè)化版本(例如 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
當實現(xiàn)解析程序的時候,確保它們不易被異常數(shù)據(jù)包攻擊(例如证鸥,異常數(shù)據(jù)包不能導致緩存溢出)僚楞。詳細請參考 Part VIIb 中的 #57。另一個需要記住的是不僅僅只有 buffer overwrites 是危險的:buffer overreads (例如枉层,對一個據(jù)稱是由空終止字符串組成的數(shù)據(jù)包調用一個 strlen()泉褐,一旦那些字符很明顯不是空終止字符)會導致 core dump(Windows 中的 0xC0000005 異常),很可能摧毀你的程序鸟蜡。
9. 要有一個單獨的網絡層與一個定義良好的接口
無論對網絡做些什么膜赃,它都應當有一個獨立的庫(在其它游戲引擎內部或相鄰)來封裝所需的所有網絡相關。盡管目前這個庫的功能很簡單——不久揉忘,它可能會演變的很復雜跳座。而且?guī)鞈撆c其它的引擎足夠的分離。這就意味著“不要把3D與網絡混淆在一起泣矛;把它們分離的越遠越好”疲眷。總之您朽,網絡庫不應該依賴于圖形庫狂丝,反之亦然。注:對于那些認為沒有人能寫出一個與網絡引擎緊密耦合的圖形引擎的人——請看一下 Gecko/Mozilla哗总,你會相當驚訝几颜。
警告:網絡庫的接口需要根據(jù)應用的需求做適當?shù)恼{整(切不可盲目模仿 TCP sockets 或者其它正在使用系統(tǒng)級 API)。在游戲應用中讯屈,任務通常是發(fā)送/接收信息(使用或者不使用保證交付)蛋哭,而且?guī)焖鶎?API 應該反映它。舉一個很好(雖然不通用)的抽象實例是 Unity 3D:他們的網絡 API 提供信息傳遞或無保證的狀態(tài)同步涮母,這兩者對于實時游戲中的任務來說都是很好的抽象選擇具壮。
還有其它是(除了封裝系統(tǒng)調用到你的抽象API)屬于網絡層的嗎准颓?做這件事情不止一種方法,但是通常會包括所有的東西棺妓,它們會傳輸網絡信息到主線程(看 Part I 中的 #1),并就地處理炮赦。同樣的怜跑,marshalling/unmarshalling(看上面的 #8)也屬于網絡層。
毫無疑問吠勘,任何系統(tǒng)級的網絡調用只會出現(xiàn)在網絡層性芬,而且絕對不應該在其他地方使用。整個想法是封裝網絡層和提供整潔的關注分離抄沮,隔離應用程序級別與無關的通信細态兴。
10. 要理解底層到底是怎么回事
當開發(fā)網絡引擎的時候礼烈,使用一些框架(例如 TCP sockets)看起來十分有誘惑力(至少乍看如此),它會自動做很多事情俊庇,不需要開發(fā)者關注。然而鸡挠,如果想讓玩家獲得更好的體驗辉饱,事情就變得棘手了。簡而言之:盡管使用框架很省心拣展,但是完全忽視它卻并不好彭沼。在實踐中它意味著只要團隊超過 2 人,通常需要有一個專門的網絡開發(fā)者——他知道框架底層是怎么回事备埃。
此外姓惑,總體項目架構師必須知道至少大部分由互聯(lián)網帶來的局限(例如 IP 數(shù)據(jù)包有固有的非保證性,如何保證其準確交付按脚,典型的往返時間等等)于毙,并且所有的團隊成員必須理解網絡是正在傳輸消息的,而這些消息很可能會被任意的延遲(有保證的消息傳輸)或者丟失(無保證的消息傳輸)乘寒。
可以總結為如下表格:
團隊成員 | 技能 |
---|---|
團隊成員 | 有關庫及底層機制的一切東西 |
總體項目架構師 | 通常的網絡局限 |
所有團隊成員 | 在網絡上的消息望众,以及潛在的延誤或潛在的丟失 |
11.不要假設所有的用戶都使用相同版本的 App(即提供一個方式去擴展游戲協(xié)議)
盡管程序會自動升級(包括網絡庫等),還是要記住那些還沒有升級APP的用戶伞辛。盡管每次應用啟動時都會強制升級烂翰,仍然有用戶在升級的那一刻正在使用互聯(lián)網,也有一些找到了忽略升級的方法(忽略升級的原因很多蚤氏,通常是不喜歡更新帶來的改變)甘耿。處理此問題的兩種常用的方法是:
- 提供一種機制,讓 App開發(fā)者將 app 和一個 app 版本協(xié)議綁定竿滨,在服務器上檢查它佳恬,讓使用過期客戶端的用戶離開捏境,強迫他們去升級。
- 提供一種方式以優(yōu)雅降級的形式處理協(xié)議之間的差異毁葱,不提供之前版本協(xié)議中沒有的功能垫言。
走第二條路是很困難的,但是卻能給終端用戶感到額外舒適(如果做的很細心)倾剿。一般來講筷频,需要在引擎中提供兩種機制,使得 app 開發(fā)者能夠根據(jù)需求作出選擇(從長遠來看前痘,甚至在是一個 app 的生命周期中凛捏,他們往往兩個都需要,)芹缔。
方法2的一個處理方式是基于這樣一個觀察坯癣,在一個差不多成熟的 app 中,大多數(shù)協(xié)議的變更都和在協(xié)議中添加新字段有關最欠。這意味著可以在 marshalling 層提供一個通用函數(shù)示罗,例如 end_of_parsing_reached(),這樣 app 開發(fā)者就能在消息的末端添加新的字段窒所,并使用下面代碼來解析可能已經修改的消息鹉勒。
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()中會做相應的改變。
這個簡單的方法皮官,即在消息的末尾添加額外的字段脯倒,運行的比較不錯,盡管需要游戲開發(fā)者弄清楚協(xié)議是如何擴展的捺氢。當然藻丢,不是所有的協(xié)議改變都能用這種方式處理,但如果 app 開發(fā)者能夠用此方法處理 90% 以上的協(xié)議更新摄乒,并將強制更新的數(shù)量降低十倍悠反,用戶將會十分感激(或許不會——取決于更新帶來的負累)。
未完待續(xù)···
顯然馍佑,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 是應用性能管理領域的新興領軍企業(yè),能幫助企業(yè)用戶和開發(fā)者輕松實現(xiàn):緩慢的程序代碼和 SQL 語句的實時抓取舅世。想閱讀更多技術文章旦委,請訪問 OneAPM 官方博客奇徒。