1. 程式人生 > >erlang 遊戲伺服器開發

erlang 遊戲伺服器開發

最近關注erlang遊戲伺服器開發 erlang大牛寫的遊戲伺服器值得參考

介紹
本文以我的OpenPoker專案為例子,講述了一個構建超強伸縮性的線上多遊戲玩家系統。
OpenPoker是一個超強多玩家紙牌伺服器,具有容錯、負載均衡和無限伸縮性等特性。
原始碼位於我的個人站點上,大概10,000行程式碼,其中1/3是測試程式碼。

在OpenPoker最終版本敲定之前我做了大量調研,我嘗試了Delphi、Python、C#、C/C++和Scheme。我還用Common Lisp寫了紙牌引擎。
雖然我花費了9個月的時間研究原型,但是最終重寫時只花了6個星期的時間。
我認為我所節約的大部分時間都得益於選擇Erlang作為平臺。

相比之下,舊版本的OpenPoker花費了一個4~5人的團隊9個月時間。

Erlang是什麼東東?



我建議你在繼續閱讀本文之前瀏覽下Erlang FAQ,這裡我給你一個簡單的總結...

Erlang是一個函式式動態型別程式語言並自帶併發支援。它是由Ericsson特別為控制開關、轉換協議等電信應用設計的。
Erlang十分適合構建分散式、軟實時的併發系統。

由Erlang所寫的程式通常由成百上千的輕量級程序組成,這些程序通過訊息傳遞來通訊。
Erlang程序間的上下文切換通常比C程式執行緒的上下文切換要廉價一到兩個數量級。

使用Erlang寫分散式程式很簡單,因為它的分散式機制是透明的:程式不需要了解它們是否分佈。

Erlang執行時環境是一個虛擬機器,類似於Java虛擬機器。這意味著在一個價格上編譯的程式碼可以在任何地方執行。
執行時系統也允許在一個執行著的系統上不間斷的更新程式碼。
如果你需要額外的效能提升,位元組碼也可以編譯成原生代碼。

請移步Erlang site,參考Getting started、Documentation和Exampes章節等資源。

為何選擇Erlang?


構建在Erlang骨子裡的併發模型特別適合寫線上多玩家伺服器。

一個超強伸縮性的多玩家Erlang後端構建為擁有不同“節點”的“叢集”,不同節點做不同的任務。
一個Erlang節點是一個Erlang VM例項,你可以在你的桌面、膝上型電腦或伺服器上上執行多個Erlang節點/VM。
推薦一個CPU一個節點。

Erlang節點會追蹤所有其他和它相連的節點。向叢集裡新增一個新節點所需要的只是將該新節點指向一個已有的節點。
一旦這兩個節點建立連線,叢集裡所有其他的節點都會知曉這個新節點。

Erlang程序使用一個程序id來相互發訊息,程序id包含了節點在哪裡執行的資訊。程序不需要知道其他程序在哪裡就可以通訊。
連線在一起的Erlang節點集可以看作一個網格或者超級計算裝置。

超多玩家遊戲裡玩家、NPC和其他實體最好建模為並行執行的程序,但是並行很難搞是眾所皆知的。Erlang讓並行變得簡單。

Erlang的位語法∞讓它在處理結構封裝/拆解的能力上比Perl和Python都要強大。這讓Erlang特別適合處理二進位制網路協議。

OpenPoker架構

OpenPoker裡的任何東西都是程序。玩家、機器人、遊戲等等多是程序。
對於每個連線到OpenPoker的客戶端都有一個玩家“代理”來處理網路訊息。
根據玩家是否登入來決定部分訊息忽略,而另一部分訊息則傳送給處理紙牌遊戲邏輯的程序。

紙牌遊戲程序是一個狀態機,包含了遊戲每一階段的狀態。
這可以讓我們將紙牌遊戲邏輯當作堆積木,只需將狀態機構建塊放在一起就可以新增新的紙牌遊戲。
如果你想了解更多的話可以看看cardgame.erl的start方法。

紙牌遊戲狀態機根據遊戲狀態來決定不同的訊息是否通過。
同時也使用一個單獨的遊戲程序來處理所有遊戲共有的一些東西,如跟蹤玩家、pot和限制等等。
當在我的膝上型電腦上模擬27,000個紙牌遊戲時我發現我擁有大約136,000個玩家以及總共接近800,000個程序。

下面我將以OpenPoker為例子,專注於講述怎樣基於Erlang讓實現伸縮性、容錯和負載均衡變簡單。
我的方式不是特別針對紙牌遊戲。同樣的方式可以用在其他地方。

伸縮性
我通過多層架構來實現伸縮性和負載均衡。
第一層是閘道器節點。
遊戲伺服器節點組成第二層。
Mnesia“master”節點可以認為是第三層。

Mnesia是Erlang實時分散式資料庫。Mnesia FAQ有一個很詳細的解釋。Mnesia基本上是一個快速的、可備份的、位於記憶體中的資料庫。
Erlang裡沒有物件,但是Mnesia可以認為是面向物件的,因為它可以儲存任何Erlang資料。

有兩種型別的Mnesia節點:寫到硬碟的節點和不寫到硬碟的節點。除了這些節點,所有其他的Mnesia節點將資料儲存在記憶體中。
在OpenPoker裡Mnesia master節點會將資料寫入硬碟。閘道器和遊戲伺服器從Mnesia master節點獲得資料庫並啟動,它們只是記憶體節點。

當啟動Mnesia時,你可以給Erlang VM和直譯器一些命令列引數來告訴Mnesia master資料庫在哪裡。
當一個新的本地Mnesia節點與master Mnesia節點建立連線之後,新節點變成master節點叢集的一部分。

假設master節點位於apple和orange節點上,新增一個新的閘道器、遊戲伺服器等等。OpenPoker叢集簡單的如下所示:

程式碼: 全選 erl -mnesia extra_db_nodes /['[email protected]','[email protected]'/] -s mnesia start


-s mnesia start相當於這樣在erlang shell裡啟動Mnedia:

程式碼: 全選 erl -mnesia extra_db_nodes /['[email protected]','[email protected]'/]
Erlang (BEAM) emulator version 5.4.8 [source] [hipe] [threads:0]

Eshell V5.4.8 (abort with ^G)
1> mnesia:start().
ok




OpenPoker在Mnesia表裡儲存配置資訊,並且這些資訊在Mnesia啟動後立即自動被新的節點下載。零配置!

容錯
通過新增廉價的Linux機器到我的伺服器叢集,OpenPoker讓我隨心所欲的變大。
將幾架1U的伺服器放在一起,這樣你就可以輕易的處理500,000甚至1,000,000的線上玩家。這對MMORPG也是一樣。

我讓一些機器執行閘道器節點,另一些執行資料庫master來寫資料庫事務到硬碟,讓其他的機器運行遊戲伺服器。
我限制遊戲伺服器接受最多5000個併發的玩家,這樣當遊戲伺服器崩潰時最多影響5000個玩家。

值得注意的是,當遊戲伺服器崩潰時沒有任何資訊丟失,因為所有的Mnesia資料庫事務都是實時備份到其他執行Mnesia以及遊戲伺服器的節點上的。

為了預防出錯,遊戲客戶端必須提供一些援助來平穩的重連線OpenPoker叢集。
一旦客戶端發現一個網路錯誤,它應該連線閘道器,接受一個新的遊戲伺服器地址,然後重新連線新的遊戲伺服器。
下面發生的事情需要一定技巧,因為不同型別的重連線場景需要不同的處理。

OpenPoker會處理如下幾種重連線的場景:
1,遊戲伺服器崩潰
2,客戶端崩潰或者由於網路原因超時
3,玩家線上並且在一個不同的連線上
4,玩家線上並且在一個不同的連線上並在一個遊戲中

最常見的場景是一個客戶端由於網路出錯而重新連線。
比較少見但仍然可能的場景是客戶端已經在一臺機器上玩遊戲,而此時從另一臺機器上重連線。

每個傳送給玩家的OpenPoker遊戲緩衝包和每個重連線的客戶端將首先接受所有的遊戲包,因為遊戲不是像通常那樣正常啟動然後接受包。
OpenPoker使用TCP連線,這樣我不需要擔心包的順序——包會按正確的順序到達。

每個客戶端連線由兩個OpenPoker程序來表現:socket程序和真正的玩家程序。
先使用一個功能受限的visitor程序,直到玩家登入。例如visitor不能參加遊戲。
在客戶端斷開連線後,socket程序死掉,而玩家程序仍然活著。

當玩家程序嘗試傳送一個遊戲包時可以通知一個死掉的socket,並讓它自己進入auto-play模式或者掛起。
在重新連線時登入程式碼將檢查死掉的socket和活著的玩家程序的結合。程式碼如下:

程式碼: 全選 login({atomic, [Player]}, [_Nick, Pass|_] = Args)
when is_record(Player, player) ->
Player1 = Player#player {
socket = fix_pid(Player#player.socket),
pid = fix_pid(Player#player.pid)
},
Condition = check_player(Player1, [Pass],
[
fun is_account_disabled/2,
fun is_bad_password/2,
fun is_player_busy/2,
fun is_player_online/2,
fun is_client_down/2,
fun is_offline/2
]),
...




condition本身由如下程式碼決定:

程式碼: 全選 is_player_busy(Player, _) ->
{Online, _} = is_player_online(Player, []),
Playing = Player#player.game /= none,
{Online and Playing, player_busy}.

is_player_online(Player, _) ->
SocketAlive = Player#player.socket /= none,
PlayerAlive = Player#player.pid /= none,
{SocketAlive and PlayerAlive, player_online}.

is_client_down(Player, _) ->
SocketDown = Player#player.socket == none,
PlayerAlive = Player#player.pid /= none,
{SocketDown and PlayerAlive, client_down}.

is_offline(Player, _) ->
SocketDown = Player#player.socket == none,
PlayerDown = Player#player.pid == none,
{SocketDown and PlayerDown, player_offline}.



注意login方法的第一件事是修復死掉的程序id:

程式碼: 全選 fix_pid(Pid)
when is_pid(Pid) ->
case util:is_process_alive(Pid) of
true ->
Pid;
_->
none
end;

fix_pid(Pid) ->
Pid.




以及:

程式碼: 全選 -module(util).

-export([is_process_alive/1]).

is_process_alive(Pid)
when is_pid(Pid) ->
rpc:call(node(Pid), erlang, is_process_alive, [Pid]).




Erlang裡一個程序id包括正在執行的程序的節點的id。
is_pid(Pid)告訴我它的引數是否是一個程序id(pid),但是不能告訴我程序是活著還是死了。
Erlang自帶的erlang:is_process_alive(Pid)告訴我一個本地程序(執行在同一節點上)是活著還是死了,但沒有檢查遠端節點是或者還是死了的is_process_alive變種。

還好,我可以使用Erlang rpc工具和node(pid)來在遠端節點上呼叫is_process_alive()。
事實上,這跟在本地節點上一樣工作,這樣上面的程式碼就可以作為全域性分散式程序檢查器。

剩下的唯一的事情是在不同的登入條件上活動。
最簡單的情況是玩家離線,我期待一個玩家程序,連線玩家到socket並更新player record。

程式碼: 全選 login(Player, player_offline, [Nick, _, Socket]) ->
{ok, Pid} = player:start(Nick),
OID = gen_server:call(Pid, 'ID'),
gen_server:cast(Pid, {'SOCKET', Socket}),
Player1 = Player#player {
oid = OID,
pid = Pid,
socket = Socket
},
{Player1, {ok, Pid}}.




假如玩家登陸資訊不匹配,我可以返回一個錯誤並增加錯誤登入次數。如果次數超過一個預定義的最大值,我就禁止該帳號:

程式碼: 全選 login(Player, bad_password, _) ->
N = Player#player.login_errors + 1,
{atomic, MaxLoginErrors} =
db:get(cluster_config, 0, max_login_errors),
if
N > MaxLoginErrors ->
Player1 = Player#player {
disabled = true
},
{Player1, {error, ?ERR_ACCOUNT_DISABLED}};
true ->
Player1 = Player#player {
login_errors =N
},
{Player1, {error, ?ERR_BAD_LOGIN}}
end;

login(Player, account_disabled, _) ->
{Player, {error, ?ERR_ACCOUNT_DISABLED}};




登出玩家包括使用Object ID(只是一個數字)找到玩家程序id,停止玩家程序,然後在資料庫更新玩家record:

程式碼: 全選 logout(OID) ->
case db:find(player, OID) of
{atomic, [Player]} ->
player:stop(Player#player.pid),
{atomic, ok} = db:set(player, OID,
[{pid, none},
{socket, none}];
_->
oops
end.




這樣我就可以完成多種重連線condition,例如從不同的機器重連線,我只需先登出再登入:

程式碼: 全選 login(Player, player_online, Args) ->
logout(Player#player.oid),
login(Player, player_offline, Args);




如果玩家空閒時客戶端重連線,我所需要做的只是在玩家record裡替換socket程序id然後告訴玩家程序新的socket:

程式碼: 全選 login(Player, client_down, [_, _, SOcket]) ->
gen_server:cast(Player#player.pid, {'SOCKET', Socket}),
Player1 = Player#player {
socket = Socket
},
{Player1, {ok, Player#player.pid}};




如果玩家在遊戲中,這是我們執行上面的程式碼,然後告訴遊戲重新發送時間歷史:

程式碼: 全選 login(Player, player_busy, Args) ->
Temp = login(Player, client_down, Args),
cardgame:cast(Player#player.game,
{'RESEND UPDATES', Player#player.pid}),
Temp;




總體來說,一個實時備份資料庫,一個知道重新建立連線到不同的遊戲伺服器的客戶端和一些有技巧的登入程式碼執行我提供一個高階容錯系統並且對玩家透明。

負載均衡
我可以構建自己的OpenPoker叢集,遊戲伺服器數量大小隨心所欲。
我希望每臺遊戲伺服器分配5000個玩家,然後在叢集的活動遊戲伺服器間分散負載。
我可以在任何時間新增一個新的遊戲伺服器,並且它們將自動賦予自己接受新玩家的能力。

閘道器節點分散玩家負載到OpenPoker叢集裡活動的遊戲伺服器。
閘道器節點的工作是選擇一個隨機的遊戲伺服器,詢問它所連線的玩家數量和它的地址、主機和埠號。
一旦閘道器找到一個遊戲伺服器並且連線的玩家數量少於最大值,它將返回該遊戲伺服器的地址到連線的客戶端,然後關閉連線。

閘道器上絕對沒有壓力,閘道器的連線都非常短。你可以使用非常廉價的機器來做閘道器節點。

節點一般都成雙成對出現,這樣一個節點崩潰後還有另一個繼續工作。你可能需要一個類似於Round-robin DNS的機制來保證不只一個單獨的閘道器節點。

程式碼: 全選 閘道器怎麼知曉遊戲伺服器?



OpenPoker使用Erlang Distirbuted Named Process Groups工具來為遊戲伺服器分組。
該組自動對所有的節點全域性可見。
新的遊戲伺服器進入遊戲伺服器後,當一個遊戲伺服器節點崩潰時它被自動刪除。

這是尋找容量最大為MaxPlayers的遊戲伺服器的程式碼:

程式碼: 全選 find_server(MaxPlayers) ->
case pg2:get_closest_pid(?GAME_SERVER) of
Pid when is_pid(Pid) ->
{Time, {Host, Port}} = timer:tc(gen_server, call, [Pid, 'WHERE']),
Coutn = gen_server:call(Pid, 'USER COUNT'),
if
Count < MaxPlayers ->
io:format("~s:~w ~w players~n", [Host, Port, Count]),
{Host, Port};
true ->
io:format("~s:~w is full...~n", [Host, Port]),
find_server(MaxPlayers)
end;
Any ->
Any
end.




pg2:get_closest_pid()返回一個隨機的遊戲伺服器程序id,因為閘道器節點上不允許跑任何遊戲伺服器。
如果一個遊戲伺服器程序id返回,我詢問遊戲伺服器的地址(host和port)和連線的玩家數量。
只要連線的玩家數量少於最大值,我返回遊戲伺服器地址給呼叫者,否則繼續查詢。

程式碼: 全選 多功能熱插拔中介軟體


OpenPoker是一個開源軟體,我最近也正在將其投向許多棋牌類運營商。所有商家都存在容錯性和可伸縮性的問題,即使有些已經經過了長年的開發維護。有些已經重寫了程式碼,而有些才剛剛起步。所有商家都在Java體系上大筆投入,所以他們不願意換到Erlang也是可以理解的。

但是,對我來說這是一種商機。我越是深入研究,越發現Erlang更適合提供一個簡單直接卻又高效可靠的解決方案。我把這個解決方案看成一個多功能插座,就像你現在電源插頭上連著的一樣。

你的遊戲伺服器可以像簡單的單一套接字伺服器一樣的寫,只用一個數據庫後臺。實際上,可能比你現在的遊戲伺服器寫得還要簡單。你的遊戲伺服器就好比一個電源插頭,多種電源插頭接在我的插線板上,而玩家就從另一端流入。

你提供遊戲服務,而我提供可伸縮性,負載平衡,還有容錯性。我保持玩家連到插線板上並監視你的遊戲伺服器們,在需要的時候重啟任何一個。我還可以在某個伺服器當掉的情況下把玩家從一個伺服器切換到另一個,而你可以隨時插入新的伺服器。

這麼一個多功能插線板中介軟體就像一個黑匣子設定在玩家與伺服器之間,而且你的遊戲程式碼不需要做出任何修改。你可以享用這個方案帶來的高伸縮性,負載平衡,可容錯性等好處,與此同時節約投資並寫僅僅修改一小部分體系結構。

你可以今天就開始寫這個Erlang的中介軟體,在一個特別為TCP連線數做了優化的Linux機器上執行,把這臺機器放到公眾網上的同時保持你的遊戲伺服器群組在防火牆背後。就算你不打算用,我也建議你抽空看看Erlang考慮一下如何簡化你的多人線上伺服器架構。而且我隨時願意幫忙!

from:http://blog.csdn.net/slmeng2002/article/details/5532771