在原版的黑马点评中没有消息通知的功能,在这里为了学习,采用了SSE功能实现一个点赞博客通知事件。
服务端实时推送技术之SSE(Server-Send Events)
服务端推送是一种允许应用服务器主动将信息发送到客户端的能力,为客户端提供了实时的信息更新和通知。
服务端推送的背景与需求主要基于以下几个诉求:
- 实时通知:在如点赞,评论、回复等情况下需要试试通知用户。
- 节省资源:如果没有服务端推送,客户端需要通过轮询的方式来获取新信息,会造成客户端、服务端的资源损耗。
- 增强用户体验/营销活动:针对特定用户或用户群发送有针对性的内容,如优惠活动、个性化推荐等。
常见的实时消息处理方案:
- 轮询:在没有服务端推送时,要想试获得实时数据智能依赖客户端发起轮询。对实时性要求越高轮询越频繁,服务端和客户端的开销和压力就越大。并且存在数据长时间没有更新的情况会浪费很多轮询。
- Websocket:基于TCP的全双工协议,能够实现客户端和服务端双向通信,非常适合实时性极强的通信场景。
- SSE:基于HTTP协议的推送技术,是 HTML5 的一部分,通过设置content-type为text/stream来告诉客户端,内容不是一次性返回的,而是返回流。允许服务端主动向客户端发送消息,但是不允许客户端通过sse向服务端实时发送数据,即只允许单向数据交互。与websocket相比,更简单、更轻量。
- 第三方推送平台:各家操作系统厂商一般都会提供推送渠道。同时,也有一些跨平台的推送服务,如个推、极光推送、友盟推送等,帮助开发者在不同平台上实现统一的推送功能。
案例实现
redis中有三种方式可以实现消息队列,分别是list,pub/sub,stream,他们的区别如下
在本案例中结合redis的stream消息队列来做sse推送。如果对于消息队列有较高的要求,请考虑其他专业的消息队列。
我们先修改点赞博客时的逻辑,当有人点赞时往redis的消息队列中添加一条消息。
// 推送消息
streamKey := constants.MESSAGE_STREAM_KEY + strconv.FormatInt(interBlog.UserId, 10)
msg := &message.Message{
From: u,
To: interBlog.UserId,
Content: "点赞了你的博客",
Type: "like",
Time: time.Now().Format("2006-01-02 15:04:05"),
}
err = redis.ProduceMq(h.Context, streamKey, msg)
if err != nil {
return nil, err
}
return &blog.LikeResp{IsLiked: true}, nil
这样博主的消息队列中就存在一条待消费的消息,然后编写sse逻辑。当客户端连接到sse后,通过一个无限循环监听消息队列,如果有新消息则返回。同时通过go的上下文监听退出状态,及时释放资源。
s := sse.NewStream(c)
//c.Status(consts.StatusOK)
subCtx, cancel := context.WithCancel(ctx)
defer cancel()
for {
select {
case <-subCtx.Done():
hlog.Debugf("SSE stream closed")
return
default:
req := ">"
serv := service.NewSseService(subCtx, c)
resp, err := serv.Run(req)
if err != nil {
hlog.Errorf("Error running SSE service: %v", err)
continue
}
if resp == nil {
continue
}
event := &sse.Event{
Event: "message",
Data: []byte(*resp),
}
hlog.Debugf("SSE event: %v", event)
err = s.Publish(event)
if err := PublishWithRetry(s, event); err != nil {
hlog.Errorf("Error publishing SSE event: %v", err)
}
}
}
通过redis查询消息队列时,设置阻塞式查询,可以减少长时间得到空信息的问题。需要注意的是,redis的stream使用消费者组读取方式,读取前需要创建消费者组,读取后需要确认消息。
func (h *SseService) Run(req string) (resp *string, err error) {
defer func() {
hlog.CtxInfof(h.Context, "req = %+v", req)
hlog.CtxInfof(h.Context, "resp = %+v", resp)
}()
xSet, err := h.consumeMq(">")
// 有更新的话,发送给前端
if err != nil {
if errors.Is(err, redis2.Nil) {
hlog.CtxDebugf(h.Context, "No messages found in Redis")
return nil, nil
}
hlog.CtxErrorf(h.Context, "redis.ConsumeMq err = %+v", err)
return nil, err
}
msgResp, err := h.handleMessage(xSet)
if err != nil {
hlog.CtxErrorf(h.Context, "handleMessage failed with data: %v, error: %v", xSet, err)
return nil, err
}
bytes, err := utils.SerializeStruct(msgResp)
if err != nil {
return nil, err
}
return &bytes, nil
}
使用的工具类
redis
// ProduceMq 写stream消息队列
func ProduceMq(ctx context.Context, key string, message interface{}) error {
messageJSON, err := utils.SerializeStruct(message)
if err != nil {
return err
}
// xadd写stream
err = RedisClient.XAdd(ctx, &redis.XAddArgs{
Stream: key,
ID: "*",
Values: []interface{}{"message", messageJSON},
}).Err()
// 错误处理
if err != nil {
return err
}
return nil
}
// ConsumeMq 读取stream消息队列
func ConsumeMq(ctx context.Context, key string, consumer string, block time.Duration, count int64, id string) ([]redis.XStream, error) {
if id == "" {
id = ">"
}
xSet, err := RedisClient.XReadGroup(ctx, &redis.XReadGroupArgs{
Group: constants.STREAM_READ_GROUP,
Consumer: consumer,
Streams: []string{key, id},
Count: count,
Block: block,
NoAck: false,
}).Result()
if err != nil {
return nil, err
}
return xSet, nil
}
// 创建消费者组
func CreateConsumerGroup(ctx context.Context, key string) {
RedisClient.XGroupCreateMkStream(ctx, key, constants.STREAM_READ_GROUP, "0")
}
//确认消息
func AckMq(ctx context.Context, key string, id string) error {
return RedisClient.XAck(ctx, key, constants.STREAM_READ_GROUP, id).Err()
}