服务器 Push 技术表示服务器可以主动推送消息给客户端浏览器。

实现方式

  • Comet
    • Ajax 轮询
    • iframe / htmlfile
    • script tag (不中断的连续请求)
    • Flash 通讯
  • WebSocket

Comet 本意是彗星,彗星尾巴痕迹很像长连接工作方式,所以 Comet 指代长连接。 在 Ajax 轮询的分类上,存在一些分歧,这里我把轮询认为一种 Comet 方式。

ps:「长连接」在一些场景下,是另外一种意义「HTTP: Keep alive」。不在本文讨论的范围中。

参考链接:

浏览器支持情况

WebSocket 属于 HTML5 规范,需要「先进」浏览器支持, Flash 通讯需要浏览器安装 Flash 插件,其他方式都可以适应常见浏览器。

参考连接:

各大网站连接情况

可以通过 url 请求来揣测一些东西,比如说,它们没有用 WebSocket, 否则 FireBug 是无法监测的,WebSocket 可以双向通讯。

新浪微博

未读信息链接: http://rm.api.weibo.com/remind/unread_count.json?target=api&_pid=10001&count=2&source=3818214747&callback=STK_133834300664875

未读信息大约每20秒触发一次,像是 Ajax 轮询。

IM 长连接: http://4.46.web1.im.weibo.com/im?jsonp=parent.org.cometd.script._callback5&message=%5B%7B%22channel%22%3A%22%2Fmeta%2Fconnect%22%2C%22connectionType%22%3A%22callback-polling%22%2C%22id%22%3A6%2C%22clientId%22%3A%22b02qp9qw9cgiuxxyn3%22%7D%5D&1338343019008

可以看出新浪在使用 JSONP 跨域做 IM 长连接,FireBug 中也始终有链接请求, 看上去像 Script Tag 请求方式。

知乎

请求链接: http://comet.zhihu.com/update?loc=http%3A%2F%2Fwww.zhihu.com%2F&channel=13781e6817833300f0a70f19&callback=zhp13781e6a6f22349b9865b47c8

依然能在 FireBug 中看到请求地址,说明客户端请求数据还是走 HTTP 方式, 并且会出现 update 动作链接一直出于请求状态,猜测知乎仍然使用 Script Tag 请求。

框架支持

orbited2

http://labs.gameclosure.com/orbited2/

  • 跨浏览器
  • 容易集成:IRC / XMPP / ActiveMQ / RabbitMQ
  • Python

StreamHub

http://www.stream-hub.com/

  • 免费版仅支持 10 个在线
  • 支持 Java / .net / iPhone

socket.io

http://socket.io/

  • NodeJS
  • 推送方式:
    • WebSocket
    • Adobe® Flash® Socket
    • AJAX long polling
    • AJAX multipart streaming
    • Forever Iframe
    • JSONP Polling
  • 支持浏览器:
    • Internet Explorer 5.5+
    • Safari 3+
    • Google Chrome 4+
    • Firefox 3+
    • Opera 10.61+
    • iPhone Safari
    • iPad Safari
    • Android WebKit
    • WebOs WebKit

sockjs-client

https://github.com/sockjs/sockjs-client

  • 支持 Node.js / Erlang / Lua / Python-Tornado
  • 跨浏览器

实战 Socket.io

考虑到上述候选框架的使用场景,这里选择 Socket.IO 作为 Comet 框架。

尴尬的 Pylons

Pylons 和 Comet 配合有问题,问题处在标准 WSGI 是非异步的。 (看邮件列表里面,似乎新的标准准备支持)。

这样的话,我就直接选择使用 Node.JS 做 Comet 服务器,Nginx 负责转发。

简单Demo

node.js 代码

/* global __dirname, console */

var app = require('http').createServer(handler),
	io = require('socket.io').listen(app),
	fs = require('fs');

app.listen(8080);

function handler(req, res) {
	fs.readFile(__dirname + '/index.html',
		function (err, data) {
			if (err) {
				res.writeHead(500);
				return res.end('Error loading index.html');
			}

			res.writeHead(200);
			res.end(data);
		});
}

io.sockets.on('connection', function (socket) {
	'use strict';
	socket.emit('news', {hello: 'world, for everyone!'});
	socket.on('my other event', function (data) {
		console.log(data);
	});
	socket.on('private message', function (from, msg) {
		console.log('I received a private message by ', from, ' saying ', msg);
	});
});

页面代码

<html>
<head>
	<meta http-equiv="content-type" content="text/html; charset=utf-8">

	<title>Socket.io Demo</title>
	<script src="/socket.io/socket.io.js"></script>
	<script>
		var socket = io.connect('/');

		socket.on('news', function (data) {
			console.log(data);
			socket.emit('my other event', { my: 'data' });
		});
	</script>
</head>
<body>

</body>
</html>

连接成功之后,在浏览器控制台里面,可以使用 socket.emit('my other event', {biu: 'biu'}); 向服务器发送消息。

服务器也可以通过 socket.emit() 来向客户端推送消息。

私有信息发送,使用 socket.emit('private message', 'James', {some: 'message'});

跨平台

实测看来,在 IE8 下面, Socket.io 会降级使用 htmlfile 来实现 Comet。

而 Firefox 中会有 websocket / htmlfile / xhr-polling / jsonp-polling 依次备选, 首选 websocket。

安全性

问题:提交数据的身份认证过程,以前在后台由 Web 框架自动完成,而现在流程是 Socket.IO -> RabbitMQ -> Web App,身份验证的复杂度增加了。

思路:Socket.IO 使用 Nginx 代理转发,从而保留同一域名下面的 cookie 信息, 这样能够提交到 Socket.IO 服务器,每次 RabbitMQ Message 都记录 cookie 信息, 后台从 RabbitMQ 读取信息时候,再进行认证。

实际操作:由于 Comet 中的数据流仅负责推送,客户端继续使用原始 POST 方式发送数据到服务器,所以暂时不会产生身份认证问题。

Node AMPQ 驱动

Socket.IO 提供了一个通用的 Comet 解决方案,下面就需要点润滑剂,将整个数据流跑通。 消息队列 RabbitMQ 正好适合用来做这个。

Rabbit 官网提到了一个套件 rabbit.js 。 遗憾的是这个库是混合了 RabbitMQ 和 Node.JS,提供了一个封装好的 Node.JS 库, 而我想要的仅仅是一个 AMPQ 协议驱动。node-amqp 则是我们需要的驱动。

Demo

服务器接收者脚本:

/* global __dirname, console */

var conn = require('amqp').createConnection({ url: 'amqp://localhost'});

console.log('socket works');
conn.on('ready', function() {
	console.log('conn ready');
	conn.queue('socket.io', {passive: true}, function(queue){
		queue.subscribe(function (json, headers, deliveryInfo) {
			console.log('#json:')
			view(json);
			console.log('#headers:')
			view(headers);
			console.log('#deliveryInfo:')
			view(deliveryInfo);
		});
	});
});

conn.on('error', function() {
	console.error('error');
});

function view(obj) {
	for (var i in obj) {
		if(obj.hasOwnProperty(i)) {
			console.log(i + ': ' + obj[i]);
		}
	}
}

用 Python 写的发送者脚本:

# coding=utf-8
#! /usr/bin/env python2

import pika
import json
import logging
import time

logger = logging.getLogger()

def main():
    conn = pika.BlockingConnection(
        pika.ConnectionParameters('localhost'))
    chan = conn.channel()
    chan.queue_declare(queue='socket.io')

    count = 10
    while (count > 0):
        message = {'no': count, 'some': 'Message', u'比如': u'中文信息'}
        publish_text(chan, 'socket.io', u'text %d' %count)
        publish_json(chan, 'socket.io', message)
        logger.info('add one message to RabbitMQ')
        #time.sleep(5) # sleep 5 sec
        count -= 1

def publish_text(channel, queue, message):
    channel.basic_publish(exchange='',
                          routing_key=queue,
                          body=json.dumps(message),
                          properties=pika.BasicProperties(
                              content_type='text/plain',
                              content_encoding='utf-8',
                              delivery_mode=1)
                         )

def publish_json(channel, queue, message):
    channel.basic_publish(exchange='',
                          routing_key=queue,
                          body=json.dumps(message),
                          properties=pika.BasicProperties(
                              content_type='application/json',
                              content_encoding='utf-8',
                              delivery_mode=1)
                         )

if __name__ == '__main__':
    main()

使用 node ./app-amqp.js 运行 Node.JS 服务器,然后运行 producter.py 产生 RabbitMQ Message,我使用的数据格式是序列化的 JSON 字串, 还有 JSON, Thrift, Protocol Buffers, MessagePack 这些格式可供选择。运行结果如下:

#json:
data: "text 1"
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-3: ordinal not icontentType: text/plain
#headers:
#deliveryInfo:
contentType: text/plain
contentEncoding: utf-8
deliveryMode: 1
queue: socket.io
deliveryTag: 19
redelivered: false
exchange:
routingKey: socket.io
consumerTag: node-amqp-10880-0.06487216474488378
#json:
比如: 中文信息
some: Message
no: 1
#headers:
#deliveryInfo:
contentType: application/json
contentEncoding: utf-8
deliveryMode: 1
queue: socket.io
deliveryTag: 20
redelivered: false
exchange:
routingKey: socket.io
consumerTag: node-amqp-10880-0.06487216474488378

里面有两个 Message,发送数据格式为 text/plainapplication/json

参考链接:

Socket.IO + RabbitMQ

最后提供 Socket.IO + RabbitMQ 的完整 Demo,客户端会实时接受到来自消息发送者的消息。

/* global __dirname, console */

var app = require('http').createServer(handler),
	io = require('socket.io').listen(app),
	fs = require('fs');

app.listen(8080);

function handler(req, res) {
	fs.readFile(__dirname + '/index.html',
		function (err, data) {
			if (err) {
				res.writeHead(500);
				return res.end('Error loading index.html');
			}

			res.writeHead(200);
			res.end(data);
		});
}

io.sockets.on('connection', function (socket) {
	console.log('io ready');

	var conn = require('amqp').createConnection({ url: 'amqp://localhost'});
	conn.on('ready', function () {
		console.log('conn ready');
		conn.queue('socket.io', {passive: true}, function(queue){
			queue.subscribe(function (json, headers, deliveryInfo) {
				console.log(json);
				console.log(deliveryInfo.contentType);
				if (deliveryInfo.contentType == 'application/json') {
					socket.emit('news', json);
				}
				if (deliveryInfo.contentType == 'text/plain') {
					socket.emit('news', json.data.toString());
				}
			});
		});
	});
});

在运行 producter.py 后,Python 脚本持续产生 Message 到 RabbitMQ, app-amqp-socket.js 订阅读取 Message 并推送到浏览器端。 浏览器可以在 Console 里面看到日志:

Object { 比如="中文信息", some="Message", no=1}

至此,我们可以完成 WebApp -> RabbitMQ -> Socket.IO -> Browser 的实时推送。


原文链接: 服务器 Push 技术 | Log4D

3a1ff193cee606bd1e2ea554a16353ee

欢迎关注我的微信公众号:窥豹

窥豹