[Twisted] Part 7: An Interlude, Deferred

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

Callbacks and Their Consequences

Part 6中我們認知到這個事實:callbacks是Twisted非同步程式設計的基礎。不僅是連接reactor的方式,callback還會被放到我們寫的Twisted的結構中。所以使用Twisted或任何reactor基礎的非同步系統,意味著以一系列藉由reactor迴圈調用的callback chain這種特定的方式組織我們的程式碼。

即使像我們的get_poetry函數那樣簡單的API也需要callbacks,事實上是需要兩個:一個用於正常結果,一個用於錯誤結果。因此,作為一個Twisted程式設計師,我們將必須充分利用它們,我們應該花點時間去思考如何好好使用callbacks,以及我們可能會遇到的種種困難。

研究一下這段程式碼,它是使用3.1版本用戶端中get_poetry的Twisted版本:
...
def got_poem(poem):
    print poem
    reactor.stop()

def poem_failed(err):
    print >>sys.stderr, 'poem download failed'
    print >>sys.stderr, 'I am terribly sorry'
    print >>sys.stderr, 'try again later?'
    reactor.stop()

get_poetry(host, port, got_poem, poem_failed)

reactor.run()

這個程式的基本計畫很明確:

  1. 如果我們收到一整首詩歌,就輸出詩歌。
  2. 如果我們沒有收到詩歌,就輸出錯誤資訊。
  3. 在上述任何一種狀況下,都要結束程式。

上述程式的「同步的模擬版本(synchronous analogue)」可能會像這樣:
...
try:
    poem = get_poetry(host, port) # the synchronous version of get_poetry
except Exception, err:
    print >>sys.stderr, 'poem download failed'
    print >>sys.stderr, 'I am terribly sorry'
    print >>sys.stderr, 'try again later?'
    sys.exit()
else:
    print poem
    sys.exit()

所以callback就像else的區塊而errback就像except的區塊。這表示調用errback是非同步程式去模擬拋出一個異常,而調用callback對應正常的程式流程。

這兩個版本之間有什麼不同?其一,在同步版本中Python直譯器會確保無論get_poetry拋出了任何異常,無論因為任何原因,except區塊都會執行。如果我們信任直譯器能正確的執行Python程式碼,我們就可以信任error區塊會在正確的時間被執行。

與非同步版本相反的是:poem_failed這個errback是由我們程式中的PeotryClientFactoryclientConnectFailed方法去調用的,是我們而不是Python負責去確認,如果出現錯誤,處理錯誤的程式碼會執行。因次我們必須確定用Failuer物件調用的errback有處理到所有可能的錯誤,否則我們的程式會卡在等待一個永遠不會出現的callback。

另一個同步與非同步版本的不同在於,如果我們在同步版本中不費心去抓取這些例外(不使用try/except),Python直譯器會為我們抓住它,並以程式崩潰向我們顯示我們的錯誤。但如果我們忘記「拋出」我們非同步程式中的例外(利用呼叫PoetryClientFactory的errback函式),我們的程式會永遠的執行下去,愉快且絲毫不覺得所有事情都不太對勁。

顯然處理錯誤在非同步程式中很重要,而且也有點棘手。你也可以說在非同步程式中處理錯誤實際比處理正常狀況更重要,因為事情出現錯誤的方法遠多於出現正確的方法。當以Twisted撰寫程式時,忘記處理錯誤情況是一個常犯的錯誤。

關於上面的同步程式碼另一個要注意的:else區塊與except區塊兩者只能執行其中一個(假設get_poetry的同步版本不是在無限迴圈中)。Python直譯器不會突然決定兩個都執行,或者一時興起執行else區塊27次。如果它這樣做,它基本上不可能是用Python寫的。

話說回來,非同步案例中,我們要負責執行callback或errback。你知道的,我們可能會犯一些錯誤,我們可能呼叫了callback與errback,或者調用了callback 27次。接下來對於get_poetry的使用者來說將是個不幸的的消息,雖然docstring沒有明確的說,但不用說也知道,就像在try/except statement中的else與except區塊,在每一次呼叫get_poetry的時候,要不就是callback執行一次,要不就是errback執行一次。我們要不就獲得這首詩歌,要不就什麼都沒有。

想像一下我們在debug一個程式,我們建立了三個詩歌的請求,然後得到7個callback調用和2個errback調用,你會從哪邊開始debug?你到頭來可能改寫你的callbacks或errbacks去檢測呼叫同一個get_poetry時,callback與errback何時被第二次調用,並且馬上丟出一個例外。看招,get_poetry!

從另一個角度來看,兩個版本都有一些重複的程式碼。非同步的版本呼叫了兩次reactor.stop,同步版本呼叫了兩次sys.exit。我們可以像這樣重構同步的版本:
...
try:
    poem = get_poetry(host, port) # the synchronous version of get_poetry
except Exception, err:
    print >>sys.stderr, 'poem download failed'
    print >>sys.stderr, 'I am terribly sorry'
    print >>sys.stderr, 'try again later?'
else:
    print poem

sys.exit()

我們可以用類似的方式重構非同步版本嗎?由於callback和errback是兩種不同的函式,所以我們不太確定我們是否必須回到單一callback才可能實現?

好,這邊是一些關於使用callback程式設計時,我們已經發現的見解:

  1. 呼叫errback是非常重要的。由於errback代替了except區塊,使用者需要能夠依靠它們,它們不是我們APIs的可選功能(譯註:是必須要有的功能)。
  2. 不在錯誤的時候調用callback與在正確的時候呼叫它們一樣重要。對於典型的用法,callback與errback是互斥的並且只調用一次。
  3. 當使用callbacks時,重構通用程式碼可能比較困難。

在未來的部分中我們會討論跟多關於callbacks的部分,但現在已經足以明白為什麼Twisted會有一個抽象專門去管理它們。

The Deferred

由於在非同步程式設計中callback被大量的使用,而正如我們知道的,要正確的使用callback會有點棘手,為了讓用callback撰寫程式更容易些,Twsited開發者創建了一種抽象叫Deferred。Deferred類別定義在twisted.internet.defer之中。

Deferred在日常英語中既可以是動詞也可以是形容詞,所以用它做為一個名詞聽起來有點奇怪。你只要知道,從現在開始當我使用「the deferred」或「a deferred」這個說法時,我所指的是Deferred類別的一個實例(譯註:我會直接使用Deferred類別、deferred實例等名詞,如果只有deferred則代表deferred這種用法)。我們會在未來的部分討論它為什麼叫做Deferred。你可以在心裡自己加上「result」,例如「the deferred result」,這就如我們最後會看到的,這就是它真正的樣子。

一個deferred實例包含一對callback鏈,一個用於正常結果,一個用於錯誤結果。一個新建立的deferred實例會有兩個空的鏈,我們可以在兩條鏈中加入callbacks與errbacks,然後用一個正常的結果(這是你要的詩歌)或者一個例外(我沒辦法收到詩歌,而這是為什麼)來「觸發」deferred實例。觸發deferred實例會依照它們添加的順序調用適當的callbacks或errbacks。圖12展示了一個帶有callback/errback鏈的deferred實例:
12.A%2BDeferred.png-Part 7: An Interlude,  Deferred

圖12.Deferred實例


讓我們來試試看。由於deferreds不使用reactor,我們可以在不啟動迴圈的狀況下測試它們。

你可能注意到在Deferred類別中有一個setTimeout方法使用到了reactor,它已經被棄用,而且在未來的版本中會被移除,所以假裝它不在那邊,並且不要使用它(譯註:在目前最新版本中已經沒有這個方法了)。

我們的第一個範例在twisted-deferred/defer-1.py
from twisted.internet.defer import Deferred

def got_poem(res):
    print 'Your poem is served:'
    print res

def poem_failed(err):
    print 'No poetry for you.'

d = Deferred()

# add a callback/errback pair to the chain
d.addCallbacks(got_poem, poem_failed)

# fire the chain with a normal result
d.callback('This poem is short.')

print "Finished"

這個程式碼建立了一個新的deferred實例,並使用addCallbacks添加了一對callback/errback,然後用callback方法觸發正常結果的callback鏈。當然,它只有一個callback所以還算不上一條鏈,但沒關係。執行程式碼然後它會產生像這樣的輸出:
Your poem is served:
This poem is short.
Finished

這個範例很簡單,這邊有些事情要注意:

  1. 就像我們在3.1版用戶端使用的一對callback/errback,我們添加到這個deferred實例中的每個callbacks都有一個引數,不是正常的結果就是錯誤的結果。但事實上deferreds支援有多個引數的callbacks與errbacks,但它們至少會有一個引數,而且第一個引數永遠是正常的結果或錯誤的結果。
  2. 我們會成對的添加callback與errback到deferred實例中。
  3. callback方法只用正常結果這個唯一的引數就觸發了deferred實例。
  4. 從輸出的順序可以我們看到觸發deferred實例就上調用了callback,沒有什麼非同步的事情發生。因為沒有reactor在執行所以也不可能有,它真的就是一個普通的Python函式呼叫。

好,讓我們試試另一種情況,這個在twisted-deferred/defer-2.py的範例觸發了deferred實例的errback鏈:
from twisted.internet.defer import Deferred
from twisted.python.failure import Failure

def got_poem(res):
    print 'Your poem is served:'
    print res

def poem_failed(err):
    print 'No poetry for you.'

d = Deferred()

# add a callback/errback pair to the chain
d.addCallbacks(got_poem, poem_failed)

# fire the chain with an error result
d.errback(Failure(Exception('I have failed.')))

print "Finished"

執行script後我們得到這個輸出:
No poetry for you.
Finished

所以觸發errback鏈只要呼叫errback方法來代替callback方法,然後方法的引數是一個錯誤的結果。就像callback一樣,在觸發之後errback會馬上被調用。

在前面的例子中,我們像在3.1版本用戶端一樣,將一個Failure物件傳給了errback方法,這沒甚麼問題,但deferred實例將會為我們把普通的Exception物件轉換為Failure物件,我們可以在twisted-deferred/defer-3.py中看到:
from twisted.internet.defer import Deferred

def got_poem(res):
    print 'Your poem is served:'
    print res

def poem_failed(err):
    print err.__class__
    print err
    print 'No poetry for you.'

d = Deferred()

# add a callback/errback pair to the chain
d.addCallbacks(got_poem, poem_failed)

# fire the chain with an error result
d.errback(Exception('I have failed.'))

這裡我們傳了一個正規的Exception物件給errback方法,然後在errback中,我們輸出錯誤結果的類別與結果本身。我們得到這樣的輸出:
twisted.python.failure.Failure 
[Failure instance: Traceback (failure with no frames): : I have failed. ]
No poetry for you.

這表示當我們使用deferreds時,我們可以回頭使用我們一般的Exception物件,而deferreds會自動幫我們轉換為Failure物件。Deferred實例會保證每個errback被調用時都有一個Failure實例。

我們試過了使用callback與errback的deferred,像任何優秀的工程師一樣,你可能想要一次又一次的測試它們。為了簡化我們縮短了程式碼,我們對callback與errback使用了相同的函式。你只需要記得它們有不同的回傳值;一個是正常結果一個是錯誤結果。來看看twisted-deferred/defer-4.py
from twisted.internet.defer import Deferred
def out(s): print s
d = Deferred()
d.addCallbacks(out, out)
d.callback('First result')
d.callback('Second result')
print 'Finished'

我們得到這樣的輸出:
First result
Traceback (most recent call last):
  ...
twisted.internet.defer.AlreadyCalledError

這很有趣,deferred實例不會讓我們觸發兩次正常結果的callback(譯註:根據我的理解應該說是不能被同樣的函示觸發兩次,因為我們只有一個out函式,但是兩次callback都使用了這個函式,所以如果要觸發多次正常結果,必須每一次都使用不同的函式,可以參考twisted-deferred/defer-block.py)。事實上,無論如何deferred實例不能被觸發第二次,就像下面這些範例一樣:


注意那些最後的print statements永遠不會被呼叫。callback與errback方法拋出了一個真正的Exceptions來讓我們知道我們已經觸發了deferred,deferred幫助我們了解使用callback進行程式設計時的一個陷阱。當我們使用deferred物件管理我們的callbacks時,我們根本不可能去犯同時呼叫callback與errback,或調用callback 27次的錯誤。我們可以試,但是deferred物件會馬上回拋給你一個例外,而不是將我們錯誤傳給callback。

Deferreds可以幫助我們重構非同步程式碼嗎?來研究一下twisted-deferred/defer-8.py這個範例:
import sys

from twisted.internet.defer import Deferred

def got_poem(poem):
    print poem
    from twisted.internet import reactor
    reactor.stop()

def poem_failed(err):
    print >>sys.stderr, 'poem download failed'
    print >>sys.stderr, 'I am terribly sorry'
    print >>sys.stderr, 'try again later?'
    from twisted.internet import reactor
    reactor.stop()

d = Deferred()

d.addCallbacks(got_poem, poem_failed)

from twisted.internet import reactor

reactor.callWhenRunning(d.callback, 'Another short poem.')

reactor.run()

這基本上是我們之前的原始範例(譯註:3.1版本用戶端),加了一些額外的程式碼讓reactor執行而已。注意我們在reactor啟動後使用了callWhenRunning去觸發deferred物件。我們利用當callWhenRunning執行時,接受額外的位置引數(positional arguments)與關鍵字引數(keyword arguments)並傳給callback。許多Twisted的APIs都遵循相同的慣例來註冊callbacks,包括增加callbacks到deferred的APIs。

範例中使用callback或errback都會停止reactor,由於deferreds支援callbacks與errbacks的鏈,因此我們可以將常用的程式碼重構為鏈中的第二個環節,這是在twisted-deferred/defer-9.py說明的技術:
import sys

from twisted.internet.defer import Deferred

def got_poem(poem):
    print poem

def poem_failed(err):
    print >>sys.stderr, 'poem download failed'
    print >>sys.stderr, 'I am terribly sorry'
    print >>sys.stderr, 'try again later?'

def poem_done(_):
    from twisted.internet import reactor
    reactor.stop()

d = Deferred()

d.addCallbacks(got_poem, poem_failed)
d.addBoth(poem_done)

from twisted.internet import reactor

reactor.callWhenRunning(d.callback, 'Another short poem.')

reactor.run()

addBoth方法將相同的函式添加到callback與errback鏈中,終於我們能夠開始重構我們的非同步程式碼了。

注意:在這種方法中deferred實際執行其errback鏈時有一個微妙之處,我們會未來的部分討論它,但要記住關於deferreds還有很多要學。

Summary

在這個章節我們分析了callback程式設計,並且確定了潛在的問題,我們也看到了Deferred類別如何幫助我們:

  1. 我們不能忽略errbacks,對於任何非同步APIs它們是必須的。deferred內建就支援errback。
  2. 多次調用callbacks可能會導致微妙且難以debug的問題。deferred只能被觸發一次,這使得deferreds就像我們熟悉的try/except statements。
  3. 使用普通的callback程式設計會讓重構變得棘手。有了deferreds,我們可以透過在鏈上添加環節,並且移動我們的程式碼到另一個環節來重構程式碼(譯註:如同defer-9.py,添加了poem_done這個環節並將reactor.stop()移到其中)。

關於deferreds的故事還沒有結束,還有更多關於它們的原理和行為的細節可以探索。但是對於開始在我們的詩歌客戶端中使用它們已經足夠了,所以我們將在Part 8中做到這點。

Suggested Exercises

  1. 上一個範例中忽略了給poem_done的引數。把它印出來。讓got_poem回傳一個值並且看看給poem_done的引數會如何改變。
  2. 修改最後兩個deferred範例去觸發errback鏈。確保用一個Exception去觸發errback。
  3. 閱讀Deferred類別的addCallback與addErrback方法的說明。

留言