实现WebSocket推送服务的基本思路

需要编写一个基于WebSocket协议的推送服务:

  • 对在线客户(而不是离线客户)推送消息
  • 尽可能实时/及时/低延迟的让巨大数量客户获得推送信息(百万在线,秒级)

选取的技术和要点:

  • 语言选用nodejs
  • WebSocket服务器端实现选用websockets/ws
  • 推送逻辑的基本思路
  • WebSocket需要注意的安全问题及措施

以下逐步展开说明。

使用websockets/ws的理由#

  • 号称是最快的nodejs跨平台WebSocket实现
  • 项目比较成熟,最近还在更新
  • 不使用另外一个著名的WebSocket项目socket.io,主要原因是:
    • 不是只针对WebSocket的,还包括其他的连接方式
    • 对WebSocket做了封装使之简化易用,但不是调用WebSocket API了
    • 性能比单纯的使用WebSocket有所下降,见Differences between socket.io and websockets

使用websockets/ws创建服务器端程序还是很容易的。安装时需要考虑性能优化,见README - Opt-in for performance,可以提高性能。

websockets/ws也提供了客户端的支持,你可以用它来写针对WebSocket服务器端的测试,可用它来写:

  • 单元测试
  • 压力测试

实现推送服务的基本思路#

实现的推送服务大致包括三部分:

  • 订阅/发布服务(redis)
  • 编辑器(nodejs/restify),也就是消息的生产者:
    • 编辑新的内容条目,保存在mongodb
    • 新内容条目保存成功后,创建消息(包含内容条目的id),消息会被发布到对应的redis主题
  • 推送服务:
    • 启动后订阅redis主题
    • 当有WebSocket客户端连接时,验证合法性,保持这个连接在数组中
    • 当redis主题有新的消息到来时,检查数组中的WebSocket连接,是否是针对这个栏目的,是的话就发送内容条目id给客户端

从数据结构来看,比如:

  • 订阅/发布服务的redis主题(topic)名为content_update,只需要这么一个主题即可
  • 编辑器发布给该主题的消息内容类似这样:

    1
    2
    3
    4
    {
    "columnId":1002,
    "itemId":'abcd1234'
    }
  • WebSocket客户端连接服务器端的url类似这样:http://my.domain/${columnId}columnId是对应的栏目Id

有关栏目更新内容的获取:

  • 推送服务通过WebSocket只负责告知客户端更新了哪个内容,并告知内容的id,即itemId
  • 客户端根据itemId需要在另外的Web RESTful服务获取条目的内容,比如http://your.domain/content/${itemId}
  • 这个另外的Web RESTful服务,通过mongodb获取到条目内容
  • 可通过CDN存放条目内容
    • 节省带宽,也能防止比如DDos攻击带来的带宽压力
    • 编辑器在保存条目后可立即发起一次到CDN的访问,使CDN回源访问获取条目的内容,这样完成在CDN的发布

WebSocket需要注意的安全问题及措施#

WebSocket本身没有提供安全方面的功能,WebSocket是一个面向连接的协议,只管连接层面的事情。

有关用户认证方面的事情,可考虑使用JWT:

  • 需要另外的基于HTTPS的Web服务来实现。比如通过POST请求传递账号和密码
  • 认证通过后,返回JWT标准的token

然后,在客户端发起WebSocket请求的时候,带上这个token,代码类似这样:

1
2
3
4
5
const ws = new WebSocket('ws://localhost:8080', {
headers: {
token: "eyJhbGciOiJIUzI1NiIsIn.."
}
});

然后,在WebSocket服务器端,可通过类似这样做检查:

1
2
3
4
5
6
7
8
9
const wss = new WebSocket.Server({
port: 8080,
verifyClient: (info, cb) => {
let token = info.req.headers.token;
checkToken((isValid)=>{
cb(isValid);
})
}
});

其中checkToken方法要做:

  • 检查token是否为合法的JWT字串
    • 是否被篡改
    • 是否超过有效期
  • 如果是合法的字串,再通过比如redis查看是否在缓存中
    • JWT token在生成时存入redis缓存(设置redis过期时间)
    • token当过期,或者客户登出操作,或者管理员禁用都可将token从redis中删除
    • 如果在redis中没有这个token,则也是不合法的,即isValid==false

verifyClient不通过时,不会建立WebSocket连接:

  • WebSocket由客户端通过发起http GET请求开始,即发起handshake,请求类似这样:

    1
    2
    3
    4
    5
    6
    7
    8
    GET /chat HTTP/1.1
    Host: server.example.com
    token: eyJhbGciOiJIUzI1NiIsIn..
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
    Sec-WebSocket-Version: 13
    Origin: http://example.com
  • 调用verifyClient函数:

    • 如果校验失败,则http连接关闭
    • 如果校验成功,则建立WebSocket连接

http GET请求发起handshake也可能遭受攻击,比如:

  • DDos
  • CC

考虑的办法是设置reqeust throttle,限定ip地址过度频繁的发起请求: