新增 WebSocket 网关:连接管理、心跳保活、限流与消息路由
This commit is contained in:
@@ -68,3 +68,8 @@ WS:
|
||||
Compression: false # 是否启用 permessage-deflate 压缩
|
||||
MultiDevice: false # false=同账号新连接踢掉旧连接
|
||||
OriginPatterns: [] # 允许的 Origin 域名(浏览器调试用,小游戏连接不带 Origin)
|
||||
ReconnectGraceSec: 30 # 掉线后保留座位的时长(秒),超时未重连则判负或平局
|
||||
RoomIdleTimeoutSec: 300 # 房间空闲自动关闭的时长(秒)
|
||||
MatchTimeoutSec: 60 # 匹配排队超过该时长(秒)自动取消
|
||||
QueueTickMs: 1000 # 匹配队列的扫描间隔(毫秒)
|
||||
MaxRooms: 1000 # 同时存在的房间数上限,防止内存失控
|
||||
|
||||
+9
-8
@@ -3,12 +3,13 @@ package global
|
||||
import "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/setting"
|
||||
|
||||
var (
|
||||
ServerSetting *setting.ServerSettingS
|
||||
LogSetting *setting.LogSettingS
|
||||
MysqlSetting *setting.MysqlSettingS
|
||||
RedisSetting *setting.RedisSettingS
|
||||
JWTSetting *setting.JWTSettingS
|
||||
UploadSetting *setting.UploadSettingS
|
||||
CosSetting *setting.CosSettingS
|
||||
WechatMiniSetting *setting.WechatMiniSettingS
|
||||
ServerSetting *setting.ServerSettingS // HTTP 服务配置(域名、运行模式、端口、读写超时)
|
||||
LogSetting *setting.LogSettingS // 日志配置(路径、文件名、级别、轮转参数)
|
||||
MysqlSetting *setting.MysqlSettingS // MySQL 连接与连接池配置
|
||||
RedisSetting *setting.RedisSettingS // Redis 配置(当前仅读取,不建连接)
|
||||
JWTSetting *setting.JWTSettingS // JWT 配置(密钥、签发方、双令牌有效期)
|
||||
UploadSetting *setting.UploadSettingS // 上传配置(临时目录、大小上限、扩展名白名单)
|
||||
CosSetting *setting.CosSettingS // 腾讯云 COS 配置(密钥、上传/下载域名、CDN 鉴权密钥)
|
||||
WechatMiniSetting *setting.WechatMiniSettingS // 微信小程序配置(AppID / AppSecret)
|
||||
WSSetting *setting.WSSettingS // WebSocket 网关配置(新增:供 internal/wsgateway 读取)
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/coder/websocket v1.8.15 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
github.com/gin-contrib/sse v1.1.2 // indirect
|
||||
|
||||
@@ -16,6 +16,8 @@ github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
||||
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/middleware"
|
||||
v1 "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers/api/v1"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,9 @@ func NewRouter() *gin.Engine {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
||||
})
|
||||
|
||||
// WebSocket 建连入口:必须放在鉴权组之外,token 走 query 由 ServeWS 自行校验。
|
||||
r.GET("/ws", wsgateway.ServeWS)
|
||||
|
||||
// handler 实例
|
||||
userHandler := v1.NewUserHandler()
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package wsgateway // 包名声明:服务端连接对象,属于传输层
|
||||
|
||||
import (
|
||||
"context" // 标准库:连接的生命周期上下文,关闭即取消
|
||||
"errors" // 标准库:用 errors.Is 判断上下文取消、连接关闭等错误
|
||||
"log/slog" // 标准库:结构化日志,记录异常读写与限流踢线
|
||||
"net" // 标准库:net.ErrClosed 用于识别「连接已关闭」类错误
|
||||
"sync" // 标准库:Mutex 保护活跃时间与限流计数,Once 保证关闭幂等
|
||||
"time" // 标准库:超时时间、心跳周期、限流窗口都依赖时间
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global" // 本项目:读取 global.WSSetting 的传输层参数
|
||||
"github.com/coder/websocket" // 第三方:coder/websocket,负责帧读写、ping、关闭
|
||||
)
|
||||
|
||||
// pongWait 返回「多久没收到消息就判死」的时长,对应配置的 ReadTimeout。
|
||||
func pongWait() time.Duration {
|
||||
if global.WSSetting != nil && global.WSSetting.ReadTimeout > 0 { // 配置存在且为正数
|
||||
return time.Duration(global.WSSetting.ReadTimeout) * time.Second // 按配置(秒)换算为 Duration
|
||||
}
|
||||
return 60 * time.Second // 配置缺失时的兜底值,避免 0 值导致连接立刻被判死
|
||||
}
|
||||
|
||||
// writeWait 返回单次写或 ping 的超时时长,对应配置的 WriteTimeout。
|
||||
func writeWait() time.Duration {
|
||||
if global.WSSetting != nil && global.WSSetting.WriteTimeout > 0 { // 配置存在且为正数
|
||||
return time.Duration(global.WSSetting.WriteTimeout) * time.Second // 按配置换算
|
||||
}
|
||||
return 10 * time.Second // 兜底 10 秒
|
||||
}
|
||||
|
||||
// pingPeriod 返回服务端主动 ping 的周期,对应配置的 PingInterval。
|
||||
func pingPeriod() time.Duration {
|
||||
if global.WSSetting != nil && global.WSSetting.PingInterval > 0 { // 配置存在且为正数
|
||||
return time.Duration(global.WSSetting.PingInterval) * time.Second // 按配置换算
|
||||
}
|
||||
return 25 * time.Second // 兜底 25 秒
|
||||
}
|
||||
|
||||
// queueSize 返回发送队列长度,对应配置的 SendQueueSize。
|
||||
func queueSize() int {
|
||||
if global.WSSetting != nil && global.WSSetting.SendQueueSize > 0 { // 配置存在且为正数
|
||||
return global.WSSetting.SendQueueSize // 使用配置值
|
||||
}
|
||||
return 64 // 兜底 64 条
|
||||
}
|
||||
|
||||
// Connection 表示服务端持有的一条 WebSocket 连接(不是客户端)。
|
||||
// 每条连接由两个 goroutine 驱动:ReadPump 收、WritePump 发,中间用 send 队列解耦。
|
||||
//
|
||||
// 说明:coder/websocket 会自动应答协议级 ping 帧,但该应答对 Read 不可见,
|
||||
// 因此「死连接」判定由 WritePump 侧的空闲检查负责。
|
||||
type Connection struct {
|
||||
hub *Hub // 所属 Hub:用于注销自己、以及业务层反向查找
|
||||
conn *websocket.Conn // 底层 ws 连接句柄:真正的帧读写入口
|
||||
userID int64 // 该连接归属的用户 ID:由建连时的 JWT 解析得到
|
||||
|
||||
send chan []byte // 发送队列:业务侧只入队,写 goroutine 负责真正发送
|
||||
closed chan struct{} // 关闭信号:close 后所有 select 立即感知
|
||||
once sync.Once // 关闭幂等:Close 可能被读泵、写泵、Hub 多处调用
|
||||
ctx context.Context // 连接生命周期上下文:关闭时取消,供 handler 起的 goroutine 感知
|
||||
cancel context.CancelFunc // 取消函数:Close 时调用,触发 ctx 取消
|
||||
|
||||
mu sync.Mutex // 保护下面三个字段(活跃时间、限流窗口起点、限流计数)
|
||||
lastActive time.Time // 最近一次收到客户端消息的时间:判死连接用
|
||||
winStart time.Time // 限流窗口起点:每秒重置一次
|
||||
winCount int // 当前窗口内已收到的消息数
|
||||
}
|
||||
|
||||
// NewConnection 为一条已升级的连接创建对象。
|
||||
func NewConnection(hub *Hub, conn *websocket.Conn, userID int64) *Connection {
|
||||
ctx, cancel := context.WithCancel(context.Background()) // 以根 context 派生可取消的连接上下文
|
||||
return &Connection{
|
||||
hub: hub, // 绑定所属 Hub
|
||||
conn: conn, // 保存底层连接
|
||||
userID: userID, // 保存用户 ID
|
||||
send: make(chan []byte, queueSize()), // 按配置创建带缓冲的发送队列
|
||||
closed: make(chan struct{}), // 创建关闭信号通道
|
||||
ctx: ctx, // 保存上下文
|
||||
cancel: cancel, // 保存取消函数,供 Close 调用
|
||||
lastActive: time.Now(), // 初始活跃时间为当前时间,避免刚连上就被判死
|
||||
}
|
||||
}
|
||||
|
||||
// UserID 返回该连接对应的用户 ID。
|
||||
func (c *Connection) UserID() int64 {
|
||||
return c.userID // 直接返回建立连接时保存的 ID
|
||||
}
|
||||
|
||||
// Context 返回连接生命周期上下文:handler 内起 goroutine 时用它感知断连。
|
||||
func (c *Connection) Context() context.Context {
|
||||
return c.ctx // 返回内部上下文
|
||||
}
|
||||
|
||||
// Send 把一条已编码的消息放入发送队列;队列满视为慢客户端并断开。
|
||||
func (c *Connection) Send(data []byte) bool {
|
||||
select {
|
||||
case <-c.closed: // 连接已关闭
|
||||
return false // 投递失败
|
||||
default: // 未关闭则继续往下走,避免阻塞
|
||||
}
|
||||
|
||||
select {
|
||||
case c.send <- data: // 尝试入队成功
|
||||
return true // 投递成功
|
||||
case <-c.closed: // 入队期间连接被关闭
|
||||
return false // 投递失败
|
||||
default: // 队列已满:说明客户端消费跟不上
|
||||
slog.WarnContext(c.ctx, "ws send queue full, close slow client", "user_id", c.userID) // 记录慢客户端日志
|
||||
c.Close() // 主动断开,避免内存被堆积消息撑爆
|
||||
return false // 投递失败
|
||||
}
|
||||
}
|
||||
|
||||
// SendEnvelope 把信封编码后入队发送。
|
||||
func (c *Connection) SendEnvelope(typ string, seq int64, payload any) bool {
|
||||
data, err := Encode(typ, seq, payload) // 先序列化为 JSON 字节
|
||||
if err != nil { // 序列化失败(payload 含不可序列化字段)
|
||||
slog.ErrorContext(c.ctx, "ws encode envelope failed", "type", typ, "error", err) // 记录错误日志
|
||||
return false // 发送失败
|
||||
}
|
||||
return c.Send(data) // 入队发送
|
||||
}
|
||||
|
||||
// SendError 便捷地发一条错误下行消息。
|
||||
func (c *Connection) SendError(seq int64, code int, msg string) bool {
|
||||
return c.SendEnvelope(TypeError, seq, ErrorPayload{Code: code, Msg: msg}) // 组装 ErrorPayload 并以 error 类型下发
|
||||
}
|
||||
|
||||
// Close 关闭连接,多处调用只生效一次。
|
||||
func (c *Connection) Close() {
|
||||
c.once.Do(func() { // 用 Once 保证幂等,避免重复 close 通道 panic
|
||||
close(c.closed) // 广播关闭信号,读写泵立即退出
|
||||
c.cancel() // 取消连接上下文,通知所有派生 goroutine
|
||||
_ = c.conn.Close(websocket.StatusNormalClosure, "") // 以正常关闭状态码关闭底层连接,忽略返回错误
|
||||
})
|
||||
}
|
||||
|
||||
// touch 把活跃时间刷新为当前时间,表示刚收到客户端消息。
|
||||
func (c *Connection) touch() {
|
||||
c.mu.Lock() // 加锁:与 idleFor 共享字段
|
||||
c.lastActive = time.Now() // 更新活跃时间
|
||||
c.mu.Unlock() // 解锁
|
||||
}
|
||||
|
||||
// idleFor 返回距上次收到消息已经过去多久。
|
||||
func (c *Connection) idleFor() time.Duration {
|
||||
c.mu.Lock() // 加锁读取
|
||||
defer c.mu.Unlock() // 函数返回时解锁
|
||||
return time.Since(c.lastActive) // 计算与当前时间的差值
|
||||
}
|
||||
|
||||
// allow 做固定窗口限流:每秒消息数超过 MaxMsgPerSec 就返回 false。
|
||||
func (c *Connection) allow() bool {
|
||||
limit := 0 // 默认不限流
|
||||
if global.WSSetting != nil { // 配置存在
|
||||
limit = global.WSSetting.MaxMsgPerSec // 取配置的每秒上限
|
||||
}
|
||||
if limit <= 0 { // 未配置或配置为 0
|
||||
return true // 不做限制
|
||||
}
|
||||
|
||||
now := time.Now() // 取当前时间
|
||||
c.mu.Lock() // 加锁:修改窗口计数
|
||||
defer c.mu.Unlock() // 返回时解锁
|
||||
|
||||
if now.Sub(c.winStart) >= time.Second { // 距窗口起点已超过 1 秒
|
||||
c.winStart = now // 开启新窗口
|
||||
c.winCount = 0 // 计数归零
|
||||
}
|
||||
c.winCount++ // 本条消息计入当前窗口
|
||||
return c.winCount <= limit // 未超过上限才放行
|
||||
}
|
||||
|
||||
// ReadPump 是读循环:收帧 → 更新活跃时间 → 限流 → 解码 → 心跳 → 路由分发。
|
||||
// 该函数阻塞运行,一旦返回就表示连接结束,defer 里完成注销与关闭。
|
||||
func (c *Connection) ReadPump() {
|
||||
defer func() { // 退出前必定执行清理
|
||||
c.hub.Unregister(c) // 从 Hub 表中摘掉自己
|
||||
c.Close() // 关闭连接(幂等)
|
||||
}()
|
||||
|
||||
for { // 持续读取,直到出错或连接关闭
|
||||
mt, data, err := c.conn.Read(c.ctx) // 阻塞读取一帧;coder/websocket 会自动应答协议级 ping,这里只拿到业务消息
|
||||
if err != nil { // 读取出错:可能是正常关闭、上下文取消或对端异常断开
|
||||
if !isNormalClose(err) { // 过滤掉正常关闭类错误,避免噪音日志
|
||||
slog.WarnContext(c.ctx, "ws read error", "user_id", c.userID, "error", err) // 记录异常读错误
|
||||
}
|
||||
return // 退出读循环
|
||||
}
|
||||
|
||||
if mt != websocket.MessageText { // 只接受文本帧(业务信封统一用 JSON 文本)
|
||||
continue // 二进制帧直接忽略,继续读下一条
|
||||
}
|
||||
|
||||
c.touch() // 收到消息:刷新活跃时间,避免被判死
|
||||
if !c.allow() { // 超过每秒消息数上限
|
||||
slog.WarnContext(c.ctx, "ws message rate exceeded", "user_id", c.userID) // 记录限流日志
|
||||
c.SendError(0, 0, "消息频率超限") // 先回一条错误告知客户端
|
||||
return // 再断开连接
|
||||
}
|
||||
|
||||
env, err := Decode(data) // 把 JSON 字节解析成统一信封
|
||||
if err != nil { // 信封格式不合法
|
||||
c.SendError(0, 0, "消息格式错误: "+err.Error()) // 回错误但不断开,允许客户端继续尝试
|
||||
continue // 继续读下一条
|
||||
}
|
||||
|
||||
if env.Type == TypePing { // 应用层心跳
|
||||
c.SendEnvelope(TypePong, env.Seq, nil) // 原样带回 seq 回一条 pong
|
||||
continue // 心跳不进业务路由
|
||||
}
|
||||
|
||||
// TODO(第二步):大厅与房间的 handler 都从这里进入(hall.match / room.action 等)。
|
||||
c.hub.Route(c.ctx, c, env) // 按类型分发给已注册的业务处理器
|
||||
}
|
||||
}
|
||||
|
||||
// WritePump 是写循环:发送队列 + 主动 ping 保活 + 空闲超时判死。
|
||||
func (c *Connection) WritePump() {
|
||||
ticker := time.NewTicker(pingPeriod()) // 按配置周期触发心跳与空闲检查
|
||||
defer func() { // 退出前清理
|
||||
ticker.Stop() // 停止定时器,避免泄漏
|
||||
c.Close() // 关闭连接(幂等)
|
||||
}()
|
||||
|
||||
for { // 循环处理三类事件
|
||||
select {
|
||||
case <-c.closed: // 连接被关闭
|
||||
return // 退出写循环
|
||||
|
||||
case msg := <-c.send: // 有待发送的下行消息
|
||||
ctx, cancel := context.WithTimeout(c.ctx, writeWait()) // 为本次写操作加超时,防止对端不读导致永久阻塞
|
||||
err := c.conn.Write(ctx, websocket.MessageText, msg) // 写出一帧文本消息
|
||||
cancel() // 立即释放超时上下文资源
|
||||
if err != nil { // 写失败:连接多半已不可用
|
||||
return // 退出写循环,由 defer 关闭
|
||||
}
|
||||
|
||||
case <-ticker.C: // 心跳周期到点
|
||||
if idle := c.idleFor(); idle > pongWait() { // 超过 ReadTimeout 未收到任何客户端消息
|
||||
slog.InfoContext(c.ctx, "ws idle timeout, close connection",
|
||||
"user_id", c.userID, "idle", idle.String()) // 记录判死日志
|
||||
_ = c.conn.Close(websocket.StatusPolicyViolation, "idle timeout") // 以策略违规码关闭,告知对端原因
|
||||
return // 退出写循环
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.ctx, writeWait()) // 为 ping 加超时
|
||||
err := c.conn.Ping(ctx) // 发送协议级 ping,探测链路与对端存活
|
||||
cancel() // 释放上下文
|
||||
if err != nil { // ping 失败:连接已死
|
||||
return // 退出写循环
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isNormalClose 判断错误是否属于「正常结束」,避免把正常断开记成异常告警。
|
||||
func isNormalClose(err error) bool {
|
||||
if err == nil { // 没有错误
|
||||
return true // 视为正常
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, net.ErrClosed) {
|
||||
// 上下文被取消(我们主动关闭)、超时、或底层连接已关闭,都属于预期内的结束
|
||||
return true // 视为正常
|
||||
}
|
||||
return websocket.CloseStatus(err) != -1 // 有明确关闭状态码说明是对端发起的正常关闭
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Package wsgateway 提供 WebSocket 长连接网关。
|
||||
// 职责边界:本包只做「传输」——建连鉴权、连接管理、心跳保活、限流、消息编解码与路由分发;
|
||||
// 不理解任何玩法语义,大厅与房间的业务由 internal/hall、internal/game 注册 handler 后接入。
|
||||
package wsgateway // 包名声明:本目录下所有文件同属 wsgateway 包
|
||||
|
||||
import (
|
||||
"encoding/json" // 标准库:用于把信封与业务体序列化为 JSON、以及把上行 JSON 反序列化
|
||||
"fmt" // 标准库:用 fmt.Errorf 包装错误,保留上下文
|
||||
"time" // 标准库:取当前时间戳,填充 hello 下行消息中的 server_time
|
||||
)
|
||||
|
||||
// ===== 上行消息类型:客户端 → 服务端 =====
|
||||
const (
|
||||
TypePing = "ping" // 应用层心跳:客户端周期性发送,服务端原样回 pong
|
||||
|
||||
// TODO(第二步 大厅):在此补充 TypeHallMatch = "hall.match"、TypeHallCancel = "hall.cancel"
|
||||
// TODO(第二步 房间):在此补充 TypeRoomJoin = "room.join"、TypeRoomReady = "room.ready"、
|
||||
// TypeRoomAction = "room.action"、TypeRoomLeave = "room.leave"、
|
||||
// TypeRoomReconnect = "room.reconnect"
|
||||
)
|
||||
|
||||
// ===== 下行消息类型:服务端 → 客户端 =====
|
||||
const (
|
||||
TypeHello = "hello" // 建连成功后下发的首条消息,携带 userID 与心跳建议间隔
|
||||
TypePong = "pong" // 对上行 ping 的应答
|
||||
TypeEvent = "event" // 业务事件统一信封:房间状态、对局事件等都走这个类型,payload 由各模块自定义
|
||||
TypeError = "error" // 错误下行:参数错误、未支持类型、业务错误统一走这里
|
||||
)
|
||||
|
||||
// Envelope 是上下行消息的统一信封。
|
||||
// 约定:业务差异全部放进 payload,网关只认识 type 与 seq,不解析业务字段。
|
||||
type Envelope struct {
|
||||
Type string `json:"type"` // 消息类型:决定路由到哪个 handler
|
||||
Seq int64 `json:"seq,omitempty"` // 客户端自增序号:服务端回包原样带回,便于客户端对账;为空则不输出该字段
|
||||
Payload json.RawMessage `json:"payload,omitempty"` // 业务体:保持原始字节,由具体模块的 handler 自行反序列化
|
||||
}
|
||||
|
||||
// ErrorPayload 是 type=error 时的下行体。
|
||||
// TODO(第二步):Code 建议复用 pkg/errcode 的业务码(例如 errcode.InvalidParams.Code()),保持前后端错误码一致。
|
||||
type ErrorPayload struct {
|
||||
Code int `json:"code"` // 业务错误码
|
||||
Msg string `json:"msg"` // 错误描述,可直接展示给用户
|
||||
Details string `json:"details,omitempty"` // 可选的补充信息(如参数校验详情),为空则不输出
|
||||
}
|
||||
|
||||
// HelloPayload 是 type=hello 时的下行体。
|
||||
type HelloPayload struct {
|
||||
UserID int64 `json:"user_id"` // 服务端从 token 解析出的用户 ID
|
||||
PingInterval int `json:"ping_interval"` // 心跳间隔(秒):客户端按此间隔发送 {"type":"ping"}
|
||||
ServerTime int64 `json:"server_time"` // 服务端当前秒级时间戳:供客户端校正时钟
|
||||
}
|
||||
|
||||
// Encode 把任意 payload 打包成信封并返回 JSON 字节。
|
||||
func Encode(typ string, seq int64, payload any) ([]byte, error) {
|
||||
env := Envelope{Type: typ, Seq: seq} // 先构造信封,填入类型与序号
|
||||
if payload != nil { // payload 为空(如 pong)时跳过序列化
|
||||
raw, err := json.Marshal(payload) // 把业务体序列化为 JSON 字节
|
||||
if err != nil { // 序列化失败通常意味着 payload 里有不可序列化的字段
|
||||
return nil, fmt.Errorf("marshal payload: %w", err) // 包装错误并返回给调用方
|
||||
}
|
||||
env.Payload = raw // 把序列化结果放入信封
|
||||
}
|
||||
return json.Marshal(env) // 把整个信封序列化为最终要发送的字节
|
||||
}
|
||||
|
||||
// Decode 把上行 JSON 字节解析为信封。
|
||||
func Decode(data []byte) (*Envelope, error) {
|
||||
var env Envelope // 声明目标结构体
|
||||
if err := json.Unmarshal(data, &env); err != nil { // 反序列化上行数据
|
||||
return nil, fmt.Errorf("unmarshal envelope: %w", err) // 格式错误:返回给上层,由上层回 error 消息
|
||||
}
|
||||
if env.Type == "" { // 缺少 type 字段无法路由
|
||||
return nil, fmt.Errorf("envelope type is empty") // 返回明确的错误原因
|
||||
}
|
||||
return &env, nil // 返回解析成功的信封
|
||||
}
|
||||
|
||||
// BindPayload 把信封中的 payload 解析到调用方给定的结构体,供各模块 handler 使用。
|
||||
func BindPayload(env *Envelope, out any) error {
|
||||
if len(env.Payload) == 0 { // 上行未携带业务体
|
||||
return fmt.Errorf("payload is empty") // 返回错误,handler 可据此回参数错误
|
||||
}
|
||||
return json.Unmarshal(env.Payload, out) // 按 out 的类型反序列化
|
||||
}
|
||||
|
||||
// nowUnix 返回当前秒级时间戳。
|
||||
func nowUnix() int64 {
|
||||
return time.Now().Unix() // 取当前时间并转换为 Unix 秒
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package wsgateway // 包名声明:与 envelope.go、connection.go、server.go 同包
|
||||
|
||||
import (
|
||||
"context" // 标准库:handler 的入参上下文,连接关闭即取消
|
||||
"log/slog" // 标准库:结构化日志,未注册消息类型时打 debug 日志
|
||||
"sync" // 标准库:RWMutex,保护 Hub 内部的两张表
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global" // 本项目:读取 global.WSSetting 判断是否允许多设备
|
||||
)
|
||||
|
||||
// Handler 是业务消息处理器签名,由 hall / room 模块实现并注册到 Hub。
|
||||
// ctx 随连接关闭而取消:handler 内部若起 goroutine,必须监听它;绝不要把 *gin.Context 带进来。
|
||||
type Handler func(ctx context.Context, c *Connection, env *Envelope)
|
||||
|
||||
// DefaultHub 是进程级默认 Hub 单例。
|
||||
// 若将来需要注入(方便单测),可改为在 main 里 NewHub() 并传给 routers.NewRouter。
|
||||
var DefaultHub = NewHub() // 包初始化时即创建,server.go 与路由共用这一个实例
|
||||
|
||||
// Hub 是连接注册表与消息路由器,本身不理解任何玩法。
|
||||
type Hub struct {
|
||||
mu sync.RWMutex // 读写锁:保护下面两个 map,读多写少所以用 RWMutex
|
||||
conns map[int64]*Connection // userID -> 连接:一个用户当前对应的那条连接
|
||||
handlers map[string]Handler // 消息类型 -> 处理器:决定上行消息由谁处理
|
||||
}
|
||||
|
||||
// NewHub 构造一个空的 Hub。
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
conns: make(map[int64]*Connection), // 初始化连接表,避免 nil map 写入 panic
|
||||
handlers: make(map[string]Handler), // 初始化处理器表,避免 nil map 写入 panic
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterHandler 注册某类消息的处理函数。
|
||||
// TODO(第二步):hall 注册 hall.match / hall.cancel;房间注册 room.join / room.action / room.leave 等。
|
||||
func (h *Hub) RegisterHandler(msgType string, fn Handler) {
|
||||
h.mu.Lock() // 加写锁:修改 handlers 表
|
||||
defer h.mu.Unlock() // 函数返回时解锁
|
||||
h.handlers[msgType] = fn // 覆盖式注册:同名类型以最后注册者为准
|
||||
}
|
||||
|
||||
// Register 把新建连接登记到 Hub;MultiDevice=false 时先踢掉同账号的旧连接。
|
||||
func (h *Hub) Register(c *Connection) {
|
||||
h.mu.Lock() // 加写锁:可能修改 conns 表
|
||||
defer h.mu.Unlock() // 返回时解锁
|
||||
|
||||
if global.WSSetting == nil || !global.WSSetting.MultiDevice { // 未配置或明确禁止多设备:执行踢线
|
||||
if old, ok := h.conns[c.UserID()]; ok && old != c { // 该账号已在线且不是同一条连接
|
||||
slog.InfoContext(c.Context(), "ws kick old connection", "user_id", c.UserID()) // 记录踢线日志,便于排查重复登录
|
||||
old.Close() // 关闭旧连接:旧连接的读写泵会随之退出
|
||||
delete(h.conns, c.UserID()) // 从表中移除旧连接,给新连接腾位置
|
||||
}
|
||||
}
|
||||
h.conns[c.UserID()] = c // 登记新连接
|
||||
}
|
||||
|
||||
// Unregister 从表中注销连接;只有表中仍是自己时才删除,避免误删踢线后新建的连接。
|
||||
func (h *Hub) Unregister(c *Connection) {
|
||||
h.mu.Lock() // 加写锁:修改 conns 表
|
||||
defer h.mu.Unlock() // 返回时解锁
|
||||
if cur, ok := h.conns[c.UserID()]; ok && cur == c { // 表中存在且指向同一条连接
|
||||
delete(h.conns, c.UserID()) // 安全删除
|
||||
}
|
||||
}
|
||||
|
||||
// Get 取指定用户当前在线的连接。
|
||||
func (h *Hub) Get(userID int64) (*Connection, bool) {
|
||||
h.mu.RLock() // 加读锁:只读不写
|
||||
defer h.mu.RUnlock() // 返回时解锁
|
||||
c, ok := h.conns[userID] // 查表
|
||||
return c, ok // 返回连接与是否在线
|
||||
}
|
||||
|
||||
// Online 返回当前在线连接数(默认一人一条连接,即在线用户数)。
|
||||
func (h *Hub) Online() int {
|
||||
h.mu.RLock() // 加读锁
|
||||
defer h.mu.RUnlock() // 返回时解锁
|
||||
return len(h.conns) // 表长度即在线数
|
||||
}
|
||||
|
||||
// Route 按消息类型分发上行消息;未注册的类型直接回错误,不静默丢弃,方便客户端自测。
|
||||
func (h *Hub) Route(ctx context.Context, c *Connection, env *Envelope) {
|
||||
h.mu.RLock() // 加读锁:查 handlers 表
|
||||
fn, ok := h.handlers[env.Type] // 按类型找处理器
|
||||
h.mu.RUnlock() // 立即解锁,避免业务执行期间占用锁
|
||||
|
||||
if !ok { // 没找到对应处理器
|
||||
slog.DebugContext(ctx, "ws unsupported message type", "type", env.Type, "user_id", c.UserID()) // 记录调试日志
|
||||
c.SendError(env.Seq, 0, "暂不支持的消息类型: "+env.Type) // 回一条错误信封,带上原 seq 便于客户端定位
|
||||
return // 结束分发
|
||||
}
|
||||
fn(ctx, c, env) // 交给业务处理器执行
|
||||
}
|
||||
|
||||
// SendTo 向单个用户下发一条已编码的消息;返回 false 表示已离线或发送队列已满。
|
||||
func (h *Hub) SendTo(userID int64, data []byte) bool {
|
||||
h.mu.RLock() // 加读锁:查表
|
||||
c, ok := h.conns[userID] // 找到目标连接
|
||||
h.mu.RUnlock() // 解锁
|
||||
if !ok { // 用户不在线
|
||||
return false // 返回投递失败,调用方可决定是否落库或重连后再发
|
||||
}
|
||||
return c.Send(data) // 入队发送
|
||||
}
|
||||
|
||||
// Broadcast 向指定用户集合下发同一条消息。
|
||||
// TODO(第二步):房间内广播就走这里——玩法产出的 Event 由房间翻译成信封后调用本方法。
|
||||
func (h *Hub) Broadcast(userIDs []int64, data []byte) {
|
||||
h.mu.RLock() // 加读锁
|
||||
targets := make([]*Connection, 0, len(userIDs)) // 预分配切片,容量等于目标数
|
||||
for _, uid := range userIDs { // 遍历目标用户 ID
|
||||
if c, ok := h.conns[uid]; ok { // 在线才加入投递列表
|
||||
targets = append(targets, c) // 收集连接指针
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock() // 解锁:后续发送不持锁,避免慢客户端阻塞注册表
|
||||
for _, c := range targets { // 逐个投递
|
||||
c.Send(data) // 入队发送(队列满会自行断开慢客户端)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastAll 向全部在线连接下发,适用于大厅公告、全服通知等。
|
||||
func (h *Hub) BroadcastAll(data []byte) {
|
||||
h.mu.RLock() // 加读锁
|
||||
targets := make([]*Connection, 0, len(h.conns)) // 预分配切片,容量等于当前在线数
|
||||
for _, c := range h.conns { // 遍历所有连接
|
||||
targets = append(targets, c) // 收集连接指针
|
||||
}
|
||||
h.mu.RUnlock() // 解锁后再发送
|
||||
for _, c := range targets { // 逐个投递
|
||||
c.Send(data) // 入队发送
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package wsgateway // 包名声明:HTTP 升级入口,属于传输层
|
||||
|
||||
import (
|
||||
"log/slog" // 标准库:记录升级失败与建连日志
|
||||
"net/http" // 标准库:HTTP 状态码常量
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global" // 本项目:读取 WS 配置(心跳、Origin、压缩、单帧上限)
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/lzcwyjwt" // 本项目:解析 access token,拿到 userID
|
||||
"github.com/coder/websocket" // 第三方:提供 Accept 完成 WebSocket 握手
|
||||
"github.com/gin-gonic/gin" // 第三方:HTTP 框架,本函数是 gin handler
|
||||
)
|
||||
|
||||
// ServeWS 是 WebSocket 建连入口:token 鉴权 → Accept 升级 → 注册 Hub → 启动读写泵。
|
||||
//
|
||||
// 小程序 ws 无法自定义请求头,因此 access token 通过 query 传入:/ws?token=xxx
|
||||
func ServeWS(c *gin.Context) {
|
||||
token := c.Query("token") // 从 URL query 中取 token
|
||||
if token == "" { // 未携带 token
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 11004, "msg": "Token 缺失"}) // 返回 401(此时尚未升级,可正常写 JSON)
|
||||
return // 结束请求
|
||||
}
|
||||
|
||||
// TODO(可选):改为一次性 ticket(HTTP 换 ticket,ws 用 ticket),避免长 token 落在 URL 与访问日志里。
|
||||
claims, err := lzcwyjwt.ParseAccessToken(token) // 解析并校验 access token(签名、签发方、有效期、类型)
|
||||
if err != nil { // token 无效或已过期
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 11001, "msg": "Token 鉴权失败"}) // 返回 401
|
||||
return // 结束请求
|
||||
}
|
||||
|
||||
// Accept 失败时库已写入 HTTP 错误响应,这里不再写 body,避免重复写响应头。
|
||||
conn, err := websocket.Accept(c.Writer, c.Request, acceptOptions()) // 完成握手,拿到长连接句柄
|
||||
if err != nil { // 握手失败(协议不支持、Origin 不匹配等)
|
||||
slog.Warn("ws accept failed", "user_id", claims.UserID, "error", err) // 记录告警日志
|
||||
return // 结束请求
|
||||
}
|
||||
|
||||
if limit := readLimitBytes(); limit > 0 { // 配置了单帧上限
|
||||
conn.SetReadLimit(limit) // 收紧单帧上限(库默认 32768 字节),防止超大帧打爆内存
|
||||
}
|
||||
|
||||
connection := NewConnection(DefaultHub, conn, claims.UserID) // 为这条连接创建服务端连接对象
|
||||
DefaultHub.Register(connection) // 登记到 Hub(必要时踢掉同账号旧连接)
|
||||
|
||||
interval := 25 // 默认心跳间隔 25 秒
|
||||
if global.WSSetting != nil && global.WSSetting.PingInterval > 0 { // 配置了心跳间隔
|
||||
interval = global.WSSetting.PingInterval // 使用配置值
|
||||
}
|
||||
connection.SendEnvelope(TypeHello, 0, HelloPayload{ // 下发建连成功的首条消息
|
||||
UserID: connection.UserID(), // 回显用户 ID
|
||||
PingInterval: interval, // 告诉客户端按此间隔发应用层 ping
|
||||
ServerTime: nowUnix(), // 回显服务端时间,供客户端校正
|
||||
})
|
||||
slog.Info("ws connected", "user_id", connection.UserID()) // 记录建连日志
|
||||
|
||||
go connection.WritePump() // 另起 goroutine 跑写循环(发送 + ping + 判死)
|
||||
connection.ReadPump() // 在当前 goroutine 跑读循环:阻塞直到连接结束才返回
|
||||
}
|
||||
|
||||
// acceptOptions 按 WS 配置构造 Accept 选项(每次建连构造一次,便于配置变更后生效)。
|
||||
//
|
||||
// 来源校验语义(与 coder/websocket 文档一致):
|
||||
// - 未配置 OriginPatterns:关闭同源校验(InsecureSkipVerify),放行所有来源;
|
||||
// 小程序连接通常不带 Origin,且安全已由 token 鉴权兜底。
|
||||
// - 配置了 OriginPatterns:按 host 模式匹配(支持 path.Match 通配,如 "*.example.com")。
|
||||
func acceptOptions() *websocket.AcceptOptions {
|
||||
opts := &websocket.AcceptOptions{} // 创建空选项
|
||||
|
||||
if global.WSSetting == nil { // 配置完全缺失:放行并直接返回
|
||||
opts.InsecureSkipVerify = true // 关闭 Origin 校验
|
||||
return opts // 直接返回
|
||||
}
|
||||
|
||||
if len(global.WSSetting.OriginPatterns) == 0 { // 未配置允许列表
|
||||
// 官方建议:放行任意来源用 InsecureSkipVerify,而不是把 "*" 写进 OriginPatterns。
|
||||
opts.InsecureSkipVerify = true // 关闭 Origin 校验
|
||||
} else { // 配置了允许列表
|
||||
opts.OriginPatterns = global.WSSetting.OriginPatterns // 交给库按 host 模式匹配(支持 path.Match 通配)
|
||||
}
|
||||
|
||||
if global.WSSetting.Compression { // 配置开启压缩
|
||||
opts.CompressionMode = websocket.CompressionNoContextTakeover // 启用压缩但不接管上下文(更省内存)
|
||||
} else { // 未开启
|
||||
opts.CompressionMode = websocket.CompressionDisabled // 显式关闭压缩
|
||||
}
|
||||
|
||||
return opts // 返回构造好的选项
|
||||
}
|
||||
|
||||
// readLimitBytes 返回单帧上限(字节),对应配置的 MaxMessageBytes。
|
||||
func readLimitBytes() int64 {
|
||||
if global.WSSetting != nil && global.WSSetting.MaxMessageBytes > 0 { // 配置存在且为正数
|
||||
return int64(global.WSSetting.MaxMessageBytes) // 使用配置值
|
||||
}
|
||||
return 0 // 0 表示不额外收紧,沿用库默认值(32768 字节)
|
||||
}
|
||||
@@ -117,6 +117,9 @@ func setupSetting() error {
|
||||
if err := s.ReadSection("WechatMini", &global.WechatMiniSetting); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ReadSection("WS", &global.WSSetting); err != nil { // 读取 WS 配置段到全局变量
|
||||
return err // 读取或反序列化失败直接返回,启动阶段 panic 暴露问题
|
||||
}
|
||||
|
||||
// ===== 必填校验:缺失即启动失败,避免运行期才发现配置错误 =====
|
||||
if global.ServerSetting == nil {
|
||||
@@ -148,6 +151,10 @@ func setupSetting() error {
|
||||
if global.WechatMiniSetting == nil {
|
||||
global.WechatMiniSetting = &setting.WechatMiniSettingS{}
|
||||
}
|
||||
// 其余 WS 默认值(心跳/超时/限流/队列)都在 internal/wsgateway 内部兜底,这里只避免 nil 解引用。
|
||||
if global.WSSetting == nil { // 配置文件里没有 WS 段
|
||||
global.WSSetting = &setting.WSSettingS{} // 赋一个零值结构体,保证各处读取不 panic
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -75,6 +75,27 @@ type WechatMiniSettingS struct {
|
||||
AppSecret string
|
||||
}
|
||||
|
||||
// WSSettingS WebSocket 网关配置。
|
||||
// 前半段供 internal/wsgateway 传输层使用;后半段是第二步房间与匹配要用的预留项。
|
||||
type WSSettingS struct {
|
||||
PingInterval int // 心跳间隔(秒):服务端按此周期发 ping,也下发给客户端作为应用层心跳建议值
|
||||
ReadTimeout int // 读超时(秒):超过该时长未收到任何客户端消息即判定死连接并断开
|
||||
WriteTimeout int // 写超时(秒):单次 Write 或 Ping 的最长等待时间
|
||||
MaxMessageBytes int // 单帧上限(字节):超过该大小的消息由库直接拒绝
|
||||
MaxMsgPerSec int // 单连接每秒消息数上限:用于限流,超出即断开
|
||||
SendQueueSize int // 发送队列长度:写满说明客户端消费太慢(慢客户端),直接断开
|
||||
Compression bool // 是否启用 permessage-deflate 压缩
|
||||
MultiDevice bool // 是否允许多设备同时在线:false 表示同账号新连接踢掉旧连接
|
||||
OriginPatterns []string // 允许的 Origin 域名列表:浏览器调试用,小游戏连接通常不带 Origin
|
||||
|
||||
// ===== 以下为房间 / 匹配预留(第二步填充) =====
|
||||
ReconnectGraceSec int // 掉线后保留座位的时长(秒):超时未重连则判负或平局
|
||||
RoomIdleTimeoutSec int // 房间空闲自动关闭的时长(秒)
|
||||
MatchTimeoutSec int // 匹配排队超过该时长(秒)自动取消
|
||||
QueueTickMs int // 匹配队列的扫描间隔(毫秒)
|
||||
MaxRooms int // 同时存在的房间数上限,防止内存失控
|
||||
}
|
||||
|
||||
func (s *Setting) ReadSection(k string, v any) error {
|
||||
err := s.vp.UnmarshalKey(k, v)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user