參考資料:RabbitMQ tutorial - Remote procedure call (RPC) — RabbitMQ
Remote Procedure Call
What this tutorial focuses on
在 RabbitMQ學習筆記03:Work Queues 中我們學會了如何使用work queue
在多個worker
進程中分發耗時的任務。
但是如果我們需要在遠程的電腦上執行一個函數並等待其結果呢?那就是完全不同的情況了。這種模式我們稱之爲遠程過程調用 Remote Procedure Call
,簡稱RPC
。
在本博文中,我們將會使用RabbitMQ
去構建一個RPC
系統:一個客戶端和一個可擴展的服務器。實際上由於我們並沒有任何值得分發的耗時任務,因此我們將會創建一個假的RPC
服務用於返回斐波那契數列。
Client interface
爲了描述一個RPC
服務是如何被使用的,我們將會創建一個簡單的客戶端類。它會暴露一個名叫call
的方法,該方法會發送一個RPC
請求並阻塞直到收到回答:
fibonacci_rpc = FibonacciRpcClient()
result = fibonacci_rpc.call(4)
print("fib(4) is %r" % result)
A note on RPC
儘管RPC
在計算機技術中是一種非常常見的模型,但是它經常備受批評。當一個程序員沒有辦法知道是否函數的調用是本地的或者如果它是一個慢RPC
,那麼問題就會產生。這樣的困惑會導致系統變得不穩定以及增加非必要的排錯複雜度。濫用RPC不僅不會簡化軟件,反而會導致出現無法維護的麪條式代碼 spaghetti code
。
永遠將以下內容記在心裏:
- 哪些函數調用是本地的,哪些函數調用是遠程的,這些一定要確保是清晰的。
- 文檔要做好,確保不同的組件之間的依賴關係是清晰的。
- 處理錯誤案例。當一個
RPC
服務器掛了很長一段時間以後,客戶端會是什麼樣的反應?
如果不確定自己是否要使用RPC
。如果可以的話,可以嘗試使用異步管道,結果會被異步地推送到下一個計算階段。
Callback queue
一般來說,通過RabbitMQ
來實現RPC
是簡單的。一個客戶端發送請求消息,然後一個服務器回覆響應消息。爲了接收響應,客戶端需要在請求的時候發出callback
隊列的地址。
result = channel.queue_declare(queue='', exclusive=True)
callback_queue = result.method.queue
channel.basic_publish(exchange='',
routing_key='rpc_queue',
properties=pika.BasicProperties(
reply_to = callback_queue,
),
body=request)
# ... and some code to read a response message from the callback_queue ...
Message properties
AMQP 0-9-1 預先定義了14種消息屬性可以伴隨消息一起發送出去。大多數屬性很少使用到,除了以下即可:
- delivery_mode: 標記一個消息是永久或者臨時存在的。我們在消息的持久化時有使用到。
- content_type: 用於描述編碼時的MIME類型。比如常見的JSON編碼
application/json
。 - reply_to: 一般用於命名一個
callback
隊列。 - correlation_id: 用於關聯
RPC
的請求和響應。
Correlation id
在上面列出的方法種我們建議每次RPC
請求都創建一個callback
隊列。這樣是非常低效的,更好的辦法是基於客戶端來創建callback
隊列而不是基於每次RPC
請求。
這會帶來一個新的問題,在callback
隊列中接收到的響應消息,我們不知道對應的是哪條請求消息。因此我們需要使用到correlation_id
屬性,我們會針對每條消息單獨設一個該屬性的唯一的取值,然後當我們在回調隊列中收到消息的時候,我們會關注消息中的這個屬性的值,如果correlation_id
的值相同就表示它們匹配的是同一條消息的請求和響應。如果我們發現correlation_id
屬性的值是我們所不知道的,那麼它就不屬於我們的請求,我們就可以安全丟棄它們。
你可能會問,爲什麼我們要丟棄回調隊列中未知的消息而不是報錯?這是因爲在服務器端可能會出現系統錯亂的情況(race condition)。儘管可能性很小,存在一種可能在RPC
服務器發送給我們回答之後至在發送請求的確認消息之前這段時間,RPC
剛好宕機了。這種情況下,當RPC
服務器重啓之後,它會重新處理一次請求。這就是爲什麼在客戶端我們必須優雅地處理重複的響應,而且RPC
理想情況下應該是冪等的。
Summary
我們的RPC
的工作流程:
- 當客戶端啓動的時候,它會創建一個匿名的獨有的回調隊列。
- 對於一次
RPC
請求,客戶端會發送一條消息並伴隨2個消息屬性。reply_to
用於指向回調隊列的地址,correlation_id
用於標誌每次請求消息。 - 請求會被髮往
rpc_queue
隊列。 RPC
的服務器端會作爲消費者
在該隊列上等待請求(消息)。一旦收到請求就會對其處理,然後將返回的消息發往reply_to
所指向的回調對類中。返回的消息會包含correlation_id
,其值和請求時保持一致。- 客戶端將會在回調隊列上等待數據。如果有新的數據抵達,並且
correlation_id
和之前請求時發出的消息中的correlation_id
值一致的話,就會處理這條響應消息;否則若不一致,則丟棄消息。
有意思的一點,在RPC
的C/S架構中,客戶端和服務器端均作爲消息隊列的生產者和消費者。
Putting it all together
rpc_server.py
#!/usr/bin/env python
import pika
connection = pika.BlockingConnection(
pika.ConnectionParameters(host='localhost'))
channel = connection.channel()
channel.queue_declare(queue='rpc_queue')
def fib(n):
if n == 0:
return 0
elif n == 1:
return 1
else:
return fib(n - 1) + fib(n - 2)
def on_request(ch, method, props, body):
n = int(body)
print(" [.] fib(%s)" % n)
response = fib(n)
ch.basic_publish(exchange='',
routing_key=props.reply_to,
properties=pika.BasicProperties(correlation_id = \
props.correlation_id),
body=str(response))
ch.basic_ack(delivery_tag=method.delivery_tag)
channel.basic_qos(prefetch_count=1)
channel.basic_consume(queue='rpc_queue', on_message_callback=on_request)
print(" [x] Awaiting RPC requests")
channel.start_consuming()
服務器端的代碼很直接:
- 像之前一樣,我們建立connection、channel,並創建名爲
rpc_queue
的隊列。 - 我們聲明瞭一個斐波那契數列函數,它只能接受正整數。我們不要傳遞太大的數,否則程序會變得很慢。
- 我們定義了回調函數
on_request
給basic_consume
使用,這是RPC
服務器的核心部分。當rpc_queue
隊列中有消息的時候它就會被執行,用於發送響應給客戶端。 - 在服務器端我們可能會想要運行多個
rpc_server.py
進程,爲了讓空閒的進程可以立即處理請求,我們使用了prefetch_count
。
rpc_client.py
#!/usr/bin/env python
import pika
import uuid
class FibonacciRpcClient(object):
def __init__(self):
self.connection = pika.BlockingConnection(
pika.ConnectionParameters(host='localhost'))
self.channel = self.connection.channel()
result = self.channel.queue_declare(queue='', exclusive=True)
self.callback_queue = result.method.queue
self.channel.basic_consume(
queue=self.callback_queue,
on_message_callback=self.on_response,
auto_ack=True)
self.response = None
self.corr_id = None
def on_response(self, ch, method, props, body):
if self.corr_id == props.correlation_id:
self.response = body
def call(self, n):
self.response = None
self.corr_id = str(uuid.uuid4())
self.channel.basic_publish(
exchange='',
routing_key='rpc_queue',
properties=pika.BasicProperties(
reply_to=self.callback_queue,
correlation_id=self.corr_id,
),
body=str(n))
self.connection.process_data_events(time_limit=None)
return int(self.response)
fibonacci_rpc = FibonacciRpcClient()
print(" [x] Requesting fib(30)")
response = fibonacci_rpc.call(30)
print(" [.] Got %r" % response)
客戶端的代碼則會稍微有點複雜:
- 建立connection、channel,並創建名爲隨機獨有的回調隊列,將該隊列的名字保存到
callback_queue
。 - 我們訂閱
callback_queue
,這樣我們就可以接收RPC響應。 - 每次有響應消息回來的時候,
on_response
回調函數就會被執行,完成一個很簡單的工作,對於每條響應的消息它會檢查是否correlation_id
是我們在找的那個。如果是的話,它會將響應保存在self.response
並且離開消費循環。 - 接下來我們定義我們的主方法
call
——它來執行實際的RPC
請求。 - 在
call
方法中我們生成了correlation_id
並保存到self.corr_id
中。on_response
回調函數會基於這個值獲取合適的響應。 - 同樣在
call
方法中發佈消息的時候,我們包含了2個屬性reply_to
和correlation_id
。 - 最後我們等待合適的響應到達,然後將該消息返回給用戶。
接下來就可以執行代碼了,打開第一個終端運行python rpc_server.py
,打開第二個終端運行python rpc_client.py
。
兩個終端最終結果如下
# 第一個終端
[root@rabbitmq-01 code]# python rpc_server.py
[x] Awaiting RPC requests
[.] fib(30)
# 第二個終端
[root@rabbitmq-01 code]# python rpc_client.py
[x] Requesting fib(30)
[.] Got 832040
代碼中所呈現出來的設計不是唯一可能的RPC
服務的實現,但是它有一些重要的優點:
- 如果
RPC
服務太慢的話我們可以通過橫向擴展的方式,在新的console窗口中再運行一個rpc_server.py
進程。 - 在客戶端方面,
RPC
規定僅發送和接收1條消息。沒有要求像queue_declare
這樣的異步調用。這樣的結果對於一次RPC
請求,RPC
客戶端只需要一次網絡來回(network round trip)。
這裏的代碼只是示例代碼而已,過度簡化了,有一些複雜且重要的問題沒有解決,比如說:
- 如果沒有server端運行的話,那麼客戶端會是什麼反應?
- 客戶端是否應該針對RPC設置超時時長?
- 如果服務器出現故障並且拋出了一個異常,那麼這個異常是否應該被轉發給客戶端?
- 處理數據前是否判斷消息的有效性,比如我們只允許接收100以內的數等等這類的邊界檢查機制。
最後,tutorial在這裏提到了 management UI ,這是一個以Web(GUI)形式展示RabbitMQ
數據以及提供一些操作的插件,蠻適合不會或者不經常寫代碼操作RabbitMQ
的人員,比如運維工程師。但是像生產者和消費者這種,還是得通過實際的代碼來實現的。由此也可以看出,很多開源軟件的應用,想要入門並且學好、用好的話,其實運維和開發相關的知識是都需要掌握的。