前言
最近一直在思考某些事情,然后就拖更了一个月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
,然后主进程统一处理这些信息,比如格式化输出,另存到文件等等。
主要代码如下:
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; }
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
|
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() { redisClient = redis.createClient(); redisPublisher = redis.createClient(); redisClient.subscribe("BA_RESP"); redisClient.on("message", function(ch, msg) { processResponse(msg); });
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 = utils.getRandomString(""); socket.uid = ""; socket.dataBuffer = ""; socket.writeBuffer = ""; socket.writeReady = true; socket.setTimeOut(30 * 1000); 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) { socket.dataBuffer += data; while (true) { const idx = socketBuffer.indexOf("\r\n\r\n"); if (idx == -1) { break; } 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) { 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
|
const processResponse = function(jsonString) { 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
|
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
|
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
|
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
|
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
|
const onRequest = function (req) { if (req.pid == 1) { onUserLogin(req); } }; module.exports = { onRequest: onRequest, setPusher: function (pusher) { m_pusher = pusher; } };
|
完整代码
上面贴出的代码片段由于篇幅限制只保留了关键部分,完整的代码可在我的github上找到