2 Commits
13 changed files with 620 additions and 79 deletions
+11 -8
View File
@@ -1,24 +1,27 @@
# zogo # zogo
zeroone Go 通用库(单仓多包)。被各后端服务以 Go module 依赖引用: zeroone Go 通用库(单仓多包,顶层即包)。被各后端服务以 Go module 依赖引用:
`go get git.zeroonesoft.cn/golib/zogo/xxx` `go get git.zeroonesoft.cn/golib/zogo/xxx`
## 包清单 ## 包清单
| 包 | 用途 | 主要依赖 | | 包 | 用途 | 文档 |
|---|---|---| |---|---|---|
| `httpx` | Gin 统一出入参:OkJson/ErrorJson(CodeMsg 错误码透传)、Handle/HandleResult、Parse(uri→query→header→json 顺序绑定 + binding 校验收口) | gin, validator | | `httpx` | Gin 统一出入参:OkJson/ErrorJson(CodeMsg 错误码透传)、Handle/HandleResult、Parse(uri→query→header→json 顺序绑定 + binding 校验收口) | [httpx/README.md](httpx/README.md) |
| `logger` | logrus 日志初始化(文件轮转) | logrus, rotatelogs | | `logger` | logrus 日志初始化(控制台彩色 + 按天轮转文件 + 历史压缩) | [logger/README.md](logger/README.md) |
| `wsc` | WebSocket 服务框架(会话/房间/路由/反射绑定) | gin, gorilla/websocket, uuid, logrus | | `wsc` | WebSocket 服务端框架(会话/房间/路由)+ 配套客户端(自动重连),同一 Message 信封 | [wsc/README.md](wsc/README.md) |
| `wscclient` | WebSocket 客户端(自动重连) | gorilla/websocket | | `jwtx` | 轻量 HMAC-SHA256 令牌签发与校验 | [jwtx/README.md](jwtx/README.md) |
| `jwtx` | 轻量 HMAC-SHA256 JWT 签发与校验 | 无 |
> 历史说明:v0.1.0 曾存在独立包 `wscclient`,v0.2.0 起并入 `wsc`(`wsc.Client`)——
> 客户端与服务端共用信封定义,协议单一来源防两端漂移。
## 约定 ## 约定
- 通用性准入:被 ≥2 个服务复制使用过的包才进入本仓库;仅单服务使用的留在服务内。 - 通用性准入:被 ≥2 个服务复制使用过的包才进入本仓库;仅单服务使用的留在服务内。
- 改动流程:在本仓库修复/演进 → 打 tag(如 v0.1.1)→ 各服务 `go get -u git.zeroonesoft.cn/golib/zogo@v0.1.1`。 - 改动流程:在本仓库修复/演进 → 打 tag(如 v0.2.0)→ 各服务 `go get git.zeroonesoft.cn/golib/zogo@v0.2.0`。
- 本地联动开发:在 `D:\zomaintain\backend\go.work` 中 `use D:/golib/zogo`(go.work 不提交)。 - 本地联动开发:在 `D:\zomaintain\backend\go.work` 中 `use D:/golib/zogo`(go.work 不提交)。
- go.mod 声明 go 1.24(不高于最低版本消费方),不要随意上调。 - go.mod 声明 go 1.24(不高于最低版本消费方),不要随意上调。
- 每个包自带 README.md(定位/用法/注意事项),改 API 先改文档。
## 私有模块拉取配置(每台开发机/CI 一次) ## 私有模块拉取配置(每台开发机/CI 一次)
+4
View File
@@ -3,6 +3,7 @@ module git.zeroonesoft.cn/golib/zogo
go 1.25.0 go 1.25.0
require ( require (
github.com/bwmarrin/snowflake v0.3.0
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/go-playground/validator/v10 v10.30.4 github.com/go-playground/validator/v10 v10.30.4
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
@@ -10,6 +11,7 @@ require (
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
github.com/mattn/go-colorable v0.1.15 github.com/mattn/go-colorable v0.1.15
github.com/sirupsen/logrus v1.10.2 github.com/sirupsen/logrus v1.10.2
gorm.io/gorm v1.31.2
) )
require ( require (
@@ -23,6 +25,8 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+12
View File
@@ -1,3 +1,5 @@
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@@ -34,6 +36,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -52,6 +58,8 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -105,3 +113,7 @@ google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+31
View File
@@ -0,0 +1,31 @@
# httpx
Gin 服务的统一出入参收口。
## 响应
统一信封 `{"code":0,"msg":"OK","data":...}`:
- `OkJson(c, data)` —— 成功出参
- `ErrorJson(c, err)` —— 错误出参;`*CodeMsg` 携带的错误码**透出**(如 401),其余错误折叠为 code 1
- `wsc.New(401, "未登录")` 构造带码错误
## Handler 收口
```go
// 泛型:Parse 取参 → 调 logic → 统一出参
httpx.Handle(c, func(req *CreateReq) (*CreateResp, error) { return svc.Create(req) })
httpx.HandleResult(c, data, err) // 非泛型场景
```
## Parse 参数绑定
`Parse(c, &req)` 按固定顺序绑定:**uri → query → header → json body**,全部绑定后统一跑
binding tag 校验(`bindingValidator`,与 gin 同源 tag 名)。
注意事项:
- 包 `init()` 会设置 `binding.Validator = nil`(关闭 gin 内置校验,避免 query 阶段误报
json 字段的 required)。**引入本包即全局生效**,服务内不要绕过 Parse 自行 ShouldBind。
- 请求结构体可实现 `Validate() error` 接口做自定义校验,在 binding 校验之前调用。
- body 解析用 `json.NewDecoder` 直解,不触发 gin 内部校验。
+19
View File
@@ -0,0 +1,19 @@
# jwtx
轻量 HS256 JWT(标准库实现,零外部依赖)。密钥显式传参,不落全局状态。
## 用法
```go
import "git.zeroonesoft.cn/golib/zogo/jwtx"
token, err := jwtx.Sign(secret, jwtx.Claims{UserID: 42, Role: "admin", Exp: time.Now().Add(time.Hour).Unix()})
claims, err := jwtx.Parse(secret, token) // 签名错→ErrInvalidToken,过期→ErrExpired
```
## 说明
- 标准三段式 `header.payload.signature`(HS256),`Claims{UserID, Role, Exp}` 为登录令牌
通用三字段;需要更多业务字段时建议换成 `github.com/golang-jwt/jwt`,本包定位是
零依赖场景的最小实现。
- **密钥必须从配置/环境注入**,不要硬编码。
+41 -30
View File
@@ -1,4 +1,7 @@
// Package jwtx 轻量 JWT(HMAC-SHA256),无外部依赖。 // Package jwtx 轻量 HS256 JWT(标准库实现,零外部依赖)。
//
// 密钥显式传入(不落包内全局状态),由调用方从配置注入。
// Claims 载荷为登录令牌通用三字段(uid/role/exp),业务扩展字段请自行加签发方案。
package jwtx package jwtx
import ( import (
@@ -11,51 +14,59 @@ import (
"time" "time"
) )
// Claims 登录令牌载荷。 var (
// ErrInvalidToken token 无效。
ErrInvalidToken = errors.New("无效的 token")
// ErrExpired token 已过期。
ErrExpired = errors.New("token 已过期")
)
// Claims JWT 载荷(登录令牌通用字段)。
type Claims struct { type Claims struct {
Uid int64 `json:"uid"` UserID int64 `json:"uid"`
Exp int64 `json:"exp"` Role string `json:"role"` // admin / user / console ...
Msg string `json:"msg,omitempty"` Exp int64 `json:"exp"` // 过期时间(Unix 秒)
} }
var secret = []byte("zo-maintain-jwt-secret-2026") // Sign 生成 HS256 token。
func Sign(secret string, claims Claims) (string, error) {
// Sign 签发 token(TTL 秒)。 header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
func Sign(uid int64, ttlSeconds int64) (string, error) { payload, err := json.Marshal(claims)
payload, err := json.Marshal(Claims{Uid: uid, Exp: time.Now().Add(time.Duration(ttlSeconds) * time.Second).Unix()})
if err != nil { if err != nil {
return "", err return "", err
} }
body := base64.RawURLEncoding.EncodeToString(payload) body := base64.RawURLEncoding.EncodeToString(payload)
sig := sign(body) signingInput := header + "." + body
return body + "." + sig, nil sig := sign(signingInput, secret)
return signingInput + "." + sig, nil
} }
// Parse 校验并解析 token。 // Parse 校验并解析 token(签名不符返回 ErrInvalidToken,过期返回 ErrExpired)。
func Parse(token string) (*Claims, error) { func Parse(secret, token string) (*Claims, error) {
parts := strings.SplitN(token, ".", 2) parts := strings.Split(token, ".")
if len(parts) != 2 { if len(parts) != 3 {
return nil, errors.New("令牌格式错误") return nil, ErrInvalidToken
} }
if sign(parts[0]) != parts[1] { signingInput := parts[0] + "." + parts[1]
return nil, errors.New("令牌签名校验失败") if !hmac.Equal([]byte(sign(signingInput, secret)), []byte(parts[2])) {
return nil, ErrInvalidToken
} }
payload, err := base64.RawURLEncoding.DecodeString(parts[0]) payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil { if err != nil {
return nil, errors.New("令牌载荷解析失败") return nil, ErrInvalidToken
} }
var c Claims var claims Claims
if err = json.Unmarshal(payload, &c); err != nil { if err = json.Unmarshal(payload, &claims); err != nil {
return nil, errors.New("令牌载荷解析失败") return nil, ErrInvalidToken
} }
if c.Exp < time.Now().Unix() { if claims.Exp > 0 && time.Now().Unix() > claims.Exp {
return nil, errors.New("令牌已过期") return nil, ErrExpired
} }
return &c, nil return &claims, nil
} }
func sign(body string) string { func sign(input, secret string) string {
mac := hmac.New(sha256.New, secret) mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body)) mac.Write([]byte(input))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
} }
+27
View File
@@ -0,0 +1,27 @@
# logger
logrus 封装:控制台彩色输出(Windows colorable)+ 按天轮转文件 + 历史日志压缩。
## 用法
```go
import "git.zeroonesoft.cn/golib/zogo/logger"
logger.SetProjectPrefix("D:/gopath/your-service/") // 可选:日志里 file 字段去掉公共前缀
logger.InitLog(logrus.InfoLevel, true, "D:/logs/app") // 可变参数传日志目录则同时落文件
```
## 行为
- 控制台:强制彩色、完整时间戳、caller 显示为 `相对路径:行号`
- 落文件(传了 logDir 时):
- `app-YYYY-MM-DD.log` 全级别
- `error-YYYY-MM-DD.log` 仅 Error 及以上
- 按天轮转(file-rotatelogs)
- `logger.StartLogCompressor(logDir, interval)`:后台协程周期压缩历史日志(每日 0:00-0:30 跳过窗口)
- `logger.CompressHistoryLogs(logDir)`:手动触发一次压缩
## 注意
初始化后请直接用 `logrus.Info` 等包级函数打日志,**不要**保存 `logrus.Logger` 实例调用,
否则 caller 文件位置信息会不准。
+27
View File
@@ -0,0 +1,27 @@
# snowid
基于 `bwmarrin/snowflake` 的雪花 ID 生成器(进程内单例)+ GORM 模型 ID 钩子模板。
## 用法
```go
import "git.zeroonesoft.cn/golib/zogo/snowid"
func main() {
if err := snowid.Init(1); err != nil { ... } // 节点ID 0-1023
id := snowid.Generate() // int64
s := snowid.GenerateString() // string
}
// 生成模型(gorm.io/gen):让 bigint 主键自动填充
// gen-model 脚本里:
opts = append(opts, gen.WithMethod(snowid.IDMethod{}))
// 生成的模型自带 BeforeCreate:Id==0 时填 snowid.Generate()
```
## 注意
- **必须先 Init 再 Generate**,否则 panic(启动期暴露,不留隐患)。
- `InitWithStartTime` 会改全局 Epoch,须在进程首个 NewNode 前调用;同一部署里
各服务的 Epoch 口径要一致,ID 才可排序比较。
- 多实例部署时节点 ID 不得重复,否则会生成重复 ID。
+69
View File
@@ -0,0 +1,69 @@
// Package snowid 基于雪花算法的全局唯一 ID 生成器(进程内单例,需先 Init)。
//
// 运行时:main 里 Init 后调用 Generate/GenerateString。
// GORM 模型:IDMethod 交给 gorm.io/gen 的 WithMethod 注入,
// 生成的模型自带 BeforeCreate 钩子(Id 为 0 时自动填充 Generate())。
package snowid
import (
"sync"
"github.com/bwmarrin/snowflake"
"gorm.io/gorm"
)
var (
node *snowflake.Node
once sync.Once
initErr error
)
// Init 初始化雪花ID生成器
// nodeID: 节点ID,范围 0-1023
func Init(nodeID int64) error {
once.Do(func() {
node, initErr = snowflake.NewNode(nodeID)
})
return initErr
}
// InitWithStartTime 使用自定义起始时间初始化雪花ID生成器
// nodeID: 节点ID,范围 0-1023
// startTime: 自定义起始时间(Unix毫秒时间戳)
func InitWithStartTime(nodeID int64, startTime int64) error {
once.Do(func() {
snowflake.Epoch = startTime
node, initErr = snowflake.NewNode(nodeID)
})
return initErr
}
// Generate 生成一个新的雪花ID
func Generate() int64 {
if node == nil {
panic("snowid: not initialized, please call Init() first")
}
return node.Generate().Int64()
}
// GenerateString 生成字符串形式的雪花ID
func GenerateString() string {
if node == nil {
panic("snowid: not initialized, please call Init() first")
}
return node.Generate().String()
}
// IDMethod GORM 模型方法模板:交给 gen.WithMethod 注入后,
// 生成的模型在 Create 前自动生成雪花ID主键(字段名须为 Id)。
type IDMethod struct {
Id int64
}
// BeforeCreate 创建前钩子,自动生成雪花ID
func (m *IDMethod) BeforeCreate(tx *gorm.DB) error {
if m.Id == 0 {
m.Id = Generate()
}
return nil
}
+44
View File
@@ -0,0 +1,44 @@
# wsc
WebSocket 服务端框架 + 配套客户端,**同一 Message 信封、同一处协议定义**。
信封格式:`{"action":"...","payload":...}`(`Message` 结构体,payload 为 `json.RawMessage`)。
心跳约定:双方默认 60s 读超时 + 周期 ping(客户端 `PongTimeout` 需 ≥ 服务端 ping 周期 54s,默认值即满足)。
## 服务端
```go
import "git.zeroonesoft.cn/golib/zogo/wsc"
wsc.HandleWS(r, "/ws/chat", wsc.QuickServer(onConnect, onDisconnect), func(router *wsc.Router) {
// 带响应:fn 返回 (resp, error),resp 序列化后以同名 action+".resp" 语义回包
router.On("ping", wsc.Bind(func(ctx *wsc.Context, req *PingReq) (*PingResp, error) {
return &PingResp{Pong: true}, nil
}))
// 无响应 / 客户端约定固定回包类型时
router.On("register", wsc.BindNoResp(func(ctx *wsc.Context, req *RegisterReq) error { ... }))
})
```
- `Server`:连接池与房间管理(`GetOrCreateRoom`/`ActiveCount`…),`OnConnect/OnDisconnect/OnUpgrade` 钩子
- `Router`:action 分发,反射校验 handler 签名;`Use` 挂中间件
- `Context`:单会话上下文,实现 `context.Context`;`WriteMessage`/`WriteError`/`JoinRoom`/`Set`…,`WriteError` 使用 `WsActionError` action
- gin 耦合仅在 `HandleWS`/`newContext`/`OnUpgrade` 三处入口,核心零 gin
## 客户端
```go
cli := wsc.NewClient(wsc.DefaultClientConfig("wss://cloud/ws/gateway?token=xxx"))
cli.On("kick", wsc.BindPayload(func(req *KickReq) { ... }))
cli.OnConnected = func() { cli.Send("register", &RegisterReq{...}) }
cli.SetURL("wss://cloud/ws/gateway?token=新token") // 重连时生效
_ = cli.Connect() // AutoReconnect=true 时阻塞到连上(指数退避,稳定 10s 后重置)
defer cli.Close()
```
- 自动重连:指数退避(1s→30s 封顶),连接稳定满 10s 才重置退避,防抖动时"永远 1s 一连"
- 断线期间 `Send` 返回错误;重连成功重建读写泵,`OnConnected` 重新触发(在此做重注册/重订阅)
## 线程安全
`Client` 与 `Session` 的公开方法均可并发调用;发送走带缓冲通道 + 单写泵。
+44 -41
View File
@@ -1,6 +1,7 @@
// Package wscclient 提供 wsc 服务端的客户端封装。 // client.go 提供 wsc 服务端的客户端封装(Client)。
// 对称设计:cli.On == router.On,cli.Send == ctx.WriteMessage。 // 对称设计:cli.On == router.On,cli.Send == ctx.WriteMessage。
package wscclient // 客户端与服务端共用同一 Message 信封与协议约定,改协议两边一起改。
package wsc
import ( import (
"context" "context"
@@ -20,8 +21,8 @@ import (
// 配置 // 配置
// ============================================================ // ============================================================
// Config 客户端配置。 // ClientConfig 客户端配置。
type Config struct { type ClientConfig struct {
URL string // WebSocket 地址(必填) URL string // WebSocket 地址(必填)
Header http.Header // 自定义请求头(如 token) Header http.Header // 自定义请求头(如 token)
DialTimeout time.Duration // 连接超时,默认 10s DialTimeout time.Duration // 连接超时,默认 10s
@@ -35,9 +36,9 @@ type Config struct {
SendBufferSize int // 发送缓冲区大小,默认 256 SendBufferSize int // 发送缓冲区大小,默认 256
} }
// DefaultConfig 返回推荐默认配置。 // DefaultClientConfig 返回推荐默认配置。
func DefaultConfig(url string) Config { func DefaultClientConfig(url string) ClientConfig {
return Config{ return ClientConfig{
URL: url, URL: url,
DialTimeout: 10 * time.Second, DialTimeout: 10 * time.Second,
AutoReconnect: true, AutoReconnect: true,
@@ -52,11 +53,11 @@ func DefaultConfig(url string) Config {
} }
// ============================================================ // ============================================================
// Handler — 客户端消息处理器 // PayloadHandler — 客户端消息处理器
// ============================================================ // ============================================================
// Handler 客户端消息处理函数。payload 为服务端发来的 JSON,已去掉 action 包装。 // PayloadHandler 客户端消息处理函数。payload 为服务端发来的 JSON,已去掉 action 包装。
type Handler func(payload json.RawMessage) type PayloadHandler func(payload json.RawMessage)
// ============================================================ // ============================================================
// Client // Client
@@ -67,11 +68,11 @@ type Handler func(payload json.RawMessage)
type Client struct { type Client struct {
url string url string
urlMu sync.RWMutex urlMu sync.RWMutex
cfg Config cfg ClientConfig
// 消息路由 // 消息路由
mu sync.RWMutex mu sync.RWMutex
routes map[string]Handler routes map[string]PayloadHandler
// 连接 // 连接
conn *websocket.Conn conn *websocket.Conn
@@ -102,7 +103,7 @@ type Client struct {
} }
// New 创建客户端(尚未连接)。 // New 创建客户端(尚未连接)。
func New(cfg Config) *Client { func NewClient(cfg ClientConfig) *Client {
if cfg.DialTimeout == 0 { if cfg.DialTimeout == 0 {
cfg.DialTimeout = 10 * time.Second cfg.DialTimeout = 10 * time.Second
} }
@@ -128,7 +129,7 @@ func New(cfg Config) *Client {
return &Client{ return &Client{
url: cfg.URL, url: cfg.URL,
cfg: cfg, cfg: cfg,
routes: make(map[string]Handler), routes: make(map[string]PayloadHandler),
writeCh: make(chan []byte, cfg.SendBufferSize), writeCh: make(chan []byte, cfg.SendBufferSize),
closeCh: make(chan struct{}), closeCh: make(chan struct{}),
doneCh: make(chan struct{}), doneCh: make(chan struct{}),
@@ -201,19 +202,24 @@ func (c *Client) getURL() string {
// ============================================================ // ============================================================
// On 注册 action 对应的消息处理器。 // On 注册 action 对应的消息处理器。
func (c *Client) On(action string, handler Handler) { func (c *Client) On(action string, handler PayloadHandler) {
c.mu.Lock() c.mu.Lock()
c.routes[action] = handler c.routes[action] = handler
c.mu.Unlock() c.mu.Unlock()
} }
// Send 发送结构化消息。并发安全。 // Send 发送结构化消息。并发安全。
// 与服务端同用 Message 信封:{"action":"...","payload":...},payload 为 nil 时字段省略。
func (c *Client) Send(action string, payload any) error { func (c *Client) Send(action string, payload any) error {
m := map[string]any{"action": action} msg := Message{Action: action}
if payload != nil { if payload != nil {
m["payload"] = payload raw, err := json.Marshal(payload)
if err != nil {
return err
} }
data, err := json.Marshal(m) msg.Payload = raw
}
data, err := json.Marshal(msg)
if err != nil { if err != nil {
return err return err
} }
@@ -223,16 +229,16 @@ func (c *Client) Send(action string, payload any) error {
// SendRaw 发送已序列化的字节。并发安全。 // SendRaw 发送已序列化的字节。并发安全。
func (c *Client) SendRaw(data []byte) error { func (c *Client) SendRaw(data []byte) error {
if c.closed.Load() { if c.closed.Load() {
return errors.New("wscclient: client closed") return errors.New("wsc: client closed")
} }
select { select {
case c.writeCh <- data: case c.writeCh <- data:
return nil return nil
case <-c.closeCh: case <-c.closeCh:
return errors.New("wscclient: client closed") return errors.New("wsc: client closed")
default: default:
logrus.Warnf("[wscclient] send buffer full, dropping message") logrus.Warnf("[wsc-client] send buffer full, dropping message")
return errors.New("wscclient: send buffer full") return errors.New("wsc: send buffer full")
} }
} }
@@ -240,29 +246,29 @@ func (c *Client) SendRaw(data []byte) error {
// Bind — 自动 JSON 反序列化 // Bind — 自动 JSON 反序列化
// ============================================================ // ============================================================
// Bind 将带类型的函数包装为 Handler,自动 JSON 反序列化 payload。 // BindPayload 将带类型的函数包装为 PayloadHandler,自动 JSON 反序列化 payload。
// //
// cli.On("ping.resp", wscclient.Bind(func(resp *PingResp) { // cli.On("ping.resp", wsc.BindPayload(func(resp *PingResp) {
// log.Println(resp.Message) // log.Println(resp.Message)
// })) // }))
func Bind(fn any) Handler { func BindPayload(fn any) PayloadHandler {
fnVal := reflect.ValueOf(fn) fnVal := reflect.ValueOf(fn)
fnType := fnVal.Type() fnType := fnVal.Type()
if fnType.Kind() != reflect.Func || fnType.NumIn() != 1 { if fnType.Kind() != reflect.Func || fnType.NumIn() != 1 {
panic("wscclient.Bind: function must have 1 parameter") panic("wsc.BindPayload: function must have 1 parameter")
} }
reqType := fnType.In(0) reqType := fnType.In(0)
if reqType.Kind() != reflect.Ptr { if reqType.Kind() != reflect.Ptr {
panic("wscclient.Bind: parameter must be a pointer") panic("wsc.BindPayload: parameter must be a pointer")
} }
return func(raw json.RawMessage) { return func(raw json.RawMessage) {
req := reflect.New(reqType.Elem()).Interface() req := reflect.New(reqType.Elem()).Interface()
if len(raw) > 0 { if len(raw) > 0 {
if err := json.Unmarshal(raw, req); err != nil { if err := json.Unmarshal(raw, req); err != nil {
logrus.Errorf("[wscclient] Bind unmarshal error: %v, raw=%s", err, string(raw)) logrus.Errorf("[wsc-client] Bind unmarshal error: %v, raw=%s", err, string(raw))
return return
} }
} }
@@ -286,7 +292,7 @@ func (c *Client) connect() error {
} }
if c.closed.Load() { if c.closed.Load() {
return errors.New("wscclient: closed") return errors.New("wsc: closed")
} }
c.reconCount++ c.reconCount++
@@ -295,7 +301,7 @@ func (c *Client) connect() error {
} }
delay := c.nextDelay() delay := c.nextDelay()
logrus.Warnf("[wscclient] connect failed (attempt %d): %v, retry in %v", c.reconCount, err, delay) logrus.Warnf("[wsc-client] connect failed (attempt %d): %v, retry in %v", c.reconCount, err, delay)
if c.OnReconnecting != nil { if c.OnReconnecting != nil {
c.OnReconnecting(c.reconCount, delay) c.OnReconnecting(c.reconCount, delay)
@@ -304,7 +310,7 @@ func (c *Client) connect() error {
select { select {
case <-time.After(delay): case <-time.After(delay):
case <-c.closeCh: case <-c.closeCh:
return errors.New("wscclient: closed during reconnect") return errors.New("wsc: closed during reconnect")
} }
} }
} }
@@ -361,7 +367,7 @@ func (c *Client) readPump() {
defer c.wg.Done() defer c.wg.Done()
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
logrus.Errorf("[wscclient] readPump panic: %v", r) logrus.Errorf("[wsc-client] readPump panic: %v", r)
} }
c.onDisconnect() c.onDisconnect()
}() }()
@@ -385,16 +391,13 @@ func (c *Client) readPump() {
if !c.closed.Load() { if !c.closed.Load() {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) && if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) &&
!errors.Is(err, context.DeadlineExceeded) { !errors.Is(err, context.DeadlineExceeded) {
logrus.Errorf("[wscclient] read error: %v", err) logrus.Errorf("[wsc-client] read error: %v", err)
} }
} }
return return
} }
var msg struct { var msg Message
Action string `json:"action"`
Payload json.RawMessage `json:"payload,omitempty"`
}
if err := json.Unmarshal(data, &msg); err != nil { if err := json.Unmarshal(data, &msg); err != nil {
continue continue
} }
@@ -406,7 +409,7 @@ func (c *Client) readPump() {
if ok { if ok {
handler(msg.Payload) handler(msg.Payload)
} else { } else {
logrus.Warnf("[wscclient] unhandled message action=%q, payload=%s", msg.Action, string(msg.Payload)) logrus.Warnf("[wsc-client] unhandled message action=%q, payload=%s", msg.Action, string(msg.Payload))
} }
} }
} }
@@ -433,7 +436,7 @@ func (c *Client) writePump() {
} }
conn.SetWriteDeadline(time.Now().Add(c.cfg.WriteTimeout)) conn.SetWriteDeadline(time.Now().Add(c.cfg.WriteTimeout))
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
logrus.Errorf("[wscclient] write error: %v", err) logrus.Errorf("[wsc-client] write error: %v", err)
return return
} }
@@ -515,13 +518,13 @@ func (c *Client) reconnectLoop() {
c.reconCount++ c.reconCount++
if c.cfg.MaxRetry > 0 && c.reconCount > c.cfg.MaxRetry { if c.cfg.MaxRetry > 0 && c.reconCount > c.cfg.MaxRetry {
logrus.Errorf("[wscclient] reconnect max retry exceeded (%d)", c.cfg.MaxRetry) logrus.Errorf("[wsc-client] reconnect max retry exceeded (%d)", c.cfg.MaxRetry)
c.Close() c.Close()
return return
} }
delay := c.nextDelay() delay := c.nextDelay()
logrus.Warnf("[wscclient] reconnect attempt %d failed: %v, retry in %v", c.reconCount, err, delay) logrus.Warnf("[wsc-client] reconnect attempt %d failed: %v, retry in %v", c.reconCount, err, delay)
if c.OnReconnecting != nil { if c.OnReconnecting != nil {
c.OnReconnecting(c.reconCount, delay) c.OnReconnecting(c.reconCount, delay)
+267
View File
@@ -0,0 +1,267 @@
// session_client.go 提供会话层客户端(SessionClient):在 Client(传输层)之上封装
// 「token 延迟连接、连上即注册、应用层心跳、重连回调」等生产编排,业务零样板直接用。
package wsc
import (
"net/http"
"strings"
"sync"
"time"
"github.com/sirupsen/logrus"
)
// SessionConfig 会话层配置(在 ClientConfig 之上做封装)。
type SessionConfig struct {
URL string // WebSocket 地址(必填),如 wss://host/api/ws
Token string // 鉴权 token,拼接在 URL ?token= 后;为空时不主动连接,待 UpdateToken 注入后再连
Header http.Header // 自定义请求头(如 Authorization),与 Token 二选一或并存
AutoReconnect bool // 是否自动重连,默认 true
HeartbeatInterval time.Duration // 应用层心跳(action=ping)间隔,默认 25s;<=0 关闭
}
// SessionClient 与服务器的会话客户端,基于 Client(传输层)封装:
// 负责连接、自动重连、发送注册/心跳并接收响应。
//
// 关键行为(生产验证过的编排):
// - token 为空时绝不空转重连(避免无凭证时的指数退避风暴),
// 等 UpdateToken 注入 token 后自动发起首次连接;
// - 连接成功(首次/重连)自动发送 register 注册请求;
// - 注册完成后再触发 OnReconnected——保证服务端先认识这个连接,再接收业务数据。
// 业务在 OnReconnected 中做全量对账重放,兜底断连期间丢失的增量上报。
//
// 用法:
//
// c := wsc.NewSessionClient(wsc.SessionConfig{URL: cfg.WsUrl, Token: cfg.Token})
// c.OnReconnected = func() { /* 全量对账 */ }
// c.OnBind("biz.action", func(resp *BizResp) { ... })
// c.Start(&RegisterReq{...})
// defer c.Close()
type SessionClient struct {
// Cli 底层传输层客户端,可直接设置 OnConnected / OnDisconnected /
// OnReconnecting 回调,或调用 On / Send 等方法。
Cli *Client
cfg SessionConfig
// registerPayload 注册请求载荷(业务自定义结构,如主机名/IP/版本号),
// 连接成功后发送;登录后可通过 UpdateRegisterInfo 更新。
registerPayload any
// OnReconnected 连接建立(首次连接与断线重连成功)后触发的回调。
// 业务可在此发起全量对账重放,兜底断连期间丢失的上报。
OnReconnected func()
heartbeatStop chan struct{}
closeOnce sync.Once
connectOnce sync.Once // 保证首次 Connect 仅执行一次(由 Start 或 UpdateToken 触发)
}
// NewSessionClient 创建会话客户端(尚未连接)。构造时会注册内置消息路由,
// 调用方应在 Start/Connect 前完成自定义配置(On/OnBind/OnReconnected 等)。
func NewSessionClient(cfg SessionConfig) *SessionClient {
url := appendToken(cfg.URL, cfg.Token)
wscCfg := DefaultClientConfig(url)
if !cfg.AutoReconnect {
wscCfg.AutoReconnect = false
}
// 加快重连节奏:默认退避上限 30s 体感太慢,收敛到 5s(与参考实现一致)。
wscCfg.MaxReconDelay = 5 * time.Second
if cfg.Header != nil {
wscCfg.Header = cfg.Header
}
if cfg.HeartbeatInterval == 0 {
cfg.HeartbeatInterval = 25 * time.Second
}
cli := NewClient(wscCfg)
c := &SessionClient{Cli: cli, cfg: cfg, heartbeatStop: make(chan struct{})}
c.registerHandlers()
return c
}
// registerHandlers 注册内置消息路由(在 Connect 之前调用)。
func (c *SessionClient) registerHandlers() {
// pong — 服务端对心跳的响应
c.Cli.On(ActionPong, BindPayload(func(resp *PingResp) {
logrus.Debugf("[wsc-session] pong received, time=%d", resp.Time)
}))
// register — 注册(上线)响应(请求与响应共用同一 action)
c.Cli.On(ActionRegister, BindPayload(func(resp *RegisterResp) {
if resp.Success {
logrus.Infof("[wsc-session] register success: %s", resp.Message)
} else {
logrus.Warnf("[wsc-session] register failed: %s", resp.Message)
}
}))
}
// ============================================================
// 连接控制
// ============================================================
// Start 启动客户端并连接。连接/重连成功后自动发送注册请求并启动心跳,
// 调用方无需在外部自行编排心跳循环(registerPayload 为 nil 时仅连接不注册)。
//
// 若 token 为空则跳过连接,等待 UpdateToken 注入 token 后再发起首次连接。
func (c *SessionClient) Start(registerPayload any) error {
c.registerPayload = registerPayload
prev := c.Cli.OnConnected
c.Cli.OnConnected = func() {
if prev != nil {
prev()
}
logrus.Infof("[wsc-session] connected to %s", c.cfg.URL)
if c.registerPayload != nil {
if err := c.SendRegister(c.registerPayload); err != nil {
logrus.Errorf("[wsc-session] send register failed: %v", err)
}
}
// 注册完成后再触发业务回调(全量对账重放),
// 保证服务端先认识这个连接,再接收业务数据。
if c.OnReconnected != nil {
c.OnReconnected()
}
}
go c.heartbeatLoop()
// 没有 token 肯定连不上,跳过连接,等 UpdateToken 注入 token 后再连。
if c.cfg.Token == "" {
logrus.Warnf("[wsc-session] token empty, skip connect, wait for UpdateToken")
return nil
}
c.doConnect()
return nil
}
// doConnect 发起首次连接(仅执行一次,由 Start 或 UpdateToken 触发)。
// 连接在独立 goroutine 中重试,不阻塞调用方(避免 UpdateToken/Start 的调用者被重连循环卡死)。
func (c *SessionClient) doConnect() {
c.connectOnce.Do(func() {
go func() {
if err := c.Cli.Connect(); err != nil {
logrus.Errorf("[wsc-session] connect loop exited: %v", err)
}
}()
})
}
// heartbeatLoop 内部心跳协程:连接状态下按间隔发送 ping,断开或 Close 时退出。
// 业务编排收口在客户端内,调用方不必在外部再写一层心跳逻辑。
func (c *SessionClient) heartbeatLoop() {
if c.cfg.HeartbeatInterval <= 0 {
return
}
ticker := time.NewTicker(c.cfg.HeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-c.heartbeatStop:
return
case <-ticker.C:
if !c.Connected() {
continue
}
if err := c.SendPing(); err != nil {
logrus.Errorf("[wsc-session] heartbeat send failed: %v", err)
}
}
}
}
// UpdateRegisterInfo 更新注册信息(如登录后拿到业务ID)。若已连接则立即重发注册,
// 确保服务端拿到完整字段;未连接则仅暂存,待连接成功后由 OnConnected 发送。
func (c *SessionClient) UpdateRegisterInfo(payload any) {
c.registerPayload = payload
if c.Connected() {
if err := c.SendRegister(payload); err != nil {
logrus.Errorf("[wsc-session] re-send register failed: %v", err)
}
}
}
// UpdateToken 动态更新鉴权 token(重新拼接到 URL)。
// 若此前因 token 为空未连接,则在此发起首次连接;已连接/正在连接则不受影响
// (connectOnce 保证仅连一次,后续断连由底层 Client 自动重连并使用新 URL)。并发安全。
func (c *SessionClient) UpdateToken(token string) {
c.cfg.Token = token
c.Cli.SetURL(appendToken(c.cfg.URL, token))
// token 就绪:若此前因无 token 未连接,则在此发起首次连接(仅一次,异步不阻塞)。
if token != "" {
logrus.Infof("[wsc-session] token updated, triggering connect")
c.doConnect()
}
}
// Close 关闭连接、停止重连与心跳协程。
func (c *SessionClient) Close() {
c.closeOnce.Do(func() {
close(c.heartbeatStop)
})
c.Cli.Close()
}
// Connected 返回当前是否已连接。
func (c *SessionClient) Connected() bool {
return c.Cli.Connected()
}
// Done 返回一个通道,客户端完全关闭后关闭。
func (c *SessionClient) Done() <-chan struct{} {
return c.Cli.Done()
}
// ============================================================
// 发送消息
// ============================================================
// SendPing 发送心跳(action=ping)。
func (c *SessionClient) SendPing() error {
return c.Cli.Send(ActionPing, &PingReq{})
}
// SendRegister 发送注册(上线)请求(action=register)。
func (c *SessionClient) SendRegister(payload any) error {
return c.Cli.Send(ActionRegister, payload)
}
// Send 发送任意自定义消息(并发安全)。
func (c *SessionClient) Send(action string, payload any) error {
return c.Cli.Send(action, payload)
}
// SendRaw 发送已序列化的原始字节(并发安全)。
// 注意:重连时底层 writeCh 会重建、旧缓冲数据被丢弃,
// 上层务必「未连接不投递」,未落盘的数据等待 OnReconnected 全量对账。
func (c *SessionClient) SendRaw(data []byte) error {
return c.Cli.SendRaw(data)
}
// ============================================================
// 接收消息:注册自定义处理器(直接透传到底层 Cli)
// ============================================================
// On 注册 action 对应的消息处理器,回调拿到原始 payload(json.RawMessage),
// 在 Connect/Start 之前调用。
func (c *SessionClient) On(action string, handler PayloadHandler) {
c.Cli.On(action, handler)
}
// OnBind 注册带类型反序列化的消息处理器(自动将 payload 反序列化到结构体指针)。
func (c *SessionClient) OnBind(action string, fn any) {
c.Cli.On(action, BindPayload(fn))
}
// appendToken 把 token 拼接到 URL 查询串。
func appendToken(url, token string) string {
if token == "" {
return url
}
sep := "?"
if strings.Contains(url, "?") {
sep = "&"
}
return url + sep + "token=" + token
}
+24
View File
@@ -0,0 +1,24 @@
// session_protocol.go 定义会话层协议:会话级 action 常量与心跳/注册消息。
// 业务 action(业务消息)由各业务模块自行定义,不放在框架层。
package wsc
// 会话级 action 常量:心跳与注册(所有业务共用)。
const (
ActionPing = "ping"
ActionPong = "pong"
ActionRegister = "register"
)
// PingReq 心跳请求(无业务字段)。
type PingReq struct{}
// PingResp 心跳响应(action=pong)。
type PingResp struct {
Time int64 `json:"time"`
}
// RegisterResp 注册(上线)响应(action=register,与请求共用同一 action)。
type RegisterResp struct {
Success bool `json:"success"`
Message string `json:"message"`
}