用Unity做個遊戲(九) - 服務端架構

Inspoy Cheng發表於2017-09-23

本文首發自inspoy的雜七雜八 | 菜雞inspoy的學習記錄

前言

最近一直在思考某些事情,然後就拖更了一個月233
其實程式碼也一直在寫,遊戲的主流程也基本上通了,就是一直懶得寫部落格。
OK我們今天來介紹下游戲的服務端是怎麼實現的。

服務端結構

BounceArena的服務端使用node.js開發,這次用了三個程式,分別處理日誌(main.js也是程式入口),socket通訊(SFSocketHandler.js)和具體的業務邏輯(SFGameServer.js)。

main.js

main.js為程式入口,我們在server/app目錄下執行node ./指令就可以了。
這個程式會啟動兩個子程式SFSocketHandler.jsSFGameServer.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上找到

相關文章