iThome 網站首載:非同步操作的多種模式
從Ajax概念被重新炒熱開始,前端開發者因為XMLHttpRequest而有了更多接觸非同步操作的機會,然而程式中其它部份仍多以同步操作為主;其它程式語言中也多少存在著一些非同步模型,不過多數開發者較少接觸或不熟悉其運用,程式多數還是以同步操作為主;近來由於Node.js的興起與話題性,加以Node.js大規模採用了非同步操作,使得不少開發者留意到同步與非同步操作之間存在著差異性,亦開始重視非同步操作時各種模式之認識。
- 回呼模式與引發之問題
在我先前專欄〈實現共用程式樣版的模式〉中曾經談到回呼(Callback),當實作程式出現許多重複流程,僅小部份需要特定實作時,可以將重複流程實作為樣版,而特定實作由呼叫者提供回呼物件或函式,例如對JavaScript的陣列排序可以寫為
[1, 3, 2, 5, 4].sort(function(a, b) { return a - b; })
。在瀏覽器中建立、使用非同步物件XMLHttpRequest
時,由於判斷環境、發出請求、回應等流程是可重複利用的,因此可予以封裝,在進行非同步請求時時提供回呼函式,以便在瀏覽器獲得回應時予以呼叫,例如jQuery可以\$.get(url, options, function(responseText) { ... } )
的模式,來建立非同步請求與回應處理。實際上,非同步操作有多種模式,然而回呼方式因為封裝了大部份流程,僅要求呼叫者提供對應事件發生時相對應的回呼,這種模式算是對呼叫者較友善的方式。不過在非同步操作時,開發者不能使用同步操作之習慣來對待回呼函式的執行結果,因為非同步操作並不會阻斷,後續的程式碼會立即執行,以同步觀念來試圖獲取回呼之成果,將會產生不正確的執行結果。例如在Node.js中使用fs模組的
readFile
時,若有以下操作:var text;
require('fs').readFile('text', 'utf-8', function(err, data) {
text = data;
});
在呼叫
readFile
後若緊接著有讀取text
的操作,例如console.log(text)
,則可能得到undefined
的結果,因為讀取檔案的動作可能還沒結束,回呼函式並沒有被呼叫;類似的問題還有不可使用同步的try-catch
風格,來處理來非同步操作時拋出(throw)的錯誤,因為非同步呼叫之後會立刻執行後續程式碼,當非同步操作拋出錯誤時,通常早就離開了try-catch
區塊,錯誤實際上不會被捕捉(catch)。除了不可用同步習慣來對待非同步操作外,如果非同步操作是串連在一起的情況,則會形成回呼地獄(Callback hell)的問題而影響可讀性。- Continuation-passing style(CPS)
CPS是一種流程控制風格,若想以回呼方式實現,方式是函式將其執行結果傳給呼叫者提供的回呼函式,因而形成一種連續呼叫的風格。例如有個
function doubleMe(n) { return n * 2; }
的話,改以CPS風格則可實作為doubleMe(n, ret) { ret(n * 2); }
,如果需要針對非同步操作的回呼函式結果進行處理,則可在回呼函式執行結果產生之後採用CPS風格,例如先前readFile
的程式的回呼函式中,最後可直接呼叫console.log(data)
。CPS也用來解決非同步操作時錯誤處理的問題,以Node.js中fs模組的
nullCheck(path, callback)
函式為例,如果呼叫時有提供callback
引數,錯誤發生時並非拋出,而是在下一次的事件迴圈中,以建立的Error
物件來呼叫回呼函式:var er = new Error('Path must be a string without null bytes.');
if (!callback) throw er;
process.nextTick(function() {
callback(er);
});
類似地,
readFile
方法的回呼函式第一個參數接受Error
物件,當readFile
本身發生錯誤時並非拋出,而是傳給回呼函式作為第一個引數,以便呼叫者在回呼中處理錯誤,也因此在Node.js中若非同步操作可能產生錯誤,慣例上回呼函式第一個參數會是err
,如果非同步操作沒有錯誤,err
的值會是null
或是undefined
。- Promise模式改善非同步邏輯
Promise模式在不同語言中會有不同的稱呼,也有人稱為Future、Delay或Deferred模式,
Promise
物件基本上是作為一個代理物件,代表著一段可能長時間執行或延後執行的計算,並承諾在未來提供計算結果,無論那是成功、失敗或其它可能的結果。例如,若有個僅處理成功與失敗的Promise
物件,那麼可定義一個readFilePromise(filename, eocnding)
,令其傳回一個Promise
物件。例如:var p = new Promise(function() {
require('fs').readFile(filename, encoding, function(err, data) {
if(err) p.reject(err);
else p.resolve(data);
});
});
傳回的
Promise
物件定義有done
方法,會執行建立Promise
物件時傳入的函式,Promise
物件上也定義了try
方法,可註冊resolve
方法執行時實際會呼叫的函式,catch
方法則可註冊reject
方法執行時實際會呼叫的函式,因此可使用以下風格來撰寫程式,readFilePromise('Project1.dev', 'UTF-8').try(function(data) {
// 處理 data
}).catch(function(err) {
// 處理錯誤
}).done();
這樣的風格也用來解決回呼地獄的問題,通常會定義一個
then
方法,可以進行readFilePromise(...).then(callback1).then(callback2).then(callback3)
的風格撰寫。Promise
物件的概念可以實作為通用化的程式庫,像是使用q模組的話,就可以直接進行類似風格的撰寫,例如上列程式碼使用q模組的話,可改寫為Q.nfcall(fs.readFile, filename, encoding).try(handleData).catch(handleError).done()
,其中handleData
、handleError
是自定義的函式。- 認識非同步更多模式
就如先前所談到的,實際上非同步操作有許多種模式,不同語言也許會有不同的稱呼,也有可能具備不同的使用風格。例如jQuery在1.5中引入了
Deferred
物件,使用上就如同這邊談到的Promise
物件;在Java中有個Future
介面,使用其實作的話,要以isDone
方法查詢看看工作是否完成,或者採用get
方法以阻斷模式取得結果,實際要在Java中要找到類似上述的Prmoise風格,可以像是guava-libraries的ListenableFuture
,或者是JDK8中將出現的CompletableFuture
,而JDK7中有個AsynchronousFileChannel
,其read
方法可以如Node.js的readFile
使用風格,也有個重載過的read
方法可傳回Future
,概念上就類似是前面實作的readFiiePromise
。無論開發者是在瀏覽器上小規模地使用非同步物件,在桌面或後端程式中部份引用非同步程式庫,或者如Node.js中大規模採用,必得要有的認知是,非同步操作與開發者熟悉的同步操作,在控制流程、風格等各方面有顯著的不同,多認識非同步操作時更多的模式是有益處的,Node.js與相關程式庫中有不少的經驗與示範,從這些模式的認識之中,反過來也可更為瞭解,非同步操作可以更適當地應用在哪些方面。