這一篇是在實際工程中遇到的一個難得的例子;反映在Node裡兩種程式設計正規化的設計衝突。這種衝突具有普適性,但本文僅分析問題本質,不探討更高層次的抽象。
我在寫一個類似HTTP的資源協議,叫RP,Resource Protocol,和HTTP不同的地方,RP是構建在一箇中立的傳輸層上的;這個傳輸層裡最小的資料單元,message,是一個JSON物件。
協議內建支援multiplexing,即一個傳輸層連線可以同時維護多個RP請求應答過程。
考慮客戶端request
類設計,類似Node內建的HTTP Client,或流行的npm包,如request
或 superagent
;
可以採用EventEmitter
方式emit error
和response
s事件,也可以採用Node Callback的形式,需要使用者提供介面形式為(err, res) => {}
的callback函式。
隨著async/await的流行,request
類也可以提供一個.then
介面,用如下方式實現(實際上superagent
就是這麼實現的):
class Request extends Duplex {
constructor () {
super()
...
this.promise = new Promise((resolve, reject) => {
this.resolve = resolve
this.reject = reject
})
}
then (...args) {
return this.promise.then(...args)
}
}
RP的實際設計,形式和大家熟悉的HTTP Client有一點小區別,response物件本身不是stream,而是把stream做為一個property提供。換句話說,callback函式形式為:
(err, { data, chunk, stream }) => {}
如果請求返回的不是stream,則data
或者chunk
有值;如果返回的是stream,則僅stream
有值,且為stream.Readable
型別。
這個形式上的區別和本文要討論的問題無關。
RP底層從傳輸層取二進位制資料,解析出message,然後emit給上層;它採用了一個簡單方式,迴圈解析收到的data chunk,直到沒有完整的message為止。
這意味著可以在一個tick裡分發多個訊息。request
物件也必須能夠在一個tick裡處理多個來自服務端的訊息。
我們具體要討論的情況是伺服器連續發了這樣兩條訊息:
- status 200 with stream
- abort
第一條意思是後面還有message stream,第二條abort指server發生意外無法繼續傳送了。
在request
物件收到第一條訊息時,它建立response
物件,包含stream
物件:
this.res = { stream: new stream.Readabe({...}) }
// this.emit('response', this.res)
// this.callback(null, this.res)
this.resolve(this.res)
象註釋中emit
或trigger使用者提供的callback
,都沒有問題;但如果呼叫resolve
,注意,Promise是保證非同步的,這意味著使用者通過then
提供的onFulfilled
,不會在當前tick被呼叫。
接下來第二條訊息,abort,在同一個tick被處理;但這個時候,因為使用者還沒來得及掛載上任何listener,包括error handler,如果設計上要求這個stream emit error——很合理的設計要求——此時,按照Node的約定,error沒有handler,整個程式crash了。
這個問題的dirty fix有很多種辦法。
首先request.handleMessage
方法,如果無法同步完成對message的處理,而message的處理順序又需要保證,它應該buffer message,這是node裡最常見的一種synchronize方式,代表性的實現就是stream.Writable
。
但這裡有一個困難,this.resolve
這個函式沒有callback提供,必須預先知道執行環境的Promise實現方式;在node裡是nextTick
,所以在this.resolve
之後nextTick
一下,同時buffer其它後續訊息的處理,可以讓使用者在onFulfilled
函式中給stream掛載上handler。
這裡可以看出,callback和emitter實際上是同步的。
當呼叫callback或者listener時,request和使用者做了一個約定,你必須在這個函式內做什麼(在物件上掛載所有的listener),然後我繼續做什麼(處理下一個訊息,emit data或者error);這相當於是interface protocol對順序的約定。
我們可以稱之為synchronous sequential composition,是程式語義意義上的。
對應的asynchronous版本呢?
如果我們不去假設執行環境的Promise的實現呢?它應該和同步版本的語義一樣對吧。
再回頭看看問題,假如stream emit error不會導致系統crash,使用者在onFulfilled
拿到{ stream }
這個物件時,它看到了什麼?一個已經發生錯誤後結束了的stream。
這個可能使用上會難過一點,需要判斷一下,但還感覺不出是多大的問題。
再進一步,如果是另一種情況呢?Server在一個chunk裡發來了3個訊息;
- status 200 with stream
- data
- abort
這個時候使用者看到的還是一個errored stream,data去哪裡了呢?你還能說asynchronous sequential composition的語義和synchronous的一致麼?不能了對吧,同步的版本處理了data,很可能對結果產生影響。
在理想的情況下,sequential composition,無論是synchronous的,還是asynchronous的,語義(執行結果)應該一致。
那麼來看看如何做到一個與Promise A+的實現無關的做法,保證非同步和同步行為一致。
如果你願意用『通訊』理解計算,這個問題的答案很容易思考出來:假想這個非同步的handler位於半人馬座阿爾法星上,那我們唯一能做的事情是老老實實按照事件發生的順序,傳送給它,不能打亂順序,就像我們收到他們時一樣。
但是當我們把進來的message,翻譯實現成stream時,沒能保證這個order,包括:
- abort訊息搶先/亂序
- data訊息丟失了
這是問題的root cause,當我們非同步處理一個訊息序列時,前面寫的實現break了順序和內容的完整性。
在數學思維上,我們說Promise增加了一個callback/EventEmitter不具備的屬性,deferred evaluation,是一個程式設計中罕見的temporal屬性;當然這不奇怪,因為這就是Promise的目的。
同時Promise -> Value還有一個屬性是它可以被不同的使用者訪問多次,保持了Value的屬性。
這也不奇怪。
只是Stream作為一種體積上可以為無窮大的值,在實踐中不可能去cache所有的值,把它整體當成一個值處理,所以這個可以被無限提取的『值』屬性就消失了。
但是這不意味著stream作為一個物件,它的行為,不能延遲等到它被構造且使用後才開始處理訊息。
一種方式是寫一個stream有這種能力的;stream.Readable
有一個flow屬性,必須通過readable.resume
開始,這是一個觸發方式;另一個方式是有點tricky,可以截獲response.stream
的getter,在它第一次被訪問時觸發非同步處理buffered message。
這樣的做法是不需要依賴Promise A+的實現的;但不是百分百asynchronous sequential composition,因為stream的handler肯定是synchronous的。
完全的asynchronous可以參照Dart的使用await消費stream的方式。
它的邏輯可以這樣理解:把所有Event,無論哪裡來的,包括error,都寫到一個流裡去,用await消費這個流;但實際上在await返回的時候仍然面對一個狀態機,好處是
- throw給力;
- 流程等待方便,即處理流輸出的物件時還可以有await語句,在取下一個流輸出的物件之前,相當於一種blocking;但這種blocking需要慎重,它是反併發的;
總結:
Node的Callback和EventEmitter在組合時handler/listener是同步的;Promise則反過來保證每個handler/listener都是非同步組合,這是兩者的根本區別。
在順序組合函式(或者程式代數意義上的程式)上,同步組合是緊耦合的;它體現在一旦功能上出現什麼原因,需要把一個同步邏輯修改成非同步時,都要大動干戈,比如本來是讀取記憶體,後來變成了讀取檔案。
如果程式天生寫成非同步組合,類似變化就不會對實現邏輯產生很大影響;但是細粒度的非同步組合有巨大的效能損失,這和現代處理器和編譯器的設計與實現有關。
真正理想的情況應該是開發者只表達“順序”,並不表達它是同步還是非同步實現;就像前面看到的,實際上同步的實現都有可以對應的非同步實現,差別只是執行效率和記憶體使用(buffer有更多的記憶體開銷,同步處理實際上更多是『閱後即焚』);
但我們使用的imperative langugage不是如此,它在強制你表達順序;而另外一類號稱未來其實狗屎的語言,在反過來強制你不得表達順序。
都是神經病。學術界就不會真正理解產業界的實際問題。