IM|im-service

文章目录

im通信服务构建思路

  • 在im通讯服务中的业务主要为:
    • 私聊:好友之间的聊天
    • 群聊:群集合的聊天
    • 消息已读未读
    • 用户在线离线
    • 历史与离线消息
  • im-api:提供历史消息、好友在线状态、创建会话等功能。
  • im-rpc:集中处理消息,会话等功能。
  • im-ws:用于实现用户聊天功能。

websocket协议

  • WebSocket 是一种 基于 TCP 的通信协议,用于在 Web 应用中实现实时、双向通信。
  • 传统 HTTP 请求是 客户端发起 → 服务器响应 → 连接关闭 的模式,而 WebSocket 则是 客户端和服务器建立一个持久连接,之后可以 双向自由传输数据,不需要每次都重新建立连接。
特点 说明
双向通信 客户端可以发送消息给服务器,服务器也可以主动推送消息给客户端。
实时性强 建立连接后消息几乎是即时传输,延迟比轮询低很多。
连接持久 建立一次连接后可以反复通信,不需要每次请求都建立新 TCP 连接。
支持浏览器 可以在现代浏览器中直接使用 JavaScript API 使用 WebSocket。
简单易用 客户端只需要调用 WebSocket(url) 创建连接,然后使用 send / onmessage 进行通信。
方式 建立连接 双向通信 实时性 使用场景
HTTP 每次请求都建立连接 不支持,服务器不能主动推送 较低,需要轮询 普通请求-响应
WebSocket 建立一次连接,长期存在 支持双向 IM 聊天、游戏、实时数据更新
  • 注意:虽然HTTP/2也具备服务器推送功能,但HTTP/2 只能推送静态资源,无法推送指定的信息。
  • gorilla/websocket:这是一个流行的第三方包,提供了完整的 WebSocket 实现,包括客户端和服务器端的功能。它支持标准的 WebSocket 协议,并提供了方便的API来处理消息的读取和写入,以及处理连接的升级和关闭等操作。
  • 我们可以依据库提供的示例代码可以构建出我们大致的 im 服务代码
go 复制代码
import (
	"fmt"
	"log"
	"net/http"
	"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{} // use default options

func serverWs(w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Print("upgrade:", err)
		return
	}
	defer conn.Close()
	for {
		mt, message, err := conn.ReadMessage()
		if err != nil {
			log.Println("read:", err)
			break
		}
		log.Printf("recv: %s", message)
		err = conn.WriteMessage(mt, message)
		if err != nil {
			log.Println("write:", err)
			break
		}
	}
}

func main() {
	http.HandleFunc("/ws", serverWs)
	fmt.Println("启动websocket")
	log.Fatal(http.ListenAndServe("0.0.0.0:1234", nil))
}

im服务业务

消息存哪里?

方案 优点 缺点 适用场景
方案1:客户端缓存 服务端压力小,实现简单 消息不同步,历史消息易丢失 社交娱乐、非关键消息
方案2:客户端 + 服务端存储 消息跨设备同步,支持已读未读,支持撤回/删除 服务端压力大,实现复杂 办公、企业 IM、工作群、重要消息
  • 对于生产级 IM 系统,方案2 更符合长期发展需求。在线用户直接推送消息,离线用户消息存储到服务端,用户上线时拉取或推送。服务端保存每条消息对应的已读列表(尤其群聊需要每个成员状态)
  • 方案1 可以作为轻量级实现,快速上线验证产品功能。
  • 实际项目中,可以动态配置存储策略,初期用方案1,业务增长后逐步过渡到方案2。

存储引擎的选择?

  • MySQL:是关系型数据库,适合处理结构化的数据,有良好的事务处理能力,对数据聚合统计有较好的处理,但在处理大量数据时可能会受到性能瓶颈的限制,分库分表的处理会较为麻烦。
  • MongoDB:是一种面向文档的NoSQL数据库,适合存储非结构化或半结构化的数据。在处理较大数据量的时候具有更好的可扩展性和高可用性。
  • 如果需要复杂的查询和统计功能,且数据量较小,可以选择关系型数据库(如MySQL);如果需要更灵活的查询和索引功能,且数据量较大,可以选择文档型数据库(如MongoDB)。
  • 为什么选择 MongoDB?
    • 消息数据量大:IM 系统每天产生大量消息,MongoDB 的水平扩展能力更适合高写入场景。
    • 数据结构灵活:消息类型多样(文本、图片、表情、系统通知等),MongoDB 文档模型方便存储不同类型消息。
    • 历史消息查询高效:可以对 user_id, group_id, timestamp 建索引,实现分页拉取历史消息。
    • 可应对业务发展:随着业务增长,用户数和消息量增加,MongoDB 可通过 Sharding 扩展存储容量。

proto文件和api文件

go 复制代码
// proto
syntax = "proto3";

package im;

option go_package = "./im";


message Request {
  string ping = 1;
}

message Response {
  string pong = 1;
}

service Im {
  rpc Ping(Request) returns (Response);
} 

// api
syntax = "v1"

info (
	title: "用户服务的实例对象"
	author: "木兮老师"
)

// -------------- im api v1 --------------


// 生成代码
goctl model mongo --type chatLog --dir ./apps/im/models/

im集成websocket服务

封装 WebSocket 服务器实例

go 复制代码
// WebSocket 服务器实例

type Server struct {
	// 服务器监听地址
    addr string 
    // websocket.Upgrader 用于把 HTTP 连接升级为 WebSocket 连接
    upgrader websocket.Upgrader
    // 日志组件,用于打印日志(logx 是一个常用日志库)
    logx.Logger
}

func NewServer(addr string) *Server {
  return &Server{
    addr: addr,
    upgrader:       websocket.Upgrader{},
    // 初始化一个带 context 的日志记录器
    Logger:         logx.WithContext(context.Background()),
  }
}

// 接收 HTTP 请求
// 使用 s.upgrader.Upgrade(w, r, nil) 升级成 WebSocket
// 处理消息收发
func (s *Server) ServerWs(w http.ResponseWriter, r *http.Request) {
	// defer 用于 延迟执行函数,会在 当前函数返回之前执行
	// 无论函数正常返回还是因为 panic 异常退出,这段匿名函数都会执行
	defer func() {
		// Go 中的 panic 捕获和恢复机制,用于在 WebSocket 处理或其他关键逻辑中防止整个程序因为单个错误崩溃。
		if r := recover(); r != nil {
			s.Errorf("server handler ws recover err %v", r)
		}
	}()
	
	_, err := s.upgrader.Upgrade(w, r, nil)
	if err != nil {
		s.Error("upgrade http conn err", err)
		return
	}

	// 添加连接记录,会有并发问题
    // todo:读取信息,完成请求,还需建立连接
}


func (s *Server) Start() {
	// 将 /ws 路径绑定到 ServerWs 处理函数
	http.HandleFunc("/ws", s.ServerWs)
	// 启动 HTTP 服务,监听 s.addr
	http.ListenAndServe(s.addr, nil)
}

func (s *Server) Stop() {
	fmt.Println("stop server")
}
  • 当浏览器通过 new WebSocket("ws://server/ws?token=xxx") 发起连接时,会先发送一个带有 Upgrade: websocket 头的 HTTP 请求;服务端接收到后先进行鉴权(验证 token 是否合法),若通过,则使用 upgrader.Upgrade() 将 HTTP 连接升级为 WebSocket 协议,返回 101 Switching Protocols 响应;从此客户端与服务端之间建立起一个持久的全双工通道,双方可以随时通过该连接进行实时消息的收发,而无需再重新建立 HTTP 请求。

创建服务并启动项目

go 复制代码
// 用于指定程序启动时的配置文件路径
var configFile = flag.String("f", "etc/dev/im.yaml", "the config file")

func main() {
	// 解析命令行参数
	flag.Parse()

	// 配置加载
	var c config.Config
	conf.MustLoad(*configFile, &c)

	if err := c.SetUp(); err != nil {
		panic(err)
	}

	// 创建 WebSocket 服务器实例,监听 c.ListenOn 地址
	srv := server.NewServer(c.ListenOn)
	defer srv.Stop()

	// 服务上下文初始化
	svc.NewServiceContext(c)
    // todo: 待处理

	fmt.Printf("Starting websocket server at %v ...\n", c.ListenOn)
	// 启动服务器
	// 将 /ws 路径绑定到 WebSocket 处理函数 s.ServerWs,并启动 HTTP 服务开始监听
	srv.Start()
}
  • 每次有人访问 /ws,就会启动一个 WebSocket 连接,并由 ServerWs 负责具体处理这个连接的消息。
  • ServerWs 的作用:
    • 升级 HTTP 连接为 WebSocket(使用 s.upgrader.Upgrade)
    • 建立持久双向连接
    • 进入消息收发循环:
    • ReadMessage 读取客户端发送的消息
    • WriteMessage 给客户端发送消息
    • 处理连接关闭和异常

websocket请求消息体

  • 定义 websocket 请求消息体数据结构,用来在客户端和服务器之间传递消息。
go 复制代码
// websocket 请求消息体数据结构
type Message struct {
	// 消息类型或操作类型
	Method    string      `json:"method,omitempty"`
	// 消息发送者的用户 ID
	UserId    string      `json:"userId,omitempty"`
	// 消息接收者的用户 ID(好友或群)
	FormId    string      `json:"formId,omitempty"`
	// 消息内容,可以是字符串、JSON 对象或任意结构体,支持灵活的数据格式
	Data      interface{} `json:"data,omitempty"`
}
  • 确定请求的消息体后,路由就可以根据 request 中的 method 属性获取指定,但在这之前我们需确定执行的方法格式。

websocket消息路由

  • 接下来我们定义 WebSocket 消息路由机制的基本结构
go 复制代码
// WebSocket 消息路由机制的基本结构
type Route struct {
	Method  string
	Handler HandlerFunc
}

// 定义了一个函数签名类型,用于处理某类 WebSocket 消息
type HandlerFunc func(srv *Server, conn *websocket.Conn, msg *Message)
  • 每一个 消息类型(Method) 对应一个 处理函数(Handler)。

  • 有了路由后,在服务中完成消息参数解析并调用路由。
go 复制代码
type Server struct {
	// 内嵌读写锁,保证并发安全,比如操作连接或路由表时
	sync.RWMutex
	// 消息路由表,Method → 对应的处理函数
    routes         map[string]HandlerFunc
	addr           string
	upgrader websocket.Upgrader
	logx.Logger
}

func NewServer(addr string) *Server {
	return &Server{
		routes:         make(map[string]HandlerFunc),
		Logger:         logx.WithContext(context.Background()),
		addr:           addr,
		upgrader:       websocket.Upgrader{},
	}
}

func (s *Server) ServerWs(w http.ResponseWriter, r *http.Request) {
	defer func() {
		if r := recover(); r != nil {
			s.Errorf("server handler ws recover err %v", r)
		}
	}()
	conn, err := s.upgrader.Upgrade(w, r, nil)
	if err != nil {
		s.Error("upgrade http conn err", err)
		return
	}
	
	// 启动一个新协程 go s.handlerConn(conn) 来处理消息
	go s.handlerConn(conn)
}

func (s *Server) handlerConn(conn *websocket.Conn) {
	// 记录连接
	// WebSocket 服务的"主循环",负责持续接收消息并按路由分发
	for {
		_, msg, err := conn.ReadMessage()
		if err != nil {
			s.Errorf("websocket conn readMessage err %v, user Id %s", err, "")
			// 关闭并删除连接
			s.Close(conn)
			return
		}
		// 请求信息
		var message Message
		json.Unmarshal(msg, &message)
		// 处理
		if handler, ok := s.routes[message.Method]; ok {
			handler(s, conn, &message)
		} else {
			conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("不存在请求方法 %v 请仔细检查", message.Method)))
		}
	}
}

func (s *Server) AddRoutes(rs []Route) {
	for _, r := range rs {
		s.routes[r.Method] = r.Handler
	}
}

// 客户端 ---> HTTP /ws ---> ServerWs()
//                          ↓
//                    upgrader.Upgrade()
//                          ↓
//                     handlerConn()
//                          ↓
//          ReadMessage() → Unmarshal → 找 handler
//                          ↓
//                    调用对应业务逻辑

连接鉴权

  • 用户在使用im聊天的时候服务也需对用户的访问进行鉴权,因此在服务的核心代码中理应在建立连接前对用户进行鉴权处理,而处理鉴权以外还需考虑当前连接是属于那个用户建立的连接,但值得注意的是websocket在连接连接的时候是使用http协议,后续则不会采用http而是以建立连接后的通道发送信息并保持长连接的状态。

  • IM 系统中 WebSocket 连接与用户鉴权的关系与实现逻辑:IM 聊天服务中必须有用户身份校验。

  • 当客户端(比如前端 App)连接 IM 服务时,服务端必须验证这个连接确实是"合法用户发起的",这就叫连接鉴权(Authentication)。

  • WebSocket 连接是通过 HTTP 发起的,一旦服务端通过了校验,执行:upgrader.Upgrade(w, r, nil),HTTP 协议就会"升级"为 WebSocket 协议,之后这条连接变成 全双工长连接,不会再走 HTTP 请求/响应流程。

  • 后续消息(Text/Binary)都是直接通过这个连接通道传输的,再也不会附带 HTTP Header、Token 等鉴权信息。所以必须在升级前就验证用户身份。

  • 容易有疑问的点:go-zero也有 auth 中间件鉴权呀,既然通过了为啥 websocket 还需要鉴权?

  • WebSocket 并不走 GoZero 的中间件链!

  • GoZero 的鉴权中间件只对 HTTP 生效,GoZero 的中间件机制(包括 auth 鉴权)是为 HTTP 路由服务的。只有在走 HTTP 请求时,这些 API 才会触发 GoZero 的中间件链,包括 JWT 鉴权、日志、限流等。

  • WebSocket 协议不走 GoZero 的 HTTP Handler!WebSocket 的连接流程虽然"以 HTTP 请求开始",但升级之后,这条连接就脱离了 HTTP 路由体系。

  • http.HandleFunc("/ws", s.ServerWs) 不走 GoZero 的中间件逻辑,没有经过 GoZero 的封装层。GoZero 所有的中间件(auth, recovery, prometheus, log 等)都只对 通过 rest.Server 注册的路由 生效,server := rest.MustNewServer(c.RestConf)

  • WebSocket 只在连接时经过一次 HTTP 请求,不会走 GoZero 的中间件链,所以必须在 Upgrade 之前手动进行鉴权,确保连接合法。

  • 可能会疑问:既然能访问 websocket 了,那肯定是已经登陆过,鉴权过了,为什么还需要额外鉴权?HTTP 登录成功 ≠ WebSocket 连接自动继承登录状态!HTTP 登录的安全上下文是每次请求重新校验的。WebSocket 是"一次性升级"的长连接。

问题 结论
/ws 会走 GoZero 的中间件吗? 不会。它注册在标准库,不经过 GoZero 的中间件链
为什么还要鉴权? 因为 WebSocket 连接只在 Upgrade 时校验一次,不会自动继承 HTTP 登录态
登录用户不是已经通过了 JWT 吗? 是的,但那个验证只发生在 HTTP 请求中,WebSocket 必须再验证一次
正确做法 ServerWs 中手动校验 token 并绑定 userId

  • 针对鉴权我们需提供两个方法:

    1. 鉴权
    2. 获取用户id
  • 在之前的 ServerWs 实现中,我们提到:当用户通过 /ws 建立连接时,服务端需要知道"这个连接是谁的"。因此要做鉴权,比如从:

    • Header 中获取 JWT;
    • Query 参数中获取 Token;
    • Cookie 中获取 Session;
    • 甚至从 URL path 中获取 userId。
  • 然而在实际项目中,鉴权逻辑可能会根据部署环境或业务演进而变化。

场景 鉴权方式
Web 前端 Token 放在 URL query 参数
App 端 Token 放在 Header
内网服务调用 签名+时间戳校验
  • 所以如果把所有鉴权逻辑都直接写死在 ServerWs 函数里,将来要改动就非常麻烦。为了解决这个问题,我们就会抽象出一个统一的接口 Authentication
  • Authentication 接口的作用:简单来说,它定义了"如何从请求中识别用户身份"的统一规则。
go 复制代码
// IM 服务只要实现了这个接口,就可以灵活替换不同的鉴权逻辑(比如用 JWT、Session、Redis Token 验证等)
type Authentication interface {
	// 鉴权函数:用来判断当前用户是否有权限建立 WebSocket 连接
	// 返回 true 表示通过,false 表示拒绝
	Auth(s *Server, w http.ResponseWriter, r *http.Request) bool
	// 提取当前请求的用户 ID(可能来自 URL 参数、请求头、JWT Token 等)
	UserId(r *http.Request) string
}

type authentication struct{}

func (*authentication) Auth(s *Server, w http.ResponseWriter, r *http.Request) bool {
	return true
}

func (*authentication) UserId(r *http.Request) string {
	// 尝试从请求 URL 参数中取出 userId
	// ws://127.0.0.1:8080/ws?userId=1001
	query := r.URL.Query()
	if query != nil && query["userId"] != nil {
		return fmt.Sprintf("%v", query["userId"])
	}
	return fmt.Sprintf("%v", time.Now().UnixMilli())
}
  • 因为 WebSocket 的建立过程如下:HTTP 握手阶段 → 鉴权 → 升级为 WS → 长连接通信
  • GoZero 的 Auth 中间件主要拦截 HTTP 接口请求(即 HTTP handler),但 WebSocket 一旦 Upgrade 成功,后续通信就 不再经过 HTTP 层,也就 不再经过 gozero 的鉴权中间件。
  • 所以:WebSocket 自己必须实现一套独立的鉴权逻辑。这也是为什么这里要定义一个独立的 Authentication 接口。
  • http.HandleFunc("/ws", ...) 注册的接口,根本没走 GoZero 的中间件系统,
    所以它不会自动鉴权。要自己在握手阶段(ServerWs)里实现认证逻辑。

  • Authentication 对 server 而言可以理解为是一种操作,可以有也可以没有,因此考虑到扩展和设置我们也可以通过 Option 的方式进行设置。
  • 不同场景下,WebSocket 鉴权的方式可能不一样,例如:
    • 开发环境:不鉴权,方便调试;
    • 测试环境:通过 URL 参数鉴权;
    • 生产环境:通过 JWT Token 鉴权;
    • 甚至有些场景下(比如系统内部通信),可能根本就不需要鉴权。
  • 因此,如果在 Server 结构体中 强制固定一种鉴权方式(例如写死 JWT 校验),就会让扩展非常不灵活。
  • Option 模式是 Go 常用的灵活配置方式,比如我们可以给 NewServer 提供一组函数参数,动态配置不同功能:
go 复制代码
type Option func(s *Server)

func WithAuth(auth Authentication) Option {
    return func(s *Server) {
        s.auth = auth
    }
}
  • 这样 NewServer 就能写成:
go 复制代码
func NewServer(addr string, opts ...Option) *Server {
    srv := &Server{
        addr: addr,
        upgrader: websocket.Upgrader{},
        Logger: logx.WithContext(context.Background()),
        auth: &authentication{}, // 默认无鉴权
    }

    for _, opt := range opts {
        opt(srv)
    }

    return srv
}
  • 使用时你可以自由选择是否要鉴权:
    • 不需要鉴权:srv := NewServer(":8080")
    • 使用自定义鉴权:srv := NewServer(":8080", WithAuth(&JwtAuth{}))
特点 说明
灵活扩展 可以轻松更换鉴权策略,不改核心逻辑
解耦 Server 不依赖任何具体的鉴权实现
默认可用 即使不设置鉴权,Server 也能正常运行
清晰职责 鉴权只负责身份验证,不干涉连接和消息逻辑
  • 把 Authentication 设计成接口 + Option 传入,是为了让 Server 具备"可插拔式"的鉴权能力:想鉴权就加,不想鉴权就用默认实现。
  • 这是一种非常标准、优雅的 Go 设计模式。
go 复制代码
package server

type Options func(opt *option)

type option struct {
	Authentication
	pattern string
}

func newOption(opts ...Options) option {
	o := option{
		Authentication: new(authentication),
		pattern:        "/ws",
	}

	for _, opt := range opts {
		opt(&o)
	}

	return o
}

func WithAuthentication(authentication Authentication) Options {
	return func(opt *option) {
		opt.Authentication = authentication
	}
}

func WithHandlerPatten(pattern string) Options {
	return func(opt *option) {
		opt.pattern = pattern
	}
}
  • 除了 Authentication 外对请求的地址也可以通过 Option 设置,修改 NewServer 方法,然后再修改 server 的核心处理做调整。
go 复制代码
type Server struct {
    // ..
	opt option

}

func NewServer(addr string, opts ...Options) *Server {
	opt := newOption(opts...)

	return &Server{
		// ..
		opt: opt,
	}
}

func (s *Server) Start() {
	http.HandleFunc(s.opt.pattern, s.ServerWs)
	http.ListenAndServe(s.addr, nil)
}

func (s *Server) ServerWs(w http.ResponseWriter, r *http.Request) {
	defer func() {
		if r := recover(); r != nil {
			s.Errorf("server handler ws recover err %v", r)
		}
	}()

	if !s.authentication.Auth(s, w, r) { // 使用 Option 中的鉴权逻辑
		s.Info("authentication failed")
		return
	}

	conn, err := s.upgrader.Upgrade(w, r, nil)
	if err != nil {
		s.Error("upgrade http conn err", err)
		return
	}

	// 添加连接记录,会有并发问题
	s.addConn(conn, r)
	// 读取信息,完成请求,还需建立连接
	go s.handlerConn(conn)
}

连接存储

  • 在第一次请求的时候做完用户鉴权后,创建的连接即就是该用户的连接,因此在内容中还需要针对用户id与连接设置关联关系,以便于项目后续的应用。
  • 在 server 中通过 connToUseruserToConn 记录用户标识与连接的关联关系,采取 map 的方式存储,注意因为连接是可能存在多个情况因此会有并发可能顾在程序 server 中还添加了 sync.RWMutex 机制用于实现并发安全。
go 复制代码
type Server struct {
	sync.RWMutex

	authentication Authentication
	routes         map[string]HandlerFunc
	addr           string

	connToUser map[*websocket.Conn]string
	userToConn map[string]*websocket.Conn

	upgrader websocket.Upgrader
	logx.Logger
}

func NewServer(addr string) *Server {

	return &Server{
        addr:           addr,
      
		authentication: opt.Authentication,
		
		Logger:         logx.WithContext(context.Background()),
        routes:         make(map[string]HandlerFunc),
		connToUser:     make(map[*websocket.Conn]string),
		userToConn:     make(map[string]*websocket.Conn),
		
		upgrader:       websocket.Upgrader{},
	}
}
go 复制代码
// 添加锁和map之后,完善后续代码逻辑

func (s *Server) ServerWs(w http.ResponseWriter, r *http.Request) {
	defer func() {
		if r := recover(); r != nil {
			s.Errorf("server handler ws recover err %v", r)
		}
	}()

	conn, err := s.upgrader.Upgrade(w, r, nil)
	if err != nil {
		s.Error("upgrade http conn err", err)
		return
	}

	// 添加连接记录,会有并发问题
	s.addConn(conn, r)
	// 读取信息,完成请求,还需建立连接
	go s.handlerConn(conn)
}

func (s *Server) addConn(conn *websocket.Conn, req *http.Request) {
	// 此处是map的写操作,在操作上会存在并发的可能问题
	uid := s.authentication.UserId(req)

	s.RWMutex.Lock()
	defer s.RWMutex.Unlock()

	s.connToUser[conn] = uid
	s.userToConn[uid] = conn
}

// 再增加获取根据连接获取用户及根据用户获取连接的操作

func (s *Server) GetConn(uid string) *Conn {
	s.RWMutex.RLock()
	defer s.RWMutex.RUnlock()

	return s.userToConn[uid]
}

func (s *Server) GetUsers(conns ...*websocket.Conn) []string {

	s.RWMutex.RLock()
	defer s.RWMutex.RUnlock()

	var res []string
	if len(conns) == 0 {
		// 获取全部
		res = make([]string, 0, len(s.connToUser))
		for _, uid := range s.connToUser {
			res = append(res, uid)
		}
	} else {
		// 获取部分
		res = make([]string, 0, len(conns))
		for _, conn := range conns {
			res = append(res, s.connToUser[conn])
		}
	}

	return res
}

func (s *Server) Close(conn *websocket.Conn) {
	conn.Close()

	s.RWMutex.Lock()
	defer s.RWMutex.Unlock()

	uid := s.connToUser[conn]
	delete(s.connToUser, conn)
	delete(s.userToConn, uid)
}

消息发送

  • 接下来介绍下:WebSocket 服务层与业务层(应用层)的职责划分,以及在 IM(即时通讯)系统中消息发送的本质逻辑。

  • 现在的代码主要是一个 WebSocket 服务层(Server),它封装了:

    • 用户连接管理;
    • 消息的收发;
    • 鉴权与路由;
    • 连接生命周期(建立、关闭)。
  • 但这个服务层 不负责业务逻辑(比如谁是好友、发给谁、存不存历史消息),而是作为一个底层的 消息通道。

  • 应用层(业务层)要实现的核心功能"

    • 私聊(点对点)→ A 发消息给 B → 即根据用户 ID 找到 B 的连接,然后发送消息
    • 广播(群聊)→ A 在群内发消息 → 服务根据群成员 ID 列表,找到每个成员的连接,逐个发送
  • 对于底层的 WebSocket 服务来说,只要能做到下面这两件事,应用层的所有消息通信需求都能实现:

    1. 根据用户 ID 集合发送消息;
    2. 根据连接集合发送消息。
  • 不论是私聊、群聊、系统通知,本质都是:通过用户 ID 找到连接,然后通过连接发送。

  • IM 聊天的本质就是:根据用户 ID 找到连接,再通过连接发送消息。

  • 在最底层,WebSocket 真正能通信的单位是连接(conn)。消息的真正发送,是基于 conn 的,而不是基于 userId 的。

  • 业务上你是"发给用户",实际上你是"找到用户对应的连接,然后通过连接发消息"。

go 复制代码
// 根据 userId 找出对应的连接 conn,再发送消息
func (s *Server) SendByUserId(msg interface{}, sendIds ...string) error {
	if len(sendIds) == 0 {
		return nil
	}

	return s.Send(msg, s.GetConn(sendIds...)...)
}

// 真正执行发送操作(底层通过 WebSocket 的连接对象发送)
func (s *Server) Send(msg interface{}, conns ...*websocket.Conn) error {
	if len(conns) == 0 {
		return nil
	}

	data, err := json.Marshal(msg)
	if err != nil {
		return err
	}

	for _, conn := range conns {
		if err = conn.WriteMessage(websocket.TextMessage, data); err != nil {
			return err
		}
	}
	return nil
}
go 复制代码
// 再给 WebSocket 消息结构体 Message 提供一个初始化方法
type Message struct {
	Method    string      `json:"method,omitempty"`
	UserId    string      `json:"userId,omitempty"`
	FormId    string      `json:"formId,omitempty"`
	Data      interface{} `json:"data,omitempty"`
}

// 创建一个新的消息对象,只填写 发送者ID (FormId) 和 消息内容 (Data),返回一个 *Message 指针
func NewMessage( fid string, data interface{}) *Message {
	return &Message{
		FormId:    fid,
		Data:      data,
	}
}

// 一个"chat"消息;来自 A;要发给 B;内容是 "你好呀!"
{
  "method": "chat",
  "userId": "B",
  "formId": "A",
  "data": "你好呀!"
}
  • 做一个消息发送的简单示例
go 复制代码
import (
	"github.com/gorilla/websocket"
	"imooc.com/easy-chat/apps/im/ws/internal/svc"
	websocketx "imooc.com/easy-chat/apps/im/ws/websocket"
)

// OnLine() 返回的是一个可以被注册到路由的事件处理函数 handler 
// 闭包(closure)+ 回调注册机制
// OnLine 只是一个"工厂函数",负责生成并返回一个带有 svc 环境上下文的 handler
func OnLine(svc *svc.ServiceContext) websocketx.HandlerFunc {
	return func(srv *websocketx.Server, conn *websocket.Conn, msg *websocketx.Message) {
		// 实时在线用户是维护在 map 里的,GetUsers 也是从 map 查看哪些用户在线
		// 一旦建立连接,就把用户ID与连接绑定到 map 里
		// 当连接断开或者出错时,会删除该用户
		uids := srv.GetUsers()
		// 根据连接对象获取当前用户对应的ID
		u := srv.GetUsers(conn)
		err := srv.Send(websocketx.NewMessage(u[0], uids), conn)
		srv.Info("err ", err)
	}
}

// 在注册 handler 时,你必须传入一个 ServiceContext
// handler := OnLine(svc)
// server.AddHandler("online", handler)
// OnLine 返回的函数里可以访问外部作用域的 svc
// 所以就是 svc 这里是参数,返回的函数里面可以使用这个类似局部变量的参数,而返回的函数的参数是我们要求别人调用这个函数所传入的参数

// 定义路由
import (
	"imooc.com/easy-chat/apps/im/ws/internal/handler/user"
	"imooc.com/easy-chat/apps/im/ws/internal/svc"
	"imooc.com/easy-chat/apps/im/ws/websocket"
)

func RegisterHandlers(srv *websocket.Server, svc *svc.ServiceContext) {
	srv.AddRoutes([]websocket.Route{
		{
			Method:  "user.online",
			Handler: user.OnLine(svc),
		},
	})
}

var configFile = flag.String("f", "etc/dev/im.yaml", "the config file")

func main() {
	flag.Parse()

	var c config.Config
	conf.MustLoad(*configFile, &c)

	if err := c.SetUp(); err != nil {
		panic(err)
	}

	srv := server.NewServer(c.ListenOn)
	defer srv.Stop()

	ctx := svc.NewServiceContext(c)
	handler.RegisterHandlers(srv, ctx)

	fmt.Printf("Starting websocket server at %v ...\n", c.ListenOn)
	srv.Start()
}

用户登入连接

过程分析

  • 首先用户会请求用户服务进行登入,在验证了用户的登入信息之后就会给用户返回一个授权token,用户依据授权的token进行后续的系统请求,同样用户也应用token去登入websocket服务,而websocket服务则也需对token进行验证在通过后建立好连接。
  • token的传输验证主要是在用户端发起的第一次http请求的时候进行验证的,在后续是构建好了长连接,此时就不需要再重复的发送token进行鉴权。
  • 在前面构建websocket服务的时候,我们是提供了一个接口用于处理用户鉴权及获取用户id的消息。因此自定义的验证方式则只需要实现这个接口,并基于option进行设置即可。
go 复制代码
type Authentication interface {
	Auth(w http.ResponseWriter, r *http.Request) bool
	UserId(r *http.Request) string
}

type authentication struct{}

func (*authentication) Auth(w http.ResponseWriter, r *http.Request) bool {
	return true
}

func (*authentication) UserId(r *http.Request) string {
	query := r.URL.Query()
	if query != nil && query["userId"] != nil {
		return fmt.Sprintf("%v", query["userId"])
	}

	return fmt.Sprintf("%v", time.Now().UnixMilli())
}

func WithAuthentication(authentication Authentication) Options {
	return func(opt *option) {
		opt.Authentication = authentication
	}
}
  • 接下来我们只需要自定义一个验证的对象,并实现好Authentication接口。
  • 但在接口中关于Auth方法中的内容怎么实现呢?目前已知用户是通过用户服务获取到登入授权的token,而用户服务是基于jwt生成的token,在api服务的验证中是直接通过go-zero的方式进行验证的。
  • 因此在自定义的验证机制对象中只需要依据go-zero的实现方式即可做好验证。

gozero对于jwt的配置和实现

  • 在 api.api 中定义了服务 server 并在其中添加 jwt:JwtAuth 的操作,在通过命令执行的时候会默认给生成的相关路由添加好 jwt 的认证中间件
go 复制代码
// api
@server(
	prefix: v1/user
	group: user
	jwt: JwtAuth
)
service user {
	@doc "获取用户信息"
	@handler detail
	get /detail (UserInfoReq) returns (UserInfoResp)
}

// 路由
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
	server.AddRoutes(
		[]rest.Route{
			{
				Method:  http.MethodGet,
				Path:    "/detail",
				Handler: user.DetailHandler(serverCtx),
			},
		},
		rest.WithJwt(serverCtx.Config.JwtAuth.AccessSecret),
		rest.WithPrefix("/v1/user"),
	)
}

// 使用的中间件组件功能信息内容
func Authorize(secret string, opts ...AuthorizeOption) func(http.Handler) http.Handler {
	var authOpts AuthorizeOptions
	for _, opt := range opts {
		opt(&authOpts)
	}

	parser := token.NewTokenParser()
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			tok, err := parser.ParseToken(r, secret, authOpts.PrevSecret)
			if err != nil {
				unauthorized(w, r, err, authOpts.Callback)
				return
			}

			if !tok.Valid {
				unauthorized(w, r, errInvalidToken, authOpts.Callback)
				return
			}

			claims, ok := tok.Claims.(jwt.MapClaims)
			if !ok {
				unauthorized(w, r, errNoClaims, authOpts.Callback)
				return
			}

			ctx := r.Context()
			for k, v := range claims {
				switch k {
				case jwtAudience, jwtExpire, jwtId, jwtIssueAt, jwtIssuer, jwtNotBefore, jwtSubject:
					// ignore the standard claims
				default:
					ctx = context.WithValue(ctx, k, v)
				}
			}

			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}
  • tokenParser:负责解析 JWT 并校验签名。
  • ParseToken:执行认证,返回 token 对象和 claims。
  • 中间件:统一管理认证失败逻辑,并把 claims 注入 context,保证下游 handler 能安全使用用户信息。
  • JWT 验证过程就是:创建解析器 → 解析 token → 校验合法性 → 注入上下文,而具体的业务处理逻辑可以根据 authHandler 的定义来完成。

具体实现

  • 根据上面的分析以及对gozero代码的学习,jwt 的核心验证代码实践如下:
go 复制代码
parser := token.NewTokenParser()
tok, err := parser.ParseToken(r, secret, authOpts.PrevSecret)
claims, ok := tok.Claims.(jwt.MapClaims)

// parser.ParseToken 的作用就是:
// 1、从请求中获取 token 字符串
// 2、用密钥解析并验证签名
// 3、校验标准字段(过期时间、生效时间等)
// 4、返回 token 对象和 claims,用于后续 handler 或中间件使用
// 换句话说,它就是把一个 HTTP 请求里的 token 变成一个可用的、已验证的用户身份对象。


// 以下为具体实现

type JwtAuto struct {
	srvCtx *svc.ServiceContext
	parser *token.TokenParser
}

func NewJwtAuto(srvCtx *svc.ServiceContext) *JwtAuto {
	return &JwtAuto{
		srvCtx: srvCtx,
		parser: token.NewTokenParser(),
	}
}

func (auto *JwtAuto) Auth(w http.ResponseWriter, r *http.Request) bool {
	tok, err := auto.parser.ParseToken(r, auto.srvCtx.Config.JwtAuth.AccessSecret, "")
	// 如果 token 格式错误、签名错误、结构不对,ParseToken 会直接返回一个 err,说明解析失败。
	if err != nil {
		return false
	}
	// JWT token 有标准字段 exp(过期时间)、nbf(生效时间)等。如果 token 解析成功,但 过期了或者还没生效:jwt.Parse 会把 token 对象的 Valid 设置为 false。
	if !tok.Valid {
		return false
	}
	claims, ok := tok.Claims.(jwt.MapClaims)
	if !ok {
		return false
	}
	
	// 每个 HTTP 请求 *http.Request 都有一个 Context,用来在请求生命周期中传递信息(如用户身份、超时、取消信号等)
	// r.Context() 获取当前请求的 Context 对象
	// WithContext 返回一个新的 Context,在原有 parentCtx 基础上增加一个键值对
	*r = *r.WithContext(context.WithValue(r.Context(), ctxdata.Identify, claims[ctxdata.Identify]))

	return true
}

func (*JwtAuto) UserId(r *http.Request) string {
	return ctxdata.GetUId(r.Context())
}
  • 在 Auth 方法中,主要负责对用户进行鉴权。鉴权通过后,可从 tok.Claims 中获取用户 ID,并将其存储到请求的 Context 中以便后续使用。而在 UserId 方法中,则直接从请求的 Context 中读取该用户 ID。
go 复制代码
func main() {
	flag.Parse()

	var c config.Config
	conf.MustLoad(*configFile, &c)

	if err := c.SetUp(); err != nil {
		panic(err)
	}

	ctx := svc.NewServiceContext(c)

	srv := server.NewServer(c.ListenOn, server.WithAuthentication(handler.NewJwtAuto(ctx)))
	defer srv.Stop()

	handler.RegisterHandlers(srv, ctx)

	fmt.Printf("Starting websocket server at %v ...\n", c.ListenOn)
	srv.Start()
}

心跳检测

为什么需要心跳检测

  • 在实时聊天过程中,如果没有心跳检测,会出现以下问题:
    • 连接超时断开:WebSocket 建立后,如果服务端和客户端长时间没有通信,服务端可能会主动断开连接。
    • 网络不可靠:连接可能因各种原因中断,例如客户端异常退出或服务端断开。
    • 资源浪费:如果客户端已断开,而服务端仍向该连接推送消息,会浪费系统资源。
    • 消息不同步:如果服务端断开连接,客户端将无法接收最新消息或通知。
  • 因此,通过心跳机制可以定期检测连接状态,及时发现异常,保证消息的可靠传输和系统资源的合理使用。

心跳检测实现分析

  • 心跳检测的主要作用可归纳为两个方面:保活(Keepalive)和死链检测(Dead Link Detection)。
  • 存活状态判定:服务端或客户端的存活状态可以通过通信活动判断:
    • 若在一定时间范围内存在正常的收发消息,则可认为双方均处于存活状态;
    • 若长时间没有通信,则可能存在连接异常或网络问题。
  • 死链判定:若在预设的时间窗口内未接收到任何消息或心跳包,可以认为目标端可能已经失效,即出现死链,应进行相应的连接关闭或重连处理。
  • 心跳收发设计
    • 通信间隔:客户端与服务端需保持固定时间间隔的通信,确保双方能够确认对方存活;
    • 主动与被动策略:以客户端主动发送心跳为主,服务端被动响应,以减少服务端的无效通信开销,提高系统性能。

  • grpc 在服务端中是以 keeplive 保持与客户端的长连接
  • 定时器:
    • idleTimer → 检测空闲,防止死链
    • ageTimer → 控制连接最大寿命,避免长期占用
    • kpTimer → 定期发送 Ping,确保连接活跃,并检测 Ping ACK
  • 关键属性:
    • idle:记录空闲时间,当有连接进入会将期设置为非0值表示为非空闲状态
    • kq:配置信息记录一个连接最大有效连接时长,以及最大空闲时长等信息
    • lastRead: 记录最后一次通信的时间
go 复制代码
// gRPC/http2 服务端 keepalive 逻辑
func (t *http2Server) keepalive() {
	p := &ping{}
	// True iff a ping has been sent, and no data has been received since then.
	outstandingPing := false
	// Amount of time remaining before which we should receive an ACK for the
	// last sent ping.
	kpTimeoutLeft := time.Duration(0)
	// Records the last value of t.lastRead before we go block on the timer.
	// This is required to check for read activity since then.
	prevNano := time.Now().UnixNano()
	// Initialize the different timers to their default values.
	idleTimer := time.NewTimer(t.kp.MaxConnectionIdle)
	ageTimer := time.NewTimer(t.kp.MaxConnectionAge)
	kpTimer := time.NewTimer(t.kp.Time)
	defer func() {
		// We need to drain the underlying channel in these timers after a call
		// to Stop(), only if we are interested in resetting them. Clearly we
		// are not interested in resetting them here.
		idleTimer.Stop()
		ageTimer.Stop()
		kpTimer.Stop()
	}()

	for {
		select {
		case <-idleTimer.C:
			t.mu.Lock()
			idle := t.idle
			if idle.IsZero() { // The connection is non-idle.
				t.mu.Unlock()
				idleTimer.Reset(t.kp.MaxConnectionIdle)
				continue
			}
			val := t.kp.MaxConnectionIdle - time.Since(idle)
			t.mu.Unlock()
			if val <= 0 {
				// The connection has been idle for a duration of keepalive.MaxConnectionIdle or more.
				// Gracefully close the connection.
				t.Drain("max_idle")
				return
			}
			idleTimer.Reset(val)
		case <-ageTimer.C:
			t.Drain("max_age")
			ageTimer.Reset(t.kp.MaxConnectionAgeGrace)
			select {
			case <-ageTimer.C:
				// Close the connection after grace period.
				if t.logger.V(logLevel) {
					t.logger.Infof("Closing server transport due to maximum connection age")
				}
				t.controlBuf.put(closeConnection{})
			case <-t.done:
			}
			return
		case <-kpTimer.C:
			lastRead := atomic.LoadInt64(&t.lastRead)
			if lastRead > prevNano {
				// There has been read activity since the last time we were
				// here. Setup the timer to fire at kp.Time seconds from
				// lastRead time and continue.
				outstandingPing = false
				kpTimer.Reset(time.Duration(lastRead) + t.kp.Time - time.Duration(time.Now().UnixNano()))
				prevNano = lastRead
				continue
			}
			if outstandingPing && kpTimeoutLeft <= 0 {
				t.Close(fmt.Errorf("keepalive ping not acked within timeout %s", t.kp.Time))
				return
			}
			if !outstandingPing {
				if channelz.IsOn() {
					atomic.AddInt64(&t.czData.kpCount, 1)
				}
				t.controlBuf.put(p)
				kpTimeoutLeft = t.kp.Timeout
				outstandingPing = true
			}
			// The amount of time to sleep here is the minimum of kp.Time and
			// timeoutLeft. This will ensure that we wait only for kp.Time
			// before sending out the next ping (for cases where the ping is
			// acked).
			sleepDuration := minTime(t.kp.Time, kpTimeoutLeft)
			kpTimeoutLeft -= sleepDuration
			kpTimer.Reset(sleepDuration)
		case <-t.done:
			return
		}
	}
}
  • idleTimer 定时器:在流程中会先验证当前是否处于 idle 状态,若是不在空闲状态则重置定时器进行下一次检测时间,如果存在则往后计算空闲时差是否超过最大约定时间,如果是则发送通知给客户端进行关闭连接。
  • ageTimer 定时器:当定时器触发时,连接不会立即关闭。系统会先向客户端发送关闭连接的通知,并启动一个延迟关闭的时间窗口(Grace Period)。随后,服务器通过选择器等待两种事件之一发生:客户端响应或延迟时间到期。
  • 这种设计的目的是避免直接关闭连接可能导致的任务中断或处理失败,同时为客户端提供缓冲时间完成未完成的请求,从而实现平滑关闭和安全资源释放。
  • kqTimer 定时器:主要检测一个连接在一定时间范围内是否有通信。kpTimer 主要用于 周期性发送 keepalive Ping。
  • 当定时器触发时,首先会获取连接的最后一次通信时间,并检查自上次通信以来是否有新的消息。如果存在新的通信,则重置定时器,等待下一轮检测;如果没有新的通信,则进入下一步处理。
  • 接下来,服务器会向客户端发送一个 Ping 消息,用于验证客户端是否存活。若客户端存活,会返回 ACK 确认,此时 outstandingPing 标记会被重置为 false。如果客户端未返回 ACK,则会根据超时判断是否关闭连接。outstandingPing:true 上一次发送的 Ping 还没有收到客户端的 ACK;outstandingPing:false 上一次 Ping 已经收到 ACK 或尚未发送新的 Ping。
  • 整个流程总结如下:
    • 检查是否有新通信 → 有则重置定时器,等待下一次检测;
    • 如果没有通信,判断上一次 Ping 是否已发送且是否超时 → 超时则关闭连接;
    • 若未超时,则发送新的 Ping,并重置定时器,进行下一轮检测。

实现流程

  • 心跳检测的本质是 双方定期检测连接是否存在通信,若通信中断,则按照约定规则发送心跳包或断开连接。具体流程如下:

    1. 客户端与服务端建立连接
    2. 消息通信更新状态
      • 客户端向服务端发送消息时,服务端会记录并更新双方的最后一次通信时间。
    3. 基于定时器进行检测
      • 当连接在一定时间内没有通信时,触发心跳检测逻辑:
        • 客户端:发送心跳包(Ping)到服务端

          • 如果收到回复(ACK),则连接正常
          • 如果未收到回复,则判定连接异常,需要重新建立连接
        • 服务端:若未接收到客户端的通信,则视为客户端异常断开,主动关闭连接

  • 注意事项:服务端的检测间隔应大于客户端的心跳间隔,否则可能导致频繁的连接建立和断开问题。

重新定义连接对象

  • 当前的连接对象以与客户端通信发送信息及接收信息为主,但心跳检测等机制需要额外扩展增加。
  • 在最初、未重构之前,根本没有自定义结构体,直接用第三方库(例如 gorilla/websocket)提供的 *websocket.Conn 对象来读写消息:
go 复制代码
func handler(w http.ResponseWriter, r *http.Request) {
    conn, _ := upgrader.Upgrade(w, r, nil)
    for {
        _, msg, err := conn.ReadMessage()
        if err != nil {
            break
        }
        fmt.Println(string(msg))
    }
}
  • 在设计上,通过嵌套原生的 *websocket.Conn 来保留其原有的通信能力,同时新增 maxConnectionIdleidledone 等属性以支持心跳检测机制。其中:
    • maxConnectionIdle 用于限制连接的最大空闲时长;
    • idle 记录连接的最后活跃时间;
    • done 是一个通道,用于检测客户端是否已关闭连接。
  • 这样的设计既保留了原有的 WebSocket 功能,又增强了连接的可管理性与稳定性。
go 复制代码
type Conn struct {

	// 内嵌 WebSocket 连接对象,提供底层读写(收发消息)的能力
	*websocket.Conn

	// 表示该连接所属的服务器实例,方便在连接中访问服务器的公共资源
	s *Server

	// 用于并发安全,保证对连接状态(如空闲时间等)的读写不会发生数据竞争
	mu                sync.Mutex

	// 记录该连接的最近一次活动时间,用于判断连接是否长时间空闲
	idle              time.Time

	// 定义允许的最大空闲时长,超过该时长则认为连接超时,需要断开
	maxConnectionIdle time.Duration

	// 用于通知连接关闭或结束,心跳检测协程可通过监听该通道安全退出
	done chan struct{}
}

// 默认属性
const (
	infinity = time.Duration(math.MaxInt64)
	defaultMaxConnectionIdle = infinity
)
  • 为 Conn 提供一个 New 方法,该方法接收 Server 实例以及 HTTP 请求与响应对象作为参数。在方法内部,通过调用 websocket.Upgrader 的 Upgrade 方法,将 HTTP 请求升级为 WebSocket 连接,并返回封装后的 Conn 实例。
  • 这样实现的目的是在创建连接时完成协议升级,并将新建的 WebSocket 连接与服务器对象关联起来,便于后续的通信与管理。
go 复制代码
func NewConn(s *Server, w http.ResponseWriter, r *http.Request) *Conn {

	c, err := s.upgrader.Upgrade(w, r, nil)
	if err != nil {
		s.Error("upgrade http conn err", err)
		return nil
	}

	conn := &Conn{
		Conn:              c,
		s:                 s,
		idle:              time.Now(),
		done:              make(chan struct{}),
		maxConnectionIdle: defaultMaxConnectionIdle,
	}

	return conn
}
  • 新增一个用于与客户端保持长连接的心跳检测机制方法。其中,对 idleTimer 的处理逻辑可以直接借鉴 gRPC 的实现方式,因为其机制已经能够很好地满足当前项目对连接存活验证的需求。
  • 若希望实现更为精细和优雅的连接保活策略,还可以进一步引入 kpTimer(keepalive 保活定时器)和 ageTimer(连接寿命定时器)进行扩展。
  • 实现了一个 WebSocket 长连接的心跳检测机制(keepalive),它的作用是:定期检查当前连接是否长时间处于空闲状态(即没有收到任何消息),如果超过设定的最大空闲时间 maxConnectionIdle,就主动关闭连接。
go 复制代码
// keepalive 方法用于保持与客户端的 WebSocket 长连接,
// 通过定时检测空闲状态和关闭信号,防止"死连接"或"僵尸连接"占用资源。
// 长连接检测机制
func (c *Conn) keepalive() {
	// 创建空闲检测定时器
	idleTimer := time.NewTimer(c.maxConnectionIdle)
	defer idleTimer.Stop()

	for {
		select {
		// 当空闲检测定时器触发时执行
		case <-idleTimer.C:
			c.mu.Lock()
			idle := c.idle // 读取最近活跃时间(受互斥锁保护)
			fmt.Printf("idle %v, maxIdle %v \n", idle, c.maxConnectionIdle)

			if idle.IsZero() {
				// 表示连接当前是非空闲的(可能刚建立还未活跃)
				c.mu.Unlock()
				idleTimer.Reset(c.maxConnectionIdle)
				continue
			}

			// 在持锁状态下计算剩余的空闲时间,防止并发更新 idle 导致状态不一致
			val := c.maxConnectionIdle - time.Since(idle)
			fmt.Printf("val %v \n", val)
			c.mu.Unlock()

			if val <= 0 {
				// 连接已空闲超过 maxConnectionIdle 时间,说明客户端长时间未通信
				// 优雅地关闭连接以释放资源
				c.s.Close(c)
				return
			}

			// 否则重新设置定时器,等待下一轮检测
			idleTimer.Reset(val)

		// 客户端主动断开时触发
		case <-c.done:
			fmt.Println("客户端结束连接")
			return
		}
	}
}
go 复制代码
// 若要增加对其他定时器的支持,可如下扩展

type Conn struct {
	*websocket.Conn
	s *Server

	mu                sync.Mutex
	idle              time.Time               // 最近活跃时间
	maxConnectionIdle time.Duration           // 最大空闲时间
	maxConnectionAge  time.Duration           // 连接最大生存时间
	keepaliveTime     time.Duration           // 保活间隔

	done chan struct{}                        // 关闭信号
}

func (c *Conn) keepalive() {
	idleTimer := time.NewTimer(c.maxConnectionIdle)
	kpTimer := time.NewTimer(c.keepaliveTime)
	ageTimer := time.NewTimer(c.maxConnectionAge)
	defer func() {
		idleTimer.Stop()
		kpTimer.Stop()
		ageTimer.Stop()
	}()

	for {
		select {
		// 空闲检测
		case <-idleTimer.C:
			c.mu.Lock()
			idle := c.idle
			c.mu.Unlock()

			if idle.IsZero() {
				idleTimer.Reset(c.maxConnectionIdle)
				continue
			}

			val := c.maxConnectionIdle - time.Since(idle)
			if val <= 0 {
				fmt.Println("连接空闲超时,关闭连接")
				c.s.Close(c)
				return
			}
			idleTimer.Reset(val)

		// 保活检测(定时发送心跳包)
		case <-kpTimer.C:
			pingMsg := []byte(`{"type":"ping","time":` + fmt.Sprint(time.Now().Unix()) + `}`)
			err := c.WriteMessage(websocket.TextMessage, pingMsg)
			if err != nil {
				fmt.Println("发送心跳包失败,关闭连接")
				c.s.Close(c)
				return
			}
			kpTimer.Reset(c.keepaliveTime)

		// 生命周期检测(最大连接时间)
		case <-ageTimer.C:
			fmt.Println("连接已达到最大生命周期,关闭连接")
			c.s.Close(c)
			return

		// 连接关闭
		case <-c.done:
			fmt.Println("客户端关闭连接")
			return
		}
	}
}
  • 在 Conn 中,需要对 idle 和 done 两个属性进行闭环管理:
    • done:用于通知相关协程连接已关闭,其信息是在 Conn.Close() 方法执行时触发的。因此,我们需要重写 Close 方法来确保所有监听该连接的协程能够收到关闭通知。
    • idle:用于记录连接的空闲时间。当连接存在消息活动时,视为非空闲;消息发送完成后,记录当前时间作为空闲起点。因此,需要重写 ReadMessage 和 WriteMessage 方法,在接收和发送消息时更新 idle 状态,以便心跳和空闲检测逻辑正确工作。
go 复制代码
// 在最初没有封装 Conn 的情况下,你直接使用的是原生的 *websocket.Conn,也就是 github.com/gorilla/websocket 提供的接口:
// ReadMessage():直接读取客户端发送的数据。
// WriteMessage():直接向客户端发送数据。
// Close():直接关闭连接。
// 原生的 *websocket.Conn 没有管理 idle 状态,也没有 done 通知通道,所以在原始使用中:
// 你无法准确判断连接空闲多久,也就无法做心跳检测或自动断开长时间空闲的连接。
// 关闭连接时没有通知其他协程去停止相关逻辑,容易出现 goroutine 泄漏。

// ----------

// 现在封装后的 Conn:
// 重写了 ReadMessage 和 WriteMessage,在读写时更新 idle,让心跳和空闲检测可用。
// 重写了 Close,通过 done 通道通知其他协程连接关闭,实现闭环管理。
// 也就是说,原来的 *websocket.Conn 只能收发消息,不管理心跳和空闲状态,封装后的 Conn 才具备完整的连接管理能力。

// ReadMessage 重写了原生 websocket.Conn 的 ReadMessage 方法
// 在读取消息的同时,更新连接的空闲状态
func (c *Conn) ReadMessage() (messageType int, p []byte, err error) {
	// 从嵌套的 websocket.Conn 中读取消息
	messageType, p, err = c.Conn.ReadMessage()
	
	// 将 idle 重置为零值时间,表示连接当前正在忙碌(非空闲)
	// 这里的设计是:当收到消息时,连接不再被视为空闲
	c.idle = time.Time{}
	
	return
}

// WriteMessage 重写了原生 websocket.Conn 的 WriteMessage 方法
// 在发送消息的同时,更新连接的空闲时间
func (c *Conn) WriteMessage(messageType int, data []byte) error {
	// 向客户端发送消息
	err := c.Conn.WriteMessage(messageType, data)
	
	// 当写操作完成后,当前连接进入空闲状态
	// 记录发送完成的时间,供 idle 检测使用
	c.idle = time.Now()
	
	return err
}

// 发送消费才视为空闲,接收到消息把 idle 置空,是因为接受后到发送这个过程中,是在处理数据

// Close 重写了原生 websocket.Conn 的 Close 方法
// 在关闭连接时进行通知和资源清理
func (c *Conn) Close() error {
	// 通过关闭 done 通道通知所有监听此连接的协程
	// 这样协程可以安全退出,防止 goroutine 泄漏
	close(c.done)
	
	// 调用原生 websocket.Conn 的 Close 方法,真正关闭底层连接
	return c.Conn.Close()
}
  • 接收消息 (ReadMessage) 时:
    • 收到消息意味着客户端有活动,服务端开始处理这条消息。
    • 在处理过程中,连接 不算空闲,所以 idle = time.Time{}(零值时间)。
    • 这样心跳检测就不会误判连接空闲而关闭。
  • 发送消息 (WriteMessage) 后:
    • 消息发送完成,意味着这条请求/响应处理完毕,连接暂时没有待处理任务。
    • 此时连接进入 空闲状态,记录当前时间 idle = time.Now()。
    • 心跳检测可以根据这个时间判断连接是否长时间无操作,从而决定是否关闭。
  • idle 的核心意义是"连接自上一次消息处理结束后的空闲时长"。
  • 收到消息 → 处理中 → 非空闲 → idle = 0
  • 处理完毕并发送完成 → 进入空闲 → idle = time.Now()
  • 这种设计保证了:
    • 心跳不会在连接处理过程中误判空闲;
    • 空闲检测只在处理完消息后开始计时。

消息类型

  • 采用了心跳检测意味着实际接收到的消息类型就有 ping 消息以及实际请求的消息 data,顾明显需做区分。
  • grpc 的区分设计通过定义 FrameType 类型基于 Switch 转为特定对象。
  • 我们可以简化一些处理,同样定义 FrameType 识别消息类型,设置在 Message 中作为其中的属性。而在默认的创建 Message 结构体中消息类型为 data 类型。
go 复制代码
// FrameType 定义了消息的类型,这里用 uint8 表示
// 主要用于区分数据类型,方便在 handlerConn 或业务处理函数中做不同逻辑处理
// 例如 FramePing 用于心跳,不会进入业务逻辑处理流程
type FrameType uint8

// 定义消息的几种帧类型
const (
	FrameData FrameType = 0x0 // 普通数据帧,用于携带业务消息
	FramePing FrameType = 0x1 // 心跳帧,用于心跳检测
)

// Message 是 websocket 消息的结构体,封装了消息的各种信息
type Message struct {
	FrameType `json:"frameType"`                    // 消息类型:数据帧还是心跳帧
	Method    string      `json:"method,omitempty"` // 业务方法名,可用于路由处理,omitempty 表示为空时不序列化
	UserId    string      `json:"userId,omitempty"` // 消息发送者或目标用户的 ID
	FormId    string      `json:"formId,omitempty"` // 发送者的标识 ID,用于区分来源
	Data      interface{} `json:"data,omitempty"`   // 消息的具体业务数据,可以是任意类型
}

// NewMessage 构造一个新的数据消息
// srv: websocket 服务对象
// conn: 当前连接对象,用于获取发送者用户 ID
// data: 具体要发送的业务数据
func NewMessage(srv *Server, conn *Conn, data interface{}) *Message {
	// 获取当前连接的用户 ID(通常返回一个切片,这里取第一个)
	fid := srv.GetUsers(conn)[0]

	return &Message{
		FrameType: FrameData, // 默认是普通数据帧
		FormId:    fid,       // 设置发送者 ID
		Data:      data,      // 设置消息的业务数据
	}
}

调整server处理

  • 在 Server 中,主要调整对 Conn 连接对象的管理,以及在接收到客户端连接后对该连接的后续处理逻辑。
  • 首先,我们需要优化对连接的记录以及读写操作的处理方式以及再调整server对象中的处理。
go 复制代码
type Server struct {
	sync.RWMutex

	authentication Authentication
	routes         map[string]HandlerFunc
	addr           string
	opt            option

	connToUser map[*Conn]string
	userToConn map[string]*Conn

	upgrader websocket.Upgrader
	logx.Logger
}

func NewServer(addr string, opts ...Options) *Server {
	opt := newOption(opts...)

	return &Server{
		authentication: opt.Authentication,
		routes:         make(map[string]HandlerFunc),
		Logger:         logx.WithContext(context.Background()),
		connToUser:     make(map[*Conn]string),
		userToConn:     make(map[string]*Conn),
		addr:           addr,
		upgrader:       websocket.Upgrader{},
		opt:            opt,
	}
}

// 发送信息
func (s *Server) Send(msg interface{}, conns ...*Conn) error {
	// ..
}

// 根据用户id获取连接
func (s *Server) GetConn(uids ...string) []*Conn {
	// ..
}

// 基于连接获取用户
func (s *Server) GetUsers(conns ...*Conn) []string {
    // ..
}

// 添加连接
func (s *Server) addConn(conn *Conn, req *http.Request) {
	// 此处是map的写操作,在操作上会存在并发的可能问题
	// ..
}

// 关闭连接
func (s *Server) Close(conn *Conn) {
	// ..
}
  • 接下来,调整连接接收与处理的函数逻辑。
go 复制代码
func (s *Server) ServerWs(w http.ResponseWriter, r *http.Request) {
	defer func() {
		if r := recover(); r != nil {
			s.Errorf("server handler ws recover err %v", r)
		}
	}()

	if !s.authentication.Auth(w, r) {
		s.Info("authentication failed")
		w.Write([]byte("authentication failed"))
		return
	}

	conn := NewConn(s, w, r)
	if conn == nil {
		return
	}

	// 添加连接记录,会有并发问题
	s.addConn(conn, r)
	// 读取信息,完成请求,还需建立连接
	go s.handlerConn(conn)
}

func (s *Server) handlerConn(conn *Conn) {
	// 记录连接
	for {
		_, msg, err := conn.ReadMessage()
		if err != nil {
			// 关闭并删除连接
			s.Close(conn)
			return
		}

		// 请求信息
		var message Message
		json.Unmarshal(msg, &message)

		// 依据请求消息类型分类处理
		switch message.FrameType {
		case FramePing:
			// ping:回复
			// 收到客户端 ping,回复 pong 比较好
			s.Send(&Message{FrameType: FramePing}, conn)
		case FrameData:
			// 处理
			if handler, ok := s.routes[message.Method]; ok {
				handler(s, conn, &message)
			} else {
				s.Send(&Message{
					FrameType: FrameData,
					Data:      fmt.Sprintf("不存在请求方法 %v 请仔细检查", message.Method),
				}, conn)
			}
		}
	}
}

路由调整

  • 在服务对象定义并完成基础处理后,还需要对路由进行管理。
  • 由于当前路由处理仍然直接操作 WebSocket 的连接对象,因此存在潜在的异常风险,需要进行优化和规范化。
go 复制代码
type HandlerFunc func(srv *Server, conn *Conn, msg *Message)

// 类似上文提到的 OnLine 函数
// Chat 函数就是一个消息发送处理器(即消息转发路由)
func Chat(srvCtx *svc.ServiceContext) server.HandlerFunc {
	return func(srv *server.Server, conn *server.Conn, msg *server.Message) {
		// 根据收到的 msg.UserId 获取目标用户
		// 将 msg.Data 封装成新的消息(通过 server.NewMessage)
		// 调用 srv.SendByUserId 将消息发送给指定用户
		err := srv.SendByUserId(server.NewMessage(srv, conn, msg.Data), msg.UserId)
		srv.Info(err)
	}
}

// 以下两个函数,上文提到过,节省往上翻的时间,粘贴在下面

// 根据 userId 找出对应的连接 conn,再发送消息
func (s *Server) SendByUserId(msg interface{}, sendIds ...string) error {
	if len(sendIds) == 0 {
		return nil
	}

	return s.Send(msg, s.GetConn(sendIds...)...)
}

// 真正执行发送操作(底层通过 WebSocket 的连接对象发送)
func (s *Server) Send(msg interface{}, conns ...*websocket.Conn) error {
	if len(conns) == 0 {
		return nil
	}

	data, err := json.Marshal(msg)
	if err != nil {
		return err
	}

	for _, conn := range conns {
		if err = conn.WriteMessage(websocket.TextMessage, data); err != nil {
			return err
		}
	}
	return nil
}
  • 客户端与服务器建立 WebSocket 连接后,会启动一个空闲定时器(idleTimer)监测通信状态;当客户端通过 Chat 处理函数发送消息时,服务器接收并转发消息,同时重置定时器;若在设定时间内未发生任何通信,定时器触发超时逻辑,服务器主动关闭该连接,以防止长时间空闲占用资源。

一些细节调整

  • 在我们现在的 IM 服务中,addConn 负责记录用户与连接的映射关系(userToConnconnToUser)。但如果同一个用户重复登录(例如刷新页面或多设备登录),旧连接仍然保留,就会导致:
    • 旧连接仍占用资源;
    • 消息可能被发送到多个连接;
    • 内存泄漏风险。
go 复制代码
// 现如今的 addConn 逻辑
func (s *Server) addConn(conn *Conn, req *http.Request) {
	uid := s.authentication.UserId(req)

	s.RWMutex.Lock()
	defer s.RWMutex.Unlock()

	s.connToUser[conn] = uid
	s.userToConn[uid] = conn
}

// 假设用户 uid = 1001,第一次登录时:
// userToConn[1001] = connA
// connToUser[connA] = 1001

// 第二次登录时(新连接 connB 建立):
// userToConn[1001] = connB   // 旧值被覆盖!
// connToUser[connB] = 1001   // 新增一条映射

// userToConn 中的旧连接 connA 被新连接覆盖;
// 但 connToUser 里仍然存在旧连接 connA → 1001;
// 旧连接并没有被关闭;
// 因为没有心跳检测机制,它会一直留在内存中,直到服务器重启或 TCP 超时。

// ------------------------

// 如果该用户已经有旧连接 c;
// 就从两个映射表中删除旧的映射;
// 然后关闭旧连接。
// 最后将新的连接映射存入 map。
func (s *Server) addConn(conn *Conn, req *http.Request) {
	// 此处是map的写操作,在操作上会存在并发的可能问题
	uid := s.authentication.UserId(req)

	s.RWMutex.Lock()
	defer s.RWMutex.Unlock()

	// 原有已经存在了连接
	if c := s.userToConn[uid]; c != nil {
		delete(s.connToUser, conn)
	  	delete(s.userToConn, uid)
		c.Close()
	}

	s.connToUser[conn] = uid
	s.userToConn[uid] = conn
}
  • 程序现在有两个地方会调用 conn.Close()
    • 心跳检测线程:如果检测到客户端长时间没有通信,会认为连接失效,然后主动关闭连接。
    • 消息读取循环 (handlerConn):当客户端主动断开或网络异常时,conn.ReadMessage() 会返回错误,这个时候也会触发关闭逻辑。
  • 问题是------这两个地方可能会几乎同时调用 conn.Close()
  • Go 的通道只能关闭一次,多次关闭会触发 panic: close of closed channel
  • 为了避免这种情况,我们要在关闭前判断连接是否已经关闭。
go 复制代码
// 关闭连接
func (s *Server) Close(conn *Conn) {
	s.RWMutex.Lock()
	defer s.RWMutex.Unlock()

	uid := s.connToUser[conn]
	if uid == "" {
		// 已经关闭了连接
		return
	}

	fmt.Printf("关闭与%s的连接\n", uid)

	delete(s.connToUser, conn)
	delete(s.userToConn, uid)

	conn.Close()
}
go 复制代码
type ServerOption func(opt *serverOption)

type serverOption struct {
	Authentication
	
	ackTimeout time.Duration
	patten     string

	maxConnectionIdle time.Duration
}

func newOption(opts ...ServerOption) serverOption {
	o := serverOption{
		Authentication:    new(authentication),
		maxConnectionIdle: defaultMaxConnectionIdle,
		patten:            "/ws",
	}
	for _, opt := range opts {
		opt(&o)
	}
	return o
}

func WithAuthentication(authentication Authentication) ServerOption {
	return func(opt *serverOption) {
		opt.Authentication = authentication
	}
}

func WithHandlerPatten(pattern string) ServerOption {
	return func(opt *serverOption) {
		opt.patten = pattern
	}
}

func WithServerMaxConnectionIdle(maxConnection time.Duration) ServerOption {
	return func(opt *serverOption) {
		if maxConnection > 0 {
			opt.maxConnectionIdle = maxConnection
		}
	}
}


func NewConn(s *Server, w http.ResponseWriter, r *http.Request) *Conn {

	c, err := s.upgrader.Upgrade(w, r, nil)
	if err != nil {
		s.Error("upgrade http conn err", err)
		return nil
	}

	conn := &Conn{
		Conn:              c,
		s:                 s,
		idle:              time.Now(),
		done:              make(chan struct{}),
		maxConnectionIdle: defaultMaxConnectionIdle,
	}

	// 调用 keepaliv
	go conn.keepalive()

	return conn
}

用户与好友私聊实现

单聊功能分析

  • 单聊的本质是:客户端A → 服务器(查找目标连接)→ 客户端B
  • 即基于 userId → Conn 的映射,将消息从发送者的连接转发到接收者的连接。
go 复制代码
1、客户端 A(发送者)通过 WebSocket 连接,向服务器发送一条消息:
{
  "frameType": 0,         // FrameData,表示普通消息
  "method": "chat",        // 路由方法名,对应 Chat 处理函数
  "userId": "B",           // 接收方用户 ID(fid)
  "data": "你好呀!"        // 消息内容
}

2、WebSocket 服务端在 handlerConn 中收到这条消息后:
switch message.Method {
case "chat":
    handler(srv, conn, &message)
}
匹配到 chat 路由后,执行:
func Chat(srv *Server, conn *Conn, msg *Message) {
    err := srv.SendByUserId(server.NewMessage(srv, conn, msg.Data), msg.UserId)
    srv.Info(err)
}

3、SendByUserId() 内部会:从 userToConn 映射表中找到接收方用户 B 的连接对象;如果找到了,就把消息通过 conn.WriteMessage() 发送过去;
如果找不到,说明用户离线,可选择:缓存到数据库 / Redis(离线消息)或直接丢弃(不记录)

4、接收方 B 的 WebSocket 连接会通过客户端监听事件,收到服务器推送的消息(WebSocket 是全双工的),然后在前端展示出来。

推和拉

  • 推模式(Push)
    • 消息一旦产生,服务器会主动将消息发送给接收方。
    • 接收方无需轮询或请求,消息会实时到达。
    • 推模式就是"服务器主动把消息推给用户",用户不用去问服务器有没有新消息。
特性 描述
实时性 高,发送方发送消息后,接收方几乎立即收到
连接要求 需要保持实时连接(如 WebSocket、TCP 长连接、MQTT 等)
服务器压力 相对较大,需要管理连接状态、推送消息
示例 IM 聊天、微信消息通知、实时股票行情
  • 拉模式(Pull)
    • 接收方主动向服务器请求自己的消息。
    • 服务器只是响应请求,不主动发送消息。
    • 拉模式就是"用户自己去问服务器有没有新消息",服务器只在收到请求时才返回消息。
特性 描述
实时性 较低,取决于轮询频率,可能有延迟
连接要求 不需要长连接,普通 HTTP 请求即可
服务器压力 相对小,按需处理请求
示例 邮箱客户端定期检查邮件、论坛刷新消息
  • 推模式用于实时通信,拉模式用于延迟或周期性获取消息,在 IM 系统中两者往往结合使用:在线用户用推,离线用户用拉。

读扩散和写扩散

  • 如何记录聊天消息以及如何去读取?
  • 在即时通信中,消息的存储以及读写策略是系统设计中需要重点关注的问题,因为它直接影响程序的实现方式、运行效率以及扩展性。
  • 在聊天系统中,核心关注点包括 聊天记录 和 聊天会话。在当前业务需求中,我们需要支持历史消息的管理。在具体的实现模型上,消息存储策略可以分为 读扩散 和 写扩散 两种模式。
  • 本次讨论的重点是消息的 扩散机制,即消息在系统中如何分发和存储的问题。
  • 读扩散
  • 如上假设存在 A, B, C, D 四个用户以及一个 Group 群,在读扩散的情况下用户 A 与其他对方通信的时候都会一个会话,并且会话是相互之间共享的,即此时如果有一条新的聊天记录只需要向会话中写一次就可以,相互从会话中获取最新的聊天记录信息。
  • 但是涉及到群的时候,所有的用户都会从群会话里读取消息,压力重;而且消息已读未读也难以解决。
  • 优点:
    • 写操作(发消息)很轻量,不管是单聊还是群聊,只需要往相应的信箱写一次就好了
    • 每一个信箱天然就是两个人的聊天记录,可以方便查看聊天记录跟进行聊天记录的搜索
  • 缺点:
    • 读操作(读消息)很重
  • 写扩散
  • 在写扩散下,每个人都有一个独立的会话并从中获取到新的消息,发送消息的情况如下:
    • 单聊:往自己的会话中写一份,然后向目标会话中写一份
    • 群聊:需要向所有群成员的会话中写一份消息,同时如果需要查看群聊天历史记录的话还需要再写一份
  • 优点:
    • 读操作很轻量
    • 可以很方便地做消息的多终端同步
  • 缺点:
    • 写操作很重,尤其是群聊

数据存储

  • 在开始前我们需定义好 chatLog 的数据结构:
    • 定义 MongoDB 中 chatLog 文档的存储结构
    • 每条记录对应一条消息
    • 既可以表示 私聊消息,也可以表示 群聊消息
    • MongoDB 存储的是 BSON(类似 JSON 的二进制格式),bson 标签告诉 Mongo 字段名
    • json 标签是为了在接口返回 JSON 时能正确序列化
go 复制代码
// ChatLog 表示一条聊天记录(存储在 MongoDB 中的文档结构)
// 它是 IM 系统中用户之间每次消息交互的基础数据结构。
type ChatLog struct {
    // MongoDB 的唯一 ID(ObjectID 是 MongoDB 的默认主键类型)
    // bson:"_id,omitempty" 表示插入时若为空,Mongo 会自动生成
    // json:"id,omitempty" 表示序列化为 JSON 时键名为 "id"
    ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`

    // 会话ID,用于标识一段会话,比如 A 与 B 的私聊或某个群聊
    // 通常由两个人的 userId 组合生成(例如:CombineId(a, b))
    ConversationId string `bson:"conversationId"`

    // 发送方用户ID(谁发的消息)
    SendId string `bson:"sendId"`

    // 接收方用户ID(发给谁)
    // 如果是群聊,可能是群ID;如果是私聊,就是对方用户ID
    RecvId string `bson:"recvId"`

    // 消息来源(例如:1 表示用户发送,2 表示系统消息)
    // 用于区分不同类型的消息来源
    MsgFrom int `bson:"msgFrom"`

    // 消息类型(文本、图片、视频、语音等)
    // 使用自定义常量类型 constants.MType 定义各种类型
    MsgType constants.MType `bson:"msgType"`

    // 消息的实际内容(字符串形式)
    // 对于图片/语音等类型,这里可能是文件的URL地址
    MsgContent string `bson:"msgContent"`

    // 发送时间(时间戳,单位:纳秒)
    // 用于消息的排序、展示
    SendTime int64 `bson:"sendTime"`

    // 消息状态(例如:0 未读、1 已读、2 撤回等)
    // 用于客户端展示消息状态
    Status int `bson:"status"`

    // 更新时间(可选字段,用于记录最后修改时间)
    // omitempty 表示如果为空则不写入数据库
    UpdateAt time.Time `bson:"updateAt,omitempty" json:"updateAt,omitempty"`

    // 创建时间(记录消息生成的时间)
    // 通常在插入数据库时自动设置
    CreateAt time.Time `bson:"createAt,omitempty" json:"createAt,omitempty"`
}

// goctl model mongo --type chatLog --dir ./apps/im/immodels

// 定义会话id生成策略,策略的方式是对两者id进行排序,然后再依据两者的顺序组合成一个新的唯一id
func CombineId(aid, bid string) string {
	ids := []string{aid, bid}

	sort.Slice(ids, func(i, j int) bool {
		a, _ := strconv.ParseUint(ids[i], 0, 64)
		b, _ := strconv.ParseUint(ids[j], 0, 64)
		return a < b
	})

	return fmt.Sprintf("%s_%s", ids[0], ids[1])
}
go 复制代码
// MongoDB 的整体结构
MongoDB 实例
 └── Database(数据库)
      └── Collection(集合)
           └── Document(文档)

// Database(数据库) 类似于 MySQL 的「库」
// Collection(集合) 类似于 MySQL 的「表」
// Document(文档) 类似于 MySQL 的「行」,不过是 JSON/BSON 结构(可以嵌套)

数据库:im_system
集合:chat_log
文档结构:ChatLog

请求信息

  • 在 WebSocket 通信中,客户端和服务端之间传递的消息通常是 JSON 格式。
  • 为了方便管理、解析和拓展,我们一般会在服务端定义一个统一的消息结构体 ------ Message。
go 复制代码
// 上面我们所定义的 Message
type Message struct {
	FrameType `json:"frameType"`                    
	Method    string      `json:"method,omitempty"` 
	UserId    string      `json:"userId,omitempty"` 
	FormId    string      `json:"formId,omitempty"` 
	Data      interface{} `json:"data,omitempty"`   
}

// ------------------------------------------------------------------

// 定义的 server/message 在程序中我们主要是用于接收客户端传递的请求消息
// 同时我们还为 message 增加错误的类型以便于客户端处理

type FrameType uint8

const (
	FrameData FrameType = 0x0
	FramePing FrameType = 0x1
	FrameErr  FrameType = 0x2
)

type Message struct {
	FrameType `json:"frameType"`
	Method    string      `json:"method,omitempty"`
	UserId    string      `json:"userId,omitempty"` 
	FormId    string      `json:"formId,omitempty"`
	Data      interface{} `json:"data,omitempty"`
}

func NewErrMessage(err error) *Message {
	return &Message{
		FrameType: FrameErr,
		Data:      err.Error(),
	}
}
  • 然后在 server 的 handlerConn 增加错误处理
go 复制代码
func (s *Server) handlerConn(conn *Conn) {
	// 记录连接
	for {
		_, msg, err := conn.ReadMessage()
		if err != nil {
			// 关闭并删除连接
			s.Close(conn)
			return
		}

		// 请求信息
		var message Message
		if err := json.Unmarshal(msg, &message); err != nil {
			s.Send(NewErrMessage(err), conn)
			continue
		}

		// 依据请求消息类型分类处理
		switch message.FrameType {
		case FramePing:
			// ping:回复
			s.Send(&Message{FrameType: FramePing}, conn)
		case FrameData:
			// 处理
			if handler, ok := s.routes[message.Method]; ok {
				handler(s, conn, &message)
			} else {
				s.Send(&Message{
					FrameType: FrameData,
					Data:      fmt.Sprintf("不存在请求方法 %v 请仔细检查", message.Method),
				}, conn)
			}
		}
	}
}
  • 然后我们还需为私聊定义好通信的消息数据结构
go 复制代码
// go get github.com/mitchellh/mapstructure

type (
	Msg struct {
		constants.MType `mapstructure:"mType"`
		Content         string `mapstructure:"content"`
	}
)

type (
	Chat struct {
		ConversationId string `mapstructure:"conversationId"`
		UserId         string `mapstructure:"userId"`
      	ChatType       constants.ChatType `json:"chatType"`
		Msg            `mapstructure:"msg"`
	}
)

实现私聊

  • 在 logic 中定义 userlogic 对象对外提供具体的逻辑
go 复制代码
type ChatLogSlg interface {
	SingleChatLog(data *types.Chat, userId string) error
}

type UserLogic struct {
	ctx    context.Context
	srv    *server.Server
	svcCtx *svc.ServiceContext
}

func NewUserLogic(ctx context.Context, srv *server.Server, svcCtx *svc.ServiceContext) *UserLogic {
	return &UserLogic{
		ctx:    ctx,
		srv:    srv,
		svcCtx: svcCtx,
	}
}

func (l *UserLogic) SingleChat(data *types.Chat, userId string) error {
	if data.ConversationId == "" {
		data.ConversationId = wuid.CombineId(data.UserId, userId)
	}


	// 获取发送目标是否在线
	chatLog := models.ChatLog{
		ConversationId: data.ConversationId,
		SendId:         userId,
		RecvId:         data.UserId,
		MsgType:        data.MType,
		MsgContent:     data.Content,
		SendTime:       time.Now().UnixNano(),
	}

	err := l.svcCtx.ChatLogModel.Insert(l.ctx, &chatLog)
	return err
}
  • 请求参数解析与响应
go 复制代码
// /im/ws/internal/handler/conversation/conversation.go
// 针对用户处理的消息
func Chat(srvCtx *svc.ServiceContext) server.HandlerFunc {
	return func(srv *server.Server, conn *server.Conn, msg *server.Message) {
		var data types.Chat
		if err := mapstructure.Decode(msg.Data, &data); err != nil {
			srv.Send(server.NewErrMessage(err), conn)
			return
		}

		var data ws.Chat

		if err := mapstructure.Decode(msg.Data, &data); err != nil {
		    srv.Send(websocket.NewErrMessage(err), conn)
		    return
		}
		
		switch data.ChatType {
		case constants.SingleChatType:
		    err := logic.NewConversation(context.Background(), srv, svc).SingleChat(&data, conn.Uid)
		    if err != nil {
		        srv.Send(websocket.NewErrMessage(err), conn)
		        return
		    }
		
		    srv.SendByUserId(websocket.NewMessage(conn.Uid, ws.Chat{
		        ConversationId: data.ConversationId,
		        ChatType:        data.ChatType,
		        SendId:          data.SendId,
		        RecvId:          data.RecvId,
		        SendTime:        time.Now().UnixMilli(),
		        Msg:             data.Msg,
		    }), data.SendId)
	}
}

// 路由
func RegisterHandlers(srv *websocket.Server, svcCtx *svc.ServiceContext) {
	srv.AddRoutes([]websocket.Route{
		{
			Method:  "conversation.chat",
			Handler: conversation.Chat(svcCtx),
		},
	})
}

消息可靠与收发优化

问题分析

  • 消息的处理流程:客户端发送消息→服务的接收到 push 信息→记录聊天记录→维护会话记录→push 给目标用户
  • 在这个流程服务的收发均在一个方法链路中完成,此时如果消息记录与消息更新环节存在耗时的情况,那么就会影响到整个接收请求的完成状态。
  • 在该模型下的问题即就有:
    • 阻塞及延迟:如果将消息的收发放在同一个处理逻辑中,当发送方发送消息时需要等待接收方的响应,这可能会导致发送方阻塞和延迟。特别是当接收方响应较慢或不可用时,发送方可能需要等待较长时间,降低整体的响应速度。
    • 可靠性问题:没有中间层来负责接收、存储和转发消息,一旦接收方出现问题,消息可能会丢失。同时,如果收发消息都在一起处理,一旦处理逻辑出现错误,可能会导致消息的错误处理或丢失,从而影响系统的可靠性。
    • 扩展性和灵活性限制:将收发消息都放在一起处理,可能会限制系统的扩展性和灵活性。如果处理逻辑需要变更或升级,可能会影响整个收发消息的过程,并且会对整体系统的性能和稳定性产生影响。同时,由于处理逻辑的复杂性和紧密耦合性,可能会难以进行水平扩展。
    • 难以实现消息分发和排队:没有中间层来实现消息的分发和排队,很难有效地管理消息的顺序和优先级。也无法实现按照不同的订阅者或消费者进行消息的分发和路由,从而无法灵活控制系统的消息流向。

消息收发模型

  • 在模型中我们主要的目的是通过客户端1向客户端2发送消息,在实现上运用了消息队列异步处理接收后的事项处理,即流程为:
  • 在该消息模型中,客户端首先通过 WebSocket 将消息发送至服务端。服务端在接收到消息后,会异步地将消息投递到 Kafka 消息队列中。
  • 在消息队列的任务机制中,主要承担两个核心职责:
    • 消息存储:将消息持久化到数据库中,确保数据可靠落地;
    • 消息转发通知:通过 WebSocket 客户端将消息发送至对应的 WebSocket 服务实例。
  • 当用户所在的 WebSocket 服务实例接收到来自消息队列的消息时,会立即将该消息推送给对应的目标用户。
  • 这种基于异步消息队列的架构有效降低了系统的处理延迟,提高了消息传输的可靠性。同时,消息队列的缓存与重试机制也能在异常情况下保障消息不丢失。
  • 此外,该模型具有良好的可扩展性。当需要横向扩展 WebSocket 服务时,只需在 Redis 中维护用户 UID 与服务节点的绑定关系,即可根据 UID 定位对应的服务节点并完成消息投递,而无需改动整体架构。

消息可靠性

  • 在 IM(即时通讯)系统中,消息可靠性指确保发送的消息能够被接收方可靠接收和处理的特性,即消息在传递过程中不会丢失、乱序,并能够及时送达。
  • 消息可靠性对实时通讯应用至关重要,尤其是在需要确保关键信息不丢失或不遗漏的场景下。其关键机制包括:
    • 传输保证:通过可靠的传输协议(如 TCP),确保消息在传输过程中完整、有序地到达。TCP 提供可靠、基于流的连接,可保证数据完整性和顺序性。
    • 确认机制(ACK):发送方在发送消息后,等待接收方的确认回复。如果未收到确认,将进行重试,直到确认成功或达到最大重试次数。
    • 重试机制:当消息因网络波动或其他原因未能及时送达时,系统会自动重试,确保消息最终送达。
    • 消息顺序保证:对于有顺序要求的消息,可靠的消息系统需确保接收方按发送顺序处理消息,避免乱序。
  • 通过这些机制,IM 系统能够保障消息安全传递、保持正确顺序和完整性,从而提升用户体验和应用可靠性。
  • 在具体实现上,可以在 IM 服务的消息处理流程中引入 ACK 确认机制,常见模式有两种:
    • 单向应答:发送方只需等待接收方确认即可;
    • 双向确认:发送方与接收方相互确认,进一步保证消息传递的可靠性。
  • 当某个服务长时间未返回 ACK(确认) 信息时,为了保证消息传递的可靠性,可以采取以下几种应对策略:
    • 超时重发:发送方在消息发送后设置超时时间,若在规定时间内未收到 ACK,则自动重发消息。通过合理设置超时和重试间隔,可在网络波动下提高消息送达率。
    • 重试次数限制:为防止无限重试造成资源浪费,应设定最大重试次数。当超过上限后,可触发后续处理,例如记录异常日志、通知业务层或将消息转入补偿机制。
    • 监控与告警:建立消息状态监控系统,定期扫描未收到 ACK 的消息,并在超时或重试失败时触发告警,方便运维人员及时排查问题。
    • 异常处理与补偿:根据业务需求,针对未确认的消息可制定相应策略。例如,将消息标记为"发送失败",记录到失败队列或数据库中,供后续人工或自动补偿机制处理。
  • 综合来看,未收到 ACK 的处理应结合系统架构与业务需求设计。
  • 在实际开发中,应通过 超时控制、重试机制、监控告警与补偿机制 的协同配合,确保消息在异常情况下仍具备可追踪性与可恢复性,从而保障整个消息系统的高可靠性。
  • 在了解 ACK 机制后,需要注意的是,消息收发模型中,消息通信的可靠性仍然是关键考虑点。尤其是在 WebSocket 将消息发送给客户端 2 的过程中,为保障消息可靠性,可以采用多种方式:
    • 基于通信 ACK 的推模式:发送方主动将消息推送给客户端,并依赖 ACK 确认机制确保消息成功送达,从而实现可靠传输。
    • 基于拉取机制的拉模式
      客户端先收到有新消息的通知,再主动向服务端发起拉取请求获取消息。
  • 这两种方式在业界均有应用,各有优劣。
  • 不对哪种方式做优劣评价,而是采用 方案 1(基于 ACK 的推模式),并结合 会话序号机制 来保证消息的接收顺序,从而实现可靠且有序的消息传递。

引入Kafka消息中间件并构建异步队列服务

为什么选择kafka

  • 目前业界常用的消息队列主要包括 Kafka、RocketMQ 和 RabbitMQ。在我们的场景中,主要关注的需求有:
    • 消息顺序性
    • 持久性与可靠性
    • 高吞吐量
  • 在消息队列机制的选择上,Kafka 和 RocketMQ 都能够满足这些需求。然而,由于 Kafka 在 Go 语言生态中拥有成熟的客户端库,能够方便地与 Go 应用进行集成,因此选择 Kafka 作为消息队列方案。

完成服务内容

  • go-zero的消息队列扩展包:go get github.com/zeromicro/go-queue
  • Go 语言的消息队列抽象和扩展库,提供统一的 Producer / Consumer 接口,不同消息队列实现(Kafka、NSQ 等)都可以用同一套调用方式,支持消费回调、异步任务、定时任务。
  • 不是 Kafka,也不是消息中间件,它是客户端封装库。
go 复制代码
// Producer 向消息队列发送消息
type Producer interface {
    Publish(topic string, msg []byte) error
}

// Consumer 从消息队列消费消息
type Consumer interface {
    Consume(handler func([]byte) error) error
}

// Queue 实现
go-queue 提供了 多种队列实现适配器:
Kafka:kq 包
NSQ:nsq 包
MemoryQueue:内存队列(测试/轻量级任务)
所有实现都遵循统一接口,所以业务逻辑可以无感切换队列类型

// 配置方式
kafka:
  brokers: // Kafka 地址
    - "127.0.0.1:9092"
  group: "im_group" // 消费组
  topics: // 订阅或发送的 Topic
    - "ws2ms_chat"
  retry: 3 // 发送/消费重试次数

// 在 Go 项目中可以通过 ServiceContext 注入:
producer, _ := kq.NewProducer(cfg.Kafka)
consumer, _ := kq.NewConsumer(cfg.Kafka)

// 初始化 Producer/Consumer
// 发送消息:业务逻辑调用 Producer.publish()
// 消费消息:Consumer.Consume(handler)
// 处理消息:handler 内部执行具体业务(如 MsgTransfer)

go 复制代码
// 定义一个 MsgTransfer 用于处理 im 通信消息内容
// 在 Config 中则是定义 MsgTransfer 的配置结构
// 这里会用 kafka 作为消息队列,直接使用 go-zero 的消息队列机制包中的配置作为配置结构。
type Config struct {
	service.ServiceConf
	ListenOn string
	Mysql struct {
		DataSource string
	}
	Cache cache.CacheConf

	MsgChatTransfer kq.KqConf
}
go 复制代码
// MsgChatTransfer kq.KqConf, Kafka 消息队列配置
// go-queue/kq 提供的 Kafka 配置结构 KqConf
// 在配置中定义了关于 kafka 的配置信息,而 kafka 的过程是生产者将消息投递到定义好的 topic(主题)中,消费者监听这个 topic 如果有信息就从中获取
Name: im.ws
ListenOn: 0.0.0.0:10090

Mysql:
  DataSource: root:easy-chat@tcp(192.168.117.80:13306)/easy-chat?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai

Cache:
  - Host: 192.168.117.80:16379
    Type: node
    Pass: easy-chat

// 初始化 MsgTransfer 消费器所需的 Kafka 配置
// MsgTransfer 会用这个配置去初始化 go-queue Consumer,从而开始消费 Kafka Topic 的消息
MsgChatTransfer:
  Name: msgChatTransfer
  Brokers:
    - 192.168.117.80:9092
  Group: kafka
  Topic: msgChatTransfer
  Offset: first
  Consumers: 1

// 告诉 MsgTransfer 消费器 去哪个 Kafka Broker 拉取消息
// 指定 消费组,保证消息消费语义(如群组消费一次)
// 指定 Topic,明确监听的消息类型
// Retry 配置消费失败的重试次数

// 这就是 go-queue/kq 初始化 Kafka Consumer 所需的完整配置

type ServiceContext struct {
	Config config.Config
}

func NewServiceContext(c config.Config) *ServiceContext {
	return &ServiceContext{
		Config: c,
	}
}
  • 通过 go-zero/queue 组件中的 kafka 示例可以了解到,如果我们想要基于它来处理 kafka 的 Consumer 时,就需要实现如下接口
go 复制代码
type (
  	ConsumeHandler interface {
		Consume(key, value string) error
	}
)
  • ConsumeHandler 就是 Kafka 消费消息的回调接口
  • 只要你实现了 Consume(key, value string) 方法,你的对象就能作为 Kafka 消费逻辑使用
  • MsgTransfer 就是你实现该接口的 消息处理逻辑结构体

  • 梳理下 go-queue 消费流程:

    1. 初始化 Kafka Consumer:consumer, _ := kq.NewConsumer(c.MsgChatTransfer),根据配置(Brokers、Group、Topics 等)连接 Kafka,订阅指定 Topic
    2. 消费消息:Kafka 有新消息,Consumer 拉取消息
    3. 回调业务逻辑:go-queue 会 检测你的 Handler 是否实现了 ConsumeHandler,自动调用你实现的 Consume(key, value string) 方法
    4. 处理消息:Consume 方法里面就是你写的业务逻辑,比如:IM 消息存库、转发给在线用户、消息去重或缓存处理等
  • go-queue 是一个 消息队列操作库,支持 Kafka(也可能支持其他 MQ),底层封装了:Kafka 连接、消费组管理、消息拉取(Consumer)、消息发送、(Producer)、消费重试、失败处理

  • ConsumeHandler 接口定义了消费消息的回调方法,只需要实现这个方法,把业务逻辑写在里面

  • 流程一般如下:

    • 初始化 Consumer:consumer, _ := kq.NewConsumer(c.MsgChatTransfer),go-queue 根据配置连接 Kafka,订阅 Topic
    • 消费消息:Kafka Topic 有新消息,go-queue Consumer 拉取消息
    • 调用你的业务逻辑:如果你实现了 ConsumeHandler,go-queue 会 自动调用你的 Consume 方法,你的方法处理消息后返回结果
  • go-queue = Kafka 的操作封装器 + 消费回调桥梁,你只需要实现 ConsumeHandler,写业务逻辑,它帮你处理连接、拉消息和回调调用


  • 所以我们应该实现这个接口
go 复制代码
type MsgChatTransfer struct {
	logx.Logger
	svcCtx *svc.ServiceContext
}

// 构造函数,返回 ConsumeHandler 接口类型
// Go 接口是隐式实现的,只要一个类型实现了接口定义的所有方法,就算实现了该接口
// MsgChatTransfer 实现了 Consume(key, value string) error 方法
// 所以 *MsgChatTransfer 隐式实现了 kq.ConsumeHandler
// 也就是说,&MsgChatTransfer{...} 可以赋值给 kq.ConsumeHandler 类型的变量
// 编译器会检查:*MsgChatTransfer 是否有 Consume(key,value string) error 方法 → 有 → 合法
func NewMsgChatTransfer(svc *svc.ServiceContext) kq.ConsumeHandler {
	return &MsgChatTransfer{
		Logger: logx.WithContext(context.Background()),
		svcCtx: svc,
	}
}

// 任何 struct 只要实现了这个方法,就算实现了 ConsumeHandler 接口
// Go 的接口是 隐式实现,不需要显式声明 implements
// go-queue Consumer 可以直接调用这个方法处理消息
func (m *MsgChatTransfer) Consume(key, value string) error {
    return nil
}

  • 考虑消息队列机制可能还会存在其他的业务可能,因此我们在 handler 中定义一个 listen,并提供 Services 方法输出 service.Service 数组类型以便于增加其他异步任务的处理。
go 复制代码
type Listen struct {
	svc *svc.ServiceContext
}

func NewListen(svc *svc.ServiceContext) *Listen {
	return &Listen{
		svc: svc,
	}
}

func (l *Listen) Services() []service.Service {
	return []service.Service{
		// go-zero 框架提供的一个工具函数,用来创建并启动一个 Kafka 消费服务(消费者)
		// 第一个参数是 Kafka 的连接配置
		// 比如 Brokers, Group, Topics 等,决定去哪儿拉消息、从哪个 topic 拉
		// 第二个参数是我们自己的消息处理逻辑,即消费到数据后要干什么
		// go-queue 负责把这两部分组合起来,创建出一个 Kafka 消费服务(Consumer Service),持续从 Kafka 拉消息并回调你的业务逻辑
		kq.MustNewQueue(l.svc.Config.MsgChatTransfer, msgTransfer.NewMsgChatTransfer(l.svc)),
	}
}
  • 在 go-zero 框架里,有一个叫 core/service 的模块,它定义了一个非常通用的接口,用来表示"可运行的后台服务"。
  • 只要一个结构体实现了 Start()Stop() 方法,它就算一个 service.Service
go 复制代码
type Service interface {
    Start()
    Stop()
}
  • 在 go-zero 的微服务架构中,可能有很多类型的"服务":
    • 一个 Kafka 消费服务(监听消息)
    • 一个 HTTP 服务(提供 REST 接口)
    • 一个 gRPC 服务(RPC 调用)
    • 一个定时任务(后台跑逻辑)
    • 一个 Redis 订阅服务(监听频道)
  • 这些虽然功能不同,但本质都是"可以启动、可以关闭的后台服务"
  • 所以 go-zero 统一用 service.Service 来表示它们,
    只要实现了这个接口,就能被框架统一管理、启动

  • 然后完善启动入口文件,启动服务
go 复制代码
var configFile = flag.String("f", "etc/local/mq.yaml", "the config file")

func main() {
	flag.Parse()
	var c config.Config

	conf.MustLoad(*configFile, &c)
	if err := c.SetUp(); err != nil {
		panic(err)
	}

	// 创建 go-zero 的服务组管理器,用来统一管理多个后台服务(HTTP、RPC、Kafka 等)
	serviceGrop := service.NewServiceGroup()
	// 确保程序退出时能优雅停止所有服务
	defer serviceGrop.Stop()

	// 初始化 ServiceContext,保存全局上下文,例如:配置、数据库连接、缓存客户端、其他共享资源等
	svcCtx := svc.NewServiceContext(c)
	
	// 创建一个 Listen 对象,用来管理所有异步任务
	// Services() 返回一个 service.Service 数组,其中包括 Kafka 消费者服务对象(由 kq.MustNewQueue() 创建)
	listen := handler.NewListen(svcCtx)
	for _, s := range listen.Services() {
		// 把这些服务注册到服务组
		serviceGrop.Add(s)
	}
	fmt.Println("Starting mqueue server at...")

	// 启动所有注册的服务(包括 Kafka 消费者)
	serviceGrop.Start()
}

基于Kafka异步数据存储落地及消息通信

回顾收发模型

  • 客户端先通过 WebSocket 向服务端发送消息,WebSocket 服务接收到消息后,会将消息异步投递到 Kafka 消息队列中。
  • 在消息队列机制中,主要承担两个任务:
    • 消息存储:将 IM 通信消息持久化存储到数据库中。
    • 消息转发与通知:通过 WebSocket 的客户端,将消息发送到对应的 WebSocket 服务实例。
  • 随后,用户所在的 WebSocket 服务会从消息队列中接收到推送的消息,并将消息转发给目标用户,实现即时通信。

实现分析

  • 实际上websocket与异步任务的处理关系从代码的体现上如下图:
  • WebSocket 服务会将接收到的消息写入消息队列的指定 Topic 中。
  • 消息队列从 Topic 中获取新的消息后,经过业务处理,再通过 WebSocket 客户端将信息发送给目标服务。
  • 目标服务收到消息后,负责将其转发给最终用户。
  • 因此,对于 WebSocket 与消息队列而言,各自需要维护对方的客户端实例,并基于实例对象完成相应的请求处理。

websocket客户端

  • 上文中基于 postman 测试 websocket 连接,能直接访问 /ws 是因为其本身已经封装了 WebSocket 客户端功能,会帮我们发送握手请求、管理连接和收发消息。
  • 用 localhost:端口/ws 建立连接时,Postman 会自动在 HTTP 请求中添加 WebSocket 握手所需的头信息。
go 复制代码
// 客户端发送一个 HTTP GET 请求到 /ws,带上特殊头部:
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: <随机值>
Sec-WebSocket-Version: 13

// 这个请求告诉服务端:"我想把这个 HTTP 连接升级为 WebSocket 协议"。
// Postman 在你选择 WebSocket 类型时,会自动帮你生成这些头,不需要你手动写。
  • 如果要在实际应用中完整实现访问,就需要自己封装一个客户端。
  • 对 websocket 的客户端而言只需要创建、关闭、发送、接收四个操作,进行如下的方式设计:
go 复制代码
// 首先定义好客户端的接口
type Client interface {
	Close() error
	Send(v any) error
	Read(v any) error
}

// 实现接口
// 在 send 方法中多做了一层处理,当发送不成功的时候再尝试建立一次连接发送,第二次发送是 容错机制,保证在客户端连接意外断开时,仍能尽量把消息送出去,而不是直接失败。

type client struct {
	// 客户端用库自带的 websocket.Dialer 建立连接
	// 之前在服务端的 Server 里封装的 conn,是服务端的连接对象,用来接收客户端的 WebSocket 请求、发送消息给客户端
	// 它的生命周期和作用都是在服务端管理的,属于 Server 端资源
	// 这里用的是标准库 gorilla/websocket 的连接对象,用来操作 WebSocket
	*websocket.Conn
	// WebSocket 服务的地址
	host string
	// 连接选项,例如 URL 路径、Header 等
	opt  dailOption
}

func NewClient(host string, opts ...DailOptions) *client {
	opt := newDailOption(opts...)

	c := &client{
		opt:  opt,
		host: host,
	}
	// 建立 WebSocket 连接
	conn, err := c.dail()
	if err != nil {
		panic(err)
	}
	c.Conn = conn
	return c
}

// 真正发起 WebSocket 连接
func (c *client) dail() (*websocket.Conn, error) {
	// 构造 WebSocket 连接的 URL
	u := url.URL{
	    Scheme: "ws",          // 协议使用 WebSocket
	    Host: c.host,          // 主机和端口,例如 "127.0.0.1:8080"
	    Path: c.opt.pattern,   // URL 路径,例如 "/chat"
	}

	// 建立连接,返回 *websocket.Conn
	// c.opt.header 的作用是自定义 HTTP 请求头,因为 WebSocket 连接其实是通过 HTTP/HTTPS 发起的握手请求的
	conn, _, err := websocket.DefaultDialer.Dial(u.String(), c.opt.header)
	
	return conn, err
}

func (c *client) Send(v any) error {
	data, err := json.Marshal(v)
	if err != nil {
		return err
	}

	// 往连接里发送消息(文本消息text)
	err = c.WriteMessage(websocket.TextMessage, data)
	if err == nil {
		return nil
	}

	// 发送失败了再建立一次连接
	conn, cerr := c.dail()
	if cerr != nil {
		return err
	}
	c.Conn = conn
	return c.WriteMessage(websocket.TextMessage, data)
}

func (c *client) Read(v any) error {
	// 从连接中读取消息
	_, msg, err := c.Conn.ReadMessage()
	if err != nil {
		return err
	}
	return json.Unmarshal(msg, v)
}

mq中的服务业务

  • 不仅是客户端连接 websocket 服务端需要鉴权,当消息队列(MQ)通过 WebSocket 客户端访问 WebSocket 服务时,同样需要进行身份验证。
  • 但是 MQ 肯定是鉴权通过的,此时,建议使用系统预先创建的 Root 角色 Token 进行访问。
  • 具体实现上,可以在程序启动时提前生成并缓存该 Root 角色的 Token,并在构建用户服务(User Service)时,将 Root 角色信息预先加入系统,以保证 MQ 客户端能够顺利进行身份认证并访问 WebSocket 服务。
go 复制代码
// user/rpc/internal/config/config.go
type Config struct {
	Redisx redis.RedisConf
}

// user/rpc/etc/local/user.yaml
Redisx:
  Host: 192.168.117.80:16379
  Pass: easy-im

// pkg/constants/redis.go
const (
	REDIS_SYSTEM_ROOT_TOEKN string = "system:root:token"
)

// 单独启动了一个 Redis 实例(或 Redis 库)
// 专门用于存放系统级数据,例如这里的 root 角色的 token
  • 系统启动时,user 服务从配置文件中读取 Redisx 连接信息;
  • 检查 REDIS_SYSTEM_ROOT_TOEKN 是否存在;
  • 如果不存在,则自动生成一个 root token 并写入 Redis;
  • 当 MQ 或 WebSocket Client 要访问需要认证的接口时,就从这个 Redis 里取出 root token 使用。

  • 接下来,因为需要是在 user 中设置 root 的 token,理论上即此操作因在 user 启动的时候完成 root 的 token 添加,故我们在 servicecontext 中增加 SetRootToken 方法
go 复制代码
func NewServiceContext(c config.Config) *ServiceContext {
	// 使用 go-zero 的 sqlx 包创建一个 MySQL 连接对象
	sqlConn := sqlx.NewMysql(c.Mysql.DataSource)
   
	return &ServiceContext{
		Config: c,
		
		// 使用了前面提到的 Redisx 配置
		// MustNewRedis 会直接创建 Redis 客户端实例
		Redis:      redis.MustNewRedis(c.Redisx),
		
		// 创建用户模型,用来操作用户表
		// 第二个参数 c.Cache 是另一个 Redis 配置(用于普通缓存,比如用户信息缓存);
		// 与 Redisx 不同,c.Cache 是面向业务层的缓存
		UserModels: models.NewUsersModel(sqlConn, c.Cache),
	}
}

// 设置超级权限的token
// 生成系统 root 用户的 JWT token 并存储到 Redis 中
func (svcCtx *ServiceContext) SetRootToken() error {
	systemToken, err := ctxdata.GetJwtToken(svcCtx.Config.Jwt.AccessSecret, time.Now().Unix(), 9999999, constants.SYSTEM_ROOT_UID)
	if err != nil {
		return err
	}
	return svcCtx.Redis.Set(constants.REDIS_SYSTEM_ROOT_TOEKN, systemToken)
}

// 在入口文件中进行调用
func main() {
	flag.Parse()

	var c config.Config
	conf.MustLoad(*configFile, &c)
	ctx := svc.NewServiceContext(c)
    // ..
	if err := ctx.SetRootToken(); err != nil {
		fmt.Errorf("%v", err)
	}

	fmt.Printf("Starting rpc server at %s...\n", c.ListenOn)
	s.Start()
}

消息通信结构

  • Kafka 在通信过程中传输的数据通常以字符串形式为主,因此生产者会先将消息序列化为 JSON 格式后写入指定的 Topic,供消费者订阅并消费处理。
  • 定义了一个结构体 MsgChatTransfer,用于表示 IM(即时通信)系统中在 Kafka 消息队列中传输的聊天消息结构,也就是消息生产者和消费者之间通信的数据格式。
go 复制代码
import "imooc.com/easy-im/pkg/constants"

type MsgChatTransfer struct {
	// 消息类型:1. 私聊、2. 群聊
	ChatType constants.ChatType `json:"chatType"`
	// 会话id
	ConversationId string `json:"conversationId"`
	// 发送者
	SendId string `json:"sendId"`
	// 接收者
	RecvId string `json:"recvId"`
  
    // 消息类型
	MsgType constants.MType `json:"msgTyp,omitempty"`
	// 消息内容
	MsgContent string `json:"msgContent,omitempty"`
	// 发送时间
	SendTime int64 `json:"sendTime"`
}
  • 生产者(例如 WebSocket 服务) 会将该结构体序列化为 JSON 字符串后发送到 Kafka 的 Topic 中;
  • 消费者(例如 MQ 消费服务) 从 Kafka 中取出 JSON 消息后,反序列化成 MsgChatTransfer 对象;
  • 然后消费者可以根据字段内容执行不同的业务逻辑,比如:
    • 存储消息记录;
    • 转发消息给接收方;
    • 推送通知等。
go 复制代码
// 配置

// 定义了程序运行时需要依赖的外部服务配置
// 当程序启动时,会从一个配置文件(比如 mq.yaml)中读取对应字段并自动赋值到该结构体中
type Config struct {
    // Redis 配置,用于连接缓存数据库
    Redisx redis.RedisConf
  
    // MongoDB 配置
    Mongo struct {
        Url string  // MongoDB 的连接地址
        Db  string  // 使用的数据库名
    }

    // WebSocket 服务配置
    Ws struct {
        Host string // WebSocket 服务的监听地址
    }
}

// 配置文件内容
Redisx:
  Host: 192.168.117.80:16379
  Type: node
  Pass: easy-im

Mongo:
  Url: "mongodb://root:easy-im@192.168.117.80:47017"
  Db: easy-im

Ws:
  Host: 127.0.0.1:10090
go 复制代码
// 整个 mq 服务运行时的"上下文环境"
// 在服务启动时,从 Redis 中读取系统的 root 角色 Token(用于鉴权)
// 使用这个 Token 创建一个带鉴权的 WebSocket 客户端实例,供后续消息推送使用

type ServiceContext struct {
	Config config.Config

	WsClient websocket.Client
	Redis    *redis.Redis

	imModels.ChatLogModel
	imModels.ConversationsModel
}

func NewServiceContext(c config.Config) *ServiceContext {

	svcCtx := &ServiceContext{
		Config:             c,
		Redis:              redis.MustNewRedis(c.Redisx),
		ChatLogModel:       imModels.MustChatLogModel(c.Mongo.Url, c.Mongo.Db),
		ConversationsModel: imModels.MustConversationsModel(c.Mongo.Url, c.Mongo.Db),
	}
	token, err := svcCtx.GetToken()
	if err != nil {
		fmt.Errorf("getToken err %v", err)
	}

	header := http.Header{}
	header.Set("Authorization", token)
	svcCtx.WsClient = websocket.NewClient(c.Ws.Host, websocket.WithDailHeader(header))

	return svcCtx
}

func (svcCtx *ServiceContext) GetToken() (string, error) {
	return svcCtx.Redis.Get(constants.REDIS_SYSTEM_ROOT_TOEKN)
}

websocket将消息推送到kafka的指定topic

  • 接下来完成 websocket 将消息写入到消息队列中的逻辑,而写入队列则需要运用到 mq 客户端,为此我们在 mq 中构建一个对外的 mqclient 对象。
go 复制代码
// 定义了一个 mq client 接口
type MsgChatTransferClient interface {
	Push(msg *mq.MsgTransfer) error
}

// 具体实现结构体
// 内部持有一个 kq.Pusher 对象(go-queue 提供的生产者工具)
// 用于实际推送消息到 Kafka
type msgChatTransferClient struct {
	pusher *kq.Pusher
}

// 根据 Kafka 地址列表 addrs 和 topic 名称创建一个客户端实例
// 内部通过 kq.NewPusher 初始化 Kafka 生产者
func NewMsgChatTransferClient(addrs []string, topic string, opts ...kq.PushOption) *msgTransferClient {
	return &msgChatTransferClient{
		pusher: kq.NewPusher(addrs, topic),
	}
}

func (c *msgChatTransferClient) Push(msg *mq.MsgChatTransfer) error {
	body, err := json.Marshal(msg)
	if err != nil {
		return err
	}
	// 调用 kq.Pusher.Push 将 JSON 消息发送到 Kafka 指定的 topic
	return c.pusher.Push(string(body))
}
go 复制代码
// 修改对应的配置

type Config struct {
	MsgChatTransfer struct {
		Addrs []string
		Topic string
	}
}

MsgChatTransfer:
  Addrs:
    - 192.168.117.80:9092
  Topic: msgChatTransfer

import (
	"github.com/zeromicro/go-zero/core/stores/sqlx"
	"imooc.com/easy-im/apps/im/models"
	"imooc.com/easy-im/apps/im/ws/internal/config"
	"imooc.com/easy-im/apps/task/mq/mqclient"
)

type ServiceContext struct {
	Config config.Config

	models.ChatLogModel
	models.ConversationsModel
	MsgChatTransfer mqclient.MsgChatTransferClient

}

func NewServiceContext(c config.Config) *ServiceContext {
	sqlConn := sqlx.NewMysql(c.Mysql.DataSource)

	return &ServiceContext{
		Config: c,

		ChatLogModel:       models.MustChatLogModel(c.Mongo.Url, c.Mongo.Db),
		ConversationsModel: models.MustConversationsModel(c.Mongo.Url, c.Mongo.Db),

		MsgTransfer: mqclient.NewMsgChatTransferClient(c.MsgChatTransfer.Addrs, c.MsgChatTransfer.Topic),
	}
}

  • 所以现在我们的 Chat 函数也要做相应的修改,因为引入了 Kafka 异步任务
go 复制代码
// 之前的逻辑
func Chat(srvCtx *svc.ServiceContext) server.HandlerFunc {
	return func(srv *server.Server, conn *server.Conn, msg *server.Message) {
		var data types.Chat
		if err := mapstructure.Decode(msg.Data, &data); err != nil {
			srv.Send(server.NewErrMessage(err), conn)
			return
		}

		var data ws.Chat

		if err := mapstructure.Decode(msg.Data, &data); err != nil {
		    srv.Send(websocket.NewErrMessage(err), conn)
		    return
		}
		
		switch data.ChatType {
		case constants.SingleChatType:
		    err := logic.NewConversation(context.Background(), srv, svc).SingleChat(&data, conn.Uid)
		    if err != nil {
		        srv.Send(websocket.NewErrMessage(err), conn)
		        return
		    }
		
// -------------------------------------------------------------------------


// 修改后
// im/ws/internal/handler/conversation/conversation.go
func Chat(svcCtx *svc.ServiceContext) websocket.HandlerFunc {
	return func(srv *websocket.Server, conn *websocket.Conn, msg *websocket.Message) {
		var data ws.Chat
		if err := mapstructure.Decode(msg.Data, &data); err != nil {
			srv.Send(websocket.NewErrMessage(err), conn)
			return
		}

		
		switch data.ChatType {
		case constants.SingleChatType:
			err := svcCtx.MsgChatTransfer.Push(&mq.MsgChatTransfer{
				ChatType:       data.ChatType,
				ConversationId: data.ConversationId,
				SendId:         conn.Uid,
				RecvId:         data.RecvId,
				MsgType:        data.MType,
				MsgContent:     data.Content,
				SendTime:       time.Now().UnixNano(),
			})
	
			if err != nil {
				srv.Send(websocket.NewErrMessage(err), conn)
			}
		}
	}
}

消费者从消息队列消费消息

  • 属于任务处理(task/mq)模块
  • 主要用于从 Kafka 消息队列中消费聊天消息,并负责消息入库 + WebSocket 推送
go 复制代码
import (
	"context"
	"encoding/json"
	"github.com/zeromicro/go-queue/kq"
	"github.com/zeromicro/go-zero/core/logx"
	"go.mongodb.org/mongo-driver/mongo"
	imModels "imooc.com/easy-im/apps/im/models"
	"imooc.com/easy-im/apps/im/ws/websocket"
	"imooc.com/easy-im/apps/task/mq/internal/svc"
	"imooc.com/easy-im/apps/task/mq/mq"
	"imooc.com/easy-im/pkg/constants"
)

// Kafka 消费者的业务逻辑处理器
type MsgChatTransfer struct {
	logx.Logger
	svcCtx *svc.ServiceContext
}

// 创建一个新的消息消费者对象(MsgChatTransfer)
// 返回一个 Kafka 消费处理器(kq.ConsumeHandler)
// 供 go-zero 的 Kafka 消费模块使用
func NewMsgChatTransfer(svc *svc.ServiceContext) kq.ConsumeHandler {
	return &MsgChatTransfer{
		Logger: logx.WithContext(context.Background()),
		svcCtx: svc,
	}
}

// Consume 方法是 Kafka 消费者框架自动调用的
// 参数 value 是从 Kafka topic 中读取到的消息(字符串)
func (m *MsgChatTransfer) Consume(key, value string) error {
	var (
		data mq.MsgTransfer
		ctx  = context.Background()
	)
	// 反序列化
	if err := json.Unmarshal([]byte(value), &data); err != nil {
		return err
	}

	// 记录消息,将消息写入 MongoDB 中的聊天记录表
	if err := m.addChatLog(ctx, data); err != nil {
		return err
	}

	// 推送发送
	// 通过 WebSocket 客户端将消息推送给目标在线用户
	return m.svcCtx.WsClient.Send(websocket.Message{
		FrameType: websocket.FrameData,
		Method:    "push",
		FormId:    constants.SYSTEM_ROOT_UID,
		Data:      data,
	})
}

// 记录消息,将消息写入 MongoDB 中的聊天记录表
func (m *MsgChatTransfer) addChatLog(ctx context.Context, data mq.MsgTransfer) error {
	chatLog := imModels.ChatLog{
		ConversationId: data.ConversationId,
		SendId:         data.SendId,
		RecvId:         data.RecvId,
		MsgType:        data.MsgType,
		MsgContent:     data.MsgContent,
		ChatType:       data.ChatType,
	}
	return m.svcCtx.ChatLogModel.Insert(ctx, &chatLog)
}

websocket将接收到的mq的消息推送给用户

go 复制代码
// 转发方法

type (
	Push struct {
		// 消息类型:1. 私聊、2. 群聊
		ChatType constants.ChatType `json:"chatType"`
		// 会话id
		ConversationId string `json:"conversationId"`
		// 发送者
		SendId string `json:"sendId"`
		// 接收者
		RecvId string `json:"recvId"`
      
		constants.MType `mapstructure:"mType"`
		Content         string `mapstructure:"content"`
		// 发送时间
		SendTime int64 `json:"sendTime"`
	}
)

func Push(svcCtx *svc.ServiceContext) websocket.HandlerFunc {
	return func(srv *websocket.Server, conn *websocket.Conn, msg *websocket.Message) {
		var data ws.Push
		if err := mapstructure.Decode(msg.Data, &data); err != nil {
			srv.Send(websocket.NewErrMessage(err), conn)
			return
		}

		rconn := srv.GetConn(data.RecvId)
		if rconn == nil {
			// 离线
			return0
		}

		srv.Infof("push msg %v", msg)

		srv.Send(websocket.NewMessage(data.SendId, &ws.Chat{
			ConversationId: data.ConversationId,
			Msg: ws.Msg{
				MType:   data.MType,
				Content: data.Content,
			},
		}), rconn)
	}
}

func RegisterHandlers(srv *websocket.Server, svcCtx *svc.ServiceContext) {
	srv.AddRoutes([]websocket.Route{
		{
			Method:  "push",
			Handler: push.Push(svcCtx),
		},
	})
}

消息收发ack

消息收发的两种ack方向

  • 在IM系统的消息传递中,ACK(acknowledgement)是"确认应答"的意思,用来保证消息的可靠传输。这里指出有两种情况:
    • 客户端发送 → 服务端确认(客户端发消息):客户端把一条消息发送给服务器后,服务器收到消息后需要返回一条"ack确认"来表示"我收到了"。用于保证消息发送成功。
    • 服务端发送 → 客户端确认(服务端推送消息):当服务器向客户端推送消息时,客户端也要返回ack确认,表示"我收到了消息"。用于保证消息接收成功。

ack机制的三种使用模式

  • 根据系统需求和可靠性要求不同,ACK机制可以分为三种模式:
    • 不采用ACK机制:即"发完即止",不管消息是否真正到达,不需要确认。适用于不太重要的消息(如系统心跳、状态同步)。
    • 一次应答处理(单次ACK):发送方发出消息,接收方返回一次确认。最常见的做法,平衡了可靠性和性能。
    • 三次通信机制(增强可靠性):通过三次通信确保消息可靠:
      • 客户端发送 → 服务端确认(ack1)
      • 服务端再返回 → 客户端确认(ack2)
      • 若超时未确认,则重传。
      • 用于对消息丢失极度敏感的场景(如金融消息、系统通知)。

不同方向ack机制的权重差异

  • 客户端发送 → 服务端确认:这一方向的ACK很重要,因为消息必须被服务器接收并持久化,确保消息发送成功。权重高。
  • 服务端发送 → 客户端确认:这一方向相对次要,因为IM系统常常使用"客户端定时拉取(pull)"机制,即使客户端暂时未收到推送消息,也可以通过下次拉取补回来。权重较低,可选实现。

分析基于收的ack机制实现

  • 现在服务的实现中,handlerConn 中的处理是先接收请求消息,然后根据消息的类型进行任务的处理。
go 复制代码
// 服务端从 WebSocket 持续读取消息;
// 解析出消息结构体;
// 根据 FrameType 判断类型;
// 如果是业务消息(FrameData),就分发给相应的处理函数。

func (s *Server) handlerConn(conn *Conn) {
	// 记录连接
	for {
		_, msg, err := conn.ReadMessage()
		if err != nil {
			// 关闭并删除连接
			s.Close(conn)
			return
		}

		// 请求信息
		var message Message
		json.Unmarshal(msg, &message)

		// 依据请求消息类型分类处理
		switch message.FrameType {
		case FramePing:
			// ping:回复
			s.Send(&Message{FrameType: FramePing}, conn)
		case FrameData:
			// 处理
			if handler, ok := s.routes[message.Method]; ok {
				handler(s, conn, &message)
			} else {
				s.Send(&Message{
					FrameType: FrameData,
					Data:      fmt.Sprintf("不存在请求方法 %v 请仔细检查", message.Method),
				}, conn)
			}
		}
	}
}
  • 目前的逻辑中,服务端在收到消息后就直接处理任务,没有做任何确认机制。这会导致:
    • 客户端不知道服务端是否真正"收到"消息;
    • 如果网络中断或消息丢失,客户端无法判断需不需要重发。
  • 为了解决这些问题,我们引入 ACK机制(确认机制)。
  • 执行流程: 获取数据 → ack 处理 → 任务处理
  • 在引入 ACK 机制后,handlerConn 不再是"读取后直接处理",而是拆分为三步流程:读取消息(获取数据) → 确认接收(ACK) → 执行业务(任务处理),从而实现消息传递的可靠性和系统逻辑的清晰分层。

连接属性设计

  • 都是耗时任务且具有顺序流程 → 异步运行,并基于相关队列机制投递任务进行处理。
  • 可以在 Conn 中定义 readMessagesreadMessageSeqmessage 三个属性用于整个连接在接收信息及ack验证与任务处理的信息通信。
go 复制代码
type Conn struct {
	// 锁
	messageMu       sync.Mutex
	// 消息接收队列
	// 保存当前连接中所有"已接收到、等待 ACK 处理"的消息
	// 数组天然保持插入顺序,有利于后续做"顺序消费"
	readMessages    []*Message
	// ACK 状态记录表
	// 保存每条消息对应的 ACK 状态
	// 记录ack机制中的消息处理结果与进展
	// 用于判断消息是否已完成 ACK,可记录重传、超时、丢失等状态
	readMessageSeq  map[string]*Message
	// 任务分发通道
	// 当某条消息通过 ACK 验证后,将其投递给业务处理逻辑(handlerWrite)
	// 有缓冲(1)避免发送阻塞
	// 但缓冲区极小,保证消息按序投递
	// 发出的消息一定是经过 ACK 验证的合法消息
	message chan *Message

	done chan struct{}
}

func NewConn(s *Server, w http.ResponseWriter, r *http.Request) *Conn {
	c, err := s.upgrader.Upgrade(w, r, nil)
	if err != nil {
		s.Error("upgrade http conn err", err)
		return nil
	}

	conn := &Conn{
		Conn:              c,
		s:                 s,
		idle:              time.Now(),
		maxConnectionIdle: defaultMaxConnectionIdle,
		done:              make(chan struct{}),
		message:           make(chan *Message, 1),
		readMessageSeq:    make(map[string]*Message),
	}

	if s.opt.maxConnectionIdle > 0 {
		conn.maxConnectionIdle = s.opt.maxConnectionIdle
	}

	// 增加对客户端的健康检测
	go conn.keepalive()

	return conn
}
  • 而 message 在创建的时候创建的并非有缓冲通道而是无缓冲通道其容量为1,采用该方式的目的主要是减少数据在投递过程中的阻塞,而容量为1刚刚好使得 channel 是有缓存,但因为容量只有1可以保证发送和接收操作的顺序,从而确保数据的有序性。

options配置与消息属性

  • 因ack的模式以及场景情况,我们可以先为ack定义好它的模式:
go 复制代码
type AckType int

const (
	// 不进行ack确认
	NoAck AckType = iota
	// 只回 - 两次通信
	OnlyAck
	// 严格 - 三次通信
	RigorAck
)

func (t AckType) ToString() string {
	switch t {
	case OnlyAck:
		return "OnlyAck"
	case RigorAck:
		return "RigorAck"
	}
	return "NoAck"
}
  • 在 options 定义好对ack配置的属性项, 在配置项中我们还定义一个 ackTimeout 属性,因为在ack的机制中可能客户端一直未返回ack的信息,顾可以设置 ackTimeout,如果超过这个时间说明目标接收消息有误可计入日志,以程序陷入死循环。
go 复制代码
type option struct {
	ack          AckType
	ackTimeout   time.Duration
}

func WithAck(ack AckType) Options {
	return func(opt *option) {
		opt.ack = ack
	}
}

// 默认属性
// ws/websocket/defaults.go
const (
	defaultAckTimeout = 30 * time.Second
)

// ws/websocket/option.go
func newOption(opts ...Options) option {
	o := option{
		ackTimeout:     defaultAckTimeout,
	}
	return o
}
  • 再定义 FrameNoAck 请求信息类型,以满足某些请求不需要进行ack的情况
go 复制代码
type FrameType uint8

const (
	FrameData  FrameType = 0x0
	FramePing  FrameType = 0x1
	FrameErr   FrameType = 0x2
	FrameAck   FrameType = 0x3
	FrameNoAck FrameType = 0x4
	FrameCAck  FrameType = 0x5
)
  • 然后在 message 结构体中增加消息 idackSeqackTime 发送的时间
go 复制代码
type Message struct {
	FrameType `json:"frameType"`
	Id       string      `json:"id"`
	AckSeq   int         `json:"ackSeq,omitempty"`
	ackTime  time.Time   `json:"-"`
	errCount int         `json:"-"`
	Method   string      `json:"method,omitempty"`
	FormId   string      `json:"formId,omitempty"`
	Data     interface{} `json:"data,omitempty"`
}

实现收消息ack机制

  • 客户端发消息到服务端(收到并加入消息队列,AckSeq 初始为 0)。
  • 服务端保存消息并发送到需要的处理模块;同时等待客户端 ack。
  • 客户端或其它确认来源发送 ack,readAck 验证并更新 AckSeq。
  • 一旦 AckSeq 达到预期(或通过其他策略确认),服务端将消息从 队列删除并把消息写入下游。
  • 若超时没 ack,按策略重发。失败次数过多则做失败处理或告警。

  • 我们为 Conn 增加一个用于添加消息记录队列的 appendMsgMq 方法,用于接收并管理消息对象 msg。
  • 在消息处理流程中,收到的消息既可能是首次接收的业务请求,也可能是已经发送过的 ack 确认消息。
  • 因此,整体处理逻辑可以设计为以下几个步骤:
    • 检查历史 ack 记录:首先判断当前消息 ID 是否已存在于 readMessageSeq(ack 记录表)中。
    • 验证处理状态:如果已存在,则检查该消息是否已经被处理过,防止重复执行。
    • 判断 ack 消息类型:若当前消息属于 ack 类型(例如 FrameAck、FrameCAck),则根据 ack 序列号更新对应的 ack 记录状态。
    • 更新 ack 状态:将最新的 ack 状态或时间戳写回 readMessageSeq,确保记录最新的消息确认进度。
    • 重复校验:再次确认该消息是否仍需进入业务处理(非 ack 类型或未处理完毕的情况)。
    • 加入消息队列:若消息仍需后续业务处理(即非 ack 或待处理消息),则将其加入 readMessages 队列,等待后续任务调度执行。
go 复制代码
// 将消息记录到队列中
func (c *Conn) appendMsgMq(msg *Message) {
	// 因为多个 goroutine(例如读写协程)可能同时访问 Conn 的消息队列
	// 这里通过 sync.Mutex 互斥锁保护数据一致性,避免并发写入造成竞态问题
	c.messageMu.Lock()
	defer c.messageMu.Unlock()
	
	// 检查当前收到的消息 msg.Id 是否在 ack 记录表中出现过
	// 如果出现过,说明这条消息之前处理过
	// 如果没有出现,则说明是第一次收到
	if m, ok := c.readMessageSeq[msg.Id]; ok {
		// 客户端可能重复发送了消息 或者收到ack消息
		// 如果当前消息队列为空,说明前面的消息都已经被消费完
		// 此时同一个消息再次出现,可以判定为重复发送(可能是客户端重发),直接丢弃
		if len(c.readMessages) == 0 {
			// 数据已经被处理不存在,顾属于重复了
			return
		}
		
		// 不是同一条消息(正常情况不会出现),丢弃
		// ack 序列号比当前消息还新或相等,说明这是旧消息或重复 ack,也丢弃
		if m.Id != msg.Id || m.AckSeq >= msg.AckSeq {
			// 数据已经被处理不存在,顾属于重复了
			// ack的消息内容还是一致或大于接收的序号说明也是重复了
			return
		}

		// 更新最新的 ack 状态
		// 如果通过前面的检查,说明这是一条新 ack 或者状态更新的消息
		// 则更新当前消息的 ack 状态记录表(readMessageSeq),不再重复入队
		c.readMessageSeq[msg.Id] = msg
		return
	}

	// 因为 ack 类型的消息只是确认作用,不属于需要业务处理的"真实消息"
	// 直接跳过,不投递进 readMessages 队列
	// 过滤掉 ack 消息(不入业务队列)
	if msg.FrameType == FrameAck {
		return
	}
	
	// 将新消息加入 readMessages 队列(按顺序存放,供任务处理协程读取)
	c.readMessages = append(c.readMessages, msg)
	// 同时在 ack 记录表中注册它,便于之后 ack 确认或查重
	c.readMessageSeq[msg.Id] = msg
}
  • appendMsgMq 是 IM WebSocket 系统中 ack 消息管理与消息入队的核心逻辑,在接收消息时,判断该消息是否是重复消息或 ack 消息,并决定是否需要将消息加入处理队列。
    • 管理消息队列 readMessages
    • 维护 ack 状态表 readMessageSeq
    • 过滤重复消息和纯 ack 消息;
    • 将有效的新消息加入处理队列。

属性 类型 作用
readMessages []*Message 队列(切片) 存放待业务处理的消息(FIFO)
readMessageSeq map[string]*Message 哈希表 记录每条消息的ack 状态(即是否已处理、序列号等)
  • "len(c.readMessages)==0 为什么就代表重复消息?",因为消息的 id 是不会重复的,既然已经记录在了 readMessageSeq 里面,证明我们已经收到过这条消息了,但是现在 readMessages 为空,证明消息已经被处理了,所以不应该重复处理。
  • 为什么更新记录 c.readMessageSeq[msg.Id] = msg?不会覆盖原纪录吗?readMessageSeq 是 "状态表",不是消息缓存。记录消息的最新处理状态(如 ack 序列号、时间等),对于同一个 msg.Id,新的 ack 或重发会覆盖旧状态,只关心最新的确认进度,旧的状态已经没意义。因为重发的消息有可能:携带了更新的 ack 序号(比之前的更大 → 表示确认了更多消息);携带了新的时间戳 / 状态信息;也可能是完全重复的(就被上面 if 拦截掉)。
  • 所以当确定是"同一条消息"的情况下,我们允许它更新状态表(覆盖旧状态),这样可以保证记录始终是"最新的消息状态"。
  • 重复消息 ≠ 无效消息。如果重复消息是"重发",那它的存在意义往往是为了更新 ack 状态或修正丢包状态。因此即便覆盖,也不会有副作用------反而能保证状态表 readMessageSeq 始终是最新的。

  • 接着我们将 server 中对消息处理的代码逻辑封装到 handleWrite 中:
go 复制代码
func (s *Server) handleWrite(conn *Conn) {
	for {
		select {
		case <-conn.done:
			// 结束
			return
		case message := <-conn.message:
			// 依据请求消息类型分类处理
			switch message.FrameType {
			case FramePing:
				// ping:回复
				s.Send(&Message{FrameType: FramePing}, conn)
			case FrameData, FrameNoAck:
				// 处理
				if handler, ok := s.routes[message.Method]; ok {
					handler(s, conn, message)
				} else {
					s.Send(&Message{
						FrameType: FrameData,
						Data:      fmt.Sprintf("不存在请求方法 %v 请仔细检查", message.Method),
					}, conn)
				}
			}
          
            if s.opt.ack != NoAck {
				// 删除 消息ack的序号记录
				conn.messageMu.Lock()
				delete(conn.readMessageSeq, message.Id)
				conn.messageMu.Unlock()
			}
		}
	}
}
  • handleWrite 是整个 ack 机制闭环的最后一环 ------也就是 消息被业务逻辑处理完成后,清理掉 ack 记录。
  • 接着,修改一下 handlerConn 的内容:
go 复制代码
func (s *Server) handlerConn(conn *Conn) {
	conn.Uid = s.GetUser(conn)
	// 处理写
	// 单独开一个协程来处理消息投递
	// 负责消费消息通道 conn.message 中的消息
	// 让读写分离,提高并发性能;防止读操作阻塞写操作
	go s.handleWrite(conn)
  
  // 当 ack 模式开启时,会额外启动一个 readAck 协程
	// 用于持续监听客户端返回的 ack 确认消息
	// 更新 readMessageSeq 状态表
	if s.opt.ack != NoAck {
		// 接收ack确认
		go s.readAck(conn)
	}
  
	for {
		_, msg, err := conn.ReadMessage()
		if err != nil {
			// 关闭并删除连接
			s.Close(conn)
			return
		}

		// 请求信息
		var message Message
		if err := json.Unmarshal(msg, &message); err != nil {
			s.Send(NewErrMessage(err), conn)
			continue
		}

		if s.opt.ack != NoAck && message.FrameType != FrameNoAck {
			// 这条消息需要ack可靠投递
			// 调用 appendMsgMq 进入 ack 队列
			s.Infof("conn message write in msgMq %v", message)
			conn.appendMsgMq(&message)
		} else {
			// 不需要 ack 验证
			// 直接丢进消息通道给 handleWrite
			conn.message <- &message
		}
	}
}
  • handlerConn 是连接的主控制器,每当有一个新的客户端连接成功升级到 WebSocket,服务端就会为它启动一个独立的 handlerConn 协程。
  • 接下来我们实现 readAck 的确认机制,readAck 负责在服务端持续监听并处理消息的确认状态(ack),保证消息在不同 ack 模式下的可靠传递。
go 复制代码
// 消息进行ack确认
func (s *Server) readAck(conn *Conn) {

	for {
		select {
		case <-conn.done:
			// 关闭了连接
			s.Infof("close message ack uid %v", conn.Uid)
			return
		default:
		}

		conn.messageMu.Lock()
		if len(conn.readMessages) == 0 {
			conn.messageMu.Unlock()
			// 没有消息可以睡眠100 毫秒 -- 目的是让程序缓一缓
			time.Sleep(100 * time.Microsecond)
			continue
		}

		// 取出队列中的第一个数据
		message := conn.readMessages[0]
		
		// 根据ack的确认策略选择合适的处理方式
		switch s.opt.ack {
		// OnlyAck 分支
		// 发送一次 Ack(把 AckSeq+1 发给客户端)
		// 从队列删除该消息(代表已完成 ack 流程)
		// 解锁后把消息发给 handleWrite(业务处理)
		// 记录日志
		case OnlyAck:
			s.Send(&Message{
				FrameType: FrameAck,
				AckSeq:    message.AckSeq + 1,
				Id:        message.Id,
			}, conn)
			// 只回答, 向客户端发送ack
			conn.readMessages = conn.readMessages[1:]
			conn.messageMu.Unlock()
			conn.message <- message
			s.Infof("message ack OnlyAck send success mid %v", message.Id)
			
		// RigorAck 分支(严格三次确认)
		// RigorAck 表示严格三次通信的 Ack 模式
		// 整个流程的核心目标是确保客户端确实收到了消息,才把消息投递给业务处理
		// 比 OnlyAck 更严格,需要确认客户端 ACK 再处理
		case RigorAck:
			if message.AckSeq == 0 {
				// 还未发送过确认信息
				conn.readMessages[0].AckSeq++
				conn.readMessages[0].ackTime = time.Now()
				s.Send(&Message{
					FrameType: FrameAck,
					AckSeq:    message.AckSeq,
					Id:        message.Id,
				}, conn)
				conn.messageMu.Unlock()
				s.Infof("message ack RigorAck send mid %v, seq %v, time %v", message.Id, message.AckSeq, message.ackTime.Unix())
				continue
			}

			// msgSeq.AckSeq 是服务器最后一次发送的 Ack 序号
			// message.AckSeq 是队列消息的当前序号
			// 如果客户端返回的 Ack 已更新到比服务器发送序号大,说明客户端收到了消息
			msgSeq := conn.readMessageSeq[message.Id]
			// 因为系统收到的消息的最新状态都会更新在 readMessageSeq 里面,所以如果收到了客户端的第二次 ack,也会保存在里面,时刻维持最新状态
			// 如果客户端返回的 AckSeq 已经大于当前 message.AckSeq,认为客户端已确认,处理该消息
			if msgSeq.AckSeq > message.AckSeq {
				// 客户端确认成功,可以处理业务了
				conn.readMessages = conn.readMessages[1:]
				conn.messageMu.Unlock()
				conn.message <- message
				s.Infof("message ack RigorAck success mid %v ", message.Id)
				continue
			}

			// 很显然没有处理成功,先看看有没有超时
			val := s.opt.ackTimeout - time.Since(message.ackTime)
			if !message.ackTime.IsZero() && val <= 0 {
				// todo: 超时了,可以选择断开与客户端的连接,但实际具体细节处理仍然还需自己结合业务完善,此处选择放弃该消息
				s.Errorf("message ack RigorAck fail mid %v, time %v because timeout", message.Id, message.ackTime)
				delete(conn.readMessageSeq, message.Id)
				conn.readMessages = conn.readMessages[1:]
                conn.messageMu.Unlock()
                continue
			} 
          
            conn.messageMu.Unlock()
            // 未超时,重发 Ack
            if val > 0 && val > 300*time.Microsecond {
                s.Send(&Message{
                    FrameType: FrameAck,
                    AckSeq:    message.AckSeq,
                    Id:        message.Id,
                }, conn)
            }
            // 没有超时,我们让程序等等
			time.Sleep(300 * time.Microsecond)
		}
	}
}
  • readAck 的职责是:在循环中按顺序检查 conn.readMessages,根据当前 Ack 策略(OnlyAck / RigorAck)向客户端发送确认帧、等待客户端回 ACK 或重发,当确认完成后把消息投递到业务通道并清理状态。
  • 还可以对 ack 机制再优化,上面的流程中可能 send 失败,因此我们还可以记录失败的次数再处理:
go 复制代码
func (s *Server) readAck(conn *Conn) {

	send := func(msg *Message, conn *Conn) error {
		err := s.Send(msg, conn)
		if err == nil {
			return nil
		}

		s.Errorf("message ack OnlyAck send err %v message %v", err, msg)
		conn.readMessages[0].errCount++
		conn.messageMu.Unlock()

		tempDelay := time.Duration(200*conn.readMessages[0].errCount) * time.Microsecond
		if max := 1 * time.Second; tempDelay > max {
			tempDelay = max
		}

		time.Sleep(tempDelay)
		return err
	}

	for {
		select {
		case <-conn.done:
			// 关闭了连接
			s.Infof("close message ack uid %v", conn.Uid)
			return
		default:
		}

		conn.messageMu.Lock()
		if len(conn.readMessages) == 0 {
			conn.messageMu.Unlock()
			// 没有消息可以睡眠100 毫秒 -- 目的是让程序缓一缓
			time.Sleep(100 * time.Microsecond)
			continue
		}

		// 取出队列中的第一个数据
		message := conn.readMessages[0]
		if message.errCount > s.opt.sendErrCount {
			s.Infof("conn send fail, message %v, ackType %v, maxSendErrCount %v", message, s.opt.ack.ToString(), s.opt.sendErrCount)
			conn.messageMu.Unlock()
			// todo:因为发送消息多次错误,而选择放弃消息
			delete(conn.readMessageSeq, message.Id)
			conn.readMessages = conn.readMessages[1:]
			continue
		}

		// 根据ack的确认策略选择合适的处理方式
		switch s.opt.ack {
		case OnlyAck:
			if err := send(&Message{
				FrameType: FrameAck,
				AckSeq:    message.AckSeq + 1,
				Id:        message.Id,
			}, conn); err != nil {
				continue
			}
			// 只回答, 向客户端发送ack
			conn.readMessages = conn.readMessages[1:]
			conn.messageMu.Unlock()
			conn.message <- message
			s.Infof("message ack OnlyAck send success mid %v", message.Id)
		case RigorAck:
			if message.AckSeq == 0 {
				// 还未发送过确认信息
				conn.readMessages[0].AckSeq++
				conn.readMessages[0].ackTime = time.Now()
				if err := send(&Message{
					FrameType: FrameAck,
					AckSeq:    message.AckSeq,
					Id:        message.Id,
				}, conn); err != nil {
					continue
				}

				conn.messageMu.Unlock()
				s.Infof("message ack RigorAck send mid %v, seq %v, time %v", message.Id, message.AckSeq, message.ackTime.Unix())
				continue
			}

			// 已经发送过序号了,需要等待客户端返回确认
			msgSeq := conn.readMessageSeq[message.Id]
			if msgSeq.AckSeq > message.AckSeq {
				// 客户端确认成功,可以处理业务了
				conn.readMessages = conn.readMessages[1:]
				conn.messageMu.Unlock()
				conn.message <- message
				s.Infof("message ack RigorAck success mid %v ", message.Id)
				continue
			}

			// 很显然没有处理成功,先看看有没有超时
			val := s.opt.ackTimeout - time.Since(message.ackTime)

			if !message.ackTime.IsZero() && val <= 0 {
				// todo: 超时了,可以选择断开与客户端的连接,但实际具体细节处理仍然还需自己结合业务完善,此处选择放弃该消息
				s.Errorf("message ack RigorAck fail mid %v, time %v because timeout", message.Id, message.ackTime)
				delete(conn.readMessageSeq, message.Id)
				conn.readMessages = conn.readMessages[1:]
				conn.messageMu.Unlock()
				continue
			}

			conn.messageMu.Unlock()
			if val > 0 && val > 300*time.Microsecond {
				if err := send(&Message{
					FrameType: FrameAck,
					AckSeq:    message.AckSeq,
					Id:        message.Id,
				}, conn); err != nil {
					continue
				}
			}
			// 没有超时,我们让程序等等
			time.Sleep(300 * time.Microsecond)
		}
	}
}
  • 整体流程可以总结成几个关键点:
    • 消息接收阶段(handlerConn)
      1. 每条客户端发来的消息都会经过 重复检查:
        • 通过 readMessageSeq 判断该 msg.Id 是否已有记录。
        • 如果是重复消息或 Ack 消息 → 忽略。
        • 如果是新消息 → 调用 appendMsgMq 放入 队列 readMessages,同时在 readMessageSeq 记录状态。
      2. 无需 Ack 的消息:
        • 直接投递到 conn.message 通道,让处理协程立即处理。
    • Ack 处理阶段(readAck 协程)
      1. 循环读取队列 readMessages:
        • 按队首顺序逐条处理,保证顺序消费。
        • 根据配置 AckType:
          • OnlyAck → 发一次 Ack,删除队列并投递消息。
          • RigorAck → 发 Ack 并等待客户端确认:
            • 客户端确认 → 删除队列,投递消息。
            • 客户端未确认 → 重发 Ack 或超时处理。
      2. 锁保护:
        • 对 readMessages 和 readMessageSeq 的操作都加锁,防止并发写冲突。
      3. 超时控制:
        • 避免客户端一直不回 Ack 导致消息堵塞。
    • 消息处理阶段(handleWrite 协程)
      1. 从 conn.message 通道读取消息。
      2. 根据消息类型(FrameData、FrameNoAck 等)执行业务处理。
      3. 对于严格 Ack 的消息,readAck 协程已经确保消息被客户端确认才投递到这里。
  • 所有新消息先去队列检查重复 → 放入队列 → 通过 readAck 确认 Ack → 投递到处理通道,保证顺序和可靠性;不需要 Ack 的消息直接投递到处理通道。
相关推荐
小二·15 小时前
Go 语言系统编程与云原生开发实战(第3篇):企业级 RESTful API 开发 —— 中间件、验证、文档与权限控制
云原生·golang·restful
还在忙碌的吴小二16 小时前
Go-View 数据可视化大屏使用手册
开发语言·后端·信息可视化·golang
码界奇点16 小时前
基于Wails框架的Ollama模型桌面管理系统设计与实现
go·毕业设计·llama·源代码管理
小二·18 小时前
Go 语言系统编程与云原生开发实战(第4篇):数据持久化深度实战 —— PostgreSQL、GORM 与 Repository 模式
postgresql·云原生·golang
麦兜*18 小时前
深入解析云原生时代的高性能消息中间件:基于Apache Pulsar与Kafka架构对比的万亿级数据吞吐与低延迟实时处理实战
云原生·kafka·apache
DolphinScheduler社区19 小时前
Linux 环境下,Apache DolphinScheduler 如何驱动 Flink 消费 Kafka 数据?
linux·flink·kafka·开源·apache·海豚调度·大数据工作流调度
女王大人万岁20 小时前
Go标准库 path 详解
服务器·开发语言·后端·golang
LuminescenceJ20 小时前
RPC通信中的Context上下文如何跨进程传递消息,gRPC为例分析
开发语言·网络·后端·网络协议·rpc·golang
码界奇点20 小时前
基于Beego v2与Go语言的网站管理后台系统设计与实现
开发语言·golang·毕业设计·go语言·源代码管理·beego
蒸蒸yyyyzwd20 小时前
go语言学习
开发语言·学习·golang