[Twisted] Part 6: And Then We Took It Higher

本文由Dave的Part 6: And Then We Took It Higher翻譯而成,你可以由Part 1開始閱讀這個系列的文章,也可以在這裡找到整個系列的目錄。

Poetry for Everyone

我們的詩歌用戶端已經有很大的進步了,我們最後一個版本(2.0版)使用了Transports,Protocols和Protocol Factories這些Twisted主要的網路工具。但還有很多地方可以改進。2.0版用戶端(以及2.1版)只能在命令列中下載詩歌,這是因為PoetryClientFactory不僅負責下載詩歌,還要負責在程式完成後關閉程式。對於一個名叫「PoetryClientFactory」的東西來說,這確實是個奇怪的工作,除了建立PoetryProtocol與收集完成的詩歌之外,它不應該做其他任何的事情。

我們需要一個方法將詩歌發送給在一開始請求詩歌的程式碼中,在同步程式中我們可能會建立一個像這樣的API:
def get_poetry(host, post):
    """Return a poem from the poetry server at the given host and port."""

當然,我們不能這樣做。上面的函式必須被阻塞直到整首詩歌被接收完,否則就無法依照文件宣稱的那樣工作,但是這是個回應式程式,因此阻塞在一個網路socket上是不可能的。我們需要一種方法在詩歌下載完畢時告訴呼叫的程式,而不會讓詩歌在傳輸時被阻塞。這跟Twisted本身要處理的問題有點類似,Twisted需要告訴我們的程式碼什麼時候socket準備好I/O、什麼時候會收到一些資料、或者當timeout發生等狀況。我們已經看到Twisted會使用callback來解決這些問題,所以我們也可以使用callback:
def get_poetry(host, port, callback):
    """
    Download a poem from the given host and port and invoke

      callback(poem)

    when the poem is complete.
    """

現在我們有一個可以和Twisted一起使用的非同步API,讓我們繼續來實現它。

正如我之前所說,我們有時會用典型Twsited程式設計師不會用的方法寫我們的程式碼,這就是其中一次,我們會在Part 7與Part 8看到如何用Twisted的方式(surprise!它用了抽象),但一開始先簡單一點會讓我們對最終版本有更多的了解。

Client 3.0

你可以在twisted-client-3/get-poetry.py找到我們3.0版的用戶端,這個版本有一個get_poetry函式的實現:
def get_poetry(host, port, callback):
    from twisted.internet import reactor
    factory = PoetryClientFactory(callback)
    reactor.connectTCP(host, port, factory)

這裡唯一的不同就是傳送了一個callback給PoetryClientFactory,這個工廠使用callback傳送下載完畢的詩歌:
class PoetryClientFactory(ClientFactory):

    protocol = PoetryProtocol

    def __init__(self, callback):
        self.callback = callback

    def poem_finished(self, poem):
        self.callback(poem)

注意看到這個工廠因為它不再負責關閉reactor,所以比2.1版本的簡單的多,它也失去了檢查連線失敗的程式碼,但我們會解決這個問題。PoetryProtocol不需要改變,所以我們沿用了2.1版本的:
class PoetryProtocol(Protocol):

    poem = ''

    def dataReceived(self, data):
        self.poem += data

    def connectionLost(self, reason):
        self.poemReceived(self.poem)

    def poemReceived(self, poem):
        self.factory.poem_finished(poem)

透過這些改變,get_poetry函式、PoetryClientFactory類別、與PoetryProtocol類別現在完全可以重複使用了。它們都僅與下載詩歌有關,啟動與關閉reactor的邏輯都在我們script的main函式中:
def poetry_main():
    addresses = parse_args()

    from twisted.internet import reactor

    poems = []

    def got_poem(poem):
        poems.append(poem)
        if len(poems) == len(addresses):
            reactor.stop()

    for address in addresses:
        host, port = address
        get_poetry(host, port, got_poem)

    reactor.run()

    for poem in poems:
        print poem

所以如果我們想要的話,我們可以把可重複使用的部分,放在任何人可以拿去下載他們的詩歌的共享模組中(當然,只要他們使用Twisted)。

順帶一提,當你實際測試3.0版本用戶端時,你可以重新設定詩歌詩伺服器讓它傳送的快一點,或一次傳送大一點的區塊,現在這個用戶端不再那麼健談,觀看它下載詩歌就不再那麼有趣了。

Discussion

在圖11我們可以以callback鏈來想像當詩歌被傳送時過程:
11.the%2Bpoem%2Bcallbacks.png-Part 6: And Then We Took It Higher

圖11.詩歌的callbacks


圖11值得好好思考一下。到目前為止,我們描述了以單次呼叫「Our code」來終止的callback鏈。但是當你用Twisted或任何單執行緒的回應式系統撰寫程式時,這些callback鏈可能會包含一些我們的程式,用於callback我們其他部分的程式。換句話說,當回應式的程式到達我們自己寫的程式碼時並不會停止(譯註:callback可能還會呼叫Twisted框架的其他程式碼,或其他模組的程式碼),在reactor基礎的系統中,一直都是callback的形式。

當你為你的專案選擇Twisted時,請務必記住這一點。當你做出決定時:
I'm going to use Twisted!
(我要使用Twisted!)


表示你也做了這個決定:
I’m going to structure my program as a series of asynchronous callback chain invocations powered by a reactor loop!
(我要把我的程式建構為以一個reactor迴圈驅動的一系列非同步callback chain!)


現在也許你不會像我這樣大喊出來,但情況確實是如此,這就是Twisted的工作原理。

似乎大部分Python的程式與模組都是同步的。如果我們正在寫一個同步的程式,然後突然意識到它需要一些詩歌,我們可以透過增加幾行程式碼到我們的script來使用我們get_poetry函式的同步版本:
...
import poetrylib # I just made this module name up
poem = poetrylib.get_poetry(host, port)
...

然後繼續我們的工作。如果稍後我們決定其實我們不是真的需要這些詩歌,我們只要刪掉這幾行,沒人會發現的。但如果我們正在寫一個同步的程式,然後決定使用Twisted版本的get_poetry,那麼我們會需要使用callbacks重新建構我們的程式成為非同步的方式。我們可能需要對程式進行重大的修改。我並不是說重寫個程式肯定是錯的,根據我們的需求這樣做可能很有道理,但是這肯定不會像上面那樣增加一行import與呼叫一個函式這樣簡單。簡單來說,同步與非同步的程式不會混合使用。

如果你對Twisted和非同步程式設計不熟悉,我會建議你在嘗試移植現有的codebase之前,先寫一些Twisted程式。這樣你會先對Twisted有些認識,而不會有直接進行移植時,同時在兩種模式間思考的額外複雜性。

如果你的程式已經是非同步的,那使用Twisted可能會更容易,Twisted與pyGTKpyQT有良好的整合,這兩個Python APIs是基於reactor的GUI toolkits。

When Things Go Wrong

在3.0版本的用戶端中,我們不在去檢測詩歌伺服器的連接失敗,這個漏洞導致的問題甚至比1.0版本還要多。如果我們讓3.0版本從一個不存在的伺服器下載詩歌,它不會像1.0版那樣崩潰,而是會永遠在那邊等待。clientConnectionFailed這個callback仍然被呼叫,但在ClientFactory父類別中clientConnectionFailed的實現什麼都沒做。所以got_poem callback永遠不會被呼叫,然後reactor永遠不會停止,就像我們在Part 2所做的一樣,我們得到了另一個無所事事的程式。

顯然我們需要處理這個錯誤,但是在哪裡處理呢?關於連接失敗的資訊繪透過clientConnectionFailed傳給工廠物件,所以我們必須從這邊開始。但這個工廠是支援被重複使用的,所以合理處理錯誤的方法是依照工廠正在使用的情境(context)去決定的。在某些應用中,遺失詩歌可能是一場災難(沒有詩歌??可能只是崩潰),在其他的狀況下,也許我們只要繼續嘗試,並且從別的地方下載其他詩歌。

換句話說,get_poetry的使用者需要知道什麼時候會出現問題,而不僅僅是甚麼時候會正常執行。在同步程式中,get_poetry會拋出一個例外,並且呼叫有try/except statement的程式去處理。但是在回應式程式中,錯誤條件也必須用非同步的方式傳遞。畢竟,在get_poetry回傳之前我們甚至不會發現連接失敗,下面是一種可能的方法:
def get_poetry(host, port, callback):
    """
    Download a poem from the given host and port and invoke

      callback(poem)

    when the poem is complete. If there is a failure, invoke:

      callback(None)

    instead.

透過測試callback引數(也就是if poem is None),用戶端可以確定我們是否真的得到了一首詩歌。這可以避免我們的用戶端永遠在執行,但這個方法仍然有一些問題。首先,使用None來表示連接失敗並不常見,一些非同步APIs可能使用None作為預設回傳值而不是錯誤條件。其次,None這個值所包含的訊息量非常少,它不能告訴我們出了什麼問題,或者包含一個我們可以用來debug的traceback物件。好吧,再試一次:
def get_poetry(host, port, callback):
    """
    Download a poem from the given host and port and invoke

      callback(poem)

    when the poem is complete. If there is a failure, invoke:

      callback(err)

    instead, where err is an Exception instance.
    """

使用Exception就類似我們習慣的撰寫同步程式,現在我們可以看看例外狀況以得到更多關於錯誤的訊息,並且None可以自由的作為一個常規值了。雖然在一般情況下,當我們在Python遇到異常時我們也會得到一個traceback,讓我們可以分析或輸出一個用於將來debug的紀錄。Tracebacks是相當有用的,所以我們不應該因為使用非同步撰寫程式而放棄它們。

記住,我們不希望在調用callback的地方出現traceback物件,那並不是出問題的地方,我們真正想要的是Exception的實例與例外拋出處的traceback(假設它是被拋出而不是單純的被創建)。

Twisted包含一個叫做Failure的抽象,它彙整了一個Exception與traceback,如果有異常發生,它就會追蹤異常。Failure的docstring說明了如何建立它。透過傳遞Failure物件給callback,我們可以保留對debug來說相當便利的traceback訊息。

twisted-failure/failure-examples.py中有使用Failure物件的範例程式。它顯示了Failure如何從一個被拋出的例外保留traceback訊息,即使在一個except區塊的context外面。我們不會對於建立Failure實例有太多詳述,在Part 7我們會看到Twisted通常會幫我們建立它。

好吧,第三次嘗試:
def get_poetry(host, port, callback):
    """
    Download a poem from the given host and port and invoke

      callback(poem)

    when the poem is complete. If there is a failure, invoke:

      callback(err)

    instead, where err is a twisted.python.failure.Failure instance.
    """

有了這個版本,當出現問題時,我們會同時獲得一個Exception與一個traceback紀錄,太棒了!

我們快達到目標了,但我們還有一個問題。對正常的結果與失敗的結果都用同樣的callback有點奇怪。一般來說,成功和失敗時我們需要做的事情相當不同。在同步的Python程式中,我們一般會在try/except statement中用兩種不同的code paths來處理成功與失敗,像這樣:
try:
    attempt_to_do_something_with_poetry()
except RhymeSchemeViolation:
    # the code path when things go wrong
else:
    # the code path when things go so, so right baby

如果我們想保留這種錯誤處理的風格,那我們需要對失敗使用單獨的code path。在非同步程式設計中,一個單獨的code path就表示單獨的callback:
def get_poetry(host, port, callback, errback):
    """
    Download a poem from the given host and port and invoke

      callback(poem)

    when the poem is complete. If there is a failure, invoke:

      errback(err)

    instead, where err is a twisted.python.failure.Failure instance.
    """

Client 3.1

既然我們有了一個合適的錯誤處理API,我們就可以實現它。3.1版本位於twisted-client-3/get-poetry-1.py,改變的內容很容易理解。PoetryClientFactory同時得到了callback和errback,並且它現在實現了clientConnectFailed:
class PoetryClientFactory(ClientFactory):

    protocol = PoetryProtocol

    def __init__(self, callback, errback):
        self.callback = callback
        self.errback = errback

    def poem_finished(self, poem):
        self.callback(poem)

    def clientConnectionFailed(self, connector, reason):
        self.errback(reason)

由於clientConnectFailed已經收到一個用來解釋為什麼連線失敗的Failure物件(reason引數),我們只要把它傳給errback

其他沒有改變任何東西,所以這邊我就不貼出它們了。你可以像這樣用一個沒有開啟伺服器的埠測試3.1版本用戶端:
python twisted-client-3/get-poetry-1.py 10004

然後你會得到一些像這樣的輸出:
Poem failed: [Failure instance: Traceback (failure with no frames): : Connection was refused by other side: 111: Connection refused.
]

這是來自我們poem_failed errback的print statement。在這種情況下,Twisted只是簡單的傳給我們一個Exception而不是拋出一個例外,所以我們在這邊不會有traceback。但因為這不是一個bug,所以也不是真的需要traceback,這只是Twisted正確的通知我們沒辦法連接到這個位址。

Summary

以下是我們在Part 6學到的內容:

  • 我們為Twisted程式寫的API必須是非同步的。
  • 我們不能將同步程式碼混在非同步程式碼中。
  • 因此,我們必須在我們的程式碼中使用callback,就像Twisted一樣。
  • 我們也必須使用callback來處理錯誤。

這是否意味者我們用Twisted寫的所有API,都必須包含callback和errback這兩個額外的引數?這聽起來不太好。幸運的是Twisted有一個抽象,我們可以用於解決這問題,而且還可以得到一些額外的功能。我們會在Part 7學習它。

Suggested Exercises

  1. 更新3.1版本用戶端,如果在給定的一段時間後沒有接收到詩歌就會逾時。在這種情況下使用自訂的例外調用errback。當你這樣做時,不要忘記關閉連接。
  2. 研究Failure物件的trap方法。將它與try/excepy statement中的except子句進行比較。
  3. 使用print statement去驗證get_poetry回傳後是否呼叫clientConnectionFailed。

留言