• [Go WebSocket] 单房间的聊天室


    我是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
    
    • 1

    拷贝chat代码

    gorilla/websocket的官方demo拷贝过来即可,我们慢慢分析:

    你需要这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)
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    上篇已经介绍了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),
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    可以看到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)
                }
             }
          }
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

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

    下一步,我们看client

    第三步,看client.go

    Client定义

    type Client struct {
       hub *Hub
       conn *websocket.Conn
       send chan []byte
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 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()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    在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
             }
          }
       }
    }
    
    • 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

    首先开启了一个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
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    readPump就是读取消息,收到客户端消息后,就借助hub.broadcast广播出去。

    此外,这个goroutine有个重要的任务:关闭连接后,负责hub.unregisterconn.Close

    总结!最重要的一个图!

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

    1.png

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

    User和Client图中只画了2个,是可以继续增加的。

    写在最后

    我是HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,发送加微信,交个朋友),转发本文前需获得作者HullQin授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费没广告。还开发了《Dice Crush》参加Game Jam 2022。喜欢可以关注我 HullQin 噢~我有空了会分享做游戏的相关技术。

  • 相关阅读:
    自定义mock服务器
    matlab使用hampel滤波,去除异常值
    零基础学Python之条件语句的使用(手把手带你做牛客网python代码练习题)
    实战ResNet:CIFAR-10数据集分类
    Java版分布式微服务云开发架构 Spring Cloud+Spring Boot+Mybatis 电子招标采购系统功能清单
    OpenHarmony Axios组件使用过程中,Api9不适配问题
    「 网络安全常用术语解读 」什么是0day、1day、nday漏洞
    YOLOv5:修改backbone为ConvNeXt
    【Python 自动化办公】docx module 的坑
    【ManageEngine卓豪】移动终端管理解决方案,助力中州航空产业数字化转型
  • 原文地址:https://blog.csdn.net/kd_2015/article/details/126926182