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


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

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

 

1. 引言

       筆者目前在參與一款FPS端游的研發,說是端游,其實研發團隊比很多手游團隊還小.
        我們的服務端團隊只有2個人,然而,小伙伴們發現:

-            后台開發極為快速,進度遠遠超前.

-            穩定,從不宕機.

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

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

 

 

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++實現中,想想,程序員寫完兩邊的消息代碼,要繼續干什么?

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

2、make ...

3、啟動服務器.

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

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

 

運維事件

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

 

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

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

 

3、 歷史

        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++綁定

實現原理

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

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

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

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

5、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

5、項目實際中的其他問題

· 與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的項目,最后很可能會是代碼維護的噩夢.

 

6、回顧,問題與展望

· 回顧

        通過基於lua的新框架,我們獲得了哪些優勢:

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

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

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

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

-            徹底擺脫空指針,野指針,內存越界,媽媽再也不用擔心服務器半夜宕機了.

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

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

-            減少團隊人員需求,降低項目成本.

 

 · 可能的問題

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

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

 

性能,性能,性能

        盡管lua已經是腳本語言中性能最好的,但是還是要強調一下性能.

-            盡量使用局部變量,某些情況下會比全局變量或table成員變量性能好很多.

-            注意table的填充,不同的寫法性能有較大差異.

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

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

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

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

-            lua對數字很多情況下都用double實現,可以全局性的定義成int64_t提升性能.

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

 

· 展望

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

 

7、參考文獻

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

-            lua非官方FAQ,值得一看

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM