基於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會不會成爲新的選擇呢?
做爲行業技術人員,咱們須要作的,即是永遠保持開發的心態.
七、參考文獻