[Twisted] Part 9: A Second Interlude, Deferred

本文由Dave的Part 9: A Second Interlude, Deferred翻譯而成,你可以由Part 1開始閱讀這個系列的文章,也可以在這裡找到整個系列的目錄。

More Consequence of Callbacks

我們將暫停一下,再思考一下callback。儘管現在我們足夠了解deferreds,可以用Twisted風格撰寫簡單的非同步程式,但Deferred類別還提供很多只有在複雜的設定中才會用到的功能。所以我們將會考慮一些更複雜的設定,並且看看當使用callbacks撰寫程式時,它們會帶來怎樣的挑戰。然後我們將研究deferreds如何解決這些挑戰。

為了給我們的討論一些動機,我們將在我們的詩歌用戶端中增加一個假想的功能。假設一些辛勤工作的電腦科學教授發明了一個新的有關詩歌的演算法-Byronification引擎。這個漂亮的演算法以一首詩作為輸入,然後產生一首類似原來的詩歌但是以Lord Byron風格撰寫的新的詩歌。更重要的是我們的教授慷慨地用這個介面提供了Python的參考實現:
class IByronificationEngine(Interface):

    def byronificate(poem):
        """
        Return a new poem like the original, but in the style of Lord Byron.

        Raises GibberishError if the input is not a genuine poem.
        """

像多數最新的軟體一樣,它的實現都存在一些bugs。這意味著除了已知的例外情況之外,byronificate方法可能在遇到教授忘記處理的corner case時會拋出隨機的例外。

我們還假設這個引擎的運作速度夠快,讓我們可以在主執行敘中呼叫它,而不用擔心需要綁定reactor。下面是我們希望我們的程式如何執行:

  1. 嘗試下載詩歌。
  2. 如果下載失敗,告訴使用者我們沒有得到詩歌。
  3. 如果下載得到了詩歌,用Byronification引擎轉換詩歌。
  4. 如果引擎拋出GibberishError,告訴使用者我們沒有得到詩歌。
  5. 如果引擎拋出其它例外,就保留原始的詩歌。
  6. 如果我們得到一首詩歌,就輸出它。
  7. 結束程式。

這個計畫是以GibberishError表示最後我們沒有收到完整的詩歌,所以我們只會告訴使用者下載失敗。這對於debug不是很有用,但我們的使用者只想知道是否有收到詩歌。另一方面,如果引擎因其他因素而處理失敗,我們將會用從伺服器收到的原始詩歌給使用者。畢竟有一些詩歌總比沒有好,即使它不是Byron風格的。

下面是同步模式的代碼:
try:
    poem = get_poetry(host, port) # synchronous get_poetry
except:
    print >>sys.stderr, 'The poem download failed.'
else:
    try:
        poem = engine.byronificate(poem)
    except GibberishError:
        print >>sys.stderr, 'The poem download failed.'
    except:
        print poem # handle other exceptions by using the original poem
    else:
        print poem

sys.exit()

這段概略的程式碼可以透過重構變得更簡單,但目前它清楚地說明了邏輯流程。我們想更新我們最近的詩歌用戶端(使用deferreds)來實現這樣的功能。但是,這件事我們到Part 10才會做。而現在讓我們想像一下,如何在我們的最後一個完全沒使用deferreds的3.1版本用戶端中實現這個功能。假設我們先不考慮例外,只有像這樣改變got_poem callback:
def got_poem(poem):
    poems.append(byron_engine.byronificate(poem))
    poem_done()

當byronificate方法拋出GibberishError或其它例外會發生什麼?觀察Part 6的圖11我們可以看到:

  1. 這個例外會傳到factory的poem_finished callback,poem_finished就是實際調用這個callback(譯註:指got_poem)的方法。
  2. 由於poem_finished沒有捕獲到這個例外,例外會繼續傳到protocol的poemReceive。
  3. 然後來到connectionLost函數,仍然在protocol中。
  4. 然後進入Twisted本身的核心,最後到達reactor。

據我們所知,reactor會捕捉與紀錄這個例外,而不是崩潰掉。但它肯定無法告訴使用者我們無法下載詩歌。Reactor對詩歌或GibberishError一無所知,它只是一段用於各種網路類型的通用程式碼,即使是與詩歌無關的網路。

注意上面列表中的每一個步驟,例外如何傳遞到比前一個更具通用性的程式碼片段中。並且例外在get_poem之後程式碼區塊中,沒有任何步驟可望以我們在這個用戶端上想要的具體得方法去處理錯誤。這種情況與例外在同步程式碼中傳送基本上是完全相反的方式。

看一下圖15,這是一個我們可能會在同步詩歌用戶端看到呼叫stack的例子:
15.synchronous%2Bcode%2Band%2Bexceptions.png-Part 9: A Second Interlude, Deferred

圖15.同步程式碼與例外


main函式是「high-context」的,這表示它知道很多關於整個程式的事情,如這個程式存在的目的,以及這個程式在整體上應該如何表現。通常的情況下,main函式可以存取那些表明使用者希望程式如何工作(以及如何出現問題應該怎麼處理)的命令列選項。它也有一個非常特別的目的:為命令列的詩歌用戶端顯示執行結果。

另一方面,socket的connect方法是「low-context」的,它所知道的就只有它應該連接到某個網路位址。它不知道另一端有什麼或者為什麼我們現在需要連接。但connect是相當通用的-無論你連接的服務是什麼都可以使用它。

get_poetry在中間,它知道它要接收一些詩歌(這也是它唯一擅長的),但如果無法接收它不知道會發生什麼事情。

所以由connect拋出的例外會向上移動stack,從low-context且通用性的程式碼,到high-context且特殊性的程式碼,直到到達某個有足夠的context的程式碼,知道當出現錯誤時該怎麼處理(或者例外直接在Python直譯器中造成程式崩潰)。

當然這個例外只是不斷的向上移動stack,而不是持續的在尋找high-context的程式碼。這只是在典型的同步程式中,「向上移動stack」與「朝向high-context」剛好在同一個方向。

現在回頭來看我們在上面對3.1版用戶端的假設性的修改。在圖16中我們簡短的用幾個函示分析一下呼叫的stack:
16.asynchronous%2Bcallbacks%2Band%2Bexceptions.png-Part 9: A Second Interlude, Deferred

圖16.非同步callbacks與例外


現在問題很清楚了:在callback期間low-context的程式碼(reactor)會呼叫high-context的程式碼,high-context可能會繼續呼叫更high-context的程式碼。所以如果發生例外而沒有在相同的stack frame中馬上處理,則之後更不可能處理這個例外。因為每次例外向上移動stack時,會移動到一段更lower-context的程式碼中,而這段程式碼更不知道該怎麼處理。

一旦例外進入了Twisted的核心,那遊戲就結束了。例外將不會被處理,它只會被記錄下來(當reactor最後捕捉到它時)。因此當我們使用「傳統老方法(plain old)」的callback(不使用deferreds)撰寫程式時,我們必須在每個例外回到Twisted之前小心的捕獲它們,如果我們希望可以依照我們的規則去處理錯誤。這也包括由我們自己的bugs引起的例外。

由於bug可能存在於我們程式碼中任何地方,因此我們必須將每個我們寫的callback都包在try/except statements中,這樣我們笨手笨腳造成的例外都可以
妥善的被處理。對我們的errbacks也一樣,因為處理錯誤的程式碼也可能有bugs。

嗯,這看起來不太理想。

The Fine Structure of Deferreds

最後證明還是要由Deferred類別幫助我們解決這個問題。每當deferred實例調用callback或errback時,它會捕捉任何可能被拋出的例外。換句話說,deferred實例扮演著try/except statemet的腳色,所以只要我們使用deferreds,我們就不需要撰寫這個「外殼」(譯註:外殼指的是try/except statement,因為在使用上它就像個外殼包住我們的程式)。但deferred實例如何處理它捕捉到的例外呢?很簡單-它將例外(以Faiiure物件的形式)傳給鏈中的下一個errback。

因此我們增加到deferred實例的第一個errback是以deferred實例的errback(err)方法被呼叫作為信號,用來處理無論哪種錯誤。第二個errback將會處理任何由第一個callback或errback拋出的例外,然後按照這個規則繼續下去。

回憶一下圖12,在鏈中有一些callbacks與errbacks的deferred實例,我們稱第一對callback/errback為階段0,下一對為階段1,並依此類推。

假設在階段N中,如果callback或errback(無論哪個被執行)失效了,那階段N+1的errback會被呼叫,並以一個適當的Failure物件作為引數,而階段N+1的callback就不會被呼叫。

透過「沿著這條鏈」傳遞callbacks拋出的例外,deferred實例將例外向「higher context」方向移動。這也代表調用deferred實例的callbacks與errbacks方法將永遠不會導致呼叫者的例外(只要你只觸發deferred實例一次),所以底層的程式碼可以安全的觸發deferred實例,而不用擔心捕捉異常的事情。相反的高層的程式碼在deferred實例中增加errbacks(使用addErrback等方法)來捕捉例外。

在同步程式碼中,一旦例外被捕捉就會停止傳遞。那errback如何知道它「捕捉」了錯誤呢?也很簡單-藉由沒有拋出例外。在這種情況下,執行會切換回callback這一邊。所以假設在階段N中,如果callback或errback成功執行(即沒有拋出例外),然後階段N+1的callback會連同階段N的回傳值一起被呼叫, 所以階段N+1的errback就不會被呼叫。

讓我們來總結一下關於我們所知道deferred觸發模式:

  1. Deferred實例包含一條成對的callback/errback鏈,它們會依照添加到deferred實例的順序排列。
  2. 階段0,即第一對callback/errback會在deferred實例觸發時被調用。如果deferred實例以callback方法觸發,那階段0的callback會被呼叫。如果以errback方法觸發,那階段0的errback會被呼叫。
  3. 如果階段N失敗,那階段N+1的errback會被呼叫,並以這次的例外(被包裝為Failure物件)作為第一個引數。
  4. 如果階段N成功,那階段N+1的callback會被呼叫,並以階段N的回傳值作為第一個引數。

圖17說明了這種模式:
17.control%2Bflow%2Bin%2Ba%2Bdeferred.png-Part 9: A Second Interlude, Deferred

圖17.deferred實例中的控制流程


綠色的線表示callback或errback執行成功,而紅線表示執行失敗。這些線顯示了沿著這條鏈的控制的流程與例外和回傳值流程。圖17顯示了deferred實例所有可能採用的路徑,但在任何單一情況中,只有一條路徑會被採用。圖18顯示了在一次「觸發」中一條可能的路徑:
18.one%2Bpossible%2Bdeferred%2Bfiring%2Bpattern.png-Part 9: A Second Interlude, Deferred

圖18.deferred觸發模式其中一種可能


在圖18中,deferred實例的callback方法被呼叫,這個方法會調用階段0的callback。階段0的callback執行成功,因此控制權(與階段0的回傳值)交給了階段1的callback。但階段1的callback失敗了(拋出一個例外),所以控制權切換給到階段2的errback。階段2的errback「處理」的錯誤(沒有再拋出例外),所以控制權回到callback鏈,然後階段3的callback被呼叫,並且以階段2 errback的結果作為引數。

注意在圖17中,任何路徑都會經過鏈中的每個階段,但任何階段中每對callback/errback中只有一個成員會被呼叫。

在圖18中,我們已經藉由一個從階段3的callback出來的綠色箭頭表明成功執行,但因為在這個deferred中沒有更多的階段,階段3的結果並不會傳到任何地方。如果階段3的callback成功執行就不會有什麼問題,但如果它失敗呢?如果deferred實例的最後一個階段失敗了,那我們會說這個失敗是未處理的(unhandled),因為沒有errback可以「捕捉」錯誤了。

在同步程式碼中,一個未處理的例外會導致直譯器崩潰,在一個傳統老方法callbacks的非同步程式碼中,一個未處理的例外會被reactor與logged捕捉。那在deferreds中未處理的例外會發生甚麼事情呢?讓我們試試看。觀察twisted-deferred/defer-unhandled.py中的範例程式碼。這個程式碼會用一個callback觸發一個deferred,而這個callback始終會拋出一個例外。這裡是這個程式的輸出:
Finished
Unhandled error in Deferred: 
Traceback (most recent call last):
  ...
--- <exception caught="" here=""> ---
  ...
exceptions.Exception: oops

有些事情要注意:

  1. 因為最後一個print statement執行了,所以程式沒有因為例外而「崩潰」。
  2. 這意味Traceback只是被印出來,它並沒有導致直譯器崩潰。
  3. Teaceback的內容告訴我們deferred實例在哪裡捕捉到這個例外。
  4. 「Unhandled」訊息在「Finished」訊息之後才被印出來。

因此當你使用deferreds時,callbacks中的未處理例外還是會被記錄,來用於debug,但他們一樣不會造成程式崩潰(事實上callback並不會把例外送到reactor,deferred實例會先捕捉到它們)。順帶一提,「Finished」訊息先出現是因為「Unhandled」訊息直到deferred實例被垃圾回收(garbage collected)時才會被印出來。我們會在未來的部份看到原因。

現在,在同步程式馬中我們可以使用raise關鍵字「重新拋出(re-raise)」一個例外而不需要任何引數。這樣做會拋出一個我們正在處理的原始例外,並且允許我們對錯誤採取一些措施而不用徹底的處理它。事實證明我們可以在errback中做同樣的事情。在下列狀況中,deferred實例將會認為callback/errback失敗:

  • Callback/errback 拋出任何種類的例外。
  • 或者callback/errback回傳一個Failure物件。

由於errback的第一個引數總是一個Failure物件,errback在執行完它要處理的行動後,可以藉由回傳它的第一個引數來「重新拋出」例外。

Callbacks and Errbacks, Two by Two

在上面的討論中,有一件事情應該要很清楚,你添加callback與errback到deferred實例的順序不同,會對deferred實例如何觸發有很大的影響。你應該還清楚,在deferred實例中,callbacks與errback總是成對出現。在Deferred類別中你有四個方法可以用來加入成對的callbacks/errbacks到鏈中:

  1. addCallbacks
  2. addCallback
  3. addErrback
  4. addBoth

很明顯的,第一個與第四個方法是在鏈中加入一對callbacks/errbacks。但中間兩個方法也在鏈中加入一對callbacks/errbacks。addCallback方法會加入一個明確的callback(你傳遞給方法的callback)與一個隱性的「pass-through」 errback。pass-through函式只是一個虛設函式(dummy function),它只會回傳它的第一個引數。由於傳給一個errback的第一個引數永遠是Failure物件,所以pass-through errback將永遠「失敗」,並且傳送它的錯誤給鏈中的下一個errback。

正如你所猜到的,addErrback函式會加入一個明確的errback與一個隱性的pass-through callback。由於callback的第一個引數永遠不會是Failure物件,所以pass-through callback會將結果傳送給鏈中下一個callback。

(譯註:這段內容須要看一下deferred的原始碼才會了解,addCallback最後會回傳addCallbacks方法,而在addCallbacks方法中會呼叫一個passthru方法,這個方法就是pass-through函式,它所做的就只有回傳它的引數。

舉例來說,我用addCallback加入了一個callback,然後addCallback會呼叫addCallbacks並將callback與callback的一些引數傳過去。addCallbacks會用cbs這個變數儲存傳入的一對callback/errback,但因為我一開始是用addCallback,根本不會有errback,所以cbs的errback的部分就會用passthru代替,而cbs在呼叫passthru的時候沒有給它引數,它的引數也就是None,所以passthru就會回傳None,最後errback的位置就會被None代替,這樣就可以沒有errback引數但還是可以使用addCallbacks方法)

The Deferred Simulator

Deferred模擬器是個用來熟悉deferreds觸發它的callbacks與errbacks的好方法。在twisted-deferred/deferred-simulator.py的python script是一個deferred模擬器,這是可以讓你探究deferreds如何觸發的小Python程式。當你執行這個script時,它會要求你輸入每對callback/errback為一行的列表,對於每個callback或errback,你可以指定:

  • 回傳一個給定的值(表示成功)
  • 或者拋出一個例外(表示失敗)
  • 或者回傳它自己的引數(表示pass-through)

輸入所有你想要模擬的callback/errback後,script會畫出一張ASCII圖,顯示鏈的內容與callback和errback方法的觸發模式。把終端視窗拉寬一點才可以正確的看到所有內容。你也可以使用--narrow選項將Deferred、callback鏈、errback鏈依序印出來,但一起將它們印出來會比較容易觀察它們的關係。

當然,一個真正的程式碼中,callback不會每次都回傳相同的值,並且某些特定的函式有時候會成功,有時候會失敗。但模擬器可以給你一個畫面,在這樣的callbacks與errbacks排列中,正常結果與失敗的特定組合會發生什麼事情。

Summary

在多了解一些關於callback的事情後,我們發現讓callback的例外使用stack的效果並不好,因為callback的程式顛倒了low-context與high-context之間的關聯。而Deferred類別透過捕捉例外並將沿著鏈傳遞它們而不是傳到reactor當中。

我們也學習到正常的結果(return的值)會沿著鏈向下移動,結合這兩件事情我們得到一個結論,當deferred實例根據每個階段的結果在callback與errback的線路上來回切換,會產生一個交錯的觸發模式。

有了這些知識,在Part 10我們將要用一些詩歌轉換的邏輯來更新我們的詩歌用戶端。

Suggested Exercises

  1. 檢視Deferred類別上四種加入callbacks與errbacks方法的實現。驗證所有方法是否都加入了一對callback/errback。
  2. 使用deferred模擬器來研究這個程式碼:
    deferred.addCallbacks(my_callback, my_errback)

    與這個程式碼之間的區別:
    deferred.addCallback(my_callback)
    deferred.addErrback(my_errback)

    回想一下最後兩個方法加入隱性的pass-through函式,作為一對引數中的其中一個。

留言