照着做完这一篇,你能用 gorilla/websocket 写出一个不 panic、会心跳保活、能广播消息的 Go 实时服务。
第一步:建项目、拉依赖
mkdir ws-demo && cd ws-demo
go mod init ws-demo
go get github.com/gorilla/websocket
gorilla/websocket 是纯 Go 实现,无 cgo,交叉编译直接过。它只做协议层,不帮你会话管理——那部分要自己写。
第二步:把 HTTP 请求升级成 WebSocket
核心是 websocket.Upgrader:
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("upgrade error:", err)
return
}
defer conn.Close()
// 后续读写都基于 conn
}
func main() {
http.HandleFunc("/ws", wsHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
注意:CheckOrigin 不写,gorilla 会默认比对 Origin 和 Host,不一致直接返回 403。本地前端跑在 5173、后端在 8080 时必然失败;生产环境别偷懒写 return true,要校验域名白名单,否则任意站点都能连你的服务。
第三步:读写循环,先避开并发写这个坑
最简单的回声服务:
for {
mt, msg, err := conn.ReadMessage()
if err != nil {
break // 对端断开、超时、协议错误都从这里出来
}
if err := conn.WriteMessage(mt, msg); err != nil {
break
}
}
注意:gorilla 明确规定一个连接同时只能有一个写操作。两个 goroutine 同时 WriteMessage 会直接 panic:concurrent write to websocket connection。读可以多协程,写必须串行。
还有两个容易忽略的点:ReadMessage 返回的 []byte 是内部缓冲区,要留存就必须 append([]byte(nil), msg...) 拷一份;另外记得 conn.SetReadLimit(512) 之类限制单条大小,否则一个客户端塞 1G 消息就能打爆内存。
第四步:用 Hub + send channel 做广播
正确姿势是每个连接配一个专属发送队列,写操作只在唯一的 writePump 里发生:
type Client struct {
conn *websocket.Conn
send chan []byte
}
func (c *Client) writePump() {
defer c.conn.Close()
for msg := range c.send {
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
}
Hub 维护 map[*Client]bool,用 register / unregister / broadcast 三个 channel 串行处理增删和群发。广播时若某个 send 已满(说明这个连接消费不过来),直接关掉它,别阻塞整个 Hub——这是防慢客户端拖垮全站的关键。
第五步:心跳保活,别等 TCP 超时才反应过来
移动网络下 NAT 会静默回收空闲连接,服务端短时间内根本收不到 FIN。所以要应用层心跳:
const (
pongWait = 60 * time.Second
pingPeriod = 54 * time.Second // 必须小于 pongWait
)
conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(pongWait))
})
// 在 writePump 里起一个 ticker
ticker := time.NewTicker(pingPeriod)
case <-ticker.C:
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
conn.WriteMessage(websocket.PingMessage, nil)
读超时一旦触发,ReadMessage 返回错误,readPump 退出并触发 unregister,连接自然回收。
第六步:前端与反向代理
const ws = new WebSocket("ws://localhost:8080/ws");
ws.onopen = () => ws.send("hello");
ws.onmessage = e => console.log(e.data);
ws.onclose = () => setTimeout(reconnect, 3000); // 必须自己重连
Nginx 反代要显式放行升级头,否则握手一直 400:
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}
小结
- 用
Upgrader 升级连接,CheckOrigin 生产环境必须做白名单校验;
- 一个连接同时只能有一个写者,用「每连接一个 send channel + 单一 writePump」来保证;
- 广播走 Hub 的三个 channel,慢客户端直接踢,不阻塞主循环;
- 心跳用
SetReadDeadline + PongHandler + 定时 Ping,pingPeriod 要小于 pongWait;
ReadMessage 的切片要拷贝再存,并设置 SetReadLimit 防大消息;
- 重连逻辑写在前端,服务端只负责断开与回收。