在第三篇教程中,我們學習瞭如何使用工作隊列在多個工作人員之間分配耗時的任務。但如果我們需要在遠程計算機上運行功能並等待結果怎麼辦?
那就算是一個不同的故事,這種模式通常稱爲“ 遠程過程調用”或“ 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());
// 編寫代碼從callback_queue讀取響應消息
郵件屬性
AMQP 0-9-1協議預定義了消息附帶的14個屬性集。除以下屬性外,大多數屬性很少使用:
- deliveryMode:將消息標記爲持久性(值爲2)或瞬態(任何其他值);
- contentType:用於描述編碼的mime類型。例如,對於經常使用的JSON編碼,將此屬性設置爲application / json是一個好習慣;
- replyTo:通常用於命名回調隊列;
- relatedId:用於將RPC響應與請求相關聯。
關聯ID
在上面介紹的方法中,我們建議爲每個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);
}
服務器代碼:
- 像往常一樣,我們首先建立連接,通道並聲明隊列。
- 我們可能要運行多個服務器進程。爲了將負載平均分配到多個服務器,我們需要在channel.basicQos中設置 prefetchCount設置。
- 我們使用basicConsume訪問隊列,在隊列中我們以對象(DeliverCallback)的形式提供回調,該回調將完成工作並將響應發送回去。
package com.mytest.rabbitMQ.Sixth;
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("127.0.0.1");
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消費工作線程通知RPC服務器所有者線程
synchronized (monitor) {
monitor.notify();
}
}
};
channel.basicConsume(RPC_QUEUE_NAME, false, deliverCallback, (consumerTag -> { }));
// 等待並準備使用來自RPC客戶端的消息
while (true) {
synchronized (monitor) {
try {
monitor.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
}
客戶端代碼稍微複雜一些:
- 我們建立連接和渠道。
- 我們的調用方法發出實際的RPC請求。
- 在這裏,我們首先生成一個唯一的relatedId 編號並將其保存-我們的使用者回調將使用該值來匹配適當的響應。
- 然後,我們爲回覆創建一個專用的排他隊列並訂閱它。
- 接下來,我們發佈具有兩個屬性的請求消息: replyTo和correlationId。
- 此時,我們可以坐下來等到正確的響應到達。
- 由於我們的消費者交付處理是在單獨的線程中進行的,因此在響應到達之前,我們將需要一些東西來掛起主線程。使用BlockingQueue是一種可行的解決方案。在這裏,我們正在創建 容量設置爲1的ArrayBlockingQueue,因爲我們只需要等待一個響應即可。
- 消費者的工作很簡單,對於每一個消耗的響應消息,它都會檢查correlationId 是否爲我們要尋找的消息。如果是這樣,它將響應放入BlockingQueue。
- 同時,主線程正在等待響應,以將其從BlockingQueue中獲取。
- 最後,我們將響應返回給用戶。
package com.mytest.rabbitMQ.Sixth;
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("127.0.0.1");
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();
}
}
之後我們先啓動RPCServer,然後再啓動PRCClient。
這是本節的demo代碼地址:https://gitee.com/mjTree/javaDevelop/tree/master/testDemo
這裏介紹的設計不是RPC服務的唯一可能的實現,但是它具有一些重要的優點:
- 如果RPC服務器太慢,則可以通過運行另一臺RPC服務器來擴大規模。嘗試在新控制檯中運行第二個RPCServer。
- 在客戶端,RPC只需要發送和接收一條消息。不需要諸如queueDeclare之 類的同步調用。結果,RPC客戶端只需要一個網絡往返就可以處理單個RPC請求。
我們的代碼仍然非常簡單,並且不會嘗試解決更復雜(但很重要)的問題,例如:
- 如果沒有服務器在運行,客戶端應該如何反應?
- 客戶端是否應該爲RPC設置某種超時時間?
- 如果服務器發生故障並引發異常,是否應該將其轉發給客戶端?
- 在處理之前防止無效的傳入消息(例如檢查邊界,類型)。