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

前言

最近一直在思考某些事情,然后就拖更了一个月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,然后主进程统一处理这些信息,比如格式化输出,另存到文件等等。

主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 格式化输出日志
* @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中断事件,当主进程接收到该信号时,不会立即退出,而是等待子进程全部安全正常地结束之后才会退出

1
2
3
4
5
6
const onExit = function() {
if (!isSocketHandlerRunning && !isGameServerRunning) {
console.log("[MAIN] - 子进程均安全退出,准备关闭主进程".magenta);
process.exit(0);
}
}

main.js的主要内容就是这些了

SFSocketHandler.js

主进程会执行这个文件作为一个子进程

这个进程负责的事情是开启TCP服务器,承载TCP连接,接收来自于客户端的原始数据并作出第一步的处理,然后把整理过的数据发送给SFGameServer来处理具体的业务逻辑

主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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这个主要的方法了:

1
2
3
4
5
6
7
8
9
10
11
12
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四个字符,以此来划分粘连在一起的数据包。大致逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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连接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 处理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事件,说明缓冲区已清空, 我们就可以继续发送数据了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 根据指定的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来处理逻辑,初始化过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 初始化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。

1
2
3
4
5
6
7
8
9
10
/**
* 收到客户端请求
* @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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 推送消息给客户端
* @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()方法用来处理请求信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 文件: 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上找到