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/elsefor 的巢狀問題,而這邊解決的是函式構成巢狀回呼地獄的問題。

在將執行結果繼續往下個指定函式傳遞的精神上,CompletableFuturePromise 之類,也算是 Continuation-passing 風格的實現。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter