本文首發自inspoy的雜七雜八 | 菜雞inspoy的學習記錄
前言
最近一直在思考某些事情,然後就拖更了一個月233
其實程式碼也一直在寫,遊戲的主流程也基本上通了,就是一直懶得寫部落格。
OK我們今天來介紹下游戲的服務端是怎麼實現的。
服務端結構
BounceArena的服務端使用node.js開發,這次用了三個程式,分別處理日誌(main.js也是程式入口),socket通訊(SFSocketHandler.js)和具體的業務邏輯(SFGameServer.js)。
main.js
main.js
為程式入口,我們在server/app
目錄下執行node ./
指令就可以了。
這個程式會啟動兩個子程式SFSocketHandler.js
和SFGameServer.js
,這兩個程式執行過程中產生的日誌會通過node的程式通訊機制傳送給main.js
,然後主程式統一處理這些資訊,比如格式化輸出,另存到檔案等等。
主要程式碼如下:
const main = function() {
socketHandler = child_process.fork(__dirname + "/SFSocketHandler.js");
socketHandler.on("message", function(msg) {
if (msg.type == "LOG") {
log(logType_SocketHandler, msg.data, msg.level);
}
});
socketHandler.on('error', function (err) {
log(logType_SocketHandler, "Process Error:\n".red + err, -2);
});
socketHandler.on('exit', function (code, signal) {
log(logType_SocketHandler, "Process Exit:\n" + ("code=" + code + " signal=" + signal), 0);
isSocketHandlerRunning = false;
onExit();
});
isSocketHandlerRunning = true;
// SFGameServer同理
}
main();複製程式碼
其中onExit()
後面會詳細說明,然後log()
是用於輸出日誌的方法(其實也可以用log4js之類的庫,我當時不知道有這個東西,寫完了才發現有個現成的庫可以用orz)
不過既然寫了,就姑且貼出來吧233
/**
* 格式化輸出日誌
* @param {number}type 日誌型別
* @param {string}str 日誌內容
* @param {number}level 日誌等級,等級越低優先度越高
*/
const log = function (type, str, level) {
if (level <= commonConfig.logLevel) {
const timeNow = new Date();
const timeStr = timeNow.Format("yy-MM-dd hh:mm:ss.S - ");
let typeStr = type == logType_SocketHandler ? "[SocketHandler]".cyan : "[ Game Server ]".blue;
let typeStr2 = "";
if (level == commonConfig.logLevel_warning) {
typeStr2 = "[WARNING]".yellow;
}
else if (level == commonConfig.logLevel_error) {
typeStr2 = "[ERROR]".red;
}
else {
typeStr2 = "[INFO]".green;
}
console.log(timeStr.white + typeStr + typeStr2 + " - " + str.white);
}
};複製程式碼
這裡使用了color
庫來方便地設定文字的顏色
我們想結束程式的時候,會按下ctrl+c
組合鍵,為了使所有程式全部正常安全地退出,我這裡監聽了SIGINT
中斷事件,當主程式接收到該訊號時,不會立即退出,而是等待子程式全部安全正常地結束之後才會退出
const onExit = function() {
if (!isSocketHandlerRunning && !isGameServerRunning) {
console.log("[MAIN] - 子程式均安全退出,準備關閉主程式".magenta);
process.exit(0);
}
}複製程式碼
main.js
的主要內容就是這些了
SFSocketHandler.js
主程式會執行這個檔案作為一個子程式
這個程式負責的事情是開啟TCP伺服器,承載TCP連線,接收來自於客戶端的原始資料並作出第一步的處理,然後把整理過的資料傳送給SFGameServer來處理具體的業務邏輯
主要程式碼如下:
const main = function() {
// 啟動redis客戶端
redisClient = redis.createClient();
redisPublisher = redis.createClient();
redisClient.subscribe("BA_RESP");
redisClient.on("message", function(ch, msg) {
processResponse(msg);
});
// 啟動TCP伺服器
const server = net.createServer(onSocket);
server.listen(commonConf.serverPort);
}
main();複製程式碼
程式通訊使用redis的訂閱機制,經過測試,node自帶的process.send()
不好用,延遲非常高,用redis的訂閱的話,延遲可以大幅降低,所以就採用redis來做程式通訊了
然後就是onSocket
這個主要的方法了:
const onSocket = function(socket) {
// 給socket連線一個唯一的ID
socket.id = utils.getRandomString("");
// uid是客戶端登入的使用者名稱,初始化為空
socket.uid = "";
// 下面三個變數在下面介紹
socket.dataBuffer = "";
socket.writeBuffer = "";
socket.writeReady = true;
socket.setTimeOut(30 * 1000); // 超時時間30s
socketData.socketMap[socket.id] = socket;
};複製程式碼
經過之前踩過的坑,socket在接收資料時,由於網路擁堵等原因可能會發生粘包或者斷包,這時就要自己處理分包邏輯。這裡約定資料包的格式為JSON字串+\r\n\r\n
四個字元,以此來劃分粘連在一起的資料包。大致邏輯如下:
socket.on("data", function(data) {
// 把接收到的資料先全部放在dataBuffer裡,這可以理解為一個佇列
socket.dataBuffer += data;
// 要處理完所有的資料,所以是while(true)
while (true) {
const idx = socketBuffer.indexOf("\r\n\r\n");
if (idx == -1) {
// 尋找當前buffer裡還有沒有分隔符,如果沒有的話說明已經處理完了,跳出迴圈
break;
}
// 根據找到的分隔符的位置來擷取單個JSON字串
const req = socket.dataBuffer.substr(0, idx);
socket.dataBuffer = socket.dataBuffer.substr(idx + 4);
try {
// 處理協議
const reqObj = JSON.parse(req);
const pid = reqOjb.pid;
const uid = req.uid;
if (pid > 0) {
// 通過redis的訂閱釋出將json字串傳送給GameServer
redisPublisher.publish("BA_REQ", req);
}
}
catch (e) {
logInfo("協議解析錯誤" + e);
}
}
});複製程式碼
GameServer處理完請求資料後,必定會傳送一個相應返回給客戶端,同樣的,Response資訊將會由GameServer先傳送給SocketHandler,然後由後者傳送給相應的socket連線
/**
* 處理GameServer發來的響應
* @param {string} jsonString 響應資料
*/
const processResponse = function(jsonString) {
// jsonString的格式:{user_list:["userA", "userB", ...], response_data:"{}"}
const respObj = JSON.parse(jsonString);
const userList = respObj["user_list"];
const respData = respObj["response_data"];
let count = 0;
for (let i = 0; i < userList.length; ++i) {
const uid = userList[i];
count += responseWithUid(uid, respData);
}
}複製程式碼
給客戶端傳送資料時,如果網路連線不暢而且傳送的資料量特別大,可能會導致系統的傳送緩衝區溢位,導致客戶端不能收到全部的資訊,就不妙了。
還好node的socket在傳送方法socket.write()
提供了一個返回值,如果返回false的話則說明緩衝區已經開始緊張了,此時如果再有資料需要傳送則可能會出問題,所以我們就先把接下來需要傳送的資料全部暫存在writeBuffer中,直到收到drain
事件,說明緩衝區已清空, 我們就可以繼續傳送資料了
/**
* 根據指定的uid推送響應資料
* @param {string} uid
* @param {string} respJsonString
*/
const responseWithUid = function (uid, respJsonString) {
if (respJsonString == "__KICK__") {
removeSocketWithUid(uid);
return 1;
}
let found = 0;
utils.traverse(socketData.socketMap, function (item) {
if (item.uid != uid) {
return false;
}
item.writeBuffer += respJsonString + "\r\n\r\n";
if (item.writeReady) {
const ret = item.write(item.writeBuffer);
logInfo(`寫入了${item.writeBuffer.length}位元組`, 3);
item.writeBuffer = "";
if (!ret) {
logInfo("有一部分資料被暫存在了緩衝區", 2);
logInfo(`當前緩衝區大小:${item.bufferSize}`, 2);
item.writeReady = false;
}
}
else {
logInfo(`緩衝區還未清空,已排隊:${item.writeBuffer.length}`, 2);
}
found = 1;
return true;
});
return found;
};
socket.on("drain", function () {
socket.writeReady = true;
logInfo("緩衝區已清空", 2);
});複製程式碼
SFGameServer.js
主程式會執行這個檔案作為另外一個子程式
這個程式負責處理具體的業務邏輯。大致的思路是根據協議號pid來選擇合適的Controller
來處理邏輯,初始化過程如下:
/**
* 初始化Controller列表
*/
const initControllers = function () {
controllerMap[0] = {
onRequest: function (req) {
logInfo("不能識別的請求:" + req.pid + "from" + req.uid);
}
};
controllerMap[1] = SFUserController;
controllerMap[3] = SFBattleController;
controllerMap[6] = SFUserController;
// ...
utils.traverse(controllerMap, function (item) {
if (item && typeof(item.setPusher) == "function") {
item.setPusher(pushMessage);
}
});
};複製程式碼
然後根據從SocketHandler收到的請求資料,選擇相應的Controller。
/**
* 收到客戶端請求
* @param {string} jsonString
*/
const onRequest = function(jsonString) {
const reqObj = JSON.parse(jsonString);
const pid = reqObj["pid"];
const controller = controllerMap[pid];
controller.onRequest(reqObj);
}複製程式碼
當然還要準備推送Response給客戶端的方法pushMessage
/**
* 推送訊息給客戶端
* @param {Array} users
* @param {string} data
*/
const pushMessage = function (users, data) {
if (users && users.length > 0) {
logInfo(`將傳送給${users.length}個使用者: ${data}` ,3);
const obj = {
user_list: users,
response_data: data
};
redisPublisher["publish"]("BA_RESP", JSON.stringify(obj));
}
};複製程式碼
之後就是具體各個Controller的實現了,具體邏輯我們下次再說
需要注意的是,每個Controller都要提供setPuhser()
方法用來設定推送方法,以及onRequest()
方法用來處理請求資訊
// 檔案: SFUserController.js
/**
* 處理請求
* @param {object} req
*/
const onRequest = function (req) {
if (req.pid == 1) {
onUserLogin(req);
}
// more...
};
module.exports = {
// 對外公開這兩個方法就足夠了
onRequest: onRequest,
setPusher: function (pusher) {
m_pusher = pusher;
}
};複製程式碼
完整程式碼
上面貼出的程式碼片段由於篇幅限制只保留了關鍵部分,完整的程式碼可在我的github上找到