用 Go 构建一个实时聊天服务器:一个 WebSocket 最小可行方案

如果你用 Go 写过 REST API,却从没碰过 WebSocket,那么构建一个聊天服务器是动手学习 Go 并发模型的最佳方式之一。不用框架,不用黑魔法,只有 goroutine、channel 和几个 struct。

在这篇文章里,我会带你走一遍一个最小但具备生产形态的聊天服务器:包含房间(rooms)、广播(broadcast)和干净的断连处理,代码不到 200 行 Go。

用 Go 构建一个实时聊天服务器:一个 WebSocket 最小可行方案

为什么用 WebSocket,为什么用 Go?

HTTP 是请求/响应式的:客户端提问,服务器回答,连接关闭。这对聊天场景非常不合适,聊天要求服务器在消息到达的瞬间就推送给客户端,而不是靠轮询。

WebSocket 通过把一个普通的 HTTP 连接升级成持久的全双工套接字来解决这个问题。升级完成后,任意一方都可以随时发送数据。

Go 在这里是天然契合的,因为它是 goroutine。每个连上的客户端都能拥有自己轻量级的执行线程(成千上万个也开销很低),而不需要像在事件循环类语言里那样写一堆回调地狱。

架构

Client <--WS--> Hub(房间、广播)<--> Client

三块组成:

  • Client:包装一条 WebSocket 连接。拥有一个读循环和一个写循环。
  • Hub:谁连在哪个房间的唯一真相来源。以单个 goroutine 的方式运行,通过 channel 而非锁来协调一切。
  • main:组装 HTTP 服务器和 /ws 升级端点。

规则一:永远不要从两个 goroutine 写同一个套接字

这是最让新手栽跟头的 WebSocket 陷阱。单条连接并不安全支持并发写——两个 goroutine 同时写会破坏帧结构。

修复办法是:只允许有且仅有一个 goroutine 负责写,由 channel 喂数据给它。

type Client struct {
    hub  *Hub
    conn *websocket.Conn
    send chan Message
    room string
    user string
}

任何想给这个客户端发消息的地方,都只需要 client.send <- msg。客户端自己的 writePump goroutine 是唯一会调用 conn.WriteJSON 的地方。

func (c *Client) writePump() {
    for {
        select {
        case m, ok := <-c.send:
            if !ok {
                c.conn.WriteMessage(websocket.CloseMessage, []byte{})
                return
            }
            c.conn.WriteJSON(m)
        case <-ticker.C:
            c.conn.WriteMessage(websocket.PingMessage, nil) // keepalive
        }
    }
}

readPump 是它的镜像,它阻塞在 conn.ReadJSON 上,读到的任何内容都会转发给 Hub:

func (c *Client) readPump() {
    for {
        var m Message
        if err := c.conn.ReadJSON(&m); err != nil {
            break // client disconnected or sent garbage
        }
        m.Room, m.User = c.room, c.user
        c.hub.broadcast <- m
    }
}

每个客户端两个 goroutine,只通过 channel 通信。没有共享内存,没有锁。

规则二:把状态集中到一个 goroutine 里,而不是互斥锁

用 sync.Mutex 来保护“谁在哪个房间”这个 map 看起来很诱人。这么做能work,但 Go 给了你更干净的工具:让状态的拥有者作为一个独立的 goroutine 运行,其他所有人通过 channel 与它对话。

type Hub struct {
    rooms      map[string]map[*Client]bool
    broadcast  chan Message
    register   chan *Client
    unregister chan *Client
}

func (h *Hub) run() {
    for {
        select {
        case c := <-h.register:
            h.rooms[c.room][c] = true
        case c := <-h.unregister:
            delete(h.rooms[c.room], c)
            close(c.send)
        case m := <-h.broadcast:
            for c := range h.rooms[m.Room] {
                select {
                case c.send <- m:
                default:
                    // client's buffer is full — drop it rather than block everyone
                    close(c.send)
                    delete(h.rooms[m.Room], c)
                }
            }
        }
    }
}

因为 run() 是唯一会碰 h.rooms 的 goroutine,所以根本不可能出现竞态。这是 Go「通过通信来共享内存」信条最纯粹的形态。

那个 default: 分支比它看起来更重要。没有它,一个缓慢或卡住的客户端就可能阻塞整个广播循环,拖垮房间里所有其他用户。有了它,channel 满了只意味着那个客户端被丢弃,这是在可靠性与可用性之间一个有意的取舍。

把它接起来

func serveWS(hub *Hub, w http.ResponseWriter, r *http.Request) {
    conn, _ := upgrader.Upgrade(w, r, nil)
    client := &Client{hub: hub, conn: conn, send: make(chan Message, 16),
        room: r.URL.Query().Get("room"), user: r.URL.Query().Get("user")}
    client.hub.register <- client
    go client.writePump()
    go client.readPump()
}

每来一个新连接:升级、构造一个 Client、注册它、启动它的两个 goroutine。这就是它的整个生命周期。

有意省略的部分(因为这是 MVP)

  • 认证: user 这个查询参数被原样信任。做 demo 没问题,上生产不行。
  • 持久化:消息只存在于内存里。服务器一重启,历史就丢了。
  • 水平扩展:这套东西只在单个进程内有效。如果在负载均衡后面跑多个实例,消息不会跨实例传递,除非你用 Redis 的 pub/sub 把广播在实例之间扇出。

以上每一项都是很自然的下一个里程碑,而且都不会改变上面这个核心形态。

要点总结

整套东西建立在两个 Go 惯用法之上:

  1. 每条连接一个写者,由 channel 喂数据,彻底避开了「不能并发写套接字」这个坑。
  2. 一个 goroutine 独占共享状态,其他所有人通过 channel 而不是互斥锁与它通信,这正是 Go 并发模型被设计出来要做的事。

一旦这两个模式想通了,Go 里的 WebSocket 服务器就不再感觉像系统编程,而开始感觉像……就是 Go 而已。

作者:Denny Sugianto

本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/im/72141.html

(0)

相关推荐