关于后端:Go-WebSocket-单房间的聊天室

49次阅读

共计 4796 个字符,预计需要花费 12 分钟才能阅读完成。

我是 HullQin,公众号 线下团聚游戏 的作者(欢送关注公众号,发送加微信,交个敌人),转发本文前需取得作者 HullQin 受权。我独立开发了《联机桌游合集》,是个网页,能够很不便的跟敌人联机玩斗地主、五子棋等游戏,不免费没广告。还开发了《Dice Crush》加入 Game Jam 2022。喜爱能够关注我 HullQin 噢~我有空了会分享做游戏的相干技术。

背景

第一篇文章:《为什么我选用 Go 重构 Python 版本的 WebSocket 服务?》,介绍了我的指标。

上篇文章讲了《你的第一个 Go WebSocket 服务: echo server》,明天咱们实现一个聊天室。

如果你没浏览上一篇文章,肯定要先看一下,因为这篇文章更简单,如果你不弄懂上一篇,这篇可能看不懂哦。

新建我的项目并装置依赖

可参考《你的第一个 Go WebSocket 服务: echo server》。

新建个我的项目文件夹,命令行执行以下,装置 Go Websocket 依赖:

go get github.com/gorilla/websocket

拷贝 chat 代码

gorilla/websocket 的官网 demo 拷贝过去即可,咱们缓缓剖析:

  • github.com/gorilla/websocket/tree/master/examples/chat

你须要这 4 个文件:

  • main.go
  • hub.go
  • client.go
  • index.html

第一步,看主函数

func main() {flag.Parse()
   hub := newHub()
   go hub.run()
   http.HandleFunc("/", serveHome)
   http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {serveWs(hub, w, r)
   })
   err := http.ListenAndServe(*addr, nil)
   if err != nil {log.Fatal("ListenAndServe:", err)
   }
}

上篇曾经介绍了 flaghttp.HandleFunc,这里跟上篇是截然不同的。

这里还开启了一个 goroutine,留神它是写在 main 函数里的,不是写在 http.HandleFunc 里的。所以不论有多少客户端连贯,这个服务只开启了一个 goroutine。newHub().run()。咱们下一步看newHub(),在 hub.go 文件中。

再看下注册的 2 个申请处理函数:

  • serveHome是一个 HTTP 服务,把 html 文件返回给申请方(浏览器)。
  • 针对 /ws 路由,则会调用 serveWs,咱们下下一步看serveWs 做了什么,在 clent.go 文件中。

第二步,看 hub.go

Hub 定义和 newHub 函数定义

type Hub struct {clients map[*Client]bool
   broadcast chan []byte
   register chan *Client
   unregister chan *Client
}

func newHub() *Hub {
   return &Hub{clients:    make(map[*Client]bool),
      register:   make(chan *Client),
      unregister: make(chan *Client),
      broadcast:  make(chan []byte),
   }
}

能够看到 newHub 只是新建了一个空白的 Hub。而 1 个 Hub 蕴含 4 个货色:

  • clients,保留了每个客户端的援用的 Map(其实这个 Map 的 value 没有用到,key 是客户端的援用,能够当作是其它语言的 set)。
  • register,用于注册客户端的 channel。每当有客户端建设 websocket 连贯时,通过 register,把客户端保留到 clients 援用中。
  • unregister,用于登记客户端的 channel。每当有客户端断开 websocket 连贯时,通过 unregister,把客户端援用从 clients 中删除。
  • broadcast,用于发送播送的 channel。把音讯存到这个 channel 后,之后会有其它 goroutine 遍历clients,把音讯发送给所有客户端。

服务开启时启动的 goroutine: hub.run()

func (h *Hub) run() {
   for {
      select {
      case client := <-h.register:
         h.clients[client] = true
      case client := <-h.unregister:
         if _, ok := h.clients[client]; ok {delete(h.clients, client)
            close(client.send)
         }
      case message := <-h.broadcast:
         for client := range h.clients {
            select {
            case client.send <- message:
            default:
               close(client.send)
               delete(h.clients, client)
            }
         }
      }
   }
}

一个死循环:一直从 channel 读取数据。读取到register,就注册客户端。读取到unregister,就断开客户端连贯,删除援用。读取到broadcast,就遍历clients,播送音讯(通过把音讯写入每个客户端的client.sendchannel 中,实现播送),正是下一步要看的逻辑。

下一步,咱们看client

第三步,看 client.go

Client 定义

type Client struct {
   hub *Hub
   conn *websocket.Conn
   send chan []byte}
  • hub: 每个 Client 客户端保留了 Hub 的援用。(尽管目前全局只有 1 个 hub,然而为了可扩展性,还是保留一份吧,因为未来会有多 hub,下篇文章咱们就介绍!)
  • conn: 即跟客户端的 websocket 连贯,通过这个 conn 能够跟客户端交互(即收发音讯)。
  • send: 一个 channel,在第二步曾经见识到了,broadcast 时,就是把音讯写入了每个 Client 的send channel 中。通过从这个 channel 读取音讯,发送音讯给客户端。

main 函数用到的 serveWs 函数

func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {conn, err := upgrader.Upgrade(w, r, nil)
   if err != nil {log.Println(err)
      return
   }
   client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
   client.hub.register <- client

   // Allow collection of memory referenced by the caller by doing all work in
   // new goroutines.
   go client.writePump()
   go client.readPump()}

在 hub 中,注册了一下。

随后启动了 2 个 goroutine: client.writePump()client.readPump(),而后这个函数逻辑就完结了。

这 2 个 goroutine,别离用于解决写入音讯和读取音讯。

client.writePump

func (c *Client) writePump() {ticker := time.NewTicker(pingPeriod)
   defer func() {ticker.Stop()
      c.conn.Close()}()
   for {
      select {
      case message, ok := <-c.send:
         c.conn.SetWriteDeadline(time.Now().Add(writeWait))
         if !ok {c.conn.WriteMessage(websocket.CloseMessage, []byte{})
            return
         }
         w, err := c.conn.NextWriter(websocket.TextMessage)
         if err != nil {return}
         w.Write(message)
         if err := w.Close(); err != nil {return}
      case <-ticker.C:
         c.conn.SetWriteDeadline(time.Now().Add(writeWait))
         if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {return}
      }
   }
}

首先开启了一个 ping 计时器。会固定周期发送 Ping 音讯给客户端。这是 WebSocket 协定要求的,参考《RFC6455》。你在浏览器上抓包看不到这个 Ping 音讯。这种形式,能够将没响应的连贯清理掉。

而后,这个 goroutine,申明了 defer 执行的逻辑:敞开计时器,敞开连贯。

最重要的局部,这个 goroutine 有个死循环:一直读取 client.send 这个 channel 中的数据。只有 hub.broadcast 给它传了音讯,那么就由这个 goroutine 来解决。c.conn.NextWriterw.Write(message) 是真正的发消息的逻辑。

此外,每隔一段时间(定时器设置的工夫距离),服务器都会发送一个 Ping 给浏览器。浏览器会主动回复一个 Pong(不须要客户端开发者关注,客户端开发者通常是 JS 开发者)。

client.readPump

func (c *Client) readPump() {defer func() {
      c.hub.unregister <- c
      c.conn.Close()}()
   c.conn.SetReadLimit(maxMessageSize)
   c.conn.SetReadDeadline(time.Now().Add(pongWait))
   c.conn.SetPongHandler(func(string) error {c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
   for {_, message, err := c.conn.ReadMessage()
      if err != nil {if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {log.Printf("error: %v", err)
         }
         break
      }
      message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1))
      c.hub.broadcast <- message
   }
}

readPump就是读取音讯,收到客户端音讯后,就借助 hub.broadcast 播送进来。

此外,这个 goroutine 有个重要的工作:敞开连贯后,负责 hub.unregisterconn.Close

总结!最重要的一个图!

为了帮忙大家了解,我绘制了这个图:

其中,黑白矩形示意 goroutine,黑白线条是各个 channel(从 A 指向 B 示意,由 goroutine A 写入数据,由 goroutine B 读取数据)。

User 和 Client 图中只画了 2 个,是能够持续减少的。

写在最初

我是 HullQin,公众号 线下团聚游戏 的作者(欢送关注公众号,发送加微信,交个敌人),转发本文前需取得作者 HullQin 受权。我独立开发了《联机桌游合集》,是个网页,能够很不便的跟敌人联机玩斗地主、五子棋等游戏,不免费没广告。还开发了《Dice Crush》加入 Game Jam 2022。喜爱能够关注我 HullQin 噢~我有空了会分享做游戏的相干技术。

正文完
 0