基於Lua的遊戲服務端框架簡介

基於Lua的遊戲服務端框架簡介

基於lua的遊戲服務端框架簡介node

 

1. 引言python

       筆者目前在參與一款FPS端遊的研發,說是端遊,其實研發團隊比不少手遊團隊還小.
        咱們的服務端團隊只有2我的,然而,小夥伴們發現:linux

-            後臺開發極爲快速,進度遠遠超前.git

-            穩定,從不宕機.程序員

-            Bug定位修復神速,服務器甚至無需重啓.github

        固然,目前只是研發期,可能問題暴露較少,而戰鬥邏輯也由UE4引擎承擔了.
         可是除此之外,是否是還有啥祕訣呢?
        這就是本文要介紹的: 基於lua的遊戲服務端框架web

 

 

2. 概述數據庫

        本文所述內容,並不涉及服務器集羣的進程劃分與拓撲結構.
        爲理解方便,咱們假定服務器集羣劃分爲以下的這些進程(跟鵝廠其餘遊戲項目大同小異):數組

-            router: 數據轉發,多進程按負載分擔,支持點對點,廣播,主從,哈希等幾種常見的數據轉發邏輯.服務器

-            gamesvr: 提供客戶端接入邏輯,以及常規的遊戲邏輯(如道具,商城,等等...), 多實例按負載分擔.

-            dbagent: 提供數據庫訪問服務,多進程按哈希分佈.

-            matchsvr: 提供戰鬥匹配服務,多進程主從備份.

-            其餘服務進程,再也不列舉.

        本文所述框架以C++爲基礎層,以lua爲業務層,旨在解決如下3個問題.

 · 如何方便的在進程之間通訊?

        假想一個情景:咱們要從gamesvr向matchsvr發一個請求,將一個玩家隊伍加入匹配隊列.
        請求中包含的信息: gamesvr的id, 匹配的模式(幾對幾),是否接受機器人,各玩家的id,各玩家的段位(elo).
        咱們的程序員要幹些什麼事情呢?

        在協議描述的XML中定義這個消息結構,呃,可能還要嵌套結構.

        轉換XML,生成對應的.h,.c,.tdr之類的.

        在gamesvr中編寫發送消息代碼.

        在matchsvr編寫消息處理邏輯代碼.

        呃,對了,可能還要在派發消息的地方註冊一下消息

        而上面這些,跟業務邏輯有關的,其實只有3,4,其餘都是累贅.
        咱們能不能只關注業務邏輯,不要這些累贅呢?
        固然能夠,這就是基於lua的遠程調用: 無需額外的協議定義,直接編寫業務代碼

        以下所示,gamesvr發起調用:

 

local mode = 3; -- 3v3

local team = {...}; --隊伍成員列表

local robot = false;

--從gamesvr調用matchsvr的消息函數: OnMatchRequest

remote.CallMatchSvr("OnMatchRequest", app.iId, mode, robot, team);

 

        下面爲matchsvr的響應代碼:

 

--遠程調用OnMatchRequest的實現

--約定全部遠程調用都必須定義在全局表s2s中

--s2s含義爲: server to server

function s2s.OnMatchRequest(svr, mode, robot, team)   

    -- 加入匹配池...

end

 · 如何使得咱們的開發過程更加順暢,運維響應更加及時.

 開發過程

        繼續上面的2.1節的場景,在傳統的C++實現中,想一想,程序員寫完兩邊的消息代碼,要繼續幹什麼?

一、關掉服務器,嗯,若是共用服務器,還得吼一下: 我要關服了.

二、make ...

三、啓動服務器.

四、呃,客戶端聯調的兄弟,你從新登陸一下,對了,記得要開幾個客戶端從新組隊哦.

        真繁瑣啊,能不能簡單點?
        是的,在新框架下,寫完業務邏輯,你須要作的只是Ctrl+S,代碼當即生效,自動!
        這就是新框架下的代碼自動熱更新,它讓上面的1,2,3,4都成多餘.

 

運維事件

        假定如今運營環境發現一個嚴重Bug,而咱們知道只要簡單的改一行代碼就行了.
        又或者,誰都不知道咋回事,還不能上GDB暫停,只能先在某函數處加行日誌看看.
        咱們要經歷怎樣的過程? 嗯,誰經歷誰知道啊.
        有了代碼熱更新技術,咱們對在線Bug的修復再也不頭疼,甚至秒修.

 

 ·  如何完全擺脫空指針,野指針,內存越界等頑疾,提供更加穩定的服務?

        嗯,這個就屬於lua語言自己的特性了.
        連指針都沒有,所謂空指針,野指針,內存越界,就無從談起.
        即使是新手程序員,也沒機會犯這種錯誤.
        即使是除零之類的錯誤,也不過是當前這一條消息出錯,下一條消息照常處理.
        另外,lua自己的實現,也屬於公認的高質量代碼,值得信賴.

 

三、 歷史

        lua在遊戲領域的應用,大概是從<魔獸世界>火起來的.
        本文要介紹的技術基礎,沿襲自<劍網三>(一些業界同行也有相似的實現).
        筆者在2004年至2011年期間,負責<劍網三>的服務端團隊.
        <劍網三>服務端架構中,雖然一開始就引入了lua支持,可是早期只是做爲業務粘合層而存在.
        在研發階段的中後期,引入了兩個技術點來加速開發速度:

-            遠程調用: 今後擺脫C++層面的協議定義,數據組織編碼,編譯更新等繁瑣的過程.

-            數據存儲: C++層面無需關心數據存儲結構,無需再寫大量的DB操做代碼(MySQL);

 其實這兩個點都是基於lua序列化&反序列化的.
到09年上線時,<劍網三>服務端的lua邏輯大概佔比在30%左右,並不算高.
這是由於:

-            <劍網三>的服務端屬於計算密集型(3D場景邏輯,戰鬥技能,AI,等等).

-            做爲筆者入行的第一個項目,出於對性能的謹慎,限制了lua在服務端的應用廣度.

         2011年初,我離開服務了6年多的<劍網三>團隊,出去創業.
        這時,咱們已經再也不擔憂性能問題,而更須要的是快速實現,快速響應,因而lua開始大行其道.
        特別是12年咱們開始作手遊時,C++層面差很少只剩下了網絡層,客戶端也是底層基於cocos2d-x,邏輯都在lua.
        順便提一下,也差很少是那個時候,從網易出來創業的雲風也推出了基於lua的skynet開源框架.

        2015年的春天,懷着對創業的絕望,我來到了騰訊.
        嗯,驚奇的發現,騰訊的遊戲服務端實現中,lua應用得很是少.
        因而,便有了此文,介紹一下咱們正在使用的基於lua的技術框架.

 

4.、技術基礎

 · lua的C++綁定

實現原理

一、 爲每一個導出的class建立了一個table(lazy模式), 其中存放了class的成員函數指針以及成員變量偏移.

二、 在上面的class專屬table中,咱們將表中的__index, __newindex指向咱們的定製的C++函數.

三、對象首次被push到lua時,會建立一個table與之綁定,稱爲影子對象,該table中記錄了對象指針,並以第1步的table做爲其元表.

四、當在腳本中經過影子對象訪問C++對象的成員時,經過元表的__index, __newindex方法定向到C++對象成員.

五、C++對象上也記錄了影子對象的引用,在對象析構的時候將清除影子對象中存放的指針.

 

C++對象導出示例

h 中的class 聲明代碼:

 

// class 聲明中須要插入一行 DECLARE_LUA_CLASS

class CPlayer
{

    char m_szName[32];

    int m_iLevel;

    int luaSay(lua_State* L);

    DECLARE_LUA_CLASS(CPlayer); // 聲明導出CPlayer類

};

 

.cpp 中的實現代碼:

 

// 在 CPP 中增長以下的導出聲明

IMPL_LUA_CLASS_BEGIN(CPlayer)

EXPORT_LUA_STRING(m_szName)

EXPORT_LUA_INT(m_iLevel)

EXPORT_LUA_FUNCTION(luaSay)

IMPL_LUA_CLASS_END()

 

int CPlayer::luaSay(lua_State* L)

{

    // ...

    return0;

}

 

        注意,這不是實際存在的代碼;對於業務邏輯都在 lua 中實現的項目而言,真正須要導出的 C++ 代碼極少.

 

主要特性

-            在lua中讀寫對象的C++成員變量(也可聲明爲只讀).

-            在lua中調用對象的C++成員函數.

-            在lua中對影子對象添加新的"成員變量","成員函數".

-            在lua中覆蓋對象中導出的C++函數.

-            在C++中調用影子對象上的lua函數.

 

實際使用示例

        C++部分代碼: 在玩家登錄時調用login.lua中定義的lua函數.

 

void OnPlayerLogin(lua_State* L, int iConnIdx, CPlayer* player)

{

    CSafeStack guard(L);

    // 除了獲取文件中的函數,還有其餘的相關的API

    // 用來獲取影子對象上的函數,以及全局 table 中的函數等等

    Lua_GetFileFunction(L, "login.lua", "OnPlayerLogin");

    lua_pushinteger(L, iConnIdx);

    Lua_PushObject(L, player);

    Lua_XCall(L, 2, 0);

}

 

lua部分代碼: 響應上面C++代碼觸發的玩家登錄事件.

 

function OnPlayerLogin(connIdx, player)

    --訪問成員變量: 讀

    log_debug("player login, name="..player.szName);

    --訪問成員變量: 寫

    player.iLevel = 1;

    --調用成員函數

    player.Say("皇上吉祥");

    --在player對象上加入新的函數/變量

    player.OnExit = function()

        -- do something !

    end

end

 

另外一個實現

        關於lua的C++綁定,其實還有另外一個基於C++14的實現(還在完善中,歡迎提意見:).
        主要特性在於函數參數操做再也不須要寫一堆的lua_to***, lua_push***之類的代碼,可直接導出通常C++函數.
        遺憾的是,我司的編譯器版本並不支持C++14標準,即使是tlinux2.0也不支持(GCC 4.8.2,其實C++11也只是部分支持).
        也許某天Docker的普及可讓項目本身指定編譯器版本.
        C++14版本的實現

 

 · lua文件沙盒

代碼示例

        關鍵函數: import

        在main.lua中import另外兩個文件: a.lua, b.lua

 

--main.lua

a = import("a.lua");

b = import("b.lua");

 

print("a.txt="..a.txt); --輸出: A

a.txt = "X"; --修改 a 中的變量,不影響 b.

print("b.txt="..b.txt); --輸出: B

 

        a.lua,注意它也import了b.lua,可是b.lua在main.lua中已經加載了,兩處會引用同一份實例.

 

txt = "A";

b = import("b.lua");

print("b.txt="..b.txt); --輸出: B

 

        b.lua,注意跟上面的a.lua定義了同名變量,但實際互不影響:

 

--變量txt並非真正的全局變量

--而是存在於本文件的環境表中.

txt = "B";

 

實現原理

-            內部維護了一個文件加載表,記錄了文件名及加載時間之類的.

-            加載lua文件時,會先爲其建立一個獨立的環境表,而後再執行文件,這樣,文件中的"全局"符號實際上就定義在了環境表中.

-            import一個文件時,先檢查文件是否已經加載,如已經加載,則再也不加載,直接返回其環境表.

-            從新加載時,跟以前使用同一個環境表.

 

爲啥不用自帶的require?

-            require一個文件時,文件中聲明的變量默認是全局的(除非加個local),項目大了容易發生名字衝突覆蓋,

-            require固然也支持在文件加載時返回一個導出表,可是得去本身去寫這個導出表.

-            咱們須要對已經加載的文件作變動檢測並熱更新.

 

 ·  序列化

        這個是整個框架中一個很基礎的模塊,但並不複雜,簡單來講有幾點:

-            序列化的數據是二進制的,反序列化時無需額外的文本解析過程.

-            序列化數據是自描述的,解析數據無需原來生成數據的代碼.

-            採用了變長整數來減小小整數的空間佔用(相似utf-8的編碼方式).

-            採用了共享字符串以減小重複字符串的空間佔用.

-            數據長度達到必定閾值時,會加一層lz4壓縮.

 

 · 遠程調用與持久化

        此兩項技術均基於上一節的序列化而實現:

-            遠程調用: 函數調用參數序列化,經過通訊層轉到目標進程再展開,調用.

-            數據持久化: 將lua數據結構序列化,存入數據庫.

 

        遠程調用原理示意圖:

 

 

        在"概述"一章中,已經簡單介紹了遠程調用的實際用法. 示例中,remote是一個C++全局導出對象,CallMatchSvr是其導出的一個成員函數.
        實際上,對應於更多種類的服務進程以及轉發方式,還有不少對應的接口.
        這些接口的C++實現都差很少,實際上是由同一個模板經過不一樣的參數特化而來. 這裏舉幾個典型的例子,方便理解:

-            remote.CallMatchSvr(FuncName, ...): 調用MatchSvr的函數,按主從邏輯轉發.

-            remote.CallGameSvrAll(FuncName, ...): 調用全部GameSvr的某函數,也就是廣播.

-            remote.CallDBAgentHash(Acc, FuncName, ...): 以Acc爲Key,按哈希的方式轉發,調用DBAgent函數.

-            remote.CallTarget(target, FuncName, ...): 調用指定tbus id(target)進程的函數.

 

 · 我爲何不喜歡協程?

        先看個例子,猜猜裏面有幾個Bug:

 

-- ibcenter,表明另外一個進程,專門負責道具交易管理

-- ibcenter.BuyItem,內部用協程實現,其實是異步的

function BuyItem(player, itemIdx, price)

    if player.fighting then

        --戰鬥中不能買東西

        return;

    end

   

    if player.money <= price then

        --錢不夠

        return;

    end

   

    local id, item = ibcenter.BuyItem(itemIdx); --協程異步

    if id then

        player.items[id] = item;

        player.money = player.money - price;

    end

end

 

        經過這個示例,咱們應該能感覺到協程的實際問題:

-              隱藏了函數調用的異步性,容易讓不知內情的人寫出意外的代碼.

-              帶有狀態數據的協程,每每是藏污納垢之處.

 

· 熱更新

        原理已經在文件沙盒一節中闡明,這裏說說注意事項.

 

不要把持import的文件內部符號,不然在文件從新加載後可能不被更新.

        以下所示,這裏的代碼把持了config.lua的的內部變量config.

 

--注意這裏的cfg,在文件config.lua被熱更新後將仍然是舊的.

cfg = import("config.lua").config;

print("config.txt="..cfg.txt);

 

 文件內的"全局"變量定義,要考慮文件熱更新,不然更新時可能會丟失運行時數據

        這樣寫在熱更新後會丟失數據:

 

-- 加入的玩家列表

-- 這樣寫在熱更新後會丟失數據

playerTable = {};

 

function c2s.OnPlayerJoin(connIdx)

    local ss = ssmgr.GetSession(connIdx);

    playerTable[ss.acc] = os.time();

end


        這樣寫在熱更新後數據保持:

 

-- 這樣寫在熱更新後數據還在

ifnot playerTable then

    playerTable = {};

end

 

function c2s.OnPlayerJoin(connIdx)

    local ss = ssmgr.GetSession(connIdx);

    playerTable[ss.acc] = os.time();

end

五、項目實際中的其餘問題

· 與TDR組件的適配

        以client到gamesvr的上行消息爲例:
        TDR 消息定義.

 

< span=""> name="MoveItemReq" version="1" desc="移動道具">

  < span=""> name="Item" type="uint64" desc=""/>

  < span=""> name="Bag" type="uint8" desc="移動到哪一個包"/>

  < span=""> name="Pos" type="uint16" desc="包中的位置"/>

 

 

        C++通訊層在收到上行的請求包後,調用中間的C++適配層函數,將消息傳入lua.
        注意這裏的C++適配層代碼僅爲示意,跟實際差異很大.
        在項目,咱們經過一個python腳本自動生成這個適配層代碼,無需手工編寫.
        咱們之因此還有這個適配層代碼,是由於咱們的客戶端不支持lua腳本.
        對大多數項目,是無需作這個適配層的.

 

void OnMoveItemReq(lua_State* L, int iConnIdx, TFMsgBody& msgBdy)

{

    if (!Lua_GetTableFunction(L, "c2s", "OnMoveItemReq"))

        return;

    lua_pushinteger(L, iConnIdx);

    tagMoveItemReq& o = msgBdy.stMoveItemReq;

    lua_pushinteger(L, o.ullItem);

    lua_pushinteger(L, o.bBag);

    lua_pushinteger(L, o.wPos);

    Lua_XCall(L, 4, 0);

}

 

        lua 業務層代碼.

 

function c2s.OnMoveItemReq(connIdx, itemId, bag, pos)

    local player = playerTable[connIdx];

    -- do some thing ...

    tdr.SendSyncItemData(connIdx, item);

end

 

 ·  策劃表格的讀取

        咱們經過一個 python 腳本,將 excel 文件直接轉換爲 lua 代碼文件.

-            轉換結果是文本文件,一目瞭然.

-            無需額外寫任何加載的代碼.

-            與lua邏輯代碼同樣方便的熱更新.

 

index

說明

適用職業

槽位

是否消耗品

10101

機槍

1,3,5

1

0

10102

能量槍

1

2

0

20101

榴彈

2

3

1

 

        excel 通過一個 python 腳本轉換後變成這樣:

 

weapons =

{

    [10101] = {index=10101, desc="機槍", profession={1,3,5}, slot=1, consumable=nil},

    [10102] = {index=10102, desc="能量槍", profession={1}, slot=2, consumable=nil},

    [20101] = {index=20101, desc="榴彈", profession={2}, slot=3, consumable=true},

};

 

 · 遠程調用與持久化中的版本兼容處理

        序列化數據用在遠程調用和數據持久化中,就不能不說起版本兼容問題.
        實際上,因爲咱們的序列化數據是自描述的,因此很是易於實現版本兼容.
        好比咱們舊版角色數據以下:

 

player

├─ lastLoginIP: 172.16.11.152

├─ name: 張三

├─ lastLoginTime: 1460514943

└─ level: 10

 

        如今咱們要在新版中增長一個任務系統,新版的角色數據像這樣.
        也就是多了一個tasks的table用來記錄任務進度.

 

player

├─ tasks

│  ├─ 12

│  │  ├─ id: 12

│  │  └─ count: 123

│  └─ 11

│     ├─ id: 11

│     └─ count: 1

├─ name: 張三

├─ level: 10

├─ lastLoginTime: 1460515197

└─ lastLoginIP: 172.16.11.152

 

        那麼,咱們如何實現版本兼容呢?
        其實很簡單,只須要在登陸加載時作一個判斷便可:

 

function OnLoadFromDB(player)

    --沒有player.tasks數據項,說明是舊版的

    ifnot player.tasks then

        player.tasks = {};

    end

end

 

· 調試輔助

        lua 在常被人詬病的一點是調試器很差用.
        不過以我實際體驗來看,這並非什麼問題.
        順便說一句,代碼難於調試一般是實現者的問題,跟語言沒啥關係:)
        但咱們仍是有些輔助手段.

        詳盡的錯誤日誌,大部分錯誤經過看日誌能知道基本脈絡.

 

20151028 16:14:41: [Lua_XCall] [string "match_script/match.lua"]:288:

attempt to perform arithmetic on a table value (field 'sideA') stack traceback:

        [string "match_script/match.lua"]:288: in global 'MatchAcrossBucket'

        [string "match_script/match.lua"]:187: in global 'MatchForBucket'

        [string "match_script/match.lua"]:181: in global 'MatchForPool'

        [string "match_script/match.lua"]:545: in field 'MatchAll'

        [string "match_script/main.lua"]:17: in function <[string "match_script/main.lua"]:15>

 

        圖形化的數據顯示,讓人直觀的理解複雜數據結構,只須要簡單的一句 tree.Show(data) :

 

20151028 16:18:05: Match sideA:

20151028 16:18:05: ├─ 1

20151028 16:18:05: │  ├─ ids

20151028 16:18:05: │  │  ├─ 1: 1000

20151028 16:18:05: │  │  ├─ 2: 1001

20151028 16:18:05: │  │  └─ 3: 1002

20151028 16:18:05: │  ├─ tag: 3.a

20151028 16:18:05: │  ├─ elos

20151028 16:18:05: │  │  ├─ 1: 800

20151028 16:18:05: │  │  ├─ 2: 800

20151028 16:18:05: │  │  └─ 3: 800

20151028 16:18:05: │  └─ svr: 1

20151028 16:18:05: └─ 2

20151028 16:18:05:    ├─ ids

20151028 16:18:05:    │  ├─ 1: 3000

20151028 16:18:05:    │  └─ 2: 3001

20151028 16:18:05:    ├─ tag: 2.c

20151028 16:18:05:    ├─ elos

20151028 16:18:05:    │  ├─ 1: 800

20151028 16:18:05:    │  └─ 2: 800

20151028 16:18:05:    └─ svr: 1

 

· 工程建議

-            儘可能不要在 lua 中去模擬其餘語言特性,如 class, 多態繼承之類的.

-            適時重構,保持代碼目錄結構,文件劃分的簡單清晰.

-            一個好的編輯器不僅是讓編碼順暢,還能幫助咱們避開不少手誤.

-            協程固然能夠適當的用,但一個處處都是yield的項目,最後極可能會是代碼維護的噩夢.

 

六、回顧,問題與展望

· 回顧

        經過基於lua的新框架,咱們得到了哪些優點:

-            遠程調用: 方便快捷的跨進程通訊,無需額外作協議定義&轉換.

-            序列化存儲: 無需額外作數據格式定義,數據自描述,不存在數據與結構體定義不一致的問題.

-            高效率開發,無需在語言自己的特性上掙扎,把更多的精力投到業務邏輯自己上來.

-            修改代碼存盤即生效,省去繁瑣的編譯,重啓,再登陸等過程,開發調試過程更順暢.

-            完全擺脫空指針,野指針,內存越界,媽媽不再用擔憂服務器半夜宕機了.

-            運營過程當中的快速熱修復,更及時的運營響應.

-            下降對程序員的要求,語言簡單,即學即會.

-            減小團隊人員需求,下降項目成本.

 

 · 可能的問題

動態一時爽,重構火葬場.

        這話當然有些誇張,但不能否認,動態語言對如何編寫高性能&易維護的代碼提出了新的挑戰.
所謂重構火葬場便是說若是在編碼時不注重代碼的易維護性,寫得"太聰明",別說重構,幾天後甚至本身都看不懂.
lua保障了程序的最差的狀況不會宕機之類的,可是一個寫很差C++的程序員,一般也寫很差lua.
反之亦然,一個始終關注代碼性能與可維護性的程序員,能寫好C++, 更能寫好lua.

 

性能,性能,性能

        儘管lua已是腳本語言中性能最好的,可是仍是要強調一下性能.

-            儘可能使用局部變量,某些狀況下會比全局變量或table成員變量性能好不少.

-            注意table的填充,不一樣的寫法性能有較大差別.

-            注意table實際上分爲數組和哈希兩種,性能也有差別.

-            拼接字符串是有消耗的.

-            儘可能避免零碎的,臨時的,大量的table,以及string.

-            對某種寫法的性能有疑惑的話,除了實測,還能夠查看字節碼(相似彙編).

-            lua對數字不少狀況下都用double實現,能夠全局性的定義成int64_t提高性能.

-            讀一讀參考文獻中的文章,寫出高性能的lua代碼並不難.

 

· 展望

        沒有什麼技術在全部領域都是最好的,更不可能一直是最好的.
        在過去端遊年代,大型MMORPG大行其道,服務端計算密集,C++是不二之選.
        而在手遊時代,即便一樣是MMORPG,已經不多是計算密集型了,而行業競爭卻愈演愈烈.
        在現階段,咱們更須要的是一種能快速實現,快速響應的技術,lua算是一個不錯的選擇.
        然而,隨着遊戲技術與web技術的逐漸融合,誰知道哪天node.js會不會成爲新的選擇呢?
        做爲行業技術人員,咱們須要作的,即是永遠保持開發的心態.

 

七、參考文獻

-            如何編寫高質量的lua代碼,也有中文版

-            lua非官方FAQ,值得一看

-            一個印度人搞的lua方言,值得一看

相關文章
相關標籤/搜索