文章

websocket同步架构说明

根据我们游戏特点的同步架构

websocket同步架构说明

基本架构

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

不同点

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

相同点

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

client

结构体

1
2
3
4
5
6
7
8
9
10
type Client struct {
	UserID string
	UserColor       int
	Room            *Room
	Conn            *websocket.Conn
	StateController *calculate.StateController
	IsInGame bool
	logger   *zap.Logger
	dataChan chan []byte
}
  • 字段解释

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

读写函数(都需要开goroutine)

1
2
3
4
5
6
7
//玩家连接一打开,必须要执行写函数
//原因:写函数中会定时发送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

结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
}
  • 字段解释

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

特别说明

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

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

  • 游戏结束或者房间没人的时候会打开清理函数,清理函数是会倒计时6秒后才会清理

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    // 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

结构体

1
2
3
4
5
6
7
8
type Hub struct {
	Clients    map[string]*Client
	Register   chan *Client
	UnRegister chan *Client
	logger     *zap.Logger
	cntSync    *sync.Mutex
	cnt        int
}
  • 字段说明

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

特别说明

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

    1
    2
    3
    4
    5
    6
    7
    
    //游戏结束或者它退出房间,都需要删除它
    		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管道中还有人在注册进来,就算到了一定时间也不会开始游戏,因为管道内还有人准备注册进来,所以现在不应该开启游戏,应该把人放进房间,等人数满了才开始游戏
    • 用户注册进来后,会进入房间匹配,如果此名玩家是房间内第四个进来的,房间直接开启,房间实例也会指向一个新的实例,如果不是第四个,就不开始游戏
    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
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    
    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()
    		}
    	}
    
本文由作者按照 CC BY 4.0 进行授权