原文链接:www.rabbitmq.com/tutorials/t…
在第二章中我们学习了如何用工作队列在多个工作者之间分配耗时的任务。
但是如果我们需要在远程计算机上执行任务并且等待返回结果该怎么办呢?这就和之前的做法完全不同了。这种模式就是人们熟知的远程过程调用简称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调用,这样调用结果就可以异步传递给下个处理单元。
回调队列
通常情况下,使用RabbitMQ构建RPC服务都挺简单。客户端发送请求,服务端返回响应消息。为了能够接受响应消息,我们需要在发送请求时附带一个回调队列地址。我们可以使用默认的队列(唯一队列)。
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 ...
消息参数
AMQP协议定义了14个消息参数,大部分参数我们都很少用到,常用的几个参数如下:
- deliveryMode:当值为2时表示需要对消息进行持久化,其他值则表示无需持久化
- contentType:用于描述mine-type编码,比如我们经常使用的JSON格式编码,对应的参数值为application/json
- replyTo:通常被用来命名回调队列
- correlationId:用于将RPC响应和请求进行关联。
我们需要引入新的类:
import com.rabbitmq.client.AMQP.BasicProperties;
关联Id
在上面的方法中,我们建议给每一个RPC请求创建一个回调队列。其实这样的做法效率极低,更佳的做法是给每一个RPC客户端创建一个回调队列。
不过又出现了一个新问题,那就是回调队列收到响应消息后无法确认该响应消息属于哪个请求。此时就该correlationId出场了,我们给每一个RPC请求都设置一个唯一的correlationId,这样当回调队列收到响应消息是就可以根据correlationId来定位该响应属于哪个请求。如果收到的是一个未知的correlationId,那就意味着该响应消息不是当前客户端请求的响应,你就可以直接把它忽略丢弃了。
你可能会问,为什么要将回调队列中的未知消息给忽略丢弃掉,而不是提示错误?那是因为服务端可能会存在资源竞争的情况。有一种几率不大但确实有可能发生的情况:RPC服务器为了响应我们的错误提示信息,导致还没来得及发送正常请求的处理完成确认消息就挂了。
这种情况一旦发生,当RPC服务器重启时就会再次处理之前没来得及进行消息确认的请求。这就是为什么客户端必须合理的处理响应消息,RPC请求理想状态下要保证幂等性。
总结

上图的RPC工要点如下:
-
对于一个RPC请求来说,需要附带两个参数:标识每个请求生成的惟一回调队列的replyTo参数,以及声明每个请求唯一ID的correlationId参数
-
每个请求都被发送到rpc_queue队列中。
-
每个RPC工作者都等待队列中的请求信息。当有请求到达时,工作者会处理请求完成对应任务后将返回给请求方的响应消息发送到replayTo指定的回调队列中。
-
客户端等待回调队列中返回的数据。当响应消息到达时,首先检查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
这个服务端的代码其实很简单:
- 和之前一样创建connection、channel,然后声明队列。
- 我们可以运行多个服务进程。为了能够对请求进行负载均衡,我们需要通过channel.basicQos方法来设置prefetchCount
- 我们使用basicConsume方法来接收请求,然后以DeliverCallback对象的形式来执行相关的任务并发起响应。
RPC客户端的代码可以查看:RPCClient
客户端的代码相对来说有点复杂:
- 创建connection、channel
- 然后创建一个唯一的correlationId并暂存,我们的消费者将会使用它来匹配合适的响应信息。
- call函数用来来发起RPC请求
- 然后我们创建一个唯一的回调队列并订阅。
- 接下来我们发布一条附带replyTo和correlationId参数的请求消息
- 等待响应消息的到来
- 由于消费者是通过单独的子线程来进行分发处理的,所有在响应消息调来前我们需要暂时将主线程挂起。我们可以使用BlockQueue来挂起主线程。这里我们创建了一个容量为1的ArrayBlockingQueue,那是因为我们只接收一条响应消息。
- 消费者只做一件很简单的事,逐一检查收到的每条响应消息的correlationId是否是我们需要的,如果是,就把对应的响应消息放入BlockingQueue
- 与此同时,主线程正等着从BlockingQueue中获取响应信息
- 最后我们把响应信息返回给用户
发起客户端请求:
RPCClient fibonacciRpc = new RPCClient();
System.out.println(" [x] Requesting fib(30)");
String response = fibonacciRpc.call("30");
System.out.println(" [.] Got '" + response + "'");
fibonacciRpc.close();
是时候来运行我们的完成样例了。
和之前一样编译代码(参考第一章)
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服务端处理效率很慢的话,可以直接运行另外的RPC服务端来扩大规模。
- 在客户端,RPC请求分别发送、接收一条消息,不需要像queueDeclare方法一样要进行同步调用。所以一个RPC请求只需要一次数据往返
我们的代码其实很简单,并不能解决复杂的问题,比如:
- 如果没有服务端运行的话,客户端该怎么处理?
- 客户端是否要设置RPC请求超时时间?
- 如果服务端发生错误或者异常,是否要转发给客户端?
- 消息处理前防止写入无效的消息(如检查队列边界、类型等)
如果你想尝试的话,可以结合管理界面来使用