Go-Zero 项目开发19:基于 Kafka 的异步消息存储与转发实战

纲要

  • 引言与系统回顾
  • 整体架构与实现分析
    • 消息收发模型回顾
    • 服务间调用与客户端设计
  • 构建可复用的 WebSocket 客户端
    • 接口定义与实现
    • 自动重连与重试机制
  • 系统级 Token 与鉴权
    • 用户服务中生成 Root Token
    • 客户端携带鉴权信息
  • 统一消息格式定义
  • Kafka 消费者业务完善
    • 消息落库与会话更新
    • 通过内部 API 推送消息
  • im-chat 服务中的推送处理
    • Push 结构体与处理逻辑
    • 在线用户消息实时转发
  • WebSocket 消息写入 Kafka
    • 定义 MQ 客户端
    • 在聊天业务中替换同步逻辑
  • 服务启动与集成测试
  • 总结

引言与系统回顾

在之前的实现中,我们已经完成了 im-chatWebSocket 接入服务)和 im-taskKafka 消费者服务)的基础搭建,消息收发流程已初步解耦。本文将基于该架构,深入完成以下四个关键步骤:

  1. 构建一个可被多个服务复用的 WebSocket 客户端,用于以编程方式推送消息;
  2. 在用户服务中生成系统级 Root Token,解决内部服务间调用的鉴权问题;
  3. 定义统一的消息格式,并完善 im-task 中的消费者逻辑,实现消息持久化与转发;
  4. im-chat 中添加内部推送接口,并改造聊天业务,将消息写入 Kafka

最终形成完整的异步消息存储与转发闭环。

整体架构与实现分析

消息收发模型回顾

当前系统的消息流程如下:
客户端2 MongoDB im-task Kafka im-chat 客户端1 客户端2 MongoDB im-task Kafka im-chat 客户端1 #mermaid-svg-Xb6wq6fhe9eG0cfh{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Xb6wq6fhe9eG0cfh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Xb6wq6fhe9eG0cfh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Xb6wq6fhe9eG0cfh .error-icon{fill:#552222;}#mermaid-svg-Xb6wq6fhe9eG0cfh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Xb6wq6fhe9eG0cfh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Xb6wq6fhe9eG0cfh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Xb6wq6fhe9eG0cfh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Xb6wq6fhe9eG0cfh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Xb6wq6fhe9eG0cfh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Xb6wq6fhe9eG0cfh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Xb6wq6fhe9eG0cfh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Xb6wq6fhe9eG0cfh .marker.cross{stroke:#333333;}#mermaid-svg-Xb6wq6fhe9eG0cfh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Xb6wq6fhe9eG0cfh p{margin:0;}#mermaid-svg-Xb6wq6fhe9eG0cfh .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Xb6wq6fhe9eG0cfh text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Xb6wq6fhe9eG0cfh .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Xb6wq6fhe9eG0cfh .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Xb6wq6fhe9eG0cfh .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Xb6wq6fhe9eG0cfh .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Xb6wq6fhe9eG0cfh #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Xb6wq6fhe9eG0cfh .sequenceNumber{fill:white;}#mermaid-svg-Xb6wq6fhe9eG0cfh #sequencenumber{fill:#333;}#mermaid-svg-Xb6wq6fhe9eG0cfh #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Xb6wq6fhe9eG0cfh .messageText{fill:#333;stroke:none;}#mermaid-svg-Xb6wq6fhe9eG0cfh .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Xb6wq6fhe9eG0cfh .labelText,#mermaid-svg-Xb6wq6fhe9eG0cfh .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Xb6wq6fhe9eG0cfh .loopText,#mermaid-svg-Xb6wq6fhe9eG0cfh .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Xb6wq6fhe9eG0cfh .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Xb6wq6fhe9eG0cfh .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Xb6wq6fhe9eG0cfh .noteText,#mermaid-svg-Xb6wq6fhe9eG0cfh .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Xb6wq6fhe9eG0cfh .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Xb6wq6fhe9eG0cfh .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Xb6wq6fhe9eG0cfh .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Xb6wq6fhe9eG0cfh .actorPopupMenu{position:absolute;}#mermaid-svg-Xb6wq6fhe9eG0cfh .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Xb6wq6fhe9eG0cfh .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Xb6wq6fhe9eG0cfh .actor-man circle,#mermaid-svg-Xb6wq6fhe9eG0cfh line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Xb6wq6fhe9eG0cfh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} WebSocket 发送消息 写入 Topic 写入成功 ACK 消费消息 存储消息 & 更新会话 调用内部推送 API 推送给目标用户

服务间调用与客户端设计

从图中可以看出两个关键依赖:

  • im-chat 需要 Kafka 客户端,将消息投递到队列;
  • im-task 需要 WebSocket 客户端,向 im-chat 发起推送请求。

其中 WebSocket 客户端的使用场景不仅限于 im-task,它可能被更多服务(如定时任务、管理后台)复用,因此我们将它设计为一个独立的、可配置重连的通用组件。

构建可复用的 WebSocket 客户端

im-chatinternal 下新增 wsclient 包,提供接口与实现。

go 复制代码
// im-chat/internal/wsclient/client.go 
package wsclient 
 
import (
    "encoding/json"
    "net/url"
 
    "github.com/gorilla/websocket"
    "github.com/zeromicro/go-zero/core/logx"
)
 
type Client interface {
    Send(data interface{}) error 
    Recv(v interface{}) error 
    Close()
}
 
type Options struct {
    Headers map[string][]string 
}
 
func WithHeaders(headers map[string][]string) func(*Options) {
    return func(o *Options) {
        o.Headers = headers 
    }
}
 
type client struct {
    host    string 
    conn    *websocket.Conn 
    options Options 
}
 
func NewClient(host string, opts ...func(*Options)) Client {
    c := &client{host: host}
    for _, opt := range opts {
        opt(&c.options)
    }
    if err := c.dial(); err != nil {
        logx.Errorf("wsclient dial error: %v", err)
    }
    return c 
}
 
func (c *client) dial() error {
    u := url.URL{Scheme: "ws", Host: c.host, Path: "/ws"}
    header := make(map[string][]string)
    for k, v := range c.options.Headers {
        header[k] = v 
    }
    conn, _, err := websocket.DefaultDialer.Dial(u.String(), header)
    if err != nil {
        return err 
    }
    c.conn = conn 
    return nil 
}
 
func (c *client) Send(data interface{}) error {
    payload, err := json.Marshal(data)
    if err != nil {
        return err 
    }
    err = c.conn.WriteMessage(websocket.TextMessage, payload)
    if err != nil {
        // 连接断开时尝试重连并重发一次 
        logx.Errorf("wsclient send error: %v, trying to reconnect", err)
        if dialErr := c.dial(); dialErr != nil {
            return dialErr 
        }
        return c.conn.WriteMessage(websocket.TextMessage, payload)
    }
    return nil 
}
 
func (c *client) Recv(v interface{}) error {
    _, msg, err := c.conn.ReadMessage()
    if err != nil {
        return err 
    }
    return json.Unmarshal(msg, v)
}
 
func (c *client) Close() {
    if c.conn != nil {
        c.conn.Close()
    }
}

系统级 Token 与鉴权

im-task 在调用 im-chat 内部 API 时需要进行身份验证。我们在用户服务中生成一个长期有效的 Root Token,存储到 Redis,其他服务通过该 Token 完成鉴权。

用户服务配置与生成逻辑

在用户服务的配置中新增 Redis 配置项:

yaml 复制代码
# user-api/etc/user.yaml 
Name: user-api 
Host: 0.0.0.0 
Port: 8000 
Redis:
  Addr: 127.0.0.1:6379 

定义 RootTokenKey 并在 ServiceContext 中提供设置和获取方法:

go 复制代码
// user-api/internal/svc/servicecontext.go 
package svc 
 
import (
    "context"
    "fmt"
    "time"
 
    "github.com/zeromicro/go-zero/core/stores/redis"
    "github.com/zeromicro/go-zero/core/utils/uuid"
    "user-api/internal/config"
)
 
const rootTokenKey = "system:root:token"
 
type ServiceContext struct {
    Config    config.Config 
    Redis     *redis.Redis 
}
 
func NewServiceContext(c config.Config) *ServiceContext {
    rds := redis.MustNewRedis(c.Redis.RedisConf)
    return &ServiceContext{
        Config: c,
        Redis:  rds,
    }
}
 
func (svc *ServiceContext) SetRootToken() error {
    token := uuid.New().String()
    return svc.Redis.SetexCtx(context.Background(), rootTokenKey, token, 3600*24*365) // 1年有效期 
}
 
func (svc *ServiceContext) GetRootToken() (string, error) {
    token, err := svc.Redis.GetCtx(context.Background(), rootTokenKey)
    if err != nil {
        return "", fmt.Errorf("failed to get root token: %w", err)
    }
    return token, nil 
}

main 函数中启动时调用 SetRootToken

go 复制代码
// user-api/user.go 
func main() {
    // ... 配置加载 
    ctx := svc.NewServiceContext(c)
    if err := ctx.SetRootToken(); err != nil {
        logx.Must(err)
    }
    // ... 启动服务 
}

im-task 中携带 Token

im-task 通过 HTTP 调用 im-chat 内部推送接口时,需要在头部携带 Token:

go 复制代码
// im-task/internal/client/chatclient.go 
func NewChatClient(endpoint string, rootToken string) *ChatClient {
    return &ChatClient{
        endpoint: endpoint,
        client: &http.Client{Timeout: 5 * time.Second},
        token:   rootToken,
    }
}
 
func (c *ChatClient) Push(userId string, msg []byte) error {
    reqBody, _ := json.Marshal(PushRequest{UserId: userId, Message: msg})
    req, _ := http.NewRequest("POST", c.endpoint, bytes.NewReader(reqBody))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+c.token)
    resp, err := c.client.Do(req)
    // ...
}

统一消息格式定义

im-task 中定义从 Kafka 接收的消息结构体,该结构体同时也用于推送。

go 复制代码
// im-task/internal/mq/chatmsg.go 
package mq 
 
type ChatMessage struct {
    MsgId     string `json:"msg_id"`
    From      string `json:"from"`
    To        string `json:"to"`
    Type      int    `json:"type"`      // 消息类型,如文本、图片 
    Content   string `json:"content"`
    SendTime  int64  `json:"send_time"`
}

该结构体与 im-chat 中 WebSocket 的 WsMessage 保持一致,便于消费者解析和转发。

Kafka 消费者业务完善

消费者收到消息后,需要完成两件事:将消息持久化到 MongoDB,然后调用 im-chat 的推送接口将消息发送给目标用户。

go 复制代码
// im-task/internal/logic/consumer.go 
package logic 
 
import (
    "context"
    "encoding/json"
 
    "github.com/zeromicro/go-zero/core/logx"
    "im-task/internal/client"
    "im-task/internal/mq"
    // 假定有内部存储包 
)
 
type MessageTransfer struct {
    chatClient *client.ChatClient 
    // 可添加 Mongo 客户端 
}
 
func NewMessageTransfer(chatClient *client.ChatClient) *MessageTransfer {
    return &MessageTransfer{chatClient: chatClient}
}
 
func (m *MessageTransfer) Consume(ctx context.Context, key, value string) error {
    var msg mq.ChatMessage 
    if err := json.Unmarshal([]byte(value), &msg); err != nil {
        logx.Errorf("unmarshal chat message error: %v", err)
        return err 
    }
 
    // 1. 存储消息到 MongoDB(示例省略具体实现)
    //    mongoClient.Insert(ctx, &msg)
    
    // 2. 推送消息 
    pushPayload := map[string]interface{}{
        "type":      "message",
        "msg_id":    msg.MsgId,
        "from":      msg.From,
        "content":   msg.Content,
        "send_time": msg.SendTime,
    }
    data, _ := json.Marshal(pushPayload)
    if err := m.chatClient.Push(msg.To, data); err != nil {
        logx.Errorf("push message to %s error: %v", msg.To, err)
        // 重试或记录死信 
        return err 
    }
    logx.Infof("message %s delivered", msg.MsgId)
    return nil 
}

im-chat 服务中的推送处理

im-chat 需要提供一个内部 HTTP 接口,接收推送请求,并通过 Hub 转发给对应 WebSocket 连接。

Push 结构体

go 复制代码
// im-chat/internal/ws/message.go 
package ws 
 
type PushMessage struct {
    UserId  string `json:"user_id"`
    Message []byte `json:"message"`
}

推送处理逻辑

go 复制代码
// im-chat/internal/logic/pushlogic.go 
package logic 
 
import (
    "encoding/json"
    "net/http"
 
    "github.com/zeromicro/go-zero/core/logx"
    "im-chat/internal/svc"
    "im-chat/internal/ws"
)
 
func PushHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var req ws.PushMessage 
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            http.Error(w, "bad request", http.StatusBadRequest)
            return 
        }
        if req.UserId == "" {
            http.Error(w, "missing user_id", http.StatusBadRequest)
            return 
        }
        // 通过 Hub 推送到对应用户的 WebSocket 连接 
        svcCtx.Hub.Push(req.UserId, req.Message)
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"status":"ok"}`))
    }
}

WebSocket 消息写入 Kafka

im-chat 中定义 MQ 客户端接口及实现,供 WebSocket 业务调用。

MQ 客户端定义

go 复制代码
// im-chat/internal/mqclient/mqclient.go 
package mqclient 
 
import "encoding/json"
 
type MessagePublisher interface {
    Push(msg *ChatMessage) error 
}
 
type ChatMessage struct {
    MsgId    string `json:"msg_id"`
    From     string `json:"from"`
    To       string `json:"to"`
    Type     int    `json:"type"`
    Content  string `json:"content"`
    SendTime int64  `json:"send_time"`
}
 
type kafkaPublisher struct {
    pusher *kq.Pusher 
}
 
func NewKafkaPublisher(addrs []string, topic string) MessagePublisher {
    return &kafkaPublisher{
        pusher: kq.NewPusher(addrs, topic),
    }
}
 
func (p *kafkaPublisher) Push(msg *ChatMessage) error {
    data, _ := json.Marshal(msg)
    return p.pusher.Push(string(data))
}

在 WebSocket 业务中替换同步存储

修改 client.go 中的读协程,将原本同步存储和推送的代码替换为投递到 Kafka 并立即返回 ACK:

go 复制代码
// im-chat/internal/ws/client.go (readPump片段)
func (c *Client) readPump(svcCtx *svc.ServiceContext) {
    defer func() {
        c.hub.Unregister(c)
        c.conn.Close()
    }()
    // ... 设置连接参数 
    for {
        _, msgBytes, err := c.conn.ReadMessage()
        // ... 错误处理 
        var msg WsMessage 
        json.Unmarshal(msgBytes, &msg)
 
        // 构造 Kafka 消息 
        kafkaMsg := mqclient.ChatMessage{
            MsgId:    msg.MsgId,
            From:     c.userId,
            To:       msg.To,
            Type:     1, // 示例 
            Content:  string(msg.Content),
            SendTime: time.Now().Unix(),
        }
        if err := svcCtx.MqPublisher.Push(&kafkaMsg); err != nil {
            // 投递失败,返回 ACK fail 
            ack, _ := json.Marshal(AckMessage{Type: "ack", MsgId: msg.MsgId, Status: "fail"})
            c.send <- ack 
            continue 
        }
        // 投递成功,返回 ACK ok 
        ack, _ := json.Marshal(AckMessage{Type: "ack", MsgId: msg.MsgId, Status: "ok"})
        c.send <- ack 
    }
}

服务启动与集成测试

启动顺序

  1. 启动基础设施:KafkaMongoDBRedis
  2. 启动用户服务,确保 Root Token 写入 Redis
  3. 启动 im-chat 服务
  4. 启动 im-task 服务

客户端测试

连接两个 WebSocket 客户端,分别模拟 user1 和 user2。user1 发送消息,观察 user2 是否收到。

若日志中先打印 ACK ok,稍后 im-task 消费并调用内部推送,user2 收到消息,则整个链路打通。

常见问题:

  • 连接断开:检查 wsclient 的重连机制是否生效;
  • Token 为空:检查用户服务是否成功生成 Root TokenRedis 可访问;
  • 消息收不到:确认 im-chat 内部推送端口未被防火墙拦截。

总结

本文在 go-zero 生态下,完成了基于 Kafka 的异步消息存储与转发全链路实现。核心工作包括:

  • 设计并实现可复用的 WebSocket 客户端,支持自动重连;
  • 在用户服务中生成系统级 Root Token,为内部服务间调用提供鉴权基础;
  • 定义统一的消息传输结构,串联 Kafka 消费者与 WebSocket 推送;
  • 将消息发布抽象为 MQ 客户端接口,实现同步逻辑向异步的平滑切换。

经过这一轮改造,im-chatim-task 职责更加清晰,系统具备了横向扩展的能力,同时消息可靠性也得到了保证。后续可在此基础上继续实现离线消息缓存、消息已读回执等高级特性。

相关推荐
小莫分享1 小时前
sshw:用交互搜索和 Web 配置高效管理 SSH Server
linux·运维·golang·开源·ssh
名字还没想好☜1 小时前
Go 结构体内存对齐:调整字段顺序,同样的字段省下 40% 内存
开发语言·后端·golang·go·内存对齐
Devin~Y1 小时前
互联网大厂 Java 面试实录:Spring Boot、MyBatis、Redis、Kafka、Spring Security、RAG 与 MCP 全链路问答
java·redis·kafka·mybatis·spring security·spring mvc·sprint boot
FfHUCisI2 小时前
sync.Mutex 互斥锁
golang
牛奔11 小时前
Go 如何打印调试深层或嵌套的结构体
开发语言·后端·golang
geovindu12 小时前
go: Iterative Algorithms
开发语言·后端·算法·golang·迭代算法
kite012113 小时前
Go语言Map深度解析与最佳实践
开发语言·后端·golang
Wang's Blog1 天前
Go-Zero 项目开发21: 实现离线消息拉取与会话管理
开发语言·后端·golang
java1234_小锋1 天前
【免费】基于Spark实时电商用户行为分析与预测(Java版本+可视化大屏+Kafka+SpringBoot+Vue3) 锋哥原创出品,必属精品
java·spark·kafka·实时电商用户行为分析与预测系统