(使用 php-amqplib)
在第二节教程里,我们学习了怎样在多个工作者之间使用工作队列来分发耗时任务。
但如果我们需要在一个远程电脑上运行一个函数并且等待运行结果要怎样做呢?好吧,那是另一个问题了。这种模式通常被称为远程过程调用,或者RPC(Remote Procedure Call)。
在这篇教程里我们要使用RabbitMQ去建立一个RPC系统:一个客户端和一个可扩展的RPB服务器。因为我们还没有任何一个值得分发的耗时任务,我们就先建立一个仿真RPC服务用来返回“斐波纳契数列(Fibonacci numbers)”。
一、客户端接口(Client interface)
为了演示一个RPC服务怎样能被使用,我们先建立一个简单的客户端类。这个类将暴露一个命名为“call”的方法用于发送一个RPC请求并且阻塞程序执行直到应答被接收。
$fibonacci_rpc = new FibonacciRpcClient();
$response = $fibonacci_rpc->call(30);
echo ' [.] Got ', $response, "
";
关于RPC的注解
尽管RPC在计算机领域方面是一个很普通的方式,它却经常被批评。当一个程序员不清楚一个函数调用是否在本地或者是否这是一个缓慢的RPC的话,问题就来了。像这样的混乱会导致一个不可预知结果的系统里会给系统调试添加不必要的复杂性,而不是简化软件,滥用RPC会导致代码像意大利式细面条似的不可维护。
牢记这一点,并考虑下面的建议:
- 搞清楚哪个功能调用在本地哪个在远程。
- 为你的系统做好文档,使得组件间的依赖清晰可见。
- 处理好错误场景。当RPC服务器出现长时间的问题时,客户端应该如何反应?
当你对这些问题还困惑时,就先不要使用RPC。如果你可以的话,你应该使用一个异步管道来代替类似于阻塞的RPC。使用异步pipeline,结果被异步推送到下一个计算阶段。
回调队列(Callback queue)
通常来说,在RabbitMQ上做RPC很容易。一个客户端发送一条请求消息,然后一个服务器回应一条响应消息。为了接收一个响应,我们需要发送一个带有请求的“回调”队列地址。我们可以使用默认的队列。让我们试试看。
list($queue_name, ,) = $channel->queue_declare("", false, false, true, false);
$msg = new AMQPMessage(
$payload,
array('reply_to' => $queue_name)
);
$channel->basic_publish($msg, '', 'rpc_queue');
# ... then code to read a response message from the callback_queue ...
消息属性
AMQP 0-9-1协议预定了一组与消息相关的14个属性。多数属性很少使用,下面情况例外:
- 配送模式(delivery_mode):标记一条消息为持久的(值2)或短暂的(值1)。你可能记着在第二篇教程中有这个属性。
- 内容类型(content_type):用于描述编码的“mime-type”。比如对于经常使用的JSON编码的例子,设置这个属性为“application/json”就是一个很好的应用。
- 应答(reply_to):一般用于命名一个回调队列。
- 关联Id(correlation_id): 用于RPC和有关的响应队列的关联。
二、关联Id(Correlation Id)
在上面列出的方法内,我建议为每个RPC请求建立一个回调队列。这虽然很低效,但是,幸运的是有一个更好的办法——让我们为每个客户端建一个单独的回调队列。
这就产生了一个新的问题,在那个队列收到响应后,它不清楚这个响应是属于哪个请求的。这就是“correlation_id”属性被用到的时候了。我们将设置它一个唯一的值给每一个请求。稍后,当我们再回调队列里收到一条消息后,我们将查看这个属性。基于此,我们就可以将一个响应匹配一个请求了。如果我们看到一个未知的“correlation_id”值,我们可以放心地丢弃它——它不属于我们的请求。
或许你会问,在回调队列里,为什么我们忽略未知消息,而不是给出带有错误信息的失败?这是由于在服务端可能会存在竞争情况。尽管不太可能,但RPC服务器可能会在刚刚发送给我们响应后就死掉也是可能的,而不是在为这个请求发送一条确认消息之前。如果发生了这样的情况,重启的RPC服务会再次处理这个请求。这就是为什么在客户端我们必须要优雅地处理重复的响应,并且理想情况下RPC应该是幂等的。
三、总结(Summary)
我们的RPC将像这样工作:
- 当客户端启动时,它建立一个匿名排外的回调队列
- 对于一个RPC请求,客户端发送一条带有两个属性的消息:“reply_to”,这被设置给回调队列;“correlation_id”,这被设置一个唯一的值给每个请求。
- 请求被发送给一个“rpc_queue”队列。
- RPC工作者(又称:服务端)在那个队列里等候请求。当一个请求出现,它就执行作业,使用来自于“reply_to”的队列,发送一条带有结果的消息给客户端。
- 客户端等候回调队列里的数据,当一条消息出现后,它检查“correlation_id”属性,如果它和来自于请求的值匹配,它就返回响应给应用。
四、合在一起(Putting it all together)
斐波那契(Fibonacci)的任务:
function fib($n)
{
if ($n == 0) {
return 0;
}
if ($n == 1) {
return 1;
}
return fib($n-1) + fib($n-2);
}
我们定义我们的斐波那契(fibonacci )函数。假设仅输入有效的正整数。(不要想用很大的数字来做,这可能是最慢的递归实现)
我们的 RPC 服务端代码 rpc_server.php 看起来是这样的:
<?php
require_once __DIR__ . '/vendor/autoload.php';
use PhpAmqpLibConnectionAMQPStreamConnection;
use PhpAmqpLibMessageAMQPMessage;
$connection = new AMQPStreamConnection('localhost', 5672, 'guest', 'guest');
$channel = $connection->channel();
$channel->queue_declare('rpc_queue', false, false, false, false);
function fib($n)
{
if ($n == 0) {
return 0;
}
if ($n == 1) {
return 1;
}
return fib($n-1) + fib($n-2);
}
echo " [x] Awaiting RPC requests
";
$callback = function ($req) {
$n = intval($req->body);
echo ' [.] fib(', $n, ")
";
$msg = new AMQPMessage(
(string) fib($n),
array('correlation_id' => $req->get('correlation_id'))
);
$req->delivery_info['channel']->basic_publish(
$msg,
'',
$req->get('reply_to')
);
$req->delivery_info['channel']->basic_ack(
$req->delivery_info['delivery_tag']
);
};
$channel->basic_qos(null, 1, null);
$channel->basic_consume('rpc_queue', '', false, false, false, false, $callback);
while ($channel->is_consuming()) {
$channel->wait();
}
$channel->close();
$connection->close();
服务端代码是很简单的:
- 像往常一样,我们首先建立连接,通道并且声明队列。
- 我们可能想运行不只一个服务端进程,为了平均地分担负载给多台服务器,我们需要在“$channel.basic_qos”中设置 “prefetchCount” 的值。
- 我们使用“basic_consume”来访问队列,然后进入我们等候的请求消息里的while循环中,执行工作并返回响应。
我们的RPC客户端代码 rpc_client.php 如下:
<?php
require_once __DIR__ . '/vendor/autoload.php';
use PhpAmqpLibConnectionAMQPStreamConnection;
use PhpAmqpLibMessageAMQPMessage;
class FibonacciRpcClient
{
private $connection;
private $channel;
private $callback_queue;
private $response;
private $corr_id;
public function __construct()
{
$this->connection = new AMQPStreamConnection(
'localhost',
5672,
'guest',
'guest'
);
$this->channel = $this->connection->channel();
list($this->callback_queue, ,) = $this->channel->queue_declare(
"",
false,
false,
true,
false
);
$this->channel->basic_consume(
$this->callback_queue,
'',
false,
true,
false,
false,
array(
$this,
'onResponse'
)
);
}
public function onResponse($rep)
{
if ($rep->get('correlation_id') == $this->corr_id) {
$this->response = $rep->body;
}
}
public function call($n)
{
$this->response = null;
$this->corr_id = uniqid();
$msg = new AMQPMessage(
(string) $n,
array(
'correlation_id' => $this->corr_id,
'reply_to' => $this->callback_queue
)
);
$this->channel->basic_publish($msg, '', 'rpc_queue');
while (!$this->response) {
$this->channel->wait();
}
return intval($this->response);
}
}
$fibonacci_rpc = new FibonacciRpcClient();
$response = $fibonacci_rpc->call(30);
echo ' [.] Got ', $response, "
";
现在是时候看一下我们完整例子的源代码了rpc_client.php and rpc_server.php.
我们的 RPC服务现在已经就绪,我们能启动服务端:
php rpc_server.php
# => [x] Awaiting RPC requests
运行客户端来请求一个斐波那契数字:
php rpc_client.php
# => [x] Requesting fib(30)
这里展示出的设计不是唯一一种实现RPC服务的可能,但是它有一些重要的优势:
- 如果RPC服务太慢,你可以通过运行另一个来同比例扩充它。试着在一个新的控制台上运行另一个“rpc_server.php”。
- 在客户端,RPC只需要发送和接收一条消息。不需要像queue_declare要求的一样同步调用。因此,对一个单独的RPC请求,RPC客户端仅需要一个网络往返。
我们的代码依然很简单,并且没有尝试解决更复杂(但很重要的)的问题,就像:
- 如果服务端没有运行,客户端将如何反应?
- 一个客户端对于RPC是否应该设置某种超时?
- 如果服务端故障并且发生异常,是否应该传给客户端?
- 在处理之前,防止无效的传入信息(例如边界检测,类型)。
如果你想要尝试,你或许会发现 management UI 对于查看队列是很有用的。