项目中使用RabbitMQ作为队列处理用户消息通知,消息由前端PHP代码产生,处理消息使用Python,这就导致代码一致性问题,调整消息定义时需要PHP和Python都进行修改。这两天抽时间研究了下,如何将消息的产生与处理(消费)全部用PHP来做。查资料时发现,关于PHP处理消息队列的资料很少,有必要把一些初学者容易混淆的地方再讲一下。
拟分成两部分: 一,RabbitMQ的原理与操作示例;二,具体服务安装及如何用PHP作为守护模式处理消息。
RabbitMQ是流行的开源消息队列系统,用erlang语言开发,完整的实现了AMPQ(高级消息队列协议)。网站在: http://www.rabbitmq.com/ 上面有教程和实例代码(Python和Java的)。
AMPQ协议为了能够满足各种消息队列需求,在概念上比较复杂。首先,rabbitMQ启动默认是没有任何配置的,需要客户端连接上去,设置交换机等才能工作。不把这些基础概念弄清楚,后面程序设计就容易产生问题。
1,vhosts : 虚拟主机。
一个RabbitMQ的实体上可以有多个vhosts,用户与权限设置就是依附于vhosts。对一般PHP应用,不需要用户权限设定,直接使用默认就存在的"/"就可以了,用户可以使用默认就存在的"guest"。一个简单的配置示例:
1
2
3
4
5
6
7
|
$conn_args
=
array
(
'host'
=>
'127.0.0.1'
,
'port'
=>
'5672'
,
'login'
=>
'guest'
,
'password'
=>
'guest'
,
'vhost'
=>
'/'
);
|
2,connection 与 channel : 连接与信道
connection是指物理的连接,一个client与一个server之间有一个连接;一个连接上可以建立多个channel,可以理解为逻辑上的连接。一般应用的情况下,有一个channel就够用了,不需要创建更多的channel。示例代码:
1
2
3
4
5
6
|
//创建连接和channel
$conn
=
new
AMQPConnection(
$conn_args
);
if
(!
$conn
->connect()) {
die
(
"Cannot connect to the broker!\n"
);
}
$channel
=
new
AMQPChannel(
$conn
);
|
3,exchange 与 routingkey : 交换机 与 路由键
为了将不同类型的消息进行区分,设置了交换机与路由两个概念。比如,将A类型的消息发送到名为‘C1’的交换机,将类型为B的发送到'C2'的交换机。当客户端连接C1处理队列消息时,取到的就只是A类型消息。进一步的,如果A类型消息也非常多,需要进一步细化区分,比如某个客户端只处理A类型消息中针对K用户的消息,routingkey就是来做这个用途的。
1
2
3
4
5
6
7
8
9
10
11
|
$e_name
=
'e_linvo'
;
//交换机名
$k_route
=
array
(0=>
'key_1'
, 1=>
'key_2'
);
//路由key
//创建交换机
$ex
=
new
AMQPExchange(
$channel
);
$ex
->setName(
$e_name
);
$ex
->setType(AMQP_EX_TYPE_DIRECT);
//direct类型
$ex
->setFlags(AMQP_DURABLE);
//持久化
echo
"Exchange Status:"
.
$ex
->
declare
().
"\n"
;
for
(
$i
=0;
$i
<5; ++
$i
){
echo
"Send Message:"
.
$ex
->publish(
$message
.
date
(
'H:i:s'
),
$k_route
[i%2]).
"\n"
;
}
|
由以上代码可以看到,发送消息时,只要有“交换机”就够了。至于交换机后面有没有对应的处理队列,发送方是不用管的。routingkey可以是空的字符串。在示例中,我使用了两个key交替发送消息,是为了下面更便于理解routingkey的作用。
对于交换机,有两个重要的概念:
A,类型。有三种类型: Fanout类型最简单,这种模型忽略routingkey;Direct类型是使用最多的,使用确定的routingkey。这种模型下,接收消息时绑定'key_1'则只接收key_1的消息;最后一种是Topic,这种模式与Direct类似,但是支持通配符进行匹配,比如: 'key_*',就会接受key_1和key_2。Topic貌似美好,但是有可能导致不严谨,所以还是推荐使用Direct。
B,持久化。指定了持久化的交换机,在重新启动时才能重建,否则需要客户端重新声明生成才行。
需要特别明确的概念:交换机的持久化,并不等于消息的持久化。只有在持久化队列中的消息,才能持久化;如果没有队列,消息是没有地方存储的;消息本身在投递时也有一个持久化标志的,PHP中默认投递到持久化交换机就是持久的消息,不用特别指定。
4,queue: 队列
讲了这么多,才讲到队列呀。事实上,队列仅是针对接收方(consumer)的,由接收方根据需求创建的。只有队列创建了,交换机才会将新接受到的消息送到队列中,交换机是不会在队列创建之前的消息放进来的。换句话说,在建立队列之前,发出的所有消息都被丢弃了。下面这个图比RabbitMQ官方的图更清楚——Queue是属于ReceiveMessage的一部分。
接下来看一下创建队列及接收消息的示例:
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
|
$e_name
=
'e_linvo'
;
//交换机名
$q_name
=
'q_linvo'
;
//队列名
$k_route
=
''
;
//路由key
//创建连接和channel
$conn
=
new
AMQPConnection(
$conn_args
);
if
(!
$conn
->connect()) {
die
(
"Cannot connect to the broker!\n"
);
}
$channel
=
new
AMQPChannel(
$conn
);
//创建交换机
$ex
=
new
AMQPExchange(
$channel
);
$ex
->setName(
$e_name
);
$ex
->setType(AMQP_EX_TYPE_DIRECT);
//direct类型
$ex
->setFlags(AMQP_DURABLE);
//持久化
echo
"Exchange Status:"
.
$ex
->
declare
().
"\n"
;
//创建队列
$q
=
new
AMQPQueue(
$channel
);
$q
->setName(
$q_name
);
$q
->setFlags(AMQP_DURABLE);
//持久化
//绑定交换机与队列,并指定路由键
echo
'Queue Bind: '
.
$q
->bind(
$e_name
,
$k_route
).
"\n"
;
//阻塞模式接收消息
echo
"Message:\n"
;
$q
->consume(
'processMessage'
, AMQP_AUTOACK);
//自动ACK应答
$conn
->disconnect();
/**
* 消费回调函数
* 处理消息
*/
function
processMessage(
$envelope
,
$queue
) {
var_dump(
$envelope
->getRoutingKey);
$msg
=
$envelope
->getBody();
echo
$msg
.
"\n"
;
//处理消息
}
|
从上述示例中可以看到,交换机既可以由消息发送端创建,也可以由消息消费者创建。
创建一个队列(line:20)后,需要将队列绑定到交换机上(line:25)队列才能工作,routingkey也是在这里指定的。有的资料上写成bindingkey,其实一回事儿,弄两个名词反倒容易混淆。
消息的处理,是有两种方式:
A,一次性。用 $q->get([...]),不管取到取不到消息都会立即返回,一般情况下使用轮询处理消息队列就要用这种方式;
B,阻塞。用 $q->consum( callback, [...] ) 程序会进入持续侦听状态,每收到一个消息就会调用callback指定的函数一次,直到某个callback函数返回FALSE才结束。
关于callback,这里多说几句: PHP的call_back是支持使用数组的,比如: $c = new MyClass(); $c->counter = 100; $q->consume( array($c,'myfunc') ) 这样就可以调用自己写的处理类。MyClass中myfunc的参数定义,与上例中processMessage一样就行。
在上述示例中,使用的$routingkey = '', 意味着接收全部的消息。我们可以将其改为 $routingkey = 'key_1',可以看到结果中仅有设置routingkey为key_1的内容了。
注意: routingkey = 'key_1' 与 routingkey = 'key_2' 是两个不同的队列。假设: client1 与 client2 都连接到 key_1 的队列上,一个消息被client1处理之后,就不会被client2处理。而 routingkey = '' 是另类,client_all绑定到 '' 上,将消息全都处理后,client1和client2上也就没有消息了。
在程序设计上,需要规划好exchange的名称,以及如何使用key区分开不同类型的标记,在消息产生的地方插入发送消息代码。后端处理,可以针对每一个key启动一个或多个client,以提高消息处理的实时性。如何使用PHP进行多线程的消息处理,将在下一节中讲述。
更多消息模型,可以参考: http://www.rabbitmq.com/tutorials/tutorial-two-python.html
RabbitMQ与PHP(二)
http://hi.baidu.com/cnjimmydong/item/b9190a841f3406db99255fe6
在上一节中,详细介绍了RabbitMQ的exchange/routingkey/queue等概念,以及示例了如何使用PHP发送和处理消息的代码。这一节,将介绍在项目中如何使用PHP多线程的进行消息实时处理,以及简要介绍一些RabbitMQ的安装相关。熟悉的可以将安装这部分跳过。
一, RabbitMQ的安装:
需要首先安装erlong
1
2
3
4
5
6
7
|
wget http:
//www
.erlang.org
/download/otp_src_R16B
.
tar
.gz
tar
-xzvf otp_src_R16B.
tar
.gz
cd
otp_src_R16B
.
/configure
--help
.
/configure
--prefix=
/usr/local/erlong
make
make
install
|
注意: 这里将erlong安装到了指定的目录: /usr/local/erlong,而不是使用默认的路径。这是一个好的习惯,对于版本控制等都会有好处。但是这会导致后面 rabbitMQ报错:找不到erl 执行文件,需要多做一些处理才行。
安装RabbitMQ
1
2
3
4
5
|
wget http:
//www
.rabbitmq.com
/releases/rabbitmq-server/v3
.0.4
/rabbitmq-server-generic-unix-3
.0.4.
tar
.gz
tar
-xzvf rabbitmq-server-generic-unix-3.0.4.
tar
.gz
mv
rabbitmq_server-3.0.4/
/usr/local/
cd
/usr/local/rabbitmq_server-3
.0.4
/sbin
.
/rabbitmq-server
|
到这里会出现一个报错信息: ./rabbitmq-server: line 86: erl: command not found 这是因为erlong指定了安装路径,在系统的PATH中找不到。只要export PATH=$PATH:/usr/local/erlong/bin 就可以了。
如果为了rc.local启动方便,可以将 export PATH=$PATH:/usr/local/erlong/bin 这一行写入到 rabbitmq-server 文件中:
执行后,ps -aux 一下,看到进程中有/usr/local/erlong/lib/erlang/erts-5.10.1/bin/epmd -daemon 和 /usr/local/erlong/lib/erlang/erts-5.10.1/bin/beam.smp 就OK了。
sbin目录下还有一个脚本: rabbitmqctl 也很常用,与 rabbitmq-server 一样需要指明erlong的路径才能正确工作。
常用的方法:
rabbitmqctl start-app 启动后,执行一下这个比较保险
rabbitmqctl list_exchanges 显示当前所有的交换机
rabbitmqctl list_queue 查看当前有效队列情况
二, PHP extension的安装:
PHP操作rabbitmq需要AMQP扩展的支持。下载扩展: http://pecl.php.net/package/amqp ,安装过程与一般扩展一样,/usr/local/php/bin/phpize; ./configure --with-php-config=/usr/local/php/bin/php-config; make && make install
然后编辑php.ini 插入:
[amqp]
extension = amqp.so
重启apache或nginx,查看phpinfo其中有关于anqp的段落,就OK了。
三,如何使用PHP进行实时后端消息处理
首先,要保证PHP文件可以正确的以堵塞方式处理消息。代码可参见上一节RabbitMQ与PHP(一)。
然后,我们来借助Python实现一个多线程的守护进程,由这个守护进程来调用PHP,把PHP作为工作线程。
启动脚本:start_jobs.py
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
|
# _*_ coding:utf-8 _*_
'''
yoka at 实现多线程处理任务的守护进程
Created on 2012-4-7
@author: xwarrior
@update: jimmy.dong@gmail.com
'''
#在此引入项目需要的数据包
from
MyJobs
import
MyJobs
from
MyThread
import
MyThread
import
logging
import
time
def
main():
logger
=
logging.getLogger(
'main()'
)
logger.info(
'server start!'
)
worker_threads
=
2
#定义需要启动的线程数量
timeline
=
2
#线程检查时间间隔,秒
thread_pool
=
{}
for
i
in
range
(
0
, worker_threads ):
param
=
'some param'
job
=
MyJobs( param )
thread
=
MyThread( job, i )
thread.setDaemon
=
True
thread.start()
logger.info(
'start thread %s'
%
( thread.getName() ))
thread_pool[i]
=
thread
#干完就结束模式
#for eachKey in thread_pool.keys():
# thread_pool[eachKey].join()
#保持线程数量模式
while
1
:
time.sleep(timeline)
# 检查每一个线程
for
eachKey
in
thread_pool.keys():
if
thread_pool[eachKey].isAlive():
print
'thread alive:'
+
str
(i)
else
:
print
'thread down:'
+
str
(i)
thread_pool[eachKey].run()
logger.info(
'main exist!'
)
return
if
__name__
=
=
'__main__'
:
#init config format
FORMAT
=
'%(asctime)-15s %(name)s %(levelname)s file %(filename)s:lineno %(lineno)s - %(message)s'
logging.basicConfig(
format
=
FORMAT
,level
=
logging.INFO)
main()
pass
|
线程脚本: MyThread.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
# _*_ coding:utf-8 _*_
'''
Created on 2013-03-25
@author: jimmy.dong@gmail.com
'''
from
threading
import
Thread
class
MyThread(Thread):
'''
创建线程
'''
def
__init__(
self
,job,thread_id):
'''
Constructor
'''
self
.job
=
job
Thread.__init__(
self
, name
=
'my_thread_%s'
%
(thread_id))
def
run(
self
):
self
.job.run()
def
stop(
self
):
self
.job.exit()
|
任务脚本: MyJobs.py
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
|
# _*_ coding:utf-8 _*_
'''
Created on 2013-03-25
@author: jimmy.dong@gmail.com
'''
import
os
import
urllib2
class
MyJobs(
object
):
def
__init__(
self
, param ):
#do something
self
.param
=
param
def
__del__(
self
):
''' destruct '''
self
.exit()
def
exit(
self
):
''' 退出'''
self
.quit
=
True
def
run(
self
):
''' 开始处理 '''
#使用shell模式
#cmd = '/usr/bin/curl "http://at.yoka.com/try/amqp_consume.php?key=' + str(self.param) + '"'
cmd
=
'/usr/local/php/bin/php -c /usr/local/php/lib/nginx.ini /home/jimmy/at/DocumentRoot/try/amqp_consume.php '
+
str
(
self
.param)
re
=
os.system(cmd)
#使用web模式
#req = urllib2.Request('http://at.yoka.com/try/amqp_consume.php?key=' + str(self.param))
#response = urllib2.urlopen(req)
#re = response.read()
#print re
|
在任务调度(start_jobs.py)中,设计了两种工作模式:
一种工作模式是一共启动N个线程去干活,适合于尽快完成一个大任务;
另一种是保持进程数量,当发现某个进程完成后,再重新将进程启动起来。显然,用户守护处理消息适合这种模式。
具体工作在MyJob.py中,提供了系统Shell调用和采用URL调用两种方式。推荐使用shell直接调用php的方式,这样可以灵活控制Php.ini,比如增加auto_prepend_file、增长max_execution_time等。
实际项目中,假定有5种类型的消息,可以启动20个线程,将thread_id当作参数传递给PHP。PHP将thread_id%5当作待处理类型,就可以得到每种类型有4个线程工作的场景了。
考虑到PHP的执行时间限制及内存泄露问题,可以将consume.php脚本进行一下改进,让PHP脚本每次处理指定数量的消息后就退出,由Python多线程框架重新启动线程,以保证运行稳定可靠。另外,将应答改为手工应答,确保消息获得正确有效处理。
1
2
3
4
5
6
7
8
9
10
11
12
|
/
$q
->consume(
'processMessage'
);
//需手动应答
/**
* 消费回调函数
*/
function
processMessage(
$envelope
,
$queue
) {
global
$counter
;
$msg
=
$envelope
->getBody();
echo
$msg
.
"\n"
;
//处理消息
$queue
->ack(
$envelope
->getDeliveryTag());
//手动发送ACK应答
if
(
$counter
++ > 5)
return
FALSE;
//处理5个消息后退出
}
|
用 两个线程,检查间隔2秒,SHELL模式 测试运行结果:
由上图可见,运行开始后,检查2个线程都处于活跃状态,并对消息进行了正确处理,当处理到一定数量后,PHP程序结束,父进程检查到有进程处于完成状态,重新将其启动(第二个绿色框)。完全与预期相符。