RabbitMQ學習筆記07:RPC RabbitMQ學習筆記03:Work Queues

參考資料: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的工作流程:

  1. 當客戶端啓動的時候,它會創建一個匿名的獨有的回調隊列。
  2. 對於一次RPC請求,客戶端會發送一條消息並伴隨2個消息屬性。reply_to用於指向回調隊列的地址,correlation_id用於標誌每次請求消息。
  3. 請求會被髮往rpc_queue隊列。
  4. RPC的服務器端會作爲消費者在該隊列上等待請求(消息)。一旦收到請求就會對其處理,然後將返回的消息發往reply_to所指向的回調對類中。返回的消息會包含correlation_id,其值和請求時保持一致。
  5. 客戶端將會在回調隊列上等待數據。如果有新的數據抵達,並且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()

服務器端的代碼很直接:

  1. 像之前一樣,我們建立connection、channel,並創建名爲rpc_queue的隊列。
  2. 我們聲明瞭一個斐波那契數列函數,它只能接受正整數。我們不要傳遞太大的數,否則程序會變得很慢。
  3. 我們定義了回調函數on_requestbasic_consume使用,這是RPC服務器的核心部分。當rpc_queue隊列中有消息的時候它就會被執行,用於發送響應給客戶端。
  4. 在服務器端我們可能會想要運行多個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)

客戶端的代碼則會稍微有點複雜:

  1. 建立connection、channel,並創建名爲隨機獨有的回調隊列,將該隊列的名字保存到callback_queue
  2. 我們訂閱callback_queue,這樣我們就可以接收RPC響應。
  3. 每次有響應消息回來的時候,on_response回調函數就會被執行,完成一個很簡單的工作,對於每條響應的消息它會檢查是否correlation_id是我們在找的那個。如果是的話,它會將響應保存在self.response並且離開消費循環。
  4. 接下來我們定義我們的主方法call——它來執行實際的RPC請求。
  5. call方法中我們生成了correlation_id並保存到self.corr_id中。on_response回調函數會基於這個值獲取合適的響應。
  6. 同樣在call方法中發佈消息的時候,我們包含了2個屬性reply_tocorrelation_id
  7. 最後我們等待合適的響應到達,然後將該消息返回給用戶。

接下來就可以執行代碼了,打開第一個終端運行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的人員,比如運維工程師。但是像生產者和消費者這種,還是得通過實際的代碼來實現的。由此也可以看出,很多開源軟件的應用,想要入門並且學好、用好的話,其實運維和開發相關的知識是都需要掌握的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章