如果你用 Go 写过 REST API,却从没碰过 WebSocket,那么构建一个聊天服务器是动手学习 Go 并发模型的最佳方式之一。不用框架,不用黑魔法,只有 goroutine、channel 和几个 struct。
在这篇文章里,我会带你走一遍一个最小但具备生产形态的聊天服务器:包含房间(rooms)、广播(broadcast)和干净的断连处理,代码不到 200 行 Go。

为什么用 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 惯用法之上:
- 每条连接一个写者,由 channel 喂数据,彻底避开了「不能并发写套接字」这个坑。
- 一个 goroutine 独占共享状态,其他所有人通过 channel 而不是互斥锁与它通信,这正是 Go 并发模型被设计出来要做的事。
一旦这两个模式想通了,Go 里的 WebSocket 服务器就不再感觉像系统编程,而开始感觉像……就是 Go 而已。
作者:Denny Sugianto
本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/im/72141.html