從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介面,使用其實作的話,要以isDoen方法查詢看看工作是否完成,或者採用get方法以阻斷模式取得結果,實際要在Java中要找到類似上述的Promise風格,可以像是guava-libraries的ListenableFuture,或者是JDK8中將出現的CompletableFuture,而JDK7中有個AsynchronousFileChannel,其read方法可以如Node.js的readFile使用風格,也有個重載過的read方法可傳回Future,概念上就類似是前面實作的readFiiePromise。

無論開發者是在瀏覽器上小規模地使用非同步物件,在桌面或後端程式中部份引用非同步程式庫,或者如Node.js中大規模採用,必得要有的認知是,非同步操作與開發者熟悉的同步操作,在控制流程、風格等各方面有顯著的不同,多認識非同步操作時的更多模式是有益處的,Node.js與相關程式庫有不少經驗與示範,從這些模式中,也可更為瞭解,非同步操作可以更適當地應用在哪些方面。

作者簡介


Advertisement

更多 iThome相關內容