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

  欢迎参与讨论,转载请注明出处。
  本文转载自:https://musoucrow.github.io/2018/03/11/bnb_2/

前言

  书接上文,这次要讲的是服务端。《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对象),但是为了偷懒我采用了这样的方式:

-- 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采用的方式是礼尚往来(客户端每隔一段时间发送心跳包,服务端收到后发送回执),即双端皆有心跳状态:在客户端看来,无论是超时没有收到心跳包、亦或是自身无法发出心跳包,都视为掉线。在服务端看来,只要该客户端超时没有发过心跳包,即踢出之:

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

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服务),在此之前会对两名用户是否在线进行检查,若不满足则将两名用户进行踢出,需重新进行连接:

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

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

-- 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
// 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)。若超时或在时间内抵达第二名用户的输入包,则进行结算(广播用户们的输入数据)。用户的输入数据在服务端是作为缓存式的,超时了也会进行记录,作为下一次结算所用,每次结算后输入数据则会清空:

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, ...)函数进行异常处理即可:

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()即会发送到目标邮箱,且整个进程生命周期内只会发送一次,避免疯狂轰炸的情况:

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进程崩溃的情况,但以防万一,还是专门做了崩溃重启的措施:

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

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

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

后记

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值