前言
上一章节我们使用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)
}
保存消息 & 测试
修改LocalSender
的Send
方法
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端宕机/重启的场景该如何处理。