纲要
- 引言与系统回顾
- 整体架构与实现分析
- 消息收发模型回顾
- 服务间调用与客户端设计
- 构建可复用的
WebSocket客户端- 接口定义与实现
- 自动重连与重试机制
- 系统级
Token与鉴权- 用户服务中生成
Root Token - 客户端携带鉴权信息
- 用户服务中生成
- 统一消息格式定义
Kafka消费者业务完善- 消息落库与会话更新
- 通过内部 API 推送消息
im-chat服务中的推送处理Push结构体与处理逻辑- 在线用户消息实时转发
- 将
WebSocket消息写入Kafka- 定义
MQ客户端 - 在聊天业务中替换同步逻辑
- 定义
- 服务启动与集成测试
- 总结
引言与系统回顾
在之前的实现中,我们已经完成了 im-chat(WebSocket 接入服务)和 im-task(Kafka 消费者服务)的基础搭建,消息收发流程已初步解耦。本文将基于该架构,深入完成以下四个关键步骤:
- 构建一个可被多个服务复用的
WebSocket客户端,用于以编程方式推送消息; - 在用户服务中生成系统级
Root Token,解决内部服务间调用的鉴权问题; - 定义统一的消息格式,并完善
im-task中的消费者逻辑,实现消息持久化与转发; - 在
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-chat 的 internal 下新增 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
}
}
服务启动与集成测试
启动顺序
- 启动基础设施:
Kafka、MongoDB、Redis - 启动用户服务,确保
Root Token写入Redis - 启动
im-chat服务 - 启动
im-task服务
客户端测试
连接两个 WebSocket 客户端,分别模拟 user1 和 user2。user1 发送消息,观察 user2 是否收到。
若日志中先打印 ACK ok,稍后 im-task 消费并调用内部推送,user2 收到消息,则整个链路打通。
常见问题:
- 连接断开:检查
wsclient的重连机制是否生效; - Token 为空:检查用户服务是否成功生成
Root Token且Redis可访问; - 消息收不到:确认
im-chat内部推送端口未被防火墙拦截。
总结
本文在 go-zero 生态下,完成了基于 Kafka 的异步消息存储与转发全链路实现。核心工作包括:
- 设计并实现可复用的
WebSocket客户端,支持自动重连; - 在用户服务中生成系统级
Root Token,为内部服务间调用提供鉴权基础; - 定义统一的消息传输结构,串联
Kafka消费者与WebSocket推送; - 将消息发布抽象为
MQ客户端接口,实现同步逻辑向异步的平滑切换。
经过这一轮改造,im-chat 和 im-task 职责更加清晰,系统具备了横向扩展的能力,同时消息可靠性也得到了保证。后续可在此基础上继续实现离线消息缓存、消息已读回执等高级特性。