使用golang实现websocket消息推送
·
需求
每隔几秒主动调用api拉取信息,会对服务器造成很大的压力,特别对于小型应用,只有一台服务器,配置也不高。客户端要实时的接收一些消息,消息推送也提升了用户的体验。很有必要。
思路
- 使用RabbitMQ接收消息,再推送给客户端
- 使用gin http api接收消息,再推送给客户端
- 客户端通过websocket连接到推送服务,然后等待接收消息并处理消息
- 使用jwt-token验证客户端真实性,确定用户身份
实现
设置模块
读取进行目录的config.toml文件,做为配置文件。
-
rabbit_conn_str RabbitMQ消息队列配置串,root是用户名,123是密码,@后为主机端口,/test是租户
-
jwt_secret jwt使用的加密串,与主服务保证一致
-
public_key_path private_key_path https配置,公钥与私钥,不配置不开启https
-
http_port http服务端口号
-
white_ips gin http 服务的白名单,使用白名单简单验证接口调用者的身份
rabbit_conn_str = "amqp://root:123@localhost:5672/test" #2323
jwt_secret = '123123123123123123123'
public_key_path = "./server.crt"
private_key_path = "./server.key"
http_port = "80"
white_ips = "127.0.0.1"
package main
import (
"fmt"
"github.com/BurntSushi/toml"
"os"
)
var CONFIG = GetConfig("config.toml")
// Config
// 系统配置类
type Config struct {
RabbitConnStr string `json:"rabbit_conn_str" toml:"rabbit_conn_str"`
JwtSecret string `json:"jwt_secret" toml:"jwt_secret"`
PublicKeyPath string `json:"public_key_path" toml:"public_key_path"`
PrivateKeyPath string `json:"private_key_path" toml:"private_key_path"`
HttpPort string `json:"http_port" toml:"http_port"`
WhiteIps string `json:"white_ips" toml:"white_ips"`
}
// GetConfig
// 获取配置
func GetConfig(path string) Config {
content, err := os.ReadFile(path)
if err != nil {
panic(err)
}
config := Config{}
mData, err := toml.Decode(string(content), &config)
if err != nil {
panic(err)
}
fmt.Printf("%s", mData)
return config
}
日志模块
日志使用滴滴的zap
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
使用"gopkg.in/natefinch/lumberjack.v2"做日志轮换
MaxSize 每个日志文件的最大大小,单位MB
MaxBackups 最多保留多少文件
MaxAge 最多保留几天
Compress 是否压缩日志文件
Filename 日志文件名,加上日志,加上主机名
File = &lumberjack.Logger{
Filename: "./logs/" + time.Now().Format("20060102150405") + hostname + ".log", // ⽇志⽂件路径
MaxSize: 500, // 文件最大大小 500MB
MaxBackups: 3, // 最多保留3个备份
MaxAge: 7, // 最多保留多少天
Compress: true, // 是否压缩 默认不压缩
}
FileW := zapcore.AddSync(File)
encoderCfg := zapcore.EncoderConfig{
MessageKey: "msg", // msg key 日志主体内容字段名msg
LevelKey: "level", // 日志级别字段名level
NameKey: "logger", // 日志名
EncodeLevel: zapcore.LowercaseLevelEncoder,
EncodeTime: zapcore.ISO8601TimeEncoder,
EncodeDuration: zapcore.StringDurationEncoder,
}
core := zapcore.NewCore(zapcore.NewJSONEncoder(encoderCfg), FileW, zap.DebugLevel)
LOG = zap.New(core).WithOptions() // options...
关闭日志时先写入磁盘再关闭日志再关闭日志文件
err := LOG.Sync() // 同步文件到磁盘
if err != nil {
fmt.Printf("log sync err %v\n", err)
}
if File != nil {
err = File.Close() // 关闭日志文件
if err != nil {
fmt.Printf("sync err %s", err.Error())
}
}
jwt 模块
直接上代码。 主要功能: 生成token 与验证token
package main
import (
"errors"
"github.com/dgrijalva/jwt-go"
"time"
)
//GetNewJwtToken 創建一個新的jwt,
func GetNewJwtToken(hs256Key []byte, userClaims *UserClaims) (string, error) {
jwtToken := jwt.New(jwt.SigningMethodHS256)
jwtToken.Claims = userClaims
return jwtToken.SignedString(hs256Key) //TODO: 這個換成rsa加密
}
//CheckJwtToken 檢查jwt是否有效
func CheckJwtToken(hs256Key []byte, jwtString string) (userClaims UserClaims, err error) {
_, err = jwt.ParseWithClaims(jwtString, &userClaims, func(_ *jwt.Token) (i interface{}, e error) {
return hs256Key, nil
})
return
}
//ErrTimeExp 同一類型的多個錯誤實例
var ErrTimeExp = errors.New("jwt is expiration")
var ErrIatTime = errors.New("jwt in bad format,iat>=exp")
//UserClaims payload的內容,如果是app角色,則appid為空。,web端默認web
type UserClaims struct {
UserId string `json:"user_id"` //用戶id
Iat int64 `json:"iat"` //發放時間
Exp int64 `json:"exp"` //過期時間
Jti string `json:"jti"` //token 的id ,唯一標識
Iss string `json:"iss"` //簽發者 是sys_user_control
}
//Valid 是否有效,如果沒效,則入出錯誤
func (u UserClaims) Valid() error {
if time.Now().Unix() > u.Exp {
return ErrTimeExp
}
if u.Iat > u.Exp {
return ErrIatTime
}
return nil
}
websocket模块
用户通过websocket连接到推送服务
UserConn 代表一个连接到服务的客户端,包括用户Id,客户端类型等
type UserConn struct {
UserId string // 用户id
ClientType string // android ios web manage-web wx ali 等 一端只能有一个登录
RemoteAddr string // 远程地址
Token string // 用户token
EnterTime time.Time // 进来的时间
Conn *websocket.Conn // ws conn 与客户端通信用
}
ConnCollect代表通过websocket连接到推送服务的客户端集合。通过userId Map聚合一个有户的不同客户端的连接,再通过token查找到某个连接,继而由这个连接投递消息。
// ConnCollect 集合
type ConnCollect struct {
// userId => token => collect
Lock sync.RWMutex
Collection map[string]*struct { // userId
Lock sync.RWMutex
UserConns map[string]*UserConn // token
}
}
添加用户连接
func (co *ConnCollect) AdUserConn(userConn *UserConn) {
co.Lock.Lock()
defer co.Lock.Unlock()
userConns, ok := co.Collection[userConn.UserId]
if ok {
userConns.Lock.Lock()
defer userConns.Lock.Unlock()
conn, ok := userConns.UserConns[userConn.Token]
if ok {
err := conn.Conn.Close()
if err != nil {
LOG.Info("close client")
}
delete(userConns.UserConns, userConn.Token) // 删除token
}
userConns.UserConns[userConn.Token] = userConn // 添加token
} else {
co.Collection[userConn.UserId] = &struct {
Lock sync.RWMutex
UserConns map[string]*UserConn
}{Lock: sync.RWMutex{}, UserConns: map[string]*UserConn{userConn.Token: userConn}}
}
}
删除用户连接
func (co *ConnCollect) RemoveUserConn(userConn *UserConn) {
co.Lock.Lock()
defer co.Lock.Unlock()
userConns, ok := co.Collection[userConn.UserId]
if ok {
userConns.Lock.Lock()
defer userConns.Lock.Unlock()
userConnItem, ok := userConns.UserConns[userConn.Token]
if ok {
if userConnItem == userConn {
err := userConn.Conn.Close()
if err != nil {
LOG.Info("close client")
}
delete(userConns.UserConns, userConn.Token) // 删除token
}
}
} else {
LOG.Warn("this user conn all ready removed", zap.Any("userConn", userConn))
}
}
关闭所有连接,准备退出
func (co *ConnCollect) CloseAllForExit() {
var closeConn = func(userConn *struct {
Lock sync.RWMutex
UserConns map[string]*UserConn
}) {
userConn.Lock.Lock()
defer userConn.Lock.Unlock()
for _, conn := range userConn.UserConns {
if err := conn.Conn.Close(); err != nil {
LOG.Error("close all err:" + err.Error())
}
}
}
co.Lock.Lock()
defer co.Lock.Unlock()
for _, collect := range co.Collection {
closeConn(collect)
}
}
webSocket Handle 处理
// WsHandle
// websocket 服务
type WsHandle struct {
upgrade websocket.Upgrader
UserConnCollect ConnCollect // userId -> user
Cfg *Config
}
创建websocket handle
func NewWsHandle() *WsHandle {
return &WsHandle{
upgrade: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// 解决跨域问题
CheckOrigin: func(r *http.Request) bool {
return true
},
}, // http 协议升级
UserConnCollect: ConnCollect{
Lock: sync.RWMutex{},
Collection: map[string]*struct {
Lock sync.RWMutex
UserConns map[string]*UserConn
}{},
}, // 客户端连接集合
Cfg: &CONFIG, // 配置文件
}
}
// 清除所有连接准备退出
func (handle *WsHandle) CloseAllForExit() {
handle.UserConnCollect.CloseAllForExit()
}
发送消息
// 发送从http api或rabbitmq接收到的消息
// userId 要发送给的人
// msg 消息本体内容
func (handle *WsHandle) SendMessage(userId string, msg []byte) error {
// 读锁
handle.UserConnCollect.Lock.RLock()
defer handle.UserConnCollect.Lock.RUnlock()
// 根据用户id查找用户连接,可能不止一个
userConns, ok := handle.UserConnCollect.Collection[userId]
if ok {
// 用户连接读锁
userConns.Lock.RLock()
defer userConns.Lock.RUnlock()
// 没有连接直接返回
if len(userConns.UserConns) == 0 {
return errors.New("no this user")
}
for _, conn := range userConns.UserConns {
// 通过websocket写入消息到客户端
err := conn.Conn.WriteMessage(websocket.TextMessage, msg)
if err != nil {
LOG.Error("send message err", zap.String("user_id", userId), zap.String("msg", string(msg)), zap.String("remote_addr", conn.RemoteAddr))
err := conn.Conn.Close() // 发送错误主动关闭链接
if err != nil {
LOG.Error("close conn err " + err.Error())
}
}
}
return nil
} else {
return errors.New("no this user")
}
}
通过http api 直接向某个用户主动投递消息
// 发送短信
func (handle *WsHandle) handleSendMessage(c *gin.Context) {
// 获取用户id 对比ip白名单
ip := c.ClientIP()
LOG.Info("ip", zap.String("ip", ip))
contain := strings.Contains(CONFIG.WhiteIps, ip)
if !contain || ip == "" {
LOG.Error("http 发送接口 客户不在ip白名单中", zap.String("ip", ip))
c.AbortWithStatusJSON(401, map[string]string{"code": "401", "message": "no auth"})
return
}
// 获取表单参数
userId := c.PostForm("user_id")
message := c.PostForm("message")
if userId == "" || message == "" {
LOG.Error("http 发送接口 参数错误", zap.String("user_id", userId), zap.String("message", message))
c.AbortWithStatusJSON(400, map[string]string{"code": "400", "message": "参数错误"})
return
}
// 根据参数发送消息
err := handle.SendMessage(userId, []byte(message))
if err != nil {
if errors.Is(err, errors.New("no this user")) {
c.JSON(200, map[string]string{"code": "201", "message": "no this user"})
} else {
LOG.Error("http 推送消息失败", zap.String("user_id", userId), zap.String("message", message), zap.Error(err))
c.AbortWithStatusJSON(400, map[string]string{"code": "400", "message": "推送失败"})
return
}
}
LOG.Error("http 推送消息成功", zap.String("user_id", userId), zap.String("message", message))
// 反回成功发送
c.JSON(200, map[string]string{"code": "200", "message": "success"})
}
处理websocket新进连接
func (handle *WsHandle) handle(c *gin.Context) {
LOG.Info("新进ws客户", zap.String("remote_addr", c.Request.RemoteAddr))
// 由http升级到websocket
wsConn, err := handle.upgrade.Upgrade(c.Writer, c.Request, nil)
if err != nil {
LOG.Info("http协议升级失败:{}", zap.Error(err))
return
}
// 退出连接,关闭连接
defer func() {
err2 := wsConn.Close()
if err2 != nil {
LOG.Error("connection exit", zap.Error(err2))
} else {
LOG.Info("ws退出链接")
}
}()
// 等待客户端发送来的认证消息,即jwt串
msgAuth := MsgAuth{}
for {
mt, msg, err := wsConn.ReadMessage()
if err != nil {
LOG.Info("read err while Wait for auth message", zap.Error(err), zap.String("remote_addr", c.Request.RemoteAddr))
return
}
// ping->pong 连接测试
if mt == websocket.PingMessage {
err := wsConn.WriteMessage(websocket.PongMessage, []byte("pong"))
if err != nil {
return
}
continue
}
// 非text消息,跳过
if mt != websocket.TextMessage {
LOG.Info("不是有用的消息", zap.ByteString("mt", msg))
continue
}
// 成功接收到认证信息
err = json.Unmarshal(msg, &msgAuth)
if err != nil {
LOG.Error("not a init message", zap.String("msg", string(msg)))
return
} else {
LOG.Info("ws 成功获得认证用户信息", zap.Any("msg_auth", msgAuth))
break
}
}
// 检查token是否有效
claim, err := CheckJwtToken([]byte(handle.Cfg.JwtSecret), msgAuth.Token)
if err != nil {
LOG.Error("ws token验证失败", zap.Any("token", msgAuth.Token))
return
}
// 返回成功认证消息
err = wsConn.WriteMessage(websocket.TextMessage, []byte("{\n \"data\": null,\n \"messageType\": \"登录成功\"\n}\n"))
if err != nil {
LOG.Error("send message error", zap.Error(err))
return
}
// 构建用户连接对象 添加到连接集中
userConn := &UserConn{
RemoteAddr: c.Request.RemoteAddr,
UserId: claim.UserId,
ClientType: msgAuth.ClientType,
Token: msgAuth.Token,
EnterTime: time.Now(),
Conn: wsConn,
}
LOG.Info("用户认证成功,进入等待消息阶段", zap.Any("user_conn", userConn), zap.String("userId", userConn.UserId), zap.String("token", userConn.Token))
handle.UserConnCollect.AdUserConn(userConn)
// 退出时的收尾工作
defer func() {
handle.UserConnCollect.RemoveUserConn(userConn)
LOG.Info("userConn defer 退出 已经删除连接信息")
}()
// for 循环接收从客户端发送来的消息,如果连接关闭则会主动退出循环
for {
mt, message, err := wsConn.ReadMessage()
if err != nil {
LOG.Error("read conn return err", zap.Error(err))
break
} else {
LOG.Info("read message from client", zap.String("content", string(message)), zap.Int("type", mt))
// ping->pong 测试连接有效性
if mt == websocket.PingMessage {
err := wsConn.WriteMessage(websocket.PongMessage, []byte("pong"))
if err != nil {
break
}
}
}
}
LOG.Info("链接退出", zap.String("token", userConn.Token), zap.String("user_id", userConn.UserId))
}
认证消息结构
// MsgAuth
// 用户认证注册消息
// 用于初始化用户信息
type MsgAuth struct {
ClientType string `json:"client_type"`
Token string `json:"token"`
}
从RabbitMQ接收来自服务器的消息,并查找用户推送消息
package main
import (
jsoniter "github.com/json-iterator/go" // json库
"github.com/streadway/amqp" // rabbitmq 客户端
"go.uber.org/zap" // zap 日志
"sync" // 锁与同步
)
type RabbitMq struct {
Mq *amqp.Connection // rabbitmq 连接
wsHandle *WsHandle // websocket 服务对象
Wait sync.WaitGroup // wait
}
// newRabbitMq 连接函数 连接至rabbit mq
func newRabbitMq(conStr string, wsHandle *WsHandle) RabbitMq {
conn, err := amqp.Dial(conStr)
if err != nil {
panic(err)
}
return RabbitMq{Mq: conn, wsHandle: wsHandle}
}
// BeginReceive 开始 消息队列开始接收消息
func (mq *RabbitMq) BeginReceive() {
LOG.Info("开始接收消息")
defer mq.Wait.Done()
// 创建一个rabbit mq通道
ch, err := mq.Mq.Channel()
if err != nil {
panic(err)
}
defer ch.Close()
// 设置这个通道一次只能处理一个消息
// 在消息未被手动确认时不会有新消息被投递来
err = ch.Qos(1, 0, false)
if err != nil {
panic(err)
}
// 交换机
err = ch.ExchangeDeclare(
"exchange_test", "direct", true, false, false, false, make(map[string]interface{}),
)
if err != nil {
panic(err)
}
// rabbitmq中的队列
q, err := ch.QueueDeclare(
"PushClientMessage", // name
true, // durable
false, // delete when unused
false, // exclusive
false, // no-Wait
nil, // arguments
)
if err != nil {
panic(err)
}
// 绑定交换机与队列 交换机收到消息后会流到队列,再由队列发到消费者
err = ch.QueueBind("PushClientMessage", "PushClientMessage", "exchange_test", false, nil)
if err != nil {
panic(err)
}
// 定义一个消费者 一个队列可以有多个消费者,共同消费这个队列中的消息
msgs, err := ch.Consume(
q.Name, // queue
"one", // consumer 消费者名称
false, // auto-ack 不自动确认 手动确认
false, // exclusive
false, // no-local
false, // no-Wait
nil, // args
)
if err != nil {
panic(err)
}
// 循环接收处理消息
for {
select {
case msg, ok := <-msgs:
if ok {
// 获取到从消息队列下发的消息
msgStr := string(msg.Body)
LOG.Info("从消息队列中取到消息", zap.String("msg", msgStr))
// 解释出这个消息要发送给那个用户
userId := jsoniter.Get(msg.Body, "userId").ToString()
if userId != "" {
// 发送消息
LOG.Info("准备投递消息", zap.String("userId", userId))
// 发送消息到用户
err := mq.wsHandle.SendMessage(userId, msg.Body)
if err != nil {
LOG.Error("消息投递到客户端失败", zap.Error(err), zap.Any("msg", msg.Body))
// 未找到用户的情况下,驳回消息不重新投递
if err.Error() == "no this user" {
err := msg.Reject(false)
if err != nil {
LOG.Error("消息驳回失败", zap.Error(err))
} else {
LOG.Info("消息驳回不重新入队", zap.Any("msg", msg.Body))
}
} else {
// 其他错误驳回后,再次投递处理
err := msg.Reject(true)
if err != nil {
LOG.Error("消息驳回失败", zap.Error(err))
} else {
LOG.Info("消息驳回重新入队", zap.Any("msg", msg.Body))
}
}
} else {
// 确认此条消息消费成功
err := msg.Ack(false)
if err != nil {
LOG.Error("消息确认失败", zap.Error(err))
} else {
LOG.Info("消息确认成功并成功处理", zap.Any("msg", msg.Body))
}
}
} else {
// 下发的消息中没有用户id,无法推送,驳回后不重新投递
err := msg.Reject(false)
if err != nil {
LOG.Error("消息驳回失败", zap.Error(err))
} else {
LOG.Info("消息驳回不重新入队 未找到客户连接", zap.Error(err))
}
}
} else {
LOG.Info("消息队列关闭,准备退出")
return // return 退出for循环
}
}
}
}
main函数的样子
func main() {
wsHandle := NewWsHandle()
router := gin.Default()
router.GET("/", func(c *gin.Context) {
c.String(http.StatusOK, "Welcome Gin Server")
})
router.POST("/push", wsHandle.handleSendMessage) // gin push api 服务器主动推送消息api
router.GET("/ws", wsHandle.handle) // gin websocket api 客户端ws连接路径
srv := &http.Server{
Addr: ":" + CONFIG.HttpPort,
Handler: router,
}
// 开启http服务
go func() {
// 确定是否开启https 并开启http和websocket服务
if CONFIG.PrivateKeyPath != "" {
// service connections
LOG.Info("开始tls http服务")
if err := srv.ListenAndServeTLS(CONFIG.PublicKeyPath, CONFIG.PrivateKeyPath); err != nil && err != http.ErrServerClosed {
LOG.Error("ws listen err", zap.Error(err))
panic(err)
} else {
LOG.Info("ws listen exist")
}
} else {
// service connections
LOG.Info("开始非tls http服务")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
LOG.Error("ws listen err", zap.Error(err))
panic(err)
} else {
LOG.Info("ws listen exist")
}
}
}()
// 开启消息队列,开始从消息队列接收消息
mq := newRabbitMq(CONFIG.RabbitConnStr, wsHandle)
go func() {
LOG.Info("开始消息队列接收服务")
mq.Wait.Add(1)
mq.BeginReceive()
}()
// 监听 ctrl+c 退出信号
quit := make(chan os.Signal)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit // 等待消息以退出程序
LOG.Info("begin shutdown server")
// 关闭消息队列服务
err2 := mq.Mq.Close()
if err2 != nil {
LOG.Info("err on mq close", zap.Error(err2))
}
mq.Wait.Wait()
// 关闭http服务
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
wsHandle.CloseAllForExit() // 关闭所有websocket连接
if err := srv.Shutdown(ctx); err != nil {
LOG.Info("http ws server shutdown err", zap.Error(err))
}
// 关闭日志服务
LOG.Info("server shutdown finish")
CloseZapLog()
fmt.Println("server exit end")
}
docker
在docker中编译
FROM golang:1.17 as goBuilder
WORKDIR /ws
ADD ./ ./
RUN export CGO_ENABLED=0 && export GOPROXY=https://goproxy.cn,direct && go build -v -o wspush
FROM alpine:3.15
WORKDIR /
COPY --from=goBuilder /ws/wspush ./
COPY --from=goBuilder /ws/config.toml ./
RUN chmod u+x ./wspush
EXPOSE 80
CMD ["./wspush"]
在docker中编译
#!/bin/bash
docker run -it -v "$PWD":/app -v "$PWD"/.cache/gopath:/go -v "$PWD"/.cache/gocache:/root/.cache/ golang:1.17 \
/bin/sh -c "cd /app && export CGO_ENABLED=0 && export GOPROXY=https://goproxy.cn,direct && go build -v"
if [ "$?" -eq 0 ];then
echo "build ok"
else
echo "no"
exit 1
fi
代码
更多推荐
https://download.csdn.net/download/a34ErxV/81270186


所有评论(0)