cst项目坦克大战websocket同步相关知识

[TOC]

说明

  • websocket使用的库是"github.com/gorilla/websocket"

缓冲区

  • 写超时发生在缓冲区堵塞的时候,假如一直写不进缓冲区,就会报错写超时,这个缓冲区一般是操作系统的缓冲区

    • 网络层的缓冲区一般非常大

    • 在测试中三个玩家每100毫秒发送,过了大概30秒才开始有写超时

      • 但是只有这三名玩家发
      • 不过我是在本地中创建docker来测试,实际可能会有所不同
    • 每个连接独立一个缓冲区

    • //只是应用层的缓冲区大小,不是系统的,系统的可能更大
      var upgrade = websocket.Upgrader{
      ReadBufferSize:  1024,
      WriteBufferSize: 1024,
      }
      //"github.com/gorilla/websocket"
      WriteMessage是把数据写到操作系统层的缓冲区

连接断开

  • 当客户端主动关闭连接的时候,没有发送关闭帧的时候,服务端的状态码会是1001
  • 如果是服务端自己断开连接,服务端的监听处返回的错误的状态码是1006
  • 心跳因为超时断开会显示超时的错误

读写注意

  • 当玩家离开的时候,注销的操作应该在连接读函数的defer中进行

  • 每一个客户端中需要释放的资源要在hub对客户端的注销过程中进行

  • 在读和写的defer中,在不影响整体架构逻辑的前提下,都可以进行连接关闭操作,连接重复关闭不会造成程序panic,这样可以确保连接被关闭,防止占用资源,造成内存泄漏

  • 不要同时向一个连接写入信息,会造成panic恐慌

基本架构

与网络上大多数用golang写的并且开源websocket多人游戏的不同点与相同点

不同点

  • 我们需要创建房间
  • 游戏的过程不同,我们匹配和开始游戏的衔接比其他的游戏复杂
  • 需要监听的信息更多,管道数量多
    • 管道资源释放时机很重要,不然容易引起恐慌
  • 我们的hub不是用来转发信息,是用来匹配;其他大部分游戏都是用来转发

相同点

  • 都是每一个客户端都会打开两个goroutine,分别用来监听数据和发送数据
  • 有一个hub来进行保存用户和删除用户的操作

client

结构体

type Client struct {
    UserID string
    UserColor       int
    Room            *Room
    Conn            *websocket.Conn
    StateController *calculate.StateController
    IsInGame bool
    logger   *zap.Logger
    dataChan chan []byte
}
  • 字段解释

    字段 类型 说明
    UserID string 玩家的id
    UserColor int 玩家的坦克颜色
    Room *Room 玩家所在的房间
    StateController *calculate.StateController 玩家状态管理器
    IsInGame bool 用来判断玩家是否在游戏内
    logger *zap.Logger 用来打日志
    dataChan chan []byte 用来接收需要发送给客户端的数据
  • 一个实例对应一个客户端

读写函数(都需要开goroutine)

//玩家连接一打开,必须要执行写函数
//原因:写函数中会定时发送ping来维持连接
func (client *Client)Write(){}

//在我的架构中,读函数必须要在匹配成功后才能执行
//原因:每个client都会有”Room“这个字段,在未匹配成功之前,这个Room是nil,读函数中有需要用到Room的地方,假如匹配成功之前执行读函数,客户端在这个时候发送了数据,会panic,为了避免这种潜在问题,所以要在匹配成功后执行
func (client *Client)ReadMessage(){}

特别说明

  • dataChan:
    • 类型:chan []byte
    • 用来接收Room发送过来的数据,进而发送到客户端
    • 特点:
    • 这个管道是有缓冲的
      • 原因:为了保证每个玩家不会因为其他玩家网络差,导致自己收到数据的速度变慢,因此需要增加缓冲,在接下来介绍Room结构体的时候会进一步解释
  • 重发数据:
    • 发生的场景:当用户的网络断开,但是连接没有关闭,心跳还未到达超时时间,服务端还是会不断往操作系统的缓冲区写入数据,由于网络断开,操作系统中的数据无法及时发出,导致操作系统的缓冲区逐渐堵塞,这样数据就会无法写入缓冲区,导致写超时,这个时候就会重新发送刚刚的数据,由于我们写超时给的时间有10秒,假如玩家这个时候恢复的网络,缓冲区就会得到释放,于是便能继续发送,由于我们会重发数据,所以就不会丢掉刚刚因为堵塞而发不出去的数据。
    • 只会重发一次
    • 原因:因为写超时有10秒,第二次产生写超时就会经过20秒,由于我们的心跳超时是30秒,而一般的操作系统给予的的缓冲区大概都在几十kb,我们的游戏的数据传输量一次也就几十b,因此产生堵塞的时间会在网络断开后几秒后,再过10秒才会产生第一次写超时,因此玩家在第二次写超时还不会回来的话,心跳超时大概率会将它断开,也就不需要第三次重发了

Room

结构体

type Room struct {
    RoomID     string
    PlayerList map[string]*Client
    StatesList map[string]*calculate.StateController
    RoomMapManager *Map.MapManager
    IsGameBegin    bool
    IsGameEnd      bool
    EndChan        chan struct{}
    DataChan           chan map[string]interface{}
    FilterableDataChan chan FilterableData
    ifClearing bool

    RoomLock sync.RWMutex

    logger *zap.Logger
}
  • 字段解释

    字段 类型 说明
    RoomID string 房间的id,唯一标识符
    PlayerList map[string]*Client 保存当前在线的玩家,玩家掉线会删除玩家
    StatesList map[string]*calculate.StateController 保存房间内玩家的信息,玩家掉线不会删除玩家,主要是用于保存所有玩家的战绩
    RoomMapManager *Map.MapManager 管理地图的状态,处理重复收到的可破坏物体被破坏信息,保证相同的物体只发送一次给客户端;处理空投生成;处理死亡道具的生成
    IsGameBegin bool 游戏是否开始
    IsGameEnd bool 游戏是否结束,不用IsGameBegin来判断结束,主要是因为在游戏开始前的匹配状态IsGameBegin也是为false,容易有混淆的情况,因此为了使得这两种情况更好的区分就创建多一个IsGameEnd
    EndChan chan struct{} 当玩家击杀到10人的时候向此管道发送游戏结束的信息
    DataChan chan map[string]interface{} 收取所有客户端发来的数据
    FilterableDataChan chan FilterableData 此管道是为了让移动和开火这些操作不发给消息的来源,也就是发这个消息的客户端,这样可以减少发送的数据量
    ifClearing Bool 是否已经打开清理goroutine,确保只清理一次,不然重复关闭管道会引起恐慌
    RoomLock sync.RWMutex 并发锁,处理并发问题
    logger *zap.Logger 打日志

特别说明

  • 控制游戏进程和转发游戏数据是分别开一个goroutine

    • 原因:由于我们有较多的管道,假如只使用一个goroutine,那么我们只能使用一个select去监听所有的管道是否有数据,而select的机制是一次只能处理一个管道,当有两个管道同时有消息的时候,select是随机选取一个管道来进行处理,当某个管道有数据的频率非常高,这样就可能会使得其他管道无法被处理,所以我们就要分开处理游戏进程(有数据频率低)和转发游戏数据(有数据的频率高)的管道,然后使用sync.RWMutex来处理并发问题,因此golang的sync.RWMutex在1.19版本后就引入了饥饿模式,来防止饥饿问题的出现,比select的公平性要好
  • 游戏结束或者房间没人的时候会打开清理函数,清理函数是会倒计时6秒后才会清理

    • 原因:防止其他goroutine往已经关闭的管道发送数据,导致恐慌
  • Room的广播转发是这样的

    // BroadcastMessage 群发消息
    func (room *Room) BroadcastMessage(message []byte) {
    for _, client := range room.PlayerList {
        if !client.IsInGame {
            continue
        } else {
            //不能立刻写入就关闭连接
            select {
            //塞入客户端的发送管道
            case client.Send() <- message:
            default:
                client.Conn.Close()
            }
        }
    }
    }
    • 解释:client的管道是有缓冲的,只要有空间就可以立马写入,因此不会因为某个玩家网络差就影响了整个转发
    • 当给玩家的管道缓冲区满后,说明玩家的操作系统层的缓冲区已经满了,在心跳的30秒内,每100毫秒塞入一次管道才可能会填满,如果填满了,也说明玩家心跳快到了,这时候就无法塞入管道了,就直接关闭此玩家连接即可

hub

结构体

type Hub struct {
    Clients    map[string]*Client
    Register   chan *Client
    UnRegister chan *Client
    logger     *zap.Logger
    cntSync    *sync.Mutex
    cnt        int
}
  • 字段说明

    字段 类型 说明
    Clients map[string]*Client 保存已经进入的玩家,主要用来防止重复匹配游玩
    Register chan *Client 玩家点击匹配,打开连接后就会往这个管道发送信息注册
    UnRegister chan *Client 玩家离开要往这里发送信息
    logger *zap.Loggerf 打日志

特别说明

  • client的资源释放一定要在发送到UnRegister后,然后才进行释放

    //游戏结束或者它退出房间,都需要删除它
        case client := <-hub.UnRegister:
            //离开关闭
            logger.Info("用户离开:" + client.UserID)
            delete(hub.Clients, client.UserID)
            //清理client的管道
            go client.Cleanup()
    • 原因:client的注册和注销都会经过hub,也就是说会依赖于hub,假如在client的Write()中结束监听的时候释放,那么就需要发信息给ReadMessage()让它不要往管道发信息,不然会发生往一个已经关闭的管道发信息的情况,从而导致恐慌,这样会增加更多的处理过程,并且会增加更多的并发情况,而把释放放在client的ReadMessage()也是一样的情况,因此最好的情况就是放在hub释放
  • 匹配机制实现

    • 创建一个房间实例room
    • 每过一定时间,检查房间内是否够人,如果人数超过一个就开始游戏,房间实例也会指向一个新的实例,而之前的指向的实例因为client在匹配成功后client的字段Room会指向这个实例,因此不会被垃圾回收掉,如果检测到Register管道中还有人在注册进来,就算到了一定时间也不会开始游戏,因为管道内还有人准备注册进来,所以现在不应该开启游戏,应该把人放进房间,等人数满了才开始游戏
    • 用户注册进来后,会进入房间匹配,如果此名玩家是房间内第四个进来的,房间直接开启,房间实例也会指向一个新的实例,如果不是第四个,就不开始游戏
    room := hub.createRoom()
    for {
        select {
        //当有客户端注册时,将客户端添加到clients的映射中
        case client := <-hub.Register:
            result, err := hub.saveClient(client)
            if err != nil {
                logger.Error("User of id:" + client.UserID + " saveClient err:" + err.Error())
                return
            }
            //如果结果为false说明此玩家已经存在,需要强制退出
            if !result {
                go client.Cleanup()
                continue
            }
            // //进来直接匹配
            result, err = hub.Match(room, client)
            if err != nil {
                logger.Error("User of id:" + client.UserID + " match error:" + err.Error())
                return
            }
            //为true则说明游戏已经开始
            if result {
                room = hub.createRoom()
            }
            go func(client *Client) {
                //匹配成功发送消息给前端
                jsonData, err := json.Marshal(map[string]interface{}{"action": "match_result", "message": "match successfully"})
                if err != nil {
                    client.logger.Error("client of id:" + client.UserID + " json marshal error:" + err.Error())
                    return
                }
                client.dataChan <- jsonData
            }(client)
        //游戏结束或者它退出房间,都需要删除它
        case client := <-hub.UnRegister:
            //离开关闭
            logger.Info("用户离开:" + client.UserID)
            delete(hub.Clients, client.UserID)
            //清理client的管道
            go client.Cleanup()
        case <-matchTicker.C:
            // fmt.Println(hub.Clients)
        //说明后面还有玩家,房间还需要加入玩家
            if len(hub.Register) > 0 {
                continue
            }
        //说明人数不够,不能开启游戏
            if !room.CheckRoomNumber() {
                continue
            }
            // hub.Add()
            go room.HandleData()
            room = hub.createRoom()
        }
    }

关闭状态码

  • 状态码一般都在关闭帧的开头

  • 长度为2字节,且为无符号整数

  • 状态码后边一般接的是关闭的text

  • 在golang的很多库的源码中都是这样设置

    //golang.org/x/net/websocket
    msg := make([]byte, 2)
    binary.BigEndian.PutUint16(msg, uint16(status))
    
    //github.com/gorilla/websocket
    func FormatCloseMessage(closeCode int, text string) []byte {
    if closeCode == CloseNoStatusReceived {
        // Return empty message because it's illegal to send
        // CloseNoStatusReceived. Return non-nil value in case application
        // checks for nil.
        return []byte{}
    }
    buf := make([]byte, 2+len(text))
    binary.BigEndian.PutUint16(buf, uint16(closeCode))
    copy(buf[2:], text)
    return buf
    }

优化点

  • 把可以确定的数据类型定义为结构体,减少reflect的使用
    • 可以把使用到map[string]interface{}的地方更改为预定的数据类型
    • 也可以使用protobuf

Tags:

Comments are closed

       

粤公网安备44011302004556号