《Brick & Ball》开发总结(二)——服务端

  欢迎参与讨论,转载请注明出处。

前言

  书接上文,这次要讲的是服务端。《Brick & Ball》(下称BNB)所需要的网络功能仅为匹配与联机对战而已,所以在最初对此是轻视的,在尝试了UNetPhoton之后感觉各有硬伤(UNet不支持纯服务端架设、Photon服务端为Windows),遂放弃了这些看似完备的服务端套件,转为使用小有名气的Skynet后,不负所望地顺利完成了,于此做个总结。

结构

  在阅读本文之前,你需对Skynet有个大致的了解。Skynet的业务单位称为服务,它是一种Actor模型的实现。以下是BNB服务端的服务结构:flow

  • Gate: 网关服务,负责管理用户(接入、踢出、发信、心跳包)。
  • Queue: 队列服务,负责用户的匹配,当有新的用户到来就会进入队列。
  • Lobby: 大厅服务,负责Room的管理(创建、关闭),完成匹配的用户就会为他们创建Room。
  • Room: 房间服务,负责用户的游戏提供(帧锁定同步)。

KCP

  对于BNB这种高实时性的游戏,自然是不方便使用TCP了(三次握手、非快速重传、滑动窗口),然而直接使用UDP又会有可靠性的问题(丢包、非顺序到达),业界流行的做法一般是在UDP的基础上实现重传保证可靠性,而其中比较著名的实现则是KCP(再次感谢Skywind!)。KCP虽然是用C语言实现的,但还是有C#Lua的移植与封装的版本。

封包

  在封包的设计上我图省事使用了JSON,并在封包的首部使用了1字节作为标志,并未考虑加密(因为觉得没有意义)。C#方面直接使用内置函数解决,Lua则是使用了CJSON,字节处理则是使用Lua5.3新增的string.packstring.unpack函数(非5.3需安装struct)。

接入用户

  按理来说客户端与服务端的初次连接使用TCP更为适合(一个KCP对象只服务一个连接,所以初次连接的客户端在服务端并没有对应的KCP对象),但是为了偷懒我采用了这样的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
-- Check ID of packet is ID.connect
if (not _agentMap[from] and string.unpack("b", data, #data) == _ID.connect) then
local addr = _SOCKET.ToAddress(from)
_SKYNET.Log("connect", addr)
_agentMap[from] = _Agent.New(1, from, function (_data)
_SOCKET.sendto(_udp, from, _data)
end)
_clientCount = _clientCount + 1
_agentMap[from]:Send(_ID.connect, {addr = addr, version = _version, isFull = _clientCount > _maxClient})
elseif (_agentMap[from]) then
_agentMap[from]:Input(data)
end

  对于不在用户列表的来源,则直接判定该包尾部1字节是否等于_ID.connect(KCP会在封包的头部添加信息,所以在没有JSON内容的情况下,该包尾部则是原封包的头部),这种野蛮的方式缺点自然是只能填写标识而不能添加JSON。所以如果客户端还需要一些信息的话还需要收到回执后补充,当然事实上就需要回执:存在着因版本不对、服务器爆满的情况而拒绝连接的情况。所以服务端需要回执后方正式将其接入。

心跳包

  鉴于UDP的无连接特性,是无法判断用户是否掉线的(事实上TCP的机制也非完美)。业界通行的做法是做心跳包,即每隔一段时间进行通信以确定对方仍存活。BNB采用的方式是礼尚往来(客户端每隔一段时间发送心跳包,服务端收到后发送回执),即双端皆有心跳状态:在客户端看来,无论是超时没有收到心跳包、亦或是自身无法发出心跳包,都视为掉线。在服务端看来,只要该客户端超时没有发过心跳包,即踢出之:

1
2
3
4
5
6
7
8
9
10
-- Server
function _FUNC.Heartbeat()
for k, v in pairs(_agentMap) do
if (not v.heartbeat) then
_FUNC.Kick(k)
else
v.heartbeat = false
end
end
end
1
2
3
4
5
6
7
8
9
10
11
// Client
private void HeartbeatTick() {
if (!this.heartbeat) {
this.Disconnect();
}
else {
this.Send(EventCode.Heartbeat);
this.heartbeat = false;
this.heartbeatTimer.Enter(HEARTBEAT_INTERVAL, this.HeartbeatTick);
}
}

匹配队列

  BNB的匹配规则就是没有规则,匹配到了两名玩家就开始游戏,所以只需设计一个队列即可。每逢有新用户接入后便会进入队列,如用户离去则从队列消除,若匹配成功则为他们创建一场游戏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function _CMD.OnHandshake(id, fd)
if (not _SKYNET.Call(_gate, "CheckAgent", fd)) then
return
end
if (not _readyFd) then
_readyFd = fd
else
_SKYNET.Send(_lobby, "NewRoom", _readyFd, fd)
_readyFd = nil
end
end
function _CMD.OnDisconnect(id, fd)
if (_readyFd == fd) then
_readyFd = nil
end
end

创建游戏

  在匹配完成后,便会由Lobby服务为一对用户创建房间(Room服务),在此之前会对两名用户是否在线进行检查,若不满足则将两名用户进行踢出,需重新进行连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function _CMD.NewRoom(leftFd, rightFd)
local fds = {leftFd, rightFd}
if (not _SKYNET.Call(_gate, "CheckAgent", fds)) then
_SKYNET.Send(_gate, "Kick", fds)
return
end
local deviceModels = _SKYNET.Call(_gate, "GetAgentValue", fds, "deviceModel")
_leftFdMap[leftFd] = rightFd
_rightFdMap[rightFd] = leftFd
_roomMap[leftFd .. rightFd] = _SKYNET.newservice("room")
_SKYNET.Send(_roomMap[leftFd .. rightFd], "Start", leftFd, rightFd, deviceModels[1], deviceModels[2])
_SKYNET.Log("start room", _SOCKET.ToAddress(leftFd), _SOCKET.ToAddress(rightFd))
end

  创建房间之后,会为对应的客户端发送开始游戏所需的数据(随机数种子、双方阵营所属)。待客户端初始化完毕后,游戏正式开始:

1
2
3
4
5
6
7
-- Server
function _CMD.Start(leftFd, rightFd, leftDevice, rightDevice)
_fds = {leftFd, rightFd}
_deviceMap[leftFd] = leftDevice
_deviceMap[rightFd] = rightDevice
_FUNC.Send(_ID.start, {seed = os.time(), leftAddr = _SOCKET.ToAddress(leftFd), rightAddr = _SOCKET.ToAddress(rightFd)})
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Client
private void OnStart(byte id, string data) {
var obj = JsonUtility.FromJson<Datas.Start>(data);
Random.InitState(obj.seed);
Judge.PlayerType = this.addr == obj.leftAddr ? PlayerType.A : PlayerType.B;
Judge.SetAddr(obj.leftAddr, obj.rightAddr);
this.startGameSlot.Run(this.gameObject);
this.online = true;
this.updateTimer = 0;
this.frame = 0;
this.playFrame = 0;
this.exitCode = ExitCode.None;
this.sendInLoop = false;
this.playDataList.Clear();
this.playDataList.Add(new PlayData());
Networkmgr.MovingValue = 0;
Networkmgr.WillElaste = false;
}

帧锁定同步

  如上文所言(在采用传统帧锁定同步的基础上,服务端设定等待时长,超时则继续),服务端的业务设计成当接收到一名用户的输入包后,就会开始进行计时(9毫秒,约等于客户端的5帧,即WAITTING_INTERVAL)。若超时或在时间内抵达第二名用户的输入包,则进行结算(广播用户们的输入数据)。用户的输入数据在服务端是作为缓存式的,超时了也会进行记录,作为下一次结算所用,每次结算后输入数据则会清空:

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
function _CMD.ReceiveInput(fd, obj)
_inputMap[fd] = obj.data
-- Must be current frame, otherwise only save input data.
if (obj.frame == _playFrame) then
if (not _readyPlay) then
_readyPlay = true
local time = _playInterval - (_SKYNET.now() - _timer)
time = time < 0 and 0 or time
_SKYNET.timeout(time, _FUNC.Play) -- Server will run _FUNC.Play after the time goes on.
else
_FUNC.Play()
end
end
end
function _FUNC.Play()
if (not _readyPlay) then
return
end
_TABLE.Clear(_playSender.addrs)
_TABLE.Clear(_playSender.inputs)
for k, v in pairs(_inputMap) do
table.insert(_playSender.addrs, _SOCKET.ToAddress(k))
table.insert(_playSender.inputs, v)
end
_FUNC.Send(_ID.input, _playSender)
_TABLE.Clear(_inputMap)
_readyPlay = false
_timer = _SKYNET.now()
_playFrame = _playFrame + 1
end

维护

  服务端不同于客户端,发生错误使程序崩溃的代价是很大的,所以有必要建立完善的应对措施。所幸目前发现Skynet发生错误时会影响的仅为Skynet.fork的函数(发生错误后函数会停止运行),并不会导致整个服务崩溃乃至进程崩溃。于是只要利用Lua的pcall(func, ...)函数进行异常处理即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function _SKYNET.Loop(Func, sleepTime)
local LoopFunc = function()
while true do
local ret, text = pcall(Func)
if (not ret) then
_SKYNET.Log(text)
_SKYNET.Warn()
end
_ORIGIN_SKYNET.sleep(sleepTime)
end
end
_ORIGIN_SKYNET.fork(LoopFunc)
end

  当然遇到问题仅仅是堵住那只是治标不治本,所以我采用了邮件报警机制。只要在config文件填写mail,然后调用_SKYNET.Warn()即会发送到目标邮箱,且整个进程生命周期内只会发送一次,避免疯狂轰炸的情况:

1
2
3
4
5
6
function _SKYNET.Warn()
if (_mail and not _DATA_CENTER.get("hasWarn")) then
os.execute(string.format("shell/warn.sh %s '%s'", _mail, _logger))
_DATA_CENTER.set("hasWarn", true)
end
end

  虽然理论上没有会令Skynet进程崩溃的情况,但以防万一,还是专门做了崩溃重启的措施:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
basepath=$(cd `dirname $0`; cd ..; pwd)
cd $basepath
while true
do
count=`ps -ef | grep skynet | grep -v "grep" | wc -l`
if [ $count -gt 0 ]; then
:
else
echo "program has crashed, restarting..."
screen shell/run.sh
fi
sleep 10
done

  还有一点就是,帧锁定同步的浮点数问题并不是那么令人放心的存在。所以有必要对其进行监控(这个在上文也有提到),同理遇到异常情况也会进行邮件报警:

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
function _CMD.ReceiveComparison(fd, obj)
if (not _comparsionHandler[obj.playFrame]) then
_comparsionHandler[obj.playFrame] = {}
end
local map = _comparsionHandler[obj.playFrame]
map[fd] = obj.content
if (_TABLE.Count(map) == _playerCount) then
local lk, lv
for k, v in pairs(map) do
if (lv and v ~= lv) then
-- Output current frame, each device name and comparsion data.
_SKYNET.Log(obj.playFrame, _deviceMap[k], v, "!=", _deviceMap[lk], lv)
_SKYNET.Warn()
end
lk = k
lv = v
end
_comparsionHandler[obj.playFrame] = nil
end
end

后记

  这次是本人初次进行服务端开发,如有不妥之处但请指教。虽无涉及数据库、反作弊、集群、运维等方面,但也不失为一个匹配-房间-帧锁定同步的好范例。