基于go+vue的多人在线聊天的im系统
文章目录
- 基于go+vue的多人在线聊天的im系统
- 一、前端部分
- 二、后端部分
- 1、中间件middleware设计jwt和cors
- 2、配置文件设计
- 3、Mysql和Redis连接
- 4、路由设计
- 5、核心功能设计
一、前端部分
打算优化一下界面,正在开发中。。。
二、后端部分
1、中间件middleware设计jwt和cors
jwt.go
package middlewaresimport ("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) {// 从请求头获取tokentoken := 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 intjwt.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.PrivateKeyissuer stringnowFunc 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
package middlewaresimport ("github.com/gin-gonic/gin""net/http"
)func Cors() gin.HandlerFnc {return func(c *gin.Context) {method := c.Request.Methodc.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
jwt:privateKeyPath: ./config/private.keypublicKeyPath: ./config/public.key
port: 9288
name: user-web
redis:ip: port: 6379
mysql:ip: username: password: db_name: im
config/config.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
package Initializeimport ("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 声明全局变量
package globalimport ("github.com/go-redis/redis/v8""github.com/jinzhu/gorm""im/config""sync"
)var (// 配置信息SrvConfig = config.SrvConfig{}// 分别管理存储已注册用户和在线用户// 已注册用户map,key为name value为passwordUserMap = sync.Map{}// 在线用户map,key为name value为连接句柄listLoginMap = sync.Map{}// redis客户端Redis *redis.Client// db服务DB *gorm.DB
)
3、Mysql和Redis连接
db.go
package Initializeimport ("fmt""github.com/jinzhu/gorm"_ "github.com/jinzhu/gorm/dialects/mysql""im/global""os"
)var err errorfunc 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
package Initializeimport ("context""fmt""github.com/go-redis/redis/v8""im/global""log""sync""time"
)var once sync.Oncefunc 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、路由设计
// 注册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)// JWTr.Use(middlewares.JWT())// 获取用户名r.GET("/api/user", handle.UserInfo)
5、核心功能设计
handle/handle.go
package handleimport ("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 Regerr := c.Bind(®)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 LoginStructerr := 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 UListglobal.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 UListglobal.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 UListglobal.UserMap.Range(func(key, value interface{}) bool {users.Names = append(users.Names, key.(string))return true})c.JSON(http.StatusOK, &users)
}
ws.go
// websocket 通信package handleimport ("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.MyClaimwsConn, _ := 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 WsInfojson.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 {//离线消息缓存到redisglobal.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
package mysqlimport ("fmt""im/global"
)type User struct {UserName string `gorm:"column:username"`Password string `gorm:"column:password"`
}func StorageUserToMap() {var users []Usererr := global.DB.Find(&users).Errorif 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).Errorif err != nil {fmt.Printf("[mysql] 注册失败:%v\n", err)return err}fmt.Printf("[mysql] 注册成功\n")return nil
}
项目地址:https://github.com/jiangxyb/goim-websocket