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
- 可以把使用到
Comments are closed