在 RabbitMQ之工作队列(Work Queues) 教程中,我们学习了如何使用工作队列在多个工作人员之间分配耗时的任务。
但是如果我们需要在远程计算机上运行一个函数并等待结果呢? 嗯,这是一个不同的故事。 这种模式通常称为远程过程调用或 RPC。
在本教程中,我们将使用 RabbitMQ 构建一个 RPC 系统:一个客户端和一个可扩展的 RPC 服务器。 由于我们没有任何值得分发的耗时任务,我们将创建一个返回斐波那契数的虚拟 RPC 服务。
为了说明如何使用 RPC 服务,我们将创建一个简单的客户端类。 它将公开一个名为 call 的方法,该方法发送一个 RPC 请求并阻塞,直到收到回答:
FibonacciRpcClient fibonacciRpc = new FibonacciRpcClient(); String result = fibonacciRpc.call("4"); System.out.println( "fib(4) is " + result);
关于 RPC 的说明
尽管 RPC 在计算中是一种非常常见的模式,但它经常受到批评。当程序员不知道函数调用是本地的还是缓慢的 RPC 时,就会出现问题。像这样的混乱会导致系统不可预测,并为调试增加不必要的复杂性。滥用 RPC 可能会导致无法维护的意大利面条式代码,而不是简化软件。
考虑到这一点,请考虑以下建议:
确保清楚哪个函数调用是本地的,哪个是远程的。
记录您的系统,明确组件之间的依赖关系。
处理错误情况,RPC服务器长时间宕机时,客户端应该如何反应?
如有疑问,请避免使用RPC。如果可以,您应该使用异步管道 ———— 而不是类似 RPC 的阻塞,结果被异步推送到下一个计算阶段。
一般来说,通过 RabbitMQ 进行 RPC 很容易。客户端发送请求消息,服务器回复响应消息。为了接收响应,我们需要在请求中发送一个 “回调” 队列地址。我们可以使用默认队列(在 Java 客户端中是专有的)。让我们尝试一下:
callbackQueueName = channel.queueDeclare().getQueue(); BasicProperties props = new BasicProperties .Builder() .replyTo(callbackQueueName) .build(); channel.basicPublish("", "rpc_queue", props, message.getBytes()); // ... then code to read a response message from the callback_queue ...
我们需要这个新的导入:
import com.rabbitmq.client.AMQP.BasicProperties;
消息属性
AMQP 0-9-1 协议预定义了一组 14 个与消息一起使用的属性。大多数属性很少使用,但以下情况除外:
deliveryMode:将消息标记为持久(值为2)或瞬态(任何其他值),您可能还记得第二个教程中的这个属性。
contentType:用于描述编码的 mime-type。例如,对于经常使用的 JSON 编码,最好将此属性设置为:application/json。
replyTo:常用来命名一个回调队列。
correlationId:用于将 RPC 响应与请求关联起来。
在上面介绍的方法中,我们建议为每个 RPC 请求创建一个回调队列。这是非常低效的,但幸运的是有一个更好的方法——让我们为每个客户端创建一个回调队列。
这引发了一个新问题,在该队列中收到响应后,尚不清楚该响应属于哪个请求。这就是使用correlationId 属性的时候。我们将为每个请求将其设置为唯一值。稍后,当我们在回调队列中收到一条消息时,我们将查看此属性,并基于此将响应与请求进行匹配。如果我们看到一个未知的correlationId 值,我们可以安全地丢弃该消息——它不属于我们的请求。
你可能会问,为什么我们要忽略回调队列中的未知消息,而不是因为错误而失败?这是由于服务器端可能存在竞争条件。虽然不太可能,但 RPC 服务器可能会在向我们发送答案之后但在发送请求的确认消息之前死掉。如果发生这种情况,重新启动的 RPC 服务器将再次处理该请求。这就是为什么在客户端上我们必须优雅地处理重复响应,并且 RPC 理想情况下应该是幂等的。
我们的 RPC 将像这样工作:
对于 RPC 请求,客户端发送具有两个属性的消息:replyTo,设置为仅为请求创建的匿名独占队列,以及correlationId,设置为每个请求的唯一值。
请求被发送到 rpc_queue 队列。
RPC 工作者(又名:服务器)正在等待该队列上的请求。当一个请求出现时,它会完成这项工作并使用replyTo 字段中的队列将带有结果的消息发送回客户端。
客户端等待回复队列中的数据。当出现一条消息时,它会检查correlationId 属性。如果它与请求中的值匹配,则将响应返回给应用程序。
斐波那契任务:
private static int fib(int n) { if (n == 0) return 0; if (n == 1) return 1; return fib(n-1) + fib(n-2); }
我们声明我们的斐波那契函数。它仅假定有效的正整数输入。(不要指望这个适用于大数字,它可能是最慢的递归实现)。
我们的 RPC 服务器的 RPCServer.java 代码如下:
import com.rabbitmq.client.*; public class RPCServer { private static final String RPC_QUEUE_NAME = "rpc_queue"; private static int fib(int n) { if (n == 0) return 0; if (n == 1) return 1; return fib(n - 1) + fib(n - 2); } public static void main(String[] argv) throws Exception { ConnectionFactory factory = new ConnectionFactory(); factory.setHost("localhost"); try (Connection connection = factory.newConnection(); Channel channel = connection.createChannel()) { channel.queueDeclare(RPC_QUEUE_NAME, false, false, false, null); channel.queuePurge(RPC_QUEUE_NAME); channel.basicQos(1); System.out.println(" [x] Awaiting RPC requests"); Object monitor = new Object(); DeliverCallback deliverCallback = (consumerTag, delivery) -> { AMQP.BasicProperties replyProps = new AMQP.BasicProperties .Builder() .correlationId(delivery.getProperties().getCorrelationId()) .build(); String response = ""; try { String message = new String(delivery.getBody(), "UTF-8"); int n = Integer.parseInt(message); System.out.println(" [.] fib(" + message + ")"); response += fib(n); } catch (RuntimeException e) { System.out.println(" [.] " + e.toString()); } finally { channel.basicPublish("", delivery.getProperties().getReplyTo(), replyProps, response.getBytes("UTF-8")); channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false); // RabbitMq consumer worker thread notifies the RPC server owner thread synchronized (monitor) { monitor.notify(); } } }; channel.basicConsume(RPC_QUEUE_NAME, false, deliverCallback, (consumerTag -> { })); // Wait and be prepared to consume the message from RPC client. while (true) { synchronized (monitor) { try { monitor.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } } } } }
服务器代码相当简单:
像往常一样,我们首先建立连接、通道和声明队列。
我们可能想要运行多个服务器进程。为了将负载均衡分配到多个服务器上,我们需要在 channel.basicQos 中设置 prefetchCount 设置。
我们使用 basicConsume 来访问队列,在这里我们以对象 (DeliverCallback) 的形式提供回调,该回调将完成工作并发送回响应。
我们的 RPC 客户端的 RPCClient.java 代码如下:
import com.rabbitmq.client.AMQP; import com.rabbitmq.client.Channel; import com.rabbitmq.client.Connection; import com.rabbitmq.client.ConnectionFactory; import java.io.IOException; import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeoutException; public class RPCClient implements AutoCloseable { private Connection connection; private Channel channel; private String requestQueueName = "rpc_queue"; public RPCClient() throws IOException, TimeoutException { ConnectionFactory factory = new ConnectionFactory(); factory.setHost("localhost"); connection = factory.newConnection(); channel = connection.createChannel(); } public static void main(String[] argv) { try (RPCClient fibonacciRpc = new RPCClient()) { for (int i = 0; i < 32; i++) { String i_str = Integer.toString(i); System.out.println(" [x] Requesting fib(" + i_str + ")"); String response = fibonacciRpc.call(i_str); System.out.println(" [.] Got '" + response + "'"); } } catch (IOException | TimeoutException | InterruptedException e) { e.printStackTrace(); } } public String call(String message) throws IOException, InterruptedException { final String corrId = UUID.randomUUID().toString(); String replyQueueName = channel.queueDeclare().getQueue(); AMQP.BasicProperties props = new AMQP.BasicProperties .Builder() .correlationId(corrId) .replyTo(replyQueueName) .build(); channel.basicPublish("", requestQueueName, props, message.getBytes("UTF-8")); final BlockingQueue<String> response = new ArrayBlockingQueue<>(1); String ctag = channel.basicConsume(replyQueueName, true, (consumerTag, delivery) -> { if (delivery.getProperties().getCorrelationId().equals(corrId)) { response.offer(new String(delivery.getBody(), "UTF-8")); } }, consumerTag -> { }); String result = response.take(); channel.basicCancel(ctag); return result; } public void close() throws IOException { connection.close(); } }
客户端代码稍微复杂一些:
我们建立连接和通道。
我们的 call 方法发出实际的 RPC 请求。
在这里,我们首先生成一个唯一的correlationId 数字并保存它——我们的消费者回调将使用这个值来匹配适当的响应。
然后,我们为回复创建一个专用的排他队列并订阅它。
接下来,我们发布请求消息,带有两个属性:replyTo 和correlationId。
在这一点上,我们可以坐下来等待正确的响应到来。
由于我们的消费者交付处理发生在一个单独的线程中,我们需要在响应到达之前暂停主线程。使用 BlockingQueue 是一种可能的解决方案。在这里,我们正在创建容量设置为 1 的 ArrayBlockingQueue,因为我们只需要等待一个响应。
消费者正在做一项非常简单的工作,对于每条消费的响应消息,它都会检查correlationId 是否是我们正在寻找的消息。如果是这样,它将响应放入 BlockingQueue。
同时主线程正在等待响应从 BlockingQueue 中获取。
最后,我们将响应返回给用户。
像往常一样编译和设置类路径(参见教程一):
javac -cp $CP RPCClient.java RPCServer.java
我们的 RPC 服务现已准备就绪。我们可以启动服务器:
java -cp $CP RPCServer # => [x] Awaiting RPC requests
要请求斐波那契数,请运行客户端:
java -cp $CP RPCClient # => [x] Requesting fib(30)
这里介绍的设计并不是 RPC 服务的唯一可能实现,但它有一些重要的优点:
如果 RPC 服务器太慢,您可以通过运行另一个服务器来扩展。尝试在新控制台中运行第二个 RPCServer。
在客户端,RPC 只需要发送和接收一条消息。不需要像 queueDeclare 这样的同步调用。 因此,对于单个 RPC 请求,RPC 客户端只需要一次网络往返。
我们的代码仍然非常简单,并没有尝试解决更复杂(但重要)的问题,例如:
如果没有服务器在运行,客户端应该如何反应?
客户端是否应该为 RPC 设置某种超时?
如果服务器发生故障并引发异常,是否应该将其转发给客户端?
在处理之前防止无效的传入消息(例如检查边界、类型)。