基于go+vue的多人在线聊天的im系统

基于go+vue的多人在线聊天的im系统

文章目录

一、前端部分

打算优化一下界面,正在开发中。。。

二、后端部分

1、中间件middleware设计jwt和cors

jwt.go

go 复制代码
package middlewares

import (
	"crypto/rsa"
	"fmt"
	"github.com/dgrijalva/jwt-go"
	"github.com/gin-gonic/gin"
	"im/global"
	"io/ioutil"
	"net/http"
	"os"
	"time"
)

func JWT() gin.HandlerFunc {
	return func(ctx *gin.Context) {
		// 从请求头获取token
		token := ctx.Request.Header.Get("w-token")
		if token == "" {
			ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
				"msg": "请登录",
			})
			return
		}
		// 打开存储公钥文件
		file, _ := os.Open(global.SrvConfig.JWTInfo.PublicKeyPath)
		// 读取公钥文件
		bytes, _ := ioutil.ReadAll(file)
		// 解析公钥
		publickey, _ := jwt.ParseRSAPublicKeyFromPEM(bytes)

		jwtVerier := &JWTTokenVerifier{PublicKey: publickey}

		claim, err := jwtVerier.Verify(token)
		if err != nil {
			fmt.Println(err)
			ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
				"msg": "请登录",
			})
			return
		}
		ctx.Set("claim", claim)        //获取全部信息
		ctx.Set("name", claim.Subject) // 获取用户名
		ctx.Next()
	}
}

func Auth(token string) (*MyClaim, error) {
	if token == "" {
		return nil, fmt.Errorf("ws认证失败,token为空")
	}
	file, _ := os.Open(global.SrvConfig.JWTInfo.PublicKeyPath)
	bytes, _ := ioutil.ReadAll(file)
	publickey, _ := jwt.ParseRSAPublicKeyFromPEM(bytes)
	jwtVerier := &JWTTokenVerifier{PublicKey: publickey}
	return jwtVerier.Verify(token)
}

type JWTTokenVerifier struct {
	// 存储用于验证签名的公钥
	PublicKey *rsa.PublicKey
}

type MyClaim struct {
	Role int
	jwt.StandardClaims
}

func (v *JWTTokenVerifier) Verify(token string) (*MyClaim, error) {
	t, err := jwt.ParseWithClaims(token, &MyClaim{},
		func(*jwt.Token) (interface{}, error) {
			return v.PublicKey, nil
		})

	if err != nil {
		return nil, fmt.Errorf("cannot parse token: %v", err)
	}

	if !t.Valid {
		return nil, fmt.Errorf("token not valid")
	}

	clm, ok := t.Claims.(*MyClaim)
	if !ok {
		return nil, fmt.Errorf("token claim is not MyClaim")
	}

	if err := clm.Valid(); err != nil {
		return nil, fmt.Errorf("claim not valid: %v", err)
	}
	return clm, nil

}

type JWTTokenGen struct {
	privateKey *rsa.PrivateKey
	issuer     string
	nowFunc    func() time.Time
}

func NewJWTTokenGen(issuer string, privateKey *rsa.PrivateKey) *JWTTokenGen {
	return &JWTTokenGen{
		issuer:     issuer,
		nowFunc:    time.Now,
		privateKey: privateKey,
	}
}

func (t *JWTTokenGen) GenerateToken(userName string, expire time.Duration) (string, error) {
	nowSec := t.nowFunc().Unix()
	tkn := jwt.NewWithClaims(jwt.SigningMethodRS512, &MyClaim{
		StandardClaims: jwt.StandardClaims{
			Issuer:    t.issuer,
			IssuedAt:  nowSec,
			ExpiresAt: nowSec + int64(expire.Seconds()),
			Subject:   userName,
		},
	})
	return tkn.SignedString(t.privateKey)
}

cors.go

go 复制代码
package middlewares

import (
	"github.com/gin-gonic/gin"
	"net/http"
)

func Cors() gin.HandlerFnc {
	return func(c *gin.Context) {
		method := c.Request.Method

		c.Header("Access-Control-Allow-Origin", "*")
		c.Header("Access-Control-Allow-Headers", "Content-Type,AccessToken,X-CSRF-Token, Authorization, Token, w-token")
		c.Header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PATCH, PUT")
		c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type")
		c.Header("Access-Control-Allow-Credentials", "true")

		if method == "OPTIONS" {
			c.AbortWithStatus(http.StatusNoContent)
		}
	}
}

2、配置文件设计

config.yaml

yaml 复制代码
jwt:
  privateKeyPath: ./config/private.key
  publicKeyPath: ./config/public.key
port: 9288
name: user-web
redis:
  ip: 
  port: 6379
mysql:
  ip: 
  username: 
  password: 
  db_name: im

config/config.go

go 复制代码
package config

// 读取yaml配置文件,形成映射的相关类
type JWTconfig struct {
	PrivateKeyPath string `mapstructure:"privateKeyPath" json:"privateKeyPath"`
	PublicKeyPath  string `mapstructure:"publicKeyPath" json:"publicKeyPath"`
}

type RedisConfig struct {
	IP   string `mapstructure:"ip"`
	Port string `mapstructure:"port"`
}

type MysqlConfig struct {
	IP       string `mapstructure:"ip"`
	Username string `mapstructure:"username"`
	Password string `mapstructure:"password"`
	DbName   string `mapstructure:"db_name"`
}

type SrvConfig struct {
	Name      string      `mapstructure:"name" json:"name"`
	Port      int         `mapstructure:"port" json:"port"`
	JWTInfo   JWTconfig   `mapstructure:"jwt" json:"jwt"`
	RedisInfo RedisConfig `mapstructure:"redis" json:"redis"`
	MysqlInfo MysqlConfig `mapstructure:"mysql" json:"mysql"`
}

initalize/config.go

go 复制代码
package Initialize

import (
	"fmt"
	"github.com/fsnotify/fsnotify"
	"github.com/spf13/viper"
	"im/global"
)

func InitConfig() {
	//从配置文件中读取出对应的配置
	var configFileName = fmt.Sprintf("./config.yaml" )
	v := viper.New()
	//文件的路径
	v.SetConfigFile(configFileName)
	if err := v.ReadInConfig(); err != nil {
		panic(err)
	}
	// 开启实时监控
	v.WatchConfig()
	//这个对象如何在其他文件中使用 - 全局变量
	if err := v.Unmarshal(&global.SrvConfig); err != nil {
		panic(err)
	}

	// 文件更新的回调函数
	v.OnConfigChange(func(in fsnotify.Event) {
		fmt.Println("配置改变")
		if err := v.Unmarshal(&global.SrvConfig); err != nil {
			panic(err)
		}
	})
}

func GetEnvInfo(env string) bool {
	viper.AutomaticEnv()
	return viper.IsSet(env)
}

global.go 声明全局变量

go 复制代码
package global

import (
	"github.com/go-redis/redis/v8"
	"github.com/jinzhu/gorm"
	"im/config"
	"sync"
)

var (
	// 配置信息
	SrvConfig = config.SrvConfig{}
	// 分别管理存储已注册用户和在线用户
	// 已注册用户map,key为name value为password
	UserMap = sync.Map{}

	// 在线用户map,key为name value为连接句柄list
	LoginMap = sync.Map{}

	// redis客户端
	Redis *redis.Client

	// db服务
	DB *gorm.DB
)

3、Mysql和Redis连接

db.go

go 复制代码
package Initialize

import (
	"fmt"
	"github.com/jinzhu/gorm"
	_ "github.com/jinzhu/gorm/dialects/mysql"
	"im/global"
	"os"
)

var err error

func InitDB() {
	// 构建数据库连接字符串
	dbConfig := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8&parseTime=True&loc=Local",
		global.SrvConfig.MysqlInfo.Username,
		global.SrvConfig.MysqlInfo.Password,
		global.SrvConfig.MysqlInfo.IP,
		global.SrvConfig.MysqlInfo.DbName)
	// 连接数据库
	global.DB, err = gorm.Open("mysql", dbConfig)
	if err != nil {
		fmt.Println("[Initialize] 数据库连接失败:%v", err)
		return
	}
	// 设置连接池参数
	global.DB.DB().SetMaxIdleConns(10)     //设置数据库连接池最大空闲连接数
	global.DB.DB().SetMaxOpenConns(100)    //设置数据库最大连接数
	global.DB.DB().SetConnMaxLifetime(100) //设置数据库连接超时时间

	// 测试数据库连接
	if err = global.DB.DB().Ping(); err != nil {
		fmt.Printf("[Initialize] 数据库连接测试失败:%v\n", err)
		os.Exit(0)
	}
	fmt.Println("[Initialize] 数据库连接测试成功")
}

redis.og

go 复制代码
package Initialize

import (
	"context"
	"fmt"
	"github.com/go-redis/redis/v8"
	"im/global"
	"log"
	"sync"
	"time"
)

var once sync.Once

func InitRedis() {
	addr := fmt.Sprintf("%v:%v", global.SrvConfig.RedisInfo.IP, global.SrvConfig.RedisInfo.Port)
	// once.Do() 在一个应用程序生命周期内只会执行一次
	once.Do(func() {
		global.Redis = redis.NewClient(&redis.Options{
			Network:      "tcp",
			Addr:         addr,
			Password:     "",
			DB:           0,               // 指定Redis服务器的数据库索引,0为默认
			PoolSize:     15,              // 连接池最大连接数
			MinIdleConns: 10,              // 连接池最小连接数
			DialTimeout:  5 * time.Second, // 连接超时时间
			ReadTimeout:  3 * time.Second, // 读超时时间
			WriteTimeout: 3 * time.Second, // 写超时时间
			PoolTimeout:  4 * time.Second, // 连接池获取连接的超时时间

			IdleCheckFrequency: 60 * time.Second,
			IdleTimeout:        5 * time.Minute,
			MaxConnAge:         0 * time.Second,

			MaxRetries:      0,
			MinRetryBackoff: 8 * time.Millisecond,
			MaxRetryBackoff: 512 * time.Millisecond,
		})
		pong, err := global.Redis.Ping(context.Background()).Result()
		if err != nil {
			log.Fatal(err)
		}
		log.Println(pong)
	})
}

4、路由设计

go 复制代码
	// 注册
	r.POST("/api/register", handle.Register)
	// 已注册用户列表
	r.GET("/api/list", handle.UserList)
	// 登录
	r.POST("/api/login", handle.Login)
	// ws连接
	r.GET("/api/ws", handle.WS)
	// 获取登录列表(目前没用到)
	r.GET("/api/loginlist", handle.LoginList)
	// JWT
	r.Use(middlewares.JWT())
	// 获取用户名
	r.GET("/api/user", handle.UserInfo)

5、核心功能设计

handle/handle.go

go 复制代码
package handle

import (
	"fmt"
	"github.com/dgrijalva/jwt-go"
	"github.com/gin-gonic/gin"
	"im/global"
	"im/middlewares"
	"im/mysql"
	"io/ioutil"
	"net/http"
	"os"
	"time"
)

type Reg struct {
	Name     string `json:"name"`
	Password string `json:"password"`
}
type UList struct {
	Names []string `json:"names"`
}
type LoginStruct struct {
	Name     string `json:"name" `
	Password string `json:"password" `
}

func Register(c *gin.Context) {
	var reg Reg
	err := c.Bind(&reg)
	if err != nil {
		fmt.Println(err)
		c.JSON(http.StatusOK, gin.H{
			"msg":  "用户名或密码格式错误,请重试",
			"code": "4001",
		})
		return
	}
	mysql.StorageUserToMap()
	_, ok := global.UserMap.Load(reg.Name)
	if ok {
		fmt.Println("用户已存在")
		c.JSON(http.StatusOK, gin.H{
			"msg":  "用户已存在,请登录或更换用户名注册",
			"code": "4000",
		})
		return
	}
	if err := mysql.AddUserToMysql(reg.Name, reg.Password); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{
			"msg":  "内部错误",
			"code": "5000",
		})
		return
	}
	mysql.StorageUserToMap()
	c.JSON(http.StatusOK, gin.H{
		"msg":  "创建用户成功,请登录",
		"code": "2000",
	})
}

func Login(c *gin.Context) {
	var loginData LoginStruct
	err := c.Bind(&loginData)
	if err != nil {
		fmt.Println(err)
		c.JSON(http.StatusOK, gin.H{
			"msg":  "用户名或密码格式错误,请重试",
			"code": "4001",
		})
		return
	}
	psw, ok := global.UserMap.Load(loginData.Name)
	if !ok {
		fmt.Println("用户不存在")
		c.JSON(http.StatusOK, gin.H{
			"msg":  "用户不存在,请注册",
			"code": "4003",
		})
		return
	}
	if loginData.Password != psw.(string) {
		c.JSON(http.StatusOK, gin.H{
			"msg":  "密码错误,请重新输入",
			"code": "4005",
		})
		return
	}

	file, err := os.Open(global.SrvConfig.JWTInfo.PrivateKeyPath)
	if err != nil {
		fmt.Println(err)
		return
	}
	pkBytes, err := ioutil.ReadAll(file)
	privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(pkBytes))
	tokenGen := middlewares.NewJWTTokenGen("user", privateKey)
	token, err := tokenGen.GenerateToken(loginData.Name, time.Hour*24*20)
	if err != nil {
		fmt.Println(err)
		return
	}

	c.JSON(http.StatusOK, &gin.H{
		"msg":   "登录成功",
		"code":  "2000",
		"name":  loginData.Name,
		"token": token,
	})
}

func LoginList(c *gin.Context) {
	var users UList
	global.LoginMap.Range(func(key, value interface{}) bool {
		users.Names = append(users.Names, key.(string))
		return true
	})
	c.JSON(http.StatusOK, &users)
}

func getLoginList() *UList {
	var users UList
	global.LoginMap.Range(func(key, value interface{}) bool {
		users.Names = append(users.Names, key.(string))
		return true
	})
	return &users
}

func UserInfo(c *gin.Context) {
	name, _ := c.Get("name")
	userName := name.(string)
	c.JSON(http.StatusOK, gin.H{
		"msg":  "成功",
		"code": "2000",
		"name": userName,
	})
}

func UserList(c *gin.Context) {
	var users UList
	global.UserMap.Range(func(key, value interface{}) bool {
		users.Names = append(users.Names, key.(string))
		return true
	})
	c.JSON(http.StatusOK, &users)
}

ws.go

go 复制代码
// websocket 通信


package handle

import (
	"container/list"
	"context"
	"encoding/json"
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/gorilla/websocket"
	"im/global"
	"im/middlewares"
	"log"
	"net/http"
)

type WsInfo struct {
	Type    string   `json:"type"`
	Content string   `json:"content"`
	To      []string `json:"to"`
	From    string   `json:"from"`
}

func WS(ctx *gin.Context) {
	var claim *middlewares.MyClaim
	wsConn, _ := Upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
	for {
		_, data, err := wsConn.ReadMessage()
		if err != nil {
			wsConn.Close()
			if claim != nil {
				RemoveWSConnFromMap(claim.Subject, wsConn)
				r, _ := json.Marshal(gin.H{
					"type":    "loginlist",
					"content": getLoginList(),
					"to":      []string{},
				})
				SendMsgToAllLoginUser(r)
			}
			fmt.Println(claim.Subject, "出错,断开连接:", err)
			fmt.Println("当前在线用户列表:", getLoginList().Names)
			return
		}

		var wsInfo WsInfo
		json.Unmarshal(data, &wsInfo)
		if wsInfo.Type == "auth" {
			claim, err = middlewares.Auth(wsInfo.Content)
			if err != nil {
				// 认证失败
				fmt.Println(err)
				rsp := WsInfo{
					Type:    "no",
					Content: "认证失败,请重新登录",
					To:      []string{},
				}
				r, _ := json.Marshal(rsp)
				wsConn.WriteMessage(websocket.TextMessage, r)
				wsConn.Close()

				continue
			}
			// 认证成功
			// 将连接加入map记录
			AddWSConnToMap(claim.Subject, wsConn)

			fmt.Println(claim.Subject, " 加入连接")
			fmt.Println("当前在线用户列表:", getLoginList().Names)

			rsp := WsInfo{
				Type:    "ok",
				Content: "连接成功,请发送消息",
				To:      []string{},
			}
			r, _ := json.Marshal(rsp)
			// 更新登录列表
			wsConn.WriteMessage(websocket.TextMessage, r)
			r, _ = json.Marshal(gin.H{
				"type":    "loginlist",
				"content": getLoginList(),
				"to":      []string{},
			})
			SendMsgToAllLoginUser(r)
			// 发送离线消息
			cmd := global.Redis.LRange(context.Background(), claim.Subject, 0, -1)
			msgs, err := cmd.Result()
			if err != nil {
				log.Println(err)
				continue
			}
			for _, msg := range msgs {
				wsConn.WriteMessage(websocket.TextMessage, []byte(msg))
			}
			global.Redis.Del(context.Background(), claim.Subject)

		} else {
			rsp, _ := json.Marshal(gin.H{
				"type":    "normal",
				"content": wsInfo.Content,
				"to":      []string{},
				"from":    claim.Subject,
			})
			SendMsgToOtherUser(rsp, claim.Subject, wsInfo.To...)
		}
	}
	wsConn.Close()
}

var (
	Upgrader = websocket.Upgrader{
		//允许跨域
		CheckOrigin: func(r *http.Request) bool {
			return true
		},
	}
)

func AddWSConnToMap(userName string, wsConn *websocket.Conn) {
	// 同一用户可以有多个ws连接(登录多次)
	loginListInter, ok := global.LoginMap.Load(userName)
	if !ok {
		// 之前没登录
		loginList := list.New()
		loginList.PushBack(wsConn)
		global.LoginMap.Store(userName, loginList)
	} else {
		// 多次登录
		loginList := loginListInter.(*list.List)
		loginList.PushBack(wsConn)
		global.LoginMap.Store(userName, loginList)
	}
}

func RemoveWSConnFromMap(userName string, wsConn *websocket.Conn) {
	loginListInter, ok := global.LoginMap.Load(userName)
	if !ok {
		fmt.Println("没有连接可以关闭")
	} else {
		// 有连接
		loginList := loginListInter.(*list.List)
		if loginList.Len() <= 1 {
			global.LoginMap.Delete(userName)
		} else {
			for e := loginList.Front(); e != nil; e = e.Next() {
				if e.Value.(*websocket.Conn) == wsConn {
					loginList.Remove(e)
					break
				}
			}
			global.LoginMap.Store(userName, loginList)
		}
	}
}

func SendMsgToOtherUser(data []byte, myName string, otherUserName ...string) {
	for _, otherName := range otherUserName {
		if otherName != myName {
			v, ok := global.LoginMap.Load(otherName)
			if ok {
				// 在线,发送给目标用户的所有客户端
				l := v.(*list.List)
				for e := l.Front(); e != nil; e = e.Next() {
					conn := e.Value.(*websocket.Conn)
					conn.WriteMessage(websocket.TextMessage, data)
				}
			} else {
				_, ok := global.UserMap.Load(otherName)
				if ok {
					//离线消息缓存到redis
					global.Redis.LPush(context.Background(), otherName, data)
				}
			}
		}
	}
}

func SendMsgToAllLoginUser(data []byte) {
	global.LoginMap.Range(func(key, value interface{}) bool {
		l := value.(*list.List)
		for e := l.Front(); e != nil; e = e.Next() {
			conn := e.Value.(*websocket.Conn)
			conn.WriteMessage(websocket.TextMessage, data)
		}
		return true
	})
}

mysql数据读取 mysql.go

go 复制代码
package mysql

import (
	"fmt"
	"im/global"
)

type User struct {
	UserName string `gorm:"column:username"`
	Password string `gorm:"column:password"`
}

func StorageUserToMap() {
	var users []User
	err := global.DB.Find(&users).Error
	if err != nil {
		fmt.Printf("[mysql] 查询用户失败:%v\n", err)
		return
	}
	// 将查询到的用户名和密码存储到 UserMap 中
	for _, user := range users {
		global.UserMap.Store(user.UserName, user.Password)
	}
}

func AddUserToMysql(userName, psw string) error {
	// 创建用户模型
	user := User{
		UserName: userName,
		Password: psw,
	}
	// 插入用户记录
	err := global.DB.Create(&user).Error
	if err != nil {
		fmt.Printf("[mysql] 注册失败:%v\n", err)
		return err
	}
	fmt.Printf("[mysql] 注册成功\n")
	return nil
}

项目地址:https://github.com/jiangxyb/goim-websocket

相关推荐
景天科技苑3 分钟前
【vue3+vite】新一代vue脚手架工具vite,助力前端开发更快捷更高效
前端·javascript·vue.js·vite·vue项目·脚手架工具
小行星12514 分钟前
前端预览pdf文件流
前端·javascript·vue.js
join815 分钟前
解决vue-pdf的签章不显示问题
javascript·vue.js·pdf
小行星12521 分钟前
前端把dom页面转为pdf文件下载和弹窗预览
前端·javascript·vue.js·pdf
yqcoder1 小时前
Vue3 + Vite + Electron + TS 项目构建
前端·javascript·vue.js
ggdpzhk3 小时前
VUE:基于MVVN的前端js框架
前端·javascript·vue.js
活宝小娜8 小时前
vue不刷新浏览器更新页面的方法
前端·javascript·vue.js
程序视点8 小时前
【Vue3新工具】Pinia.js:提升开发效率,更轻量、更高效的状态管理方案!
前端·javascript·vue.js·typescript·vue·ecmascript
coldriversnow8 小时前
在Vue中,vue document.onkeydown 无效
前端·javascript·vue.js
刚刚好ā9 小时前
js作用域超全介绍--全局作用域、局部作用、块级作用域
前端·javascript·vue.js·vue