RabbitMQ 入门 - 远程调用 (RPC)

远程调用 (RPC)

本文基于 官方文档 翻译

RPC = Remote procedure call

(using php-amqplib)

在第二篇教程中,我们学习了如何使用工作队列在多个工作人员之间分配耗时的任务。

但是如果我们需要在远程计算机上运行某个功能并等待结果呢? 那么,这是一个不同的故事。 这种模式通常称为远程调用(Remote procedure call)。

在本教程中,我们将使用 RabbitMQ 构建一个 RPC 系统:一个客户端和一个可扩展的 RPC 服务器。 由于我们没有任何值得分发的耗时任务,我们将创建一个返回斐波那契数字的虚拟 RPC 服务。

客户端界面

为了说明如何使用 RPC 服务,我们将创建一个简单的客户端类。 它将公开一个名为 call 的方法,它发送一个 RPC 请求并阻塞,直到收到答案:

$fibonacci_rpc = new FibonacciRpcClient();
$response = $fibonacci_rpc->call(30);
echo " [.] Got ", $response, "\n";

有关 RPC 的说明

虽然 RPC 是计算中很常见的模式,但它经常受到批评。 当程序员不知道函数调用是本地的还是慢速的 RPC 时会出现这些问题。 像这样的混乱导致了不可预测的系统,并增加了调试的不必要的复杂性。 而不是简化软件,滥用 RPC 会导致不可维护的意大利面代码。

铭记这一点,请考虑以下建议:

  • 确保显而易见哪个函数调用是本地的,哪个是远程的。
  • 记录您的系统,清楚组件之间的依赖关系。
  • 处理错误情况。 比如,当 RPC 服务器长时间关闭时,客户端应该如何反应?

有疑问时避免 RPC。 如果可以的话,你应该使用异步管道 - 而不是类似于 RPC 的阻塞,结果被异步推送到下一个计算阶段。

回调队列

一般来说,通过 RabbitMQ 来执行 RPC 是很容易的。 客户端发送请求消息,服务器回复响应消息。 为了收到回应,我们需要发送一个 “callback” 队列地址与请求。 我们可以使用默认队列。 让我们试试看:

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类型。 例如,对于经常使用的 JSON 编码,将此属性设置为 application/json 是一种很好的做法。
  • reply_to:通常用于命名回调队列。
  • correlation_id:用于将 RPC 响应与请求关联起来。

相关性 ID (correlation_id)

在上面介绍的方法中,我们建议为每个 RPC 请求创建一个回调队列。 这是非常低效的,但幸运的是有一个更好的方法 - 让我们为每个客户端创建一个回调队列。

这引发了一个新问题,在该队列中收到回复后,不清楚回复属于哪个请求。 那是什么时候使用 correlation_id 属性。 我们将把它设置为每个请求的唯一值。 稍后,当我们在回调队列中收到消息时,我们会查看该属性,并基于此属性,我们将能够将响应与请求进行匹配。 如果我们看到未知的 correlation_id 值,我们可以放心地丢弃该消息 - 它不属于我们的请求。

您可能会问,为什么我们应该忽略回调队列中的未知消息,而不是因为错误而失败? 这是由于服务器端可能出现竞争状况。 尽管不太可能,但在发送给我们答案之后和发送请求的确认消息之前,RPC 服务器可能会死亡。 如果发生这种情况,重新启动的 RPC 服务器将再次处理该请求。 这就是为什么在客户端,我们必须优雅地处理重复的响应,理想情况下 RPC 应该是幂等的。

概要

img

我们的 RPC 会像这样工作:

  • 当客户端启动时,它创建一个匿名且独占的回调队列。
  • 对于 RPC 请求,客户端将发送具有两个属性的消息:reply_to,该消息设置为回调队列和 correlation_id,该值设置为每个请求的唯一值。
  • 该请求会被发送到 rpc_queue 队列。
  • RPC worker(又名:服务器)正在等待该队列上的请求。 当出现请求时,它执行该作业,并使用 reply_to 字段中的队列将结果发送回客户端。
  • 客户端在回调队列中等待数据。 当出现消息时,它会检查 correlation_id 属性。 如果它匹配到来自请求的值,则返回对应用程序的响应。

把它们放在一起

斐波那契任务:

function fib($n) {
    if ($n == 0)
        return 0;
    if ($n == 1)
        return 1;
    return fib($n-1) + fib($n-2);
}

我们声明我们的斐波那契函数。 它只假定有效的正整数输入。 (不要指望这个版本适用于大数字,它可能是最慢的递归实现)。

我们的 RPC 服务器 rpc_server.php 的代码如下所示:

<?php

require_once __DIR__ . '/vendor/autoload.php';
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;

$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\n";
$callback = function($req) {
    $n = intval($req->body);
    echo " [.] fib(", $n, ")\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(count($channel->callbacks)) {
    $channel->wait();
}

$channel->close();
$connection->close();

?>

服务器代码非常简单:

  • 像往常一样,我们首先建立连接,通道并声明队列。
  • 我们可能想要运行多个服务器进程。 为了在多个服务器上平均分配负载,我们需要在 $channel.basic_qos 中设置 prefetch_count 设置。
  • 我们使用 basic_consume 来访问队列。 然后,我们进入 while 循环,在其中我们等待请求消息,完成工作并发回响应。

我们的 RPC 客户端 rpc_client.php 的代码:

<?php

require_once __DIR__ . '/vendor/autoload.php';
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;

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, false, false, false,
            array($this, 'on_response'));
    }
    public function on_response($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, "\n";

?>

我们的 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 有某种超时?
  • 如果服务器发生故障并引发异常,是否应将其转发给客户端?
  • 在处理之前防止无效的传入消息(例如检查边界,类型)。

如果您想进行实验,您可能会发现管理界面对查看队列很有用。

本系列入门教程至此完结

本作品采用《CC 协议》,转载必须注明作者和本文链接
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 2
AdamLu

:+1:感谢分享

4年前 评论

很好入门系列,感谢 :+1:

4年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!