用Unity做个游戏(九) - 服务端架构

1,916 阅读5分钟

本文首发自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上找到