基于Redis以及WebSocket的一个实时消息推送系统

本文介绍了一个简易的实时消息推送系统。

需求分析

后台同时对接了网页,微信公众号,iOS以及Android客户端。在某些特定场景下,比如一个用户接收到其他用户的提问,我们就需要向这个用户推送一条消息。用户或者在手机上收到了一条弹窗通知,或者在网页上看到了消息图标显示小红点。

公众号消息推送使用客服接口推送消息。

移动端的消息推送使用国内某些知名的推送平台。在用户从APP登录的时候,APP会主动向推送平台设置自己的ID。后台将消息发送到推送平台时指明这个ID即可。

网页的消息推送一般常见的实现方法有轮询,长连接,WebSocket等等。关于三者的区别这里不加以讨论,总之我们使用的是WebSocket。

消息传递的基本流程

后台服务器在某些情况下生成了一条消息, 首先将消息保存到本地数据库,这样客户端可以调用API显示消息列表。随后消息被放入任务队列,任务队列将消息通过推送平台发送至APP,通过微信公众号后台发送至用户微信客户端。

为了将消息通过WebSocket发送至在线的用户手中,我们先将消息发布到Redis。订阅了Redis的Node收到消息,将消息通过WebSocket传递至与之连接的浏览器。

一个大致的消息流如下图所示:

Redis的发布订阅机制

所谓的Publish/Subscribe,可以让发布者将消息发布至某一个channel,所有订阅了这个channel的订阅者就可以立即收到这个消息。在Redis的发布订阅机制里面,一个消息可以被发布至多个channel,订阅者也可以同时订阅多个channel的消息。

为了订阅一个名为message-channel的消息,我们可以在Redis命令行下执行

127.0.0.1:6379> subscribe message-channel
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "message-channel"
3) (integer) 1

为了向message-channel发布消息,我们打开一个新的命令行窗口,运行

127.0.0.1:6379> publish message-channel "This is a message"
(integer) 1
127.0.0.1:6379>

消息发布之后,原本订阅的那个终端就可以收到消息了:

Python发布消息·Node订阅消息

向Redis布的消息的数据类型必须为byte,如果我们需要传递复杂的数据结构,就需要将数据dump为json格式。

import json
from redis import StrictRedis

client = StrictRedis()
data = {
    "uid": "user id",
    "message": {
        "content": "You have a message",
        "message_id": "message id"
    }
}

client.publish("message-channel", json.dumps(data))

订阅message-channel的Node在获取消息之后,将消息体解析,获取到里面的user id,根据这个id决定消息发送的对象。如果此时用户不在线,消息就不会被发出。

var redis = require('redis');
var redisListener = redis.createClient();

redisListener.subscribe(config.get('redis_message_channel'));
redisListener.on('message', function(channel, data){
  console.log('get a redis message', channel, data);
  var data = JSON.parse(data);
  io.sockets.in(data.uid).emit('message', data.message);
})

创建WebSocket的服务

本文对Socket.IO以及WebSocket没有加以严格的区分,但严格的来说,Socket.IO并非完全是WebSocket。Socket.IO是一个封装了WebSocket协议的库,隐藏了底层协议的细节,提供比较高层次的功能。它首先尝试创建一个长连接,在可能的情况之下尝试将连接升级到更加轻量级的WebSocket。此外它还提供了一些更加高级的功能,比如断线检测,断线重连等。

Socket.IO的服务需要使用它自带的client去连接服务,浏览器默认的WebSocket对象是不能用的。

如下代码可以创建一个简单的Socket.IO服务

var app = require('express')();
var server = require('http').createServer(app);
var io = require('socket.io')(server);
io.on('connection', function(){
	
});
server.listen(3000);

此时socket会运行一个自带的http服务,你可以打开http://127.0.0.1:3000/, 开启调试工具并执行如下代码

// 加载socket.io 这个库
var script = document.createElement('script');
script.src = 'http://127.0.0.1:3000/socket.io/socket.io.js';
document.body.append(script);

// 连接到服务器,将任何收到的消息log到console
var socket = io.connect();
socket.on('message', function(data){
    console.log(data);
})

WebSocket的权限验证

对于每一个WebSocket连接,你需要验证连接人的身份,验证后才能够向这个连接发送消息。

一般的HTTP请求协议可以通过验证cookie,或者在HTTP头部放置token达到验证的目的。WebSocket也可以用类似的方法,不同之处在于WebSocket只需要在连接建立时验证一次即可。注意此时WebSocket服务以及后端的HTTP服务必须在同一个域下,不然后端服务的cookie不会被传递给WebSocket服务。一个可行的做法是使用nginx同时反向代理后端的HTTP以及WebSocket。

后端的Node服务接收到连接请求之后,将cookie转发给Web服务做验证。转发给Web做验证的原因在于WebSocket常用于高并发的场景,应该避免Node服务直接请求数据库

比如我们使用cookie验证用户,那么我们可以这样:

var cookie = require('cookie');

io.on('connection', function(){
    var cookies = cookie.parse(socket.handshake.headers.cookie || '');
    // 将cookie通过http协议发送至后端服务器验证。
    var uid = validateCookie(cookies);
    if(uid){
        // 加入一个房间,房间号即为用户id
        socket.join(uid);
    }
    else{
        socket.disconnect()
    }
});

如果验证失败,主动关闭连接,或者通知客户端关闭。如果验证成功,我们可以让这个连接监听加入一个专门的room,为了简单起见我们直接使用户的id。这样,从Redis获取的消息体里面也有用户id,我们可以据此将指定的消息送入指定用户的浏览器里面。

可能存在的问题

按照我们的需求,任何一个消息最终只会被分发给一个用户。而Socket.IO的设计初衷则是基于聊天室的。它认为一个消息有可能会被分发给一个聊天室里的所有用户。在这个矛盾之下,你会发现这个系统的水平扩展并不是很方便

目前这个系统的消息会被发布到单一的Redis channel,并且只有一个Node进程在处理所有的连接。考虑连接过多,单一进程无法处理的情况,为了扩展,一般的做法无非是:

  1. 增加单一机器上面的Node进程数。
  2. 增加多台物理机器,每台物理机器运行多个Node服务。

上面的(1比较容易实现,简单的来说,每一个用户的WebSocket会被随机分配到任何一个Node进程。所有Node进程订阅同一个Redis channel。这样一个消息会被所有Node查看到,然后可能会被其中一个进程传递给自己正在连接的用户。

在(1这个方法里,消息如果仅仅被相关的Node进程捕获就好了,毕竟最终只会有一个Node进程处理这个消息。但退一步讲,哪怕消息被传递给了所有Node进程也应该不会有太大性能的问题。考虑实现(2的机制,难道对于一个消息也要将它发布多多台物理机器的多个Node进程上面去?

一个可行的扩展方法是基于uid做一致性hash,客户端的连接按照uid被hash到指定的Node进程。Node进程按照同样的算法处理指定uid的消息,当然这个已经超出了本文的讨论范围。

出处:https://ifconfiger.com/articles/push-message-with-redis-and-websocket

猜你喜欢

转载自blog.csdn.net/JackLiu16/article/details/81437703