在团队协同工具Worktile的使用过程中,你会发现无论是右上角的消息通知,还是在任务面板中拖动任务,还有用户的在线状态,都是实时刷新。Worktile中的推送服务是采用的是基于xmpp协议、erlang语言实现的ejabberd,并在其源码基础上,结合我们的业务,对源码作了修改以适配我们自身的需求。另外,基于amqp协议也可以作为实时消息推送的一种选择,踢踢网就是采用 rabbitmq+stomp 协议实现的消息推送服务。本文将结合我在Worktile和踢踢网的项目实践,介绍下消息推送服务的具体实现。
相较于手机端的消息推送(一般都是以socket方式实现),web端是基于http协议,很难像tcp一样保持长连接。但随着技术的发展,出现了websocket, comet等新的技术可以达到类似长连接的效果,这些技术大体可分为以下几类:
1)短轮询。页面端通过js定时异步刷新,这种方式实时效果较差。
2)长轮询。页面端通过js异步请求服务端,服务端在接收到请求后,如果该次请求没有数据,则挂起这次请求,直到有数据到达或时间片(服务端设定)到,则返回本次请求,客户端接着下一次请求。示例如下:
3)Websocket。浏览器通过websocket协议连接服务端,实现了浏览器和服务器端的全双工通信。需要服务端和浏览器都支持websocket协议。
以上几种方式中,方式1实现较简单,但效率和实时效果较差。方式2对服务端实现的要求比较高,尤其是并发量大的情况下,对服务端的压力很大。方式3效率较高,但对较低版本的浏览器不支持,另外服务端也需要有支持websocket的实现。Worktile的web端实时消息推送,采用的是xmpp扩展协议xep-0124 BOSH(http://xmpp.org/extensions/xep-0124.html),本质是采用方式2长轮询的方式。踢踢网则采用了websocket连接rabbitmq的方式实现,下面我会具体介绍如何用这两种方式实现Server Push。
服务端的实现中,无论采用ejabberd还是rabbitmq,都是基于erlang语言开发的,所以必须安装erlang运行时环境。Erlang是一种函数式语言,具有容错、高并发的特点,借助OTP的函数库,很容易构建一个健壮的分布式系统。目前,基于erlang开发的产品有,数据库方面:Riak(Dynamo实现)、CouchDB, Webserver方面:Cowboy、Mochiweb, 消息中间件有rabbitmq等。对于服务端程序员来说,erlang提供的高并发、容错、热部署等特性是其他语言无法达到的。无论在实时通信还是在游戏程序中,用erlang可以很容易为每一个上线用户创建一个对应的process,对一台4核8个G的服务器来说,承载上百万个这样的process是非常轻松的事。下图是erlang程序发起process的一般性示意图:
如图所示,Session manager(or gateway)负责为每个用户(uid)创建相对应的process, 并把这个对应关系(map)存放到数据表中。每个process则对应用户数据,并且他们之间可以相互发送消息。Erlang的优势就是在内存足够的情况下创建上百万个这样的process,而且它的创建和销毁比java的thread要轻量的多,两者不是一个数量级的。
好了,我们现在开始着手erlang环境的搭建(实验的系统为ubuntu12.04, 4核8个G内存):
1、依赖库安装
1
2
3
4
5
6
7
8
|
sudo
apt-get
install
build-essential
sudo
apt-get
install
libncurses5-dev
sudo
apt-get
install
libssl-dev libyaml-dev
sudo
apt-get
install
m4
sudo
apt-get
install
unixodbc unixodbc-dev
sudo
apt-get
install
freeglut3-dev libwxgtk2.8-dev
sudo
apt-get
install
xsltproc
sudo
apt-get
install
fop tk8.5 libxml2-utils
|
2、官网下载otp源码包(http://www.erlang.org/download.html), 解压并安装:
1
2
3
4
|
tar
zxvf otpsrcR16B01.
tar
.gz
cd
otpsrcR16B01
configure
make
&
make
install
|
至此,erlang运行环境就完成了。下面将分别介绍rabbitmq和ejabberd构建实时消息服务。
RabbitMQ是在业界广泛应用的消息中间件,也是对AMQP协议实现最好的一种中间件。AMQP协议中定义了Producer、 Consumer、MessageQueue、Exchange、Binding、Virtual Host等实体,他们的关系如下图所示:
消息发布者(Producer)连接交换器(Exchange), 交换器和消息队列(Message Queue)通过key进行Binding,Binding是根据Exchange的类型(分为fanout、direct、topic、header)分别对消息作不同形式的派发。Message Queue又分为durable、temporary、auto-delete三种类型,durable queue是持久化队列,不会因为服务shutdown而消失,temporary queue则服务重启后会消失,auto-delete则是在没有consumer连接时自动删除。另外RabbitMQ有很多第三方插件,可以基于AMQP协议基础之上做出很多扩展的应用。下面我们将介绍web stomp插件构建基于AMQP之上的stomp文本协议,通过浏览器websocket达到实时的消息传输。系统的结构如图:
如图所示,web端我们使用stomp.js和sockjs.js与rabbitmq的web stomp plugin通信,手机端可以用stompj, gozirra(Android)或者objc-stomp(IOS)通过stomp协议与rabbitmq收发消息。因为我们是实时消息系统通常都是要与已有的用户系统结合,rabbitmq可以通过第三方插件rabbitmq-auth-backend-http来适配已有的用户系统,这个插件可以通过http接口完成用户连接时的认证过程。当然,认证方式还有ldap等其他方式。下面介绍具体步骤:
从官网(http://rabbitmq.com/download.html)下载最新版本的源码包,解压并安装:
1
2
3
|
tar
zxf rabbitmq-server-x.x.x.
tar
.gz
cd
rabbitmq-server-x.x.x
make
&
make
install
|
为rabbitmq安装web-stomp插件
1
2
3
4
5
6
|
cd
/path/to/your/rabbitmq
.
/sbin/rabbitmq-plugins
enable
rabbitmq_web_stomp
.
/sbin/rabbitmq-plugins
enable
rabbitmq_web_stomp_examples
.
/sbin/rabbitmqctl
stop
.
/sbin/rabbitmqctl
start
.
/sbin/rabbitmqctl
status
|
将会显示下图所示的运行的插件列表
安装用户授权插件
1
2
3
4
|
cd
/path/to/your/rabbitmq/plugins
wget http:
//www
.rabbitmq.com
/community-plugins/v3
.3.x
/rabbitmq_auth_backend_http-3
.3.x-e7ac6289.ez
cd
..
.
/sbin/rabbitmq-plugins
enable
rabbitmq_auth_backend_http
|
编辑rabbitmq.config文件(默认存放于/etc/rabbitmq/下),添加:
1
2
3
4
5
6
7
8
9
10
11
|
[
...
{rabbit, [{auth_backends, [rabbit_auth_backend_http]}]},
...
{rabbitmq_auth_backend_http,
[{user_path, “http://your-server/auth/user”},
{vhost_path, “http://your-server/auth/vhost”},
{resource_path, “http://your-server/auth/resource”}
]}
...
].
|
其中,user_path是根据用户名密码进行校验,vhost_path是校验是否有权限访问vhost, resource_path是校验用户对传入的exchange、queue是否有权限。我下面的代码是用nodejs实现的这三个接口的示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
var
express = require(
'express'
);
var
app = express();
app.get(
'/auth/user'
,
function
(req, res){
var
name = req.query.username;
var
pass = req.query.password;
console.log(
"name : "
+ name +
", pass : "
+ pass);
if
(name ===
'guest'
&& pass ===
"guest"
){
console.log(
"allow"
);
res.send(
"allow"
);
}
else
{
res.send(
'deny'
);
}
});
app.get(
'/auth/vhost'
,
function
(req, res){
console.log(
"/auth/vhost"
);
res.send(
"allow"
);
});
app.get(
'/auth/resource'
,
function
(req, res){
console.log(
"/auth/resource"
);
res.send(
"allow"
);
});
app.listen(3000);
|
浏览器端js实现,示例代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
......
var
ws =
new
SockJS(
'http://'
+ window.location.hostname +
':15674/stomp'
);
var
client = Stomp.over(ws);
// SockJS does not support heart-beat: disable heart-beats
client.heartbeat.outgoing = 0;
client.heartbeat.incoming = 0;
client.debug = pipe(
'#second'
);
var
print_first = pipe(
'#first'
,
function
(data) {
client.send(
'/exchange/feed/user_x'
, {
"content-type"
:
"text/plain"
}, data);
});
var
on_connect =
function
(x) {
id = client.subscribe(
"/exchange/feed/user_x"
,
function
(d) {
print_first(d.body);
});
};
var
on_error =
function
() {
console.log(
'error'
);
};
client.connect(
'guest1'
,
'guest1'
, on_connect, on_error,
'/'
);
......
|
需要说明的时,在这里我们首先要在rabbitmq实例中创建feed这个exchange,我们用stomp.js连接成功后,根据当前登陆用户的id(user_x)绑定到这个exchange,即 subscribe(“/exchange/feed/user_x”, …) 这个操作的行为,这样在向rabbitmq中feed exchange发送消息并指定用户id(user_x)为key,页面端就会通过websocket实时接收到这条消息。
到目前为止,基于rabbitmq+stomp实现web端消息推送就已经完成,其中很多的细节需要小伙伴们亲自去实践了,这里就不多说了。实践过程中可以参照官方文档:
http://rabbitmq.com/stomp.html
http://rabbitmq.com/web-stomp.html
https://github.com/simonmacmullen/rabbitmq-auth-backend-http
以上的实现是我本人在踢踢网时采用的方式,下面接着介绍一下现在在Worktile中如何通过ejabberd实现消息推送。
与rabbitmq不同,ejabberd是xmpp协议的一种实现,与amqp相比,xmpp广泛应用于即时通信领域。Xmpp协议的实现有很多种,比如java的openfire,但相较其他实现,ejabberd的并发性能无疑使最优秀的。Xmpp协议的前身是jabber协议,早期的jabber协议主要包括在线状态(presence)、好友花名册(roster)、IQ(Info/Query)几个部分。现在jabber已经成为rfc的官方标准,如rfc2799, rfc4622, rfc6121,以及xmpp的扩展协议(xep)。Worktile Web端的消息提醒功能就是基于XEP-0124、XEP-0206定义的BOSH扩展协议。
由于自身业务的需要,我们对ejabberd的用户认证和好友列表模块的源码进行修改,通过redis保存用户的在线状态,而不是mnesia和mysql。另外好友这块我们是从已有的数据库中(mongodb)中获取项目或团队的成员。Web端通过strophe.js来连接(http-bind),strophe.js可以以长轮询和websocket两种方式来连接,由于ejabberd还没有好的websocket的实现,就采用了BOSH的方式模拟长连接。整个系统的结构如下:
Web端用strophe.js通过http-bind进行连接nginx代理,nginx反向代理ejabberd cluster。iOS用xmpp-framwork连接, Android可以用smack直接连ejabberd服务器集群。这些都是现有的库,无需对client进行开发。在线状态根据用户uid作为key定义了在线、离线、忙等状态存放于redis中。好友列表从mongodb的project表中获取。用户认证直接修改了ejabberd_auth_internal.erl文件,通过mongodb驱动连接用户库,在线状态等功能是新加了模块,其部分代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
|
-
module
(wt_mod_proj).
-behaviour(gen_mod).
-behaviour(gen_server).
-include(
"ejabberd.hrl"
).
-include(
"logger.hrl"
).
-include(
"jlib.hrl"
).
-
define
(
SUPERVISOR
, ejabberd_sup).
...
-
define
(
ONLINE
, 1).
-
define
(
OFFLINE
, 0).
-
define
(
BUSY
, 2).
-
define
(
LEAVE
, 3).
...
%% API
-
export
([start_link/2, get_proj_online_users/2]).
%% gen_mod callbacks
-
export
([start/2, stop/1]).
%% gen_server callbacks
-
export
([init/1, terminate/2, handle_call/3, handle_cast/2, handle_info/2, code_change/3]).
%% Hook callbacks
-
export
([user_available/1, unset_presence/3, set_presence/4]).
-
export
([get_redis/1, remove_online_user/3, append_online_user/3]).
...
-record(state,{host = <<
""
>>, server_host, rconn, mconn}).
start_link(
Host
,
Opts
) ->
Proc
=
gen_mod:get_module_proc
(
Host
,
?MODULE
),
gen_server:start_link
({local,
Proc
},
?MODULE
, [
Host
,
Opts
], []).
user_available(
New
) ->
LUser
=
New
#jid.luser,
LServer
=
New
#jid.lserver,
Proc
=
gen_mod:get_module_proc
(
LServer
,
?MODULE
),
gen_server:cast
(
Proc
, {user_available,
LUser
,
LServer
}).
append_online_user(
Uid
,
Proj
,
Host
) ->
Proc
=
gen_mod:get_module_proc
(
Host
,
?MODULE
),
gen_server:call
(
Proc
, {append_online_user,
Uid
,
Proj
}).
remove_online_user(
Uid
,
Proj
,
Host
) ->
Proc
=
gen_mod:get_module_proc
(
Host
,
?MODULE
),
gen_server:call
(
Proc
, {remove_online_user,
Uid
,
Proj
}).
...
set_presence(
User
,
Server
,
Resource
,
Packet
) ->
Proc
=
gen_mod:get_module_proc
(
Server
,
?MODULE
),
gen_server:cast
(
Proc
, {set_presence,
User
,
Server
,
Resource
,
Packet
}).
...
start(
Host
,
Opts
) ->
Proc
=
gen_mod:get_module_proc
(
Host
,
?MODULE
),
ChildSpec
= {
Proc
, {
?MODULE
, start_link, [
Host
,
Opts
]},
transient, 2000, worker, [
?MODULE
]},
supervisor:start_child
(
?SUPERVISOR
,
ChildSpec
).
stop(
Host
) ->
Proc
=
gen_mod:get_module_proc
(
Host
,
?MODULE
),
gen_server:call
(
Proc
, stop),
supervisor:delete_child
(
?SUPERVISOR
,
Proc
).
init([
Host
,
Opts
]) ->
MyHost
=
gen_mod:get_opt_host
(
Host
,
Opts
, <<
"wtmuc.@HOST@"
>>),
RedisHost
=
gen_mod:get_opt
(redis_host,
Opts
,
fun
(B) -> B
end
,
?REDIS_HOST
),
RedisPort
=
gen_mod:get_opt
(redis_port,
Opts
,
fun
(I)
when
is_integer(I), I>0 -> I
end
,
?REDIS_PORT
),
ejabberd_hooks:add
(set_presence_hook,
Host
,
?MODULE
, set_presence, 100),
ejabberd_hooks:add
(user_available_hook,
Host
,
?MODULE
, user_available, 50),
ejabberd_hooks:add
(sm_remove_connection_hook,
Host
,
?MODULE
, unset_presence, 50),
MongoHost
=
gen_mod:get_opt
(mongo_host,
Opts
,
fun
(B) -> binary_to_list(B)
end
,
?MONGO_HOST
),
MongoPort
=
gen_mod:get_opt
(mongo_port,
Opts
,
fun
(I)
when
is_integer(I), I>0 -> I
end
,
?MONGO_PORT
),
{ok,
Mongo
} =
mongo_connection:start_link
({
MongoHost
,
MongoPort
}),
C = c(
RedisHost
,
RedisPort
),
ejabberd_router:register_route
(
MyHost
), {ok, #state{host =
Host
, server_host =
MyHost
, rconn = C, mconn =
Mongo
}}.
terminate(_
Reason
, #state{host =
Host
, rconn = C, mconn =
Mongo
}) ->
ejabberd_hooks:delete
(set_presence_hook,
Host
,
?MODULE
, set_presence, 100),
ejabberd_hooks:delete
(user_available_hook,
Host
,
?MODULE
, user_available, 50),
ejabberd_hooks:delete
(unset_presence_hook,
Host
,
?MODULE
, unset_presence, 50),
eredis:stop
(C),
ok.
...
handle_call({append_online_user,
Uid
,
ProjId
}, _
From
,
State
) ->
C =
State
#state.rconn,
Key
= <<!--
?PRE_RPOJ_ONLINE_USERS
/binary,
ProjId
/binary-->>,
Resp
=
eredis:q
(C, [
"SADD"
,
Key
,
Uid
]),
{reply,
Resp
,
State
};
handle_call({remove_online_user,
Uid
,
ProjId
}, _
From
,
State
) ->
...
handle_call({get_proj_online_users,
ProjId
}, _
From
,
State
) ->
|