第五章:使用Gorm来持久化消息

前言

上一章节我们使用WebSocket实现了一对一聊天,但是存在一个问题,当用户A给用户B发送消息时,如果用户B不在线,那么消息就会丢失,可在日常生活中,当我微信不在线时其他用户给我发的消息会被微信服务器存起来,当我微信上线时就会读取到这些消息,所以消息需要被持久化,并且应该还有一个已读未读的状态标记。本章节我们将在项目中集成gorm和mysql完成消息的持久化。

创建DB和Table

在mysql中创建DBgo_im

sql 复制代码
CREATE DATABASE go_im DEFAULT CHARACTER SET = 'utf8mb4';

在go_im库下创建表instant_message

sql 复制代码
CREATE TABLE `instant_message` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'Primary Key',
  `from_user_id` bigint(20) NOT NULL COMMENT 'message send by',
  `to_user_id` bigint(20) NOT NULL COMMENT 'message send to',
  `type` varchar(20) NOT NULL COMMENT 'message type',
  `read` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'message has been read or not',
  `send_time` datetime(6) DEFAULT NULL COMMENT 'time when message send',
  `read_time` datetime(6) DEFAULT NULL COMMENT 'time when message be read',
  `data` text NOT NULL COMMENT 'message content',
  PRIMARY KEY (`id`),
  KEY `idx_fid` (`from_user_id`),
  KEY `idx_tid` (`to_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天消息记录表';

集成gorm

在项目中安装gorm和mysql-driver依赖

shell 复制代码
go get -u gorm.io/gorm
go get -u gorm.io/driver/mysql

修改Message结构体

go 复制代码
type Message struct {
    ID         int64 `gorm:"primarykey"`
    Type       string
    FromUserID int64
    ToUserID   int64
    Read       bool
    SendTime   *time.Time
    ReadTime   *time.Time
    Data       string
}

// 通过为结构体添加 TableName 字段来指定表名
func (Message) TableName() string {
    return "instant_message"
}

// gorm钩子函数,insert之前触发
func (m *Message) BeforeCreate(tx *gorm.DB) error {
    // 自动填充发送时间
    if m.SendTime == nil {
        now := time.Now()
        m.SendTime = &now
    }
    return nil
}

在src目录下创建repo文件夹,在repo下新建db.go, 创建DB操作工具

go 复制代码
package repo

import (
    "database/sql"
    "fmt"
    "time"

    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

var DatabaseOps *gorm.DB = Connect(DataSource{
    Host:     "localhost",
    Port:     3306,
    Database: "go_im",
    User:     "root",
    Password: "12345678",
})

type DataSource struct {
    Host     string
    Port     int
    Database string
    User     string
    Password string
}

func Connect(ds DataSource) *gorm.DB {
    var (
        db    *gorm.DB
        sqlDB *sql.DB
        err   error
    )
    dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
            ds.User, ds.Password, ds.Host, ds.Port, ds.Database)
    db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        panic(err)
    }
    sqlDB, err = db.DB()
    if err != nil {
        panic(err)
    }
    // SetMaxIdleConns 设置空闲连接池中连接的最大数量
    sqlDB.SetConnMaxIdleTime(10)
    // SetMaxOpenConns 设置打开数据库连接的最大数量
    sqlDB.SetMaxOpenConns(100)
    // SetConnMaxLifetime 设置了连接可复用的最大时间
    sqlDB.SetConnMaxLifetime(time.Hour)
    return db
}

在repo文件夹下创建msg_repo.go,与聊天记录表相关方法放到该文件中

go 复制代码
package repo

import (
    "aoki.com/go-im/src/model"
)

type MessageRepo struct {
}

var MessageRepoOps = MessageRepo{}

func (MessageRepo) Create(m *model.Message) {
    DatabaseOps.Create(m)
}

保存消息 & 测试

修改LocalSenderSend方法

go 复制代码
func (sender *LocalSender) Send(msg *model.Message) error {
    // 保存数据库
    repo.MessageRepoOps.Create(msg)
    conn := GetConnManager().FindConn(msg.ToUserID)
    if conn == nil {
        return fmt.Errorf("%v does not online", msg.ToUserID)
    }
    return conn.WriteJSON(msg)
}

分别在两个Terminal中启动server和client,在client中使用用户1001给1002发送消息,此时1002用户并不在线,可以看到数据库中有记录了

获取未读消息

MessageRepo中添加获取未读消息的方法

go 复制代码
// 查询用户未读消息
func (MessageRepo) FindUnRead(userID int64) []*model.Message {
    messages := make([]*model.Message, 0)
    DatabaseOps.Where("to_user_id = ? AND `read` = 0", userID).Find(&messages)
    return messages
}

LocalSender中添加方法SendUnRead,将未读消息通过WebSocket发送

go 复制代码
func (LocalSender) SendUnRead(userID int64, conn *websocket.Conn) error {
    // 获取当前用户未读的消息
    messages := repo.MessageRepoOps.FindUnRead(userID)
    if len(messages) == 0 {
        return nil
    }
    log.Printf("Send unread to %d", userID)
    var err error
    for _, msg := range messages {
        err = conn.WriteJSON(msg)
    }
    return err
}

修改server.go中的ws方法,当用户WebSocket连接成功时,即获取未读消息并返回

go 复制代码
func ws(c *gin.Context) {
    ... // 省略
    ConnManager.AddConn(user.ID, conn)
    // 将用户的未读消息发回去
    log.Printf("%s connected... \n", user.Name)
    defer OnDisconnect(*user)
    err = Sender.SendUnRead(user.ID, conn)
    if err != nil {
        log.Printf("Send Unread to %s failed... \n", user.Name, err)
    }
    ... // 省略
}

重新启动server和client,再另新开一个Terminal,使用用户1002启动client,然后可以在Terminal中可以看到收到了之前1001发过来的消息

标记消息已读

当我们再次登录1002用户,会发现会再次读取到那条消息,已经收到的消息不应该再次被读取,所以当client收到消息时应该告知server端消息已读,server端此时将消息状态改为已读,下次就不会推送给client端了。

MessageRepo中添加标记已读的方法

go 复制代码
// 标记已读
func (MessageRepo) MarkRead(m *model.Message) {
    now := time.Now()
    DatabaseOps.Model(&m).Updates(model.Message{Read: true, ReadTime: &now})
}

在server端添加标记已读的http接口, 修改server.go

go 复制代码
func markRead(ctx *gin.Context) {
    user := model.ResolveUser(ctx.Request)
    if user == nil {
        ctx.AbortWithError(http.StatusUnauthorized,
                errors.New("ResolveUser failed..."))
        return
    }
    var message model.Message
    // 将请求的JSON绑定到Message结构体中
    if err := ctx.ShouldBindJSON(&message); err != nil {
        ctx.AbortWithError(http.StatusBadRequest, err)
        return
    }
    repo.MessageRepoOps.MarkRead(&message)
    ctx.JSON(http.StatusOK, gin.H{"message": "mark read success"})
}

func main() {
    server := gin.Default()
    server.GET("/ws", ws).POST("/markRead", markRead)
    server.Run("localhost:8848")
}

修改client.go, 当收到消息时调用/markRead接口

go 复制代码
func markMsgRead(m model.Message, token string) (err error) {
    var (
        bodyBytes []byte
        req       *http.Request
        resp      *http.Response
    )
    bodyBytes, err = json.Marshal(m)
    if err != nil {
        return err
    }
    // 创建一个请求体,这里使用的是JSON格式的数据
    body := bytes.NewBufferString(string(bodyBytes))
    req, err = http.NewRequest("POST", "http://localhost:8848/markRead", body)
    if err != nil {
        return
    }
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("X-TOKEN", token)
    // 使用http.DefaultClient.Do方法来发送请求
    resp, err = http.DefaultClient.Do(req)
    if err != nil {
        return
    }
    defer resp.Body.Close()
    // 读取响应体
    bodyBytes, err = io.ReadAll(resp.Body)
    if err != nil {
        return
    }
    log.Printf("markRead resp: %s \n", string(bodyBytes))
    return
}

func main() {
    ... // 省略
    go func() {
        for {
            m := &model.Message{}
            err = conn.ReadJSON(m)
            if err != nil {
                log.Println("Read WS message failed:", err)
                return
            }
            log.Printf("Received Message %v From %v \n", m.Data, m.FromUserID)
            err = markMsgRead(*m, token)
            if err != nil {
                log.Println("MarkRead failed:", err)
            }
        }
    }()
    .... // 省略
}

交互调试

再次用2个Terminal分别启动server和client(登录1002用户),此时收到消息后会立即标记消息已读,重启client端后也不会再收到已读的消息了。

小结

本章节中我们通过gorm来实现了go连接mysql数据库并执行insert、select、update操作,结合业务场景实现登录消息持久化、读取未读消息、标记消息已读功能。完成这些功能后已经完成了IM服务的最基本功能诉求,但是离高可用还差很多,下一章节我们将探讨如果server端宕机/重启的场景该如何处理。

相关推荐
拾伍廿肆1 天前
python - websocket
python·websocket·django
Sun_Sherry2 天前
FastAPI: websocket的用法及举例
websocket·网络协议·fastapi
貂蝉空大2 天前
uni-app 封装websocket 心跳检测,开箱即用
websocket·网络协议·uni-app
白鹭float.2 天前
【Unity AI】基于 WebSocket 和 讯飞星火大模型
人工智能·websocket·unity
滔滔不绝tao3 天前
五子棋双人对战项目(4)——匹配模块(解读代码)
spring boot·websocket
极客小张3 天前
智能教室云平台管理系统:基于Spring Boot、WebSocket与传感器的设计方案
c语言·spring boot·后端·stm32·物联网·websocket·毕业设计
IT小白34 天前
使用 Node.js 创建一个 WebSocket 服务器
websocket·node.js
Jiaberrr5 天前
解锁微信小程序新技能:ECharts动态折线图搭配WebSocket,数据刷新快人一步!
前端·javascript·websocket·微信小程序·echarts
沥川同学6 天前
计算机网络自顶向下(2)----socket编程
linux·网络·websocket·tcp/ip·计算机网络·udp
浩水浮生7 天前
redis 的发布订阅解决分布式下的websocket session 共享问题
redis·分布式·websocket