别用「关闭 channel」通知退出:close(done) 的时序安全性
关闭 send 通道来通知退出,为什么会 panic
一个连接要有两件事:待发送队列 send,以及「关门」的信号。
最直觉的写法是关闭 send —— 但关闭之后任何一次发送都会 panic,
而发送方可能有好几个:
- Hub 投递消息;
- 读循环回复回执;
- 定时心跳。
它们的时序没有任何保证,你无法证明「关闭之后不会再有人投」。 所以正确的做法是另开一个只用于通知的通道,并且只由退出路径关闭一次:
func (c *Client) push(data []byte) bool {
select { case <-c.done: return false; default: }
select { case c.send <- data: return true; default: return false }
}
// close(done) 由 sync.Once 保证只发生一次:
// 投递方永远不会因为「通道已关闭」而 panic,最多是白投一次。
func (c *Client) close() { c.once.Do(func() { close(c.done) }) }
这里还有一个 Go 的语义要点:关闭通道是广播,所有 <-done 同时解除阻塞;
而「往通道里发一个值」只能唤醒一个接收者。通知退出要的就是广播,所以只能靠 close。
退出顺序:先摘出映射,再关闭连接
读循环退出时要做的是:
defer func() {
h.unregister <- c // 先让 Hub 把这条连接移出映射
c.close() // 再关闭:此后没人会再往 send 投递
}()
顺序反了会出现「连接已经被移出、但还有人在往它的 send 里投」, 那些消息就静静躺在通道里等着被 GC。
校验必须在升级之前
WebSocket 握手一旦完成,就只能靠关闭帧表达错误,前端拿不到任何原因。
因此身份校验必须放在 Upgrade 之前,并且以 JSON 返回:
uid := strings.TrimSpace(ctx.Query("uid"))
if !common.ValidUID(uid) {
ctx.JSON(http.StatusBadRequest, gin.H{
"error": "uid 非法:只允许字母、数字、下划线、连字符,长度 1~64",
})
return
}
字符集限制不只是洁癖:它顺手挡掉了往日志里注入换行、往页面里注入 HTML 的输入, 也避免了「同名不同形」的困惑(全角字符、零宽空格)。
身份不能由客户端说了算
msg.Sender = c.uid // 发送者以连接身份为准,客户端传的 sender 一律忽略
msg.Time = common.Now()
同理,报文解码要严格:早期版本直接 json.Unmarshal 且忽略错误,
结果客户端发一个空字符串就能让服务端往 Hub 里塞一条空消息。
现在三类非法上行(非 JSON / 缺 receiver / 空内容)都会回一条 type=error,
而不是静默丢弃 —— 静默丢弃的表现是「前端一直等回执」,比报错难排查得多。
优雅退出要分两步
http.Server.Shutdown 只等普通请求,已经升级成 WebSocket 的连接不在它的管理范围内。
所以顺序必须是:
- 停掉监听(
Shutdown); - 再统一断开还挂着的 WebSocket(
hub.Close())。
// h.Close() 放在 serve 之后:先把监听停掉,再统一断开还挂着的 WebSocket。
h := handle.New(webSocketConfig())
defer h.Close()
顺带:用 nil channel 表达「关闭」
配置项里 ping_period <= 0 表示关闭心跳。不用 if 包一层,而是:
var ping <-chan time.Time
if cfg.PingPeriod > 0 {
t := time.NewTicker(cfg.PingPeriod)
defer t.Stop()
ping = t.C
}
// select 里 <-ping 在 ping 为 nil 时永久阻塞 —— 心跳随之关闭
nil channel 上的接收永远阻塞,这是 Go 里最干净的「禁用某个 case」写法,
比在 select 外面套判断、或者在循环里加 if enabled 都要可靠。