在過去的十年間,越來越多的遊戲改版為網路遊戲,這是個強烈的趨勢。當為一款遊戲新增網路支援面臨著全世界的挑戰時,我最近的經驗(同時作為一名玩家和諮詢者)表明,太多遊戲開發者違反了做一款足夠好的網路應用程式的基本準則。我們經常看到使用者介面“一動不動”、網路中斷未被啟用(同時Internet的其餘部分是可訪問的)、遊戲隨機崩潰,還有高峰期的伺服器超載。壞訊息是,這些問題直接影響到遊戲玩家的滿意度(比開發者通常以為的管理和影象因素來得直接得多)。
好訊息是,處理這些問題並非是一門高精尖科技,只要網路引擎開發者確實知道他們在做什麼——以及他們確實讀了這篇文章。
在這篇文章裡,我們主要聚焦於網路開發的某些方面,這對很多遊戲引擎開發者而言並不那麼明顯。如果我寫的一些東西對你來說顯而易見,那麼很抱歉,但請別因為我寫了而太厲害地抨擊我。我向你保證,有大量擁有百萬級玩家的流行遊戲都違反了這份清單上的其中一項(可能有一兩項除外)。因此,我寫這篇文章是為了給出一份建議清單,它會幫你避免開發者在為有大量互動的應用程式,如遊戲或股票交易程式,實現網路層時會犯的最煩人同時也是最容易犯的錯誤。
在這篇文章的第(1)部分,我們將拋開網路協議,討論客戶端網路開發的常見事項。接下來的章節有:
- (2a). 協議和API(上)
- (2b). 協議和API(下)
- (3a). 伺服器端(儲存處理和發展的體系)
- (3b). 伺服器端(配置、優化和測試)
- (4). TCP-vs-UDP的大論戰
- (5). UDP
- (6). TCP
- (7). 安全
0. 範圍
整體來看,遊戲引擎獲得了大量的網路支援。這就是我們為什麼限制了我們給出忠告的範圍。更具體地說:
- 我們將專注於有客戶端的遊戲app,不考慮瀏覽器端或基於AJAX的遊戲。而App遊戲和基於瀏覽器的遊戲有很多類似之處,但它們的確各自也有相當不少的差異之處。
不過,這篇文章試圖要覆蓋大部分和遊戲的網路層開發相關的其他方面:
- 我們不會將自己限制在一個指定的遊戲型別(比如MMORPG的模擬世界類)。當MMORPG在這篇文章的一定範圍內,社交遊戲、多人策略遊戲(既是實時遊戲又是基於回合制的那種遊戲)、賭場遊戲以及股票交易遊戲也是如此。令人驚奇地是,大多數的風格型遊戲需要的網路支援相當類似(儘管很多時候依賴於時間因素,正如我們將在(4). TCP vs UDP的大論戰裡討論的那樣)。
- 我們也不必將自己侷限於一個平臺:實際上,我們強烈支援開發跨平臺的引擎,包括網路引擎。作為練習,我自己開發了一套網路引擎,它能在5個以上不同的平臺上執行(這些平臺的清單在第6條建議下列出)。
- 當這篇文章是出自一名遊戲引擎開發者的觀點時,我們應當注意到相當多的時候開發者需要為他們自己開發遊戲引擎。在這些情況下,這篇文章的大部分忠告仍然適用。
- 當“哪個現有的引擎或者網路引擎是最好的?”這樣的問題不在這篇文章的探討範圍內時,我們仍然期望本文在回答這個問題時能派得上用場。然而,這個問題的答案取決於你的遊戲細節,因此你需要讀讀這篇文章,決定哪些忠告適用於你的遊戲,哪些不適用。換句話說:如果你的遊戲引擎或框架提供了網路訪問功能——你可以用這篇文章作為允許一窺遊戲框架的工具,再看看如果他們的網路實現是否適合你的遊戲。
現在,拋開前提,讓我們開始吧:
1. 一定要在客戶端使用事件驅動程式設計
大多數客戶端的框架都有個所謂的“主執行緒”(或者是能隱含在這個“主執行緒”裡執行的“主迴圈”),而這個“主執行緒”基本上只訪問特定的事件(起初是UI事件)。這個模式適用於整個客戶端框架的領域,從Windows GUI、Direct X和Cocoa,到Unity 3D、Android和iOS。這個現象也有個能解釋它的原因:因為不這樣做的話,開發就會成為一場噩夢。事實上,據我所知只有一個沒有這樣運作的框架:它是Java開發的原始AWT框架,而在AWT框架裡開發一個app是一件公認相當痛苦的事情(中肯地說,AWT框架從來就沒流行過;Google特別需要為Android開發一整套全新的GUI框架)。
當我們把可執行程式或者app聯網時,它的事件驅動模式應該怎樣變化呢?答案是,“不應該變化”。從邏輯上而言,所有的遊戲網路通訊包括髮送和接收的資訊,每個接收的網路資訊應當被看作是遊戲事件驅動邏輯的又一個觸發事件(伴隨著傳統UI事件的觸發,比如點選滑鼠或按下鍵盤等)。通過把訊息注入到主執行緒的“訊息佇列”中(例如,在Win32環境下通過PostMessage()或者PostThreadMessage()來注入)可以相當輕易地實現事件觸發。假如你使用的圖形框架(比如Unity3D)不支援這個概念,你可能需要建立佇列並使用它來模擬該框架(瞧,例如,[Unity3D2012])。和在一個單執行緒裡強制訪問所有事件(包括UI事件和網路訊息)比起來,是否將事件作為資訊傳遞(像在Win32下),抑或將事件作為回撥(像在[Unity3D2012]裡)都不那麼重要了。
注意:如果用的是Unity,這個小技巧幾乎用不上,因為Unity的內建網路機制(該機制已使用Unity的事件訪問執行緒)對“實時世界模擬器”而言足夠好用。然而,使用Unity的網路即意味著用UDP方式傳播資訊,這種方式,如同我們將在(4)裡看到的一樣,可能是也可能不是最好的方式,這取決於遊戲本身——特別是如果遊戲偏離了“實時世界模擬器”的話。
在某些情況下,事件處理執行緒可能和框架的“主執行緒”不一樣,但重要的是,要把所有至少有點相關的事件都放在一個單獨執行緒中處理。然而,僅僅是通訊相關的事(那些和遊戲邏輯一點關係都沒有的事),比如資訊集結、加/解密和(壓縮)解壓縮,可能(以及如果可能的話,應該)在“主執行緒”外處理。我們將在接下來的第3條建議裡討論一些執行緒分離的細節
2.一定不要在事件處理執行緒外呼叫應用程式回撥
當我年輕並且是個相對經驗不足的開發新手時,我為一個股票交易開發了一個網路框架(不要問一個缺乏經驗的開發者是怎麼拿到這個照理說來這樣大的一個任務的——我自己也不知道為什麼)。我不得不承認,儘管第一次嘗試開發網路庫,我已經做得相當好了,但是我還是犯了一個重大錯誤。我建立了一個屬於網路框架的執行緒,用它來呼叫應用層的一個回撥(如果我沒弄錯的話,它是一個迴應屬於我的sendMessageOverTheNetworkAndCallbackOnReply()型別函式的回撥)。這個微不足道的回撥給後來幾任使用該框架的開發者帶來了相當大的不便。首先,對後來的開發者而言,遊戲中的互動(和潛在的資源競爭(!))變得相當難理解(對我而言一切顯而易見,但它還是我的問題,還有一個原因是:這是個可以避免的問題,而我害得他們不得不處理它)。其次,它引起了好幾個很難追蹤的缺陷和資源爭用。最後,這個框架不是太糟糕,整體而言程式執行得相當不錯,但是如果沒有做這個單次回撥的話,在該框架上的開發原本可以比現在更平滑。
有好幾年我曾被分配任務,為一款相當大的多玩家遊戲開發一個網路框架(我樂意炫耀的是這款遊戲同時線上使用者50萬,每天收發5億個網路資料資訊)。這次我學了乖,避免了這種執行緒回撥。整個框架執行得非常流暢(同時也更容易移植到多平臺上)。
底線:如果你需要從網路層嚮應用層回撥,首先把事件傳送給事件處理執行緒(通常是‘主’執行緒),然後在事件處理執行緒生成的網路層庫的呼叫裡處理事件,必要時呼叫應用級回撥。
換句話說,下面這種方式不錯:
network thread –> inter-thread-communication –>event-processing thread –> network-library-call –>application-callback –> no-thread-sync-needed
還有如下的訪問方法是可工作的,但是長遠來說對其他開發者而言沒有那麼好:
network thread –> network-library-call –>application-callback –> thread-sync-required
對以上描述的“好的”方法來說,事件處理執行緒總是需要呼叫回撥函式,這樣能大大簡化程式的開發。所有應用級的處理都成為了嚴格意義上確定性的處理(這些轉化成了“更少機會產生資源爭用”),同時應用層沒有任何必要的執行緒同步。以上的措詞是公認的鬆散,而方法可能聽上去很複雜,但是它將省掉開發者開發過程中很多的麻煩。
3. 一定不要從事件處理執行緒裡呼叫潛在的鎖定網路函式
這是網路開發者所能犯的最討厭的錯誤之一。正如之前所說,你應該在單個執行緒內處理事件。然而,在事件控制程式碼內(這些控制程式碼通常在事件處理執行緒內被隱性地呼叫)呼叫一個看上去很無辜的gethostbyname(),通常在你的辦公環境下執行沒有明顯問題,這事兒既行得通又方便。但是在實時環境裡的有些情況下,它會阻塞好幾分鐘(!)。如果你從GUI執行緒呼叫了這樣一個函式,這通常意味著,對使用者來說,當函式被阻塞時,GUI看上去一直是“一動不動的”或者“中止的”。從使用者體驗的觀點來看,這是個大大的不。
和GUI進行網路互動的適當方式,是對所有網路函式的呼叫,不管是非阻塞式的,還是在單獨的執行緒內。在這種情境下,你需要把事件狀態機設定得更復雜(你將得到有效的狀態通知譬如“等待DNS解析”),但是同時它將允許避免“一動不動的”GUI(這本身是一件好事),也將額外允許你處理網路延遲,包括:
- 適當的時候通知使用者。比如,在程式僵死一秒或五秒時,你知道有問題發生了,使用者一般也知道有問題發生了,所以讓她知道你已經注意到了這個問題並且正著手解決它,這樣比較好。
- 必要時退出操作並重試(比如,它和在(6)裡簡短討論到的保持應用層活躍相關聯)
- 允許使用者體面地終止請求/遊戲(而不是強制她求助於使用工作管理員)
應該注意到,這一項看上去和上面的第1條建議和第2條建議相矛盾,但它其實沒有。對問題:“嘿,所以我應該單執行緒開發還是多執行緒開發?”的答案是:“系統級網路呼叫應該要麼是非阻塞性的,要麼由非事件處理執行緒呼叫;同時,所有的事件應該在事件處理執行緒中處理”。這表明,如果使用執行緒,你應該在非事件處理的網路處理執行緒中呼叫一些像阻塞recv()的函式,把這個呼叫的結果轉化成一個事件,並且把這個事件通過某種佇列傳送到事件處理執行緒(詳情參照上面的第1條建議)。嚴格說來,諸如像解密/解壓一類的事情可能在這兩種執行緒裡的任意一箇中被處理,儘管要避免事件處理執行緒成為效能瓶頸,一般來說把加密/壓縮工作留給網路處理執行緒要好些。
呼叫網路執行緒的另一種方法是非阻塞型的IO。這兒有好些警告(包括gethostbyname()和getaddrinfo()沒有在至少一個主要的平臺上有非阻塞型的對應函式),總的說來,我不敢肯定訪問非阻塞型的IO值得它給客戶端帶來的麻煩(伺服器端又是另一回事,它將在文章的Part III裡被描述)。
4.一定不要把使用者當成免費錯誤處理器
有一些開發者用一種非常簡單的(而我要說,可悲的)方法來處理網路錯誤。就是說,他們只是把錯誤擺在使用者的面前並且說一些像“伺服器有一個小問題。請重試”的話。這種方法特別惹人討厭,而且毫無幫助(除了能使得開發者的生活稍微容易一點,以犧牲使用者的生活為代價)。絕對沒有理由不在內部處理網路錯誤(除了開發者耍懶),非自動化地重試。我們應該發生某種通知給使用者,告訴他們有問題發生了,但這種通知不應該需要任何使用者輸入。要完成這樣一個通知,你可以把它作為一條資訊顯示在螢幕的某些主要區域,或者做一個對話方塊(沒有‘ok’鍵,只有一個‘cancel’鍵(!))。當你處理通知提示的問題時,通知會自動消失(是的,如果當你搞定問題時,使用者東張西望,而同時你能處理這些問題,沒有理由用你的問題去煩使用者)。
給那些辯解說依賴使用者會減少網路擁堵的人提個醒:網際網路應當服務於使用者需求,而不是別的事,作為該觀點的強烈擁護者,我敢肯定我們作為開發者的責任是讓使用者的生活更方便。即便我同意控制網路擁堵很重要,終端使用者的需求仍應排第一位。另一方面,當使用者不是真的需要網路訪問時,減少網路請求,像一個合理的阻止重試的超時(像是好幾分鐘,這通常足夠讓使用者感到沮喪,從電腦前走開)還有顯示“抱歉,我們努力試過了但現在真的無能為力”將是一件好事。
噢,還有為了遵守之前的第1-3條建議,你通常應該在網路處理執行緒裡檢測一個網路問題,把它轉化為一個事件,並在事件處理執行緒裡處理該事件(比如,彈出一個對話方塊)。
5. 一定要給使用者提供有意義的錯誤資訊提示
從終端使用者的觀點看來,“網路不可訪問”、“拒絕連線”和“連線被中止”這些資訊之間根本沒什麼不同。如果你可以的話,你可能想要告訴使用者,他的網線沒插好,或者你的伺服器宕了,或者是這兩者之間的什麼原因,然而將他的空間胡亂地用諸如以上這類“對你而言有意義但對他毫無意義”的資訊塞滿,這可是個壞主意。更糟的是,要把這些技術細節隱藏在試圖把網路故障原因“翻譯”成像“伺服器正有點小毛病”和“你的連線已中斷”這樣的資訊後面,而同一時間有不止一條這樣的資訊(更糟的是不同的資訊有不同外觀的UI)。
盡一切辦法,一定要把錯誤資訊從你的空間“翻譯”到終端使用者的空間,不過只要從終端使用者的角度看來錯誤是無法辨別的就行,也就是讓錯誤資訊看起來一樣(用錯誤程式碼的非闖入式的外觀,或者‘更多資訊’的按鈕,讓技術支援的生活更簡單)。
6. 一定要支援多平臺
在現代遊戲環境的前提下,單平臺的遊戲引擎一般不太有吸引力。即使你的引擎是為單個app開發的,你能肯定它永遠不會被移植到另一個平臺上嗎?就算你肯定的話,你也不應該這麼做。實際上,使網路程式碼跨平臺比影象開發的問題要少得多(就是說,除非你為某種具體的技術而瘋狂到你忽略了其他所有的技術,這樣是很糟糕的一件事),因此讓你的網路層只適合單平臺就不那麼理由充分(除非你的整個遊戲引擎已經是單平臺的了)。
僅供參考——我個人已經看過我自己的網路庫在Windows, Linux, Mac OS X, FreeBSD, iOS,甚至是Android上執行,幾乎沒有什麼變化(在Android上從NDK裡執行)。因此,它甚至用一種行對行的方式被移植到Java,不過這是另一個故事了。
6a. 一定要在客戶端使用伯克利套介面
伯克利套介面……是一個有為網路套介面和Unix域套介面設計的API的計算庫,可用於過程間通訊(IPC)。——維基百科
如果你用C/C++完成你的網路引擎並且認為你的應用程式是“僅用於Windows”的,使用Windows特定函式(那些有WSA*()字首的函式)來通訊可能是誘人的。別這麼做,用伯克利套介面(那些socket()/connect()/send()/recv())函式取而代之吧。至於它們的用法的細節,Google吧。需要更進一步的細節,請參考[Stevens]。
對其他程式語言來說,它們提供了它們自己的跨平臺API,一般選擇一個可移植的網路庫,問題就少得多。
7. 一定要考慮給自動更新程式提供一個通道
通常,自動更新不被認為是遊戲引擎的一部分。然而,以敝人之見,這裡有一個把它包含進網路層的好例子。理由如下:
- 使用者可能想要得到一些可選擇的東西(從主題到DLC)
- 如果能邊玩邊下載,使用者會感激的
- 使用者不會喜歡下載被遊戲中斷
- 通過在網路層保持可選式的下載,你在某些情況下可以優先化擁堵網路,最小化遊戲下載的影響(我們將在(2b)的第17條建議裡討論某些相關技巧)
- 當QoS在網路中不起作用時(見(2b)的第17b條建議),兩條平行連線更有可能互相干擾
- 如果你支援可選式下載,它們應該也是自動更新的,這樣可選式下載和自動更新的整合就是一件好事
- 那麼,整個自動更新最好作為網路引擎的一部分被實現
- 作為一項附加福利,你將能在使用者玩遊戲時下載自動更新,使得他們的遊戲時間最大化。
以上這些理由遠不是那麼絕對,不過,這樣一個體系已經被實現了,我看到它工作得極其出色。
一個要注意的小貼士:儘管和網路庫作了整合,你也應該通過HTTP(而不是通過你自己的協議)實現自動更新(那些在遊戲程式啟動之前就啟動的自動更新)的初始化。這樣不會太增加遊戲本身的複雜度,但允許大大地改變網路協議。
自動更新主題的剩餘部分相當複雜,所以我很可能會為它單獨寫一篇文章。
待續……
為了避免太長懶得看的問題,文章被分成了好幾部分。請繼續閱讀(2). 協議和API。
編輯:剩下的部分已經發表了:
(2a). 協議和API(上)
(2b). 協議和API(下)
(3a). 伺服器端(儲存處理和發展的體系)
(3b). 伺服器端(配置,優化,和測試)
Part IV. TCP和UDP的大論戰
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!
任選一種支付方式