百万级并发 - IM项目实战
需求分析:
项目目的:
项目背景:IM对性能和体验敏感度非常高 。 大厂必备
你将获得什么:
熟悉开发流程 ,熟练相关技术栈 gin+GORM+swagger + logrus auth 等中间件,三高性能
核心功能:
发送和接受消息,文字 表情 图片 音频 ,访客,点对点,群聊 ,广播,快捷回复,撤回,心跳检测…
技术栈:
前端 后端 (webSocket ,channel/goroutine ,gin ,temlate,gorm ,sql,nosql,mq…)
系统架构:
四层:前端,接入层,逻辑层,持久层
消息发送流程:
A > 登录> 鉴权>(游客) > 消息类型 >(群/广播) > B
环境搭建:
go version go1.17.8 windows/amd64
set GO111MODULE=on
go mod init go_exam
go mod tidy
系统架构
核心流程:
效果展示:
部分项目代码展示
功能实现
完成用户模块基本的
加入修改电话号码和邮箱 并校验
先引入
get github.com/asaskevich/govalidator
结构体字段后面 加检验规则
最后service govalidator.ValidatorStrut(user)
1.router包 app.go
r.GET("/user/getUserList", service.GetUserList)
r.GET("/user/createUser", service.CreateUser)
r.GET("/user/deleteUser", service.DeleteUser)
r.POST("/user/updateUser", service.UpdateUser)
2.service 包 userservice.go
// GetUserList
// @Summary 所有用户
// @Tags 用户模块
// @Success 200 {string} json{"code","message"}
// @Router /user/getUserList [get]
func GetUserList(c *gin.Context) {
data := make([]*models.UserBasic, 10)
data = models.GetUserList()
c.JSON(200, gin.H{
"message": data,
})
}
// CreateUser
// @Summary 新增用户
// @Tags 用户模块
// @param name query string false "用户名"
// @param password query string false "密码"
// @param repassword query string false "确认密码"
// @Success 200 {string} json{"code","message"}
// @Router /user/createUser [get]
func CreateUser(c *gin.Context) {
user := models.UserBasic{}
user.Name = c.Query("name")
password := c.Query("password")
repassword := c.Query("repassword")
if password != repassword {
c.JSON(-1, gin.H{
"message": "两次密码不一致!",
})
return
}
user.PassWord = password
models.CreateUser(user)
c.JSON(200, gin.H{
"message": "新增用户成功!",
})
}
// DeleteUser
// @Summary 删除用户
// @Tags 用户模块
// @param id query string false "id"
// @Success 200 {string} json{"code","message"}
// @Router /user/deleteUser [get]
func DeleteUser(c *gin.Context) {
user := models.UserBasic{}
id, _ := strconv.Atoi(c.Query("id"))
user.ID = uint(id)
models.DeleteUser(user)
c.JSON(200, gin.H{
"message": "删除用户成功!",
})
}
// UpdateUser
// @Summary 修改用户
// @Tags 用户模块
// @param id formData string false "id"
// @param name formData string false "name"
// @param password formData string false "password"
// @param phone formData string false "phone"
// @param email formData string false "email"
// @Success 200 {string} json{"code","message"}
// @Router /user/updateUser [post]
func UpdateUser(c *gin.Context) {
user := models.UserBasic{}
id, _ := strconv.Atoi(c.PostForm("id"))
user.ID = uint(id)
user.Name = c.PostForm("name")
user.PassWord = c.PostForm("password")
user.Phone = c.PostForm("phone")
user.Email = c.PostForm("email")
fmt.Println("update :", user)
_, err := govalidator.ValidateStruct(user)
if err != nil {
fmt.Println(err)
c.JSON(200, gin.H{
"message": "修改参数不匹配!",
})
} else {
models.UpdateUser(user)
c.JSON(200, gin.H{
"message": "修改用户成功!",
})
}
}
3.modesl包 user_basic.go
Phone string `valid:"matches(^1[3-9]{1}\\d{9}$)"`
Email string `valid:"email"`
4,然后测试
重复注册校验:
func FindUserByName(name string) UserBasic {
user := UserBasic{}
utils.DB.Where("name = ?", name).First(&user)
return user
}
func FindUserByPhone(phone string) *gorm.DB {
user := UserBasic{}
return utils.DB.Where("Phone = ?", phone).First(&user)
}
func FindUserByEmail(email string) *gorm.DB {
user := UserBasic{}
return utils.DB.Where("email = ?", email).First(&user)
}
再到service层 加入判断
data := models.FindUserByName(user.Name)
if data.Name != "" {
c.JSON(-1, gin.H{
"message": "用户名已注册!",
})
return
}
注册 加密操作
package utils
import (
"crypto/md5"
"encoding/hex"
"fmt"
"strings"
)
//小写
func Md5Encode(data string) string {
h := md5.New()
h.Write([]byte(data))
tempStr := h.Sum(nil)
return hex.EncodeToString(tempStr)
}
//大写
func MD5Encode(data string) string {
return strings.ToUpper(Md5Encode(data))
}
//加密
func MakePassword(plainpwd, salt string) string {
return Md5Encode(plainpwd + salt)
}
//解密
func ValidPassword(plainpwd, salt string, password string) bool {
md := Md5Encode(plainpwd + salt)
fmt.Println(md + " " + password)
return md == password
}
service层 判断之后加入
//user.PassWord = password
user.PassWord = utils.MakePassword(password, salt)
user.Salt = salt //表更新了字段 db.AutoMigrate(&models.UserBasic{})
fmt.Println(user.PassWord)
models.CreateUser(user)
登录解密 :
//dao层
func FindUserByNameAndPwd(name string, password string) UserBasic {
user := UserBasic{}
utils.DB.Where("name = ? and pass_word=?", name, password).First(&user)
return user
}
// GetUserList
// @Summary 所有用户
// @Tags 用户模块
// @param name query string false "用户名"
// @param password query string false "密码"
// @Success 200 {string} json{"code","message"}
// @Router /user/findUserByNameAndPwd [get]
func FindUserByNameAndPwd(c *gin.Context) {
data := models.UserBasic{}
name := c.Query("name")
password := c.Query("password")
user := models.FindUserByName(name)
if user.Name == "" {
c.JSON(200, gin.H{
"message": "该用户不存在",
})
return
}
flag := utils.ValidPassword(password, user.Salt, user.PassWord)
if !flag {
c.JSON(200, gin.H{
"message": "密码不正确",
})
return
}
pwd := utils.MakePassword(password, user.Salt)
data = models.FindUserByNameAndPwd(name, pwd)
c.JSON(200, gin.H{
"message": data,
})
}
router层 :
r.POST("/user/findUserByNameAndPwd", service.FindUserByNameAndPwd)
token的加入对返回的结构调整 。
修改登录的方法:
func FindUserByNameAndPwd(name string, password string) UserBasic {
user := UserBasic{}
utils.DB.Where("name = ? and pass_word=?", name, password).First(&user)
//token加密
str := fmt.Sprintf("%d", time.Now().Unix())
temp := utils.MD5Encode(str)
utils.DB.Model(&user).Where("id = ?", user.ID).Update("identity", temp)
return user
}
// 返回的结果:
c.JSON(200, gin.H{
"code": 0, // 0成功 -1失败
"message": "修改用户成功!",
"data": user,
})
加入Redis
go get github.com/go-redis/redis
配置redis
redis:
addr: "192.168.137.131:6379"
password: ""
DB: 0
poolSize: 30
minIdleConn: 30
然后main方法中
utils.InitRedis()
最后再 utils
func InitRedis() {
Red = redis.NewClient(&redis.Options{
Addr: viper.GetString("redis.addr"),
Password: viper.GetString("redis.password"),
DB: viper.GetInt("redis.DB"),
PoolSize: viper.GetInt("redis.poolSize"),
MinIdleConns: viper.GetInt("redis.minIdleConn"),
})
pong, err := Red.Ping().Result()
if err != nil {
fmt.Println("init redis 。。。。", err)
} else {
fmt.Println(" Redis inited 。。。。", pong)
}
}
测试看是否正常
通过WebSocket通信
go get github.com/gorilla/websocket
go get github.com/go-redis/redis/v8
package utils
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/go-redis/redis/v8"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var (
DB *gorm.DB
Red *redis.Client
)
func InitConfig() {
viper.SetConfigName("app")
viper.AddConfigPath("config")
err := viper.ReadInConfig()
if err != nil {
fmt.Println(err)
}
fmt.Println("config app inited 。。。。")
}
func InitMySQL() {
//自定义日志模板 打印SQL语句
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Second, //慢SQL阈值
LogLevel: logger.Info, //级别
Colorful: true, //彩色
},
)
DB, _ = gorm.Open(mysql.Open(viper.GetString("mysql.dns")),
&gorm.Config{Logger: newLogger})
fmt.Println(" MySQL inited 。。。。")
//user := models.UserBasic{}
//DB.Find(&user)
//fmt.Println(user)
}
func InitRedis() {
Red = redis.NewClient(&redis.Options{
Addr: viper.GetString("redis.addr"),
Password: viper.GetString("redis.password"),
DB: viper.GetInt("redis.DB"),
PoolSize: viper.GetInt("redis.poolSize"),
MinIdleConns: viper.GetInt("redis.minIdleConn"),
})
}
const (
PublishKey = "websocket"
)
//Publish 发布消息到Redis
func Publish(ctx context.Context, channel string, msg string) error {
var err error
fmt.Println("Publish 。。。。", msg)
err = Red.Publish(ctx, channel, msg).Err()
if err != nil {
fmt.Println(err)
}
return err
}
//Subscribe 订阅Redis消息
func Subscribe(ctx context.Context, channel string) (string, error) {
sub := Red.Subscribe(ctx, channel)
fmt.Println("Subscribe 。。。。", ctx)
msg, err := sub.ReceiveMessage(ctx)
if err != nil {
fmt.Println(err)
return "", err
}
fmt.Println("Subscribe 。。。。", msg.Payload)
return msg.Payload, err
}
userservice.go中加入
//防止跨域站点伪造请求
var upGrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func SendMsg(c *gin.Context) {
ws, err := upGrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
fmt.Println(err)
return
}
defer func(ws *websocket.Conn) {
err = ws.Close()
if err != nil {
fmt.Println(err)
}
}(ws)
MsgHandler(c, ws)
}
func MsgHandler(c *gin.Context, ws *websocket.Conn) {
for {
msg, err := utils.Subscribe(c, utils.PublishKey)
if err != nil {
fmt.Println(" MsgHandler 发送失败", err)
}
tm := time.Now().Format("2006-01-02 15:04:05")
m := fmt.Sprintf("[ws][%s]:%s", tm, msg)
err = ws.WriteMessage(1, []byte(m))
if err != nil {
log.Fatalln(err)
}
}
}
router层 app.go
//发送消息
r.GET("/user/sendMsg", service.SendMsg)
测试: http://www.jsons.cn/websocket/
ws://localhost:8081/user/sendMsg
设计 关系表 ,群信息表 ,消息表
package models
import "gorm.io/gorm"
//消息
type Message struct {
gorm.Model
FormId uint //发送者
TargetId uint //接受者
Type string //消息类型 群聊 私聊 广播
Media int //消息类型 文字 图片 音频
Content string //消息内容
Pic string
Url string
Desc string
Amount int //其他数字统计
}
func (table *Message) TableName() string {
return "message"
}
package models
import "gorm.io/gorm"
//群信息
type GroupBasic struct {
gorm.Model
Name string
OwnerId uint
Icon string
Type int
Desc string
}
func (table *GroupBasic) TableName() string {
return "group_basic"
}
package models
import "gorm.io/gorm"
//人员关系
type Contact struct {
gorm.Model
OwnerId uint //谁的关系信息
TargetId uint //对应的谁
Type int //对应的类型 0 1 3
Desc string
}
func (table *Contact) TableName() string {
return "contact"
}
发送消息 接受消息
需要 :发送者ID ,接受者ID ,消息类型,发送的内容,发送类型
校验token ,关系 ,
package models
import (
"encoding/json"
"fmt"
"net"
"net/http"
"strconv"
"sync"
"github.com/gorilla/websocket"
"gopkg.in/fatih/set.v0"
"gorm.io/gorm"
)
//消息
type Message struct {
gorm.Model
FormId int64 //发送者
TargetId int64 //接受者
Type int //发送类型 群聊 私聊 广播
Media int //消息类型 文字 图片 音频
Content string //消息内容
Pic string
Url string
Desc string
Amount int //其他数字统计
}
func (table *Message) TableName() string {
return "message"
}
type Node struct {
Conn *websocket.Conn
DataQueue chan []byte
GroupSets set.Interface
}
//映射关系
var clientMap map[int64]*Node = make(map[int64]*Node, 0)
//读写锁
var rwLocker sync.RWMutex
// 需要 :发送者ID ,接受者ID ,消息类型,发送的内容,发送类型
func Chat(writer http.ResponseWriter, request *http.Request) {
//1. 获取参数 并 检验 token 等合法性
//token := query.Get("token")
query := request.URL.Query()
Id := query.Get("userId")
userId, _ := strconv.ParseInt(Id, 10, 64)
//msgType := query.Get("type")
//targetId := query.Get("targetId")
// context := query.Get("context")
isvalida := true //checkToke() 待.........
conn, err := (&websocket.Upgrader{
//token 校验
CheckOrigin: func(r *http.Request) bool {
return isvalida
},
}).Upgrade(writer, request, nil)
if err != nil {
fmt.Println(err)
return
}
//2.获取conn
node := &Node{
Conn: conn,
DataQueue: make(chan []byte, 50),
GroupSets: set.New(set.ThreadSafe),
}
//3. 用户关系
//4. userid 跟 node绑定 并加锁
rwLocker.Lock()
clientMap[userId] = node
rwLocker.Unlock()
//5.完成发送逻辑
go sendProc(node)
//6.完成接受逻辑
go recvProc(node)
sendMsg(userId, []byte("欢迎进入聊天系统"))
}
func sendProc(node *Node) {
for {
select {
case data := <-node.DataQueue:
err := node.Conn.WriteMessage(websocket.TextMessage, data)
if err != nil {
fmt.Println(err)
return
}
}
}
}
func recvProc(node *Node) {
for {
_, data, err := node.Conn.ReadMessage()
if err != nil {
fmt.Println(err)
return
}
broadMsg(data)
fmt.Println("[ws] <<<<< ", data)
}
}
var udpsendChan chan []byte = make(chan []byte, 1024)
func broadMsg(data []byte) {
udpsendChan <- data
}
func init() {
go udpSendProc()
go udpRecvProc()
}
//完成udp数据发送协程
func udpSendProc() {
con, err := net.DialUDP("udp", nil, &net.UDPAddr{
IP: net.IPv4(192, 168, 0, 255),
Port: 3000,
})
defer con.Close()
if err != nil {
fmt.Println(err)
}
for {
select {
case data := <-udpsendChan:
_, err := con.Write(data)
if err != nil {
fmt.Println(err)
return
}
}
}
}
//完成udp数据接收协程
func udpRecvProc() {
con, err := net.ListenUDP("udp", &net.UDPAddr{
IP: net.IPv4zero,
Port: 3000,
})
if err != nil {
fmt.Println(err)
}
defer con.Close()
for {
var buf [512]byte
n, err := con.Read(buf[0:])
if err != nil {
fmt.Println(err)
return
}
dispatch(buf[0:n])
}
}
//后端调度逻辑处理
func dispatch(data []byte) {
msg := Message{}
err := json.Unmarshal(data, &msg)
if err != nil {
fmt.Println(err)
return
}
switch msg.Type {
case 1: //私信
sendMsg(msg.TargetId, data)
// case 2: //群发
// sendGroupMsg()
// case 3://广播
// sendAllMsg()
//case 4:
//
}
}
func sendMsg(userId int64, msg []byte) {
rwLocker.RLock()
node, ok := clientMap[userId]
rwLocker.RUnlock()
if ok {
node.DataQueue <- msg
}
}
集成html 登录和注册
//app.go 加入
//首页
r.GET("/", service.GetIndex)
r.GET("/index", service.GetIndex)
r.GET("/toRegister", service.ToRegister)
// index.go
package service
import (
"text/template"
"github.com/gin-gonic/gin"
)
// GetIndex
// @Tags 首页
// @Success 200 {string} welcome
// @Router /index [get]
func GetIndex(c *gin.Context) {
ind, err := template.ParseFiles("index.html", "views/chat/head.html")
if err != nil {
panic(err)
}
ind.Execute(c.Writer, "index")
// c.JSON(200, gin.H{
// "message": "welcome !! ",
// })
}
func ToRegister(c *gin.Context) {
ind, err := template.ParseFiles("views/user/register.html")
if err != nil {
panic(err)
}
ind.Execute(c.Writer, "register")
// c.JSON(200, gin.H{
// "message": "welcome !! ",
// })
}
然后页面 :
<!DOCTYPE html>
<html>
<head>
<!--js include-->
{
{template "/chat/head.shtml"}}
</head>
<body>
<header class="mui-bar mui-bar-nav">
<h1 class="mui-title">登录</h1>
</header>
{
{.}}
<div class