基於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、參考文獻