Promise
January 25, 2022在事情簡單時,〈Callback〉模式沒什麼問題,然而,若希望任務完成後,接續地執行下一個非同步任務時,就會發生回呼函式形成巢狀結構的問題。例如:
let fs = require('fs');
fs.readFile('files.txt', function(_, files) {
files.toString().split('\r\n').forEach(function(file) {
fs.readFile(file, function(_, content) {
console.log(content.toString());
});
});
});
回呼函式形成巢狀結構的問題可能會變得嚴重,因而引發回呼地獄(Callback hell)的問題,令撰寫程式或後續閱讀程式碼發生困難,例如:
function asyncFoo(n, callback) {
setTimeout(
() => callback(n * Math.random()),
2000
);
}
asyncFoo(10, r1 => {
asyncFoo(r1, r2 => {
asyncFoo(r2, r3 => {
console.log(r3);
});
});
});
Promise
Promise 模式在不同語言中會有不同的稱呼,也有人稱為 Delay、Deferred 模式,由於 JavaScript 經常會進行非同步操作,為了解決回呼地獄的問題,也就有了各種 Delay、Deferred、Promise 的實現,後來 ES6 統一規範了 Promise
API,因此這邊就使用這個 Promise 作為模式名稱,以下也直接使用 Promise
API 來示範。
在 ES6 以後,Promise
實例可以作為非同步函式的傳回值,正如名稱暗示的,Promise 實例代表著「承諾」在未來提供任務執行結果。例如:
function asyncFoo(n) {
return new Promise(resolve => {
setTimeout(
() => resolve(n * Math.random()),
2000
);
});
}
asyncFoo(10)
.then(r1 => asyncFoo(r1))
.then(r2 => asyncFoo(r2))
.then(r3 => console.log(r3));
Promise
實例建立後,處於未定(Pending)狀態,在建立 Promise
實例時可以指定回呼函式,該函式可以具有參數,第一個參數慣例上常命名為 resolve
,這兩個參數會各自接受函式,這邊僅定義了 resolve
參數,若呼叫 resolve
函式,表示 Promise
指定的任務達成(Fulfilled)狀態,呼叫 resolve
時指定的引數,就是任務達成的結果。
就程式碼閱讀來說,Promise
的撰寫風格就像是循序的,也避免了回呼地獄的問題,不過順序的保證,僅限於 then
指定的任務。,上面的範例若在最後一行加上了 console.log('end')
:
asyncFoo(10)
.then(r1 => asyncFoo(r1))
.then(r2 => asyncFoo(r2))
.then(r3 => console.log(r3));
console.log('end');
那麼會先顯示 end 字樣,後續才會顯示非同步任務的結果,如果想要顯示非同步任務的結果,最後才顯示 end 字樣,可以再寫個 then
:
asyncFoo(10)
.then(r1 => asyncFoo(r1))
.then(r2 => asyncFoo(r2))
.then(r3 => console.log(r3))
.then(_ => console.log('end'));
在建立 Promise
實例時指定的回呼函式,可以定義第二個參數,慣例上常命名為 reject
,如果指定的任務無法達成,可以使用傳入的 reject
函式來否決任務,此時 Promise
就會處於否決(Rejected)狀態。例如:
function randomDivided(divisor) {
return new Promise((resolve, reject) => {
let n = Math.floor(Math.random() * 10);
if(n !== 0) {
resolve(divisor / n);
} else {
reject('Shit happens: divided by zero');
}
});
}
randomDivided(10)
.then(
n => console.log(n),
err => console.log(err)
);
在上例中,若 n
不為 0 任務就會達成,若為 0 就會否決任務,若有定義 then
的第二個回呼函式,該函式就會被呼叫, reject
接受的引數,可以成為 then
第二個回呼函式的引數,在這邊 reject
時指定了字串值。
本質上,Promise
被否決時,表示任務因為某個錯誤而無法執行下去,如果想要突顯錯誤處理的流程,建議使用 catch
方法。例如:
randomDivided(10)
.then(n => console.log(n))
.catch(err => console.log(err));
簡單來說,Promise
是非同步,錯誤不是用 try/catch
語法處理,而是使用回呼方式來處理。
Promise
可以形成連續呼叫風格,每個 then
呼叫都會傳回 Promise
實例,如果 then
指定的回呼函式中拋出了錯誤,就是否決 then
傳回的 Promise
,因此底下的程式片段會顯示10與Shit happens:
Promise.resolve(10)
.then(v => {
console.log(v);
throw new Error('Shit happens');
})
.then(
_ => console.log('resolve'),
err => console.log(err.message)
);
CompletableFuture
以上只是借 JavaScript 的 Promise
,來說明一下 Promise 這個模式的基本概念,有些語法都具有這類實現,例如 Java 的 CompletableFuture
。
例如,若有以下的 Java 靜態方法:
static void readFileAsync(String file, Consumer<String> success,
Consumer<IOException> fail, ExecutorService service) {
service.submit(() -> {
try {
success.accept(
new String(Files.readAllBytes(Paths.get(file))));
} catch (IOException ex) {
fail.accept(ex);
}
});
}
這麼一來,就可使用以下非同步的風格來讀取文字檔案:
readFileAsync(args[0],
content -> out.println(content), // 成功處理
ex -> ex.printStackTrace(), // 失敗處理
Executors.newFixedThreadPool(10)
);
類似地,這種非同步操作的回呼(Callback)風格,在每次回呼中若又進行非同步操作及回呼,很容易寫出回呼地獄,例如若有個類似 readFileAsync
風格的非同步 processContentAsync
方法,用來繼續處理 readFileAsync
讀取的檔案內容,就會撰寫出以下的程式碼:
readFileAsync(args[0],
content -> processContentAsync(content,
processedContent -> out.println(processedContent),
ex -> ex.printStackTrace(), service),
ex -> ex.printStackTrace(), service);
如果改用 CompletableFuture
的話:
static CompletableFuture<String> readFileAsync(
String file, ExecutorService service) {
return CompletableFuture.supplyAsync(() -> {
try {
return new String(Files.readAllBytes(Paths.get(file)));
} catch(IOException ex) {
throw new UncheckedException(ex);
}
}, service);
}
就可以這麼撰寫:
var poolService = Executors.newFixedThreadPool(10);
readFileAsync(args[0], poolService).whenComplete((ok, ex) -> {
Optional.ofNullable(ex)
.ifPresentOrElse(
Throwable::printStackTrace,
() -> out.println(ok)
);
});
若第一個 CompletableFuture
任務完成後,想繼續以非同步方式處理結果,可以使用 thenApplyAsync
。例如:
readFileAsync(args[0], poolService)
.thenApplyAsync(String::toUpperCase)
.whenComplete((ok, ex) -> {
Optional.ofNullable(ex)
.ifPresentOrElse(
Throwable::printStackTrace,
() -> out.println(ok)
);
});
flatMap?
CompletableFuture
也有個 thenComposeAsync
,作用就類似〈FlatMap〉,因為可以指定函式,將前一 CompletableFuture
處理後的結果 T 映射為值 CompletableFuture<U>
。
舉例來說,想在 readFileAsync
傳回的 CompletableFuture<String>
處理完後,繼續組合 processContentAsync
方法傳回的 CompletableFuture<String>
,就可以如下撰寫:
readFileAsync(args[0], poolService)
.thenComposeAsync(content -> processContentAsync(content, poolService))
.whenComplete((ok, ex) -> {
Optional.ofNullable(ex)
.ifPresentOrElse(
Throwable::printStackTrace,
() -> out.println(ok)
);
});
你指定給 thenComposeAsync
的函式,將前一 CompletableFuture
處理後的結果 T 映射為值 CompletableFuture<U>
,如果將 CompletableFuture
看成是個封裝值的盒子,這不就像是〈FlatMap〉中談到的,你指定的是盒子中的值,該怎麼轉換為另一個盒子!
方才的 Promise
也是,它的 then
方法可以指定函式,將前一 Promise
處理後的結果,映射為另一個 Promise
,雖然方法名是 then,不過也是 flatMap 的概念。
記得嗎?flatMap 可以用來解決巢狀深度的問題,只不過在〈FlatMap〉解決的是 if/else
、for
的巢狀問題,而這邊解決的是函式構成巢狀回呼地獄的問題。
在將執行結果繼續往下個指定函式傳遞的精神上,CompletableFuture
、Promise
之類,也算是 Continuation-passing 風格的實現。