erlang 遊戲服務器開發

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

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

 

介紹
本文以個人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集羣簡單的以下所示:shell

代碼: 
erl -mnesia extra_db_nodes /['db@apple','db@orange'/] -s mnesia start


-s mnesia start至關於這樣在erlang shell裏啓動Mnedia:數據庫

代碼: 
erl -mnesia extra_db_nodes /['db@apple','db@orange'/]
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考慮一下如何簡化你的多人在線服務器架構。並且我隨時願意幫忙!

相關文章
相關標籤/搜索