JDeferred: 簡單處理 Promises 及 Futures

不再對非同步運算頭疼

Du Spirit
Java Magazine 翻譯系列
10 min readJul 2, 2017

--

Translated from “JDeferred: Simple Handling of Promises and Futures” By Andrés Almiray, Java Magazine May/June 2017, page 16–21. Copyright Oracle Corporation.

開發者十分有能力處理依序出現的事件,但是我們得與平行、延遲或擱置的事件奮鬥,幸運地是,有些技巧可以協助處理延遲的或擱置的結果,這些技術中主要的是 promises 及 futures [譯註:這裡將此二字視為專有名詞,就不特別翻譯了],這是本文的焦點,有 JDeferred 函式庫的協助,能更容易使用它們。

維基百科將其背後的關鍵概念定義成一個物件,作為一開始不知道結果的代理,future 是一個變數的唯讀佔位符號,也就是說它的角色就是包含一個數值,沒別的事。promise 是一個可寫,且只能設定一次的容器,設定 future 的數值,promise 可能定義 API 用來與 future 狀態變化互動,例如數值已經解答出來,或是數值因為 (預期中或非預期的) 錯誤被否決,或是計算的任務被取消,讓我們更仔細檢視這概念。

Java 中的 Promises

Java 標準函式庫基於 java.util.concurrent.Future<V>,包含許多 future 概念的實作,其中 Java 8 新增一個稱為 CompletableFuture的類別,提供下列能力:

  • 取得一個值可能以非同步的方式計算
  • 註冊修改的函式在數值就緒時改變計算後的結果
  • 建立函式串列接受計算的結果,可能將其與其他結果結合
  • 初始化一個背景的任務以計算預期的結果

您能快速從 CompletableFuture (從現在起我將這視為 promise) 開始,透過使用一組工廠方法,您可以下面程式建立一個沒有回傳值的 promise:

這版本能讓您定義一個任務執行一些計算,但結果不重要,重要的是任務是否正確被完成,您可以如下加上一段程式處理結果:

如果您對計算結果有興趣,您必須呼叫不同的工廠方法,需要一個 Supplier 作為參數,例如:

當您有一個 promise 的參考,您可以用額外的運算修飾它,與計算的結果互動,或是處理計算過程中拋出的例外,又或是對回傳值做額外的轉換。

現在,假設您被要求以組織名字顯示在 GitHub 找到的所有儲存庫,這需要您呼叫 REST API,處理結果,並顯示它們。我們更進一步假設這程式必須嵌在 JavaFX 應用程式中,最後這需求迫使您須思考使用 promise 的概念,因為儲存庫的計算必須在背景的執行緒中執行,但結果必須發佈到 UI 執行緒中 — 這是建立互動式 JavaFX 應用程式的通則,任何與 UI 無關的運算 (在我們的例子中像是網路呼叫) 必須在非 UI 執行緒中執行,反之,任何與 UI 相關的運算 (更新元件的屬性) 必須只能在 UI 執行緒中執行。我不會深究網路呼叫實際是如何進行,但完整的程式碼可在 GitHub 上找到,以下的片段展示如何使用 promise 讓運算在背景執行,在這專案中,您會發現我注入一些相關的資源:

透過 execute(),這程式發起網路呼叫,如果有通訊問題或是解析時發生錯誤,IOException 會被拋出,如果呼叫成功,回傳解析後的結果,反之,拋出IllegalStateException,最後,promse 被建立並指定特定的Executor,您可能會注意到在先前的程式片段我沒有明確指定 executor,這是因為若沒有指定 Executor 會直接使用共用的 ForkJoinPool

現在,我們使用承諾的結果,我假設有另一個元件 (例如,一個 controller)負責呼叫剛定義的服務,產生一串結果,同時在呼叫服務過程中發生例外時,負責顯示錯誤。

我簡短解釋程式是如何執行的,我們將程式一行一行拆解,首先,controller 設定狀態,用來讓 UI 禁止後續的動作直到計算完成,接著,呼叫先前描述的服務 repositories 以取得個 promise,這 promise 允許 controller 設定後續的動作,例如處理結果,在這個例子中,將儲存庫的列表放到 UI 顯示用的 model 中,然後在 exceptionally() 中處理任何執行服務期間可能拋出的例外,最後,在 thenAccept() 將狀態設為成功或是失敗。

例外的警告

多關注 promise 處理結果的步驟順序,若步驟的順序不同,您會得到完全不一樣,甚至是不預期的行為,讓我們標示這些步驟為 SUCCESS、FAILURE 和 ALWAYS,目前的順序是 SUCCESS、FAILURE 及 ALWAYS。

如果您用不同的順序,可能產生不同的結果:

  • ALWAYS、SUCCESS、FAILURE 將無法編譯,因為 ALWAYS 改變結果的型別為 Void,lambda 的函式並沒有回傳值。
  • SUCCESS、ALWAYS、FAILURE 導致當錯誤發生時,UI 仍是無法使用的狀態,因為 UI 一直等待的狀態從不會更新。
  • FAILURE、SUCCESS、ALWAYS 也會造成 UI 當錯誤發生時,UI 無法使用,因為狀態不會更新。

所以您必須非常小心附加上這類 promise 上動作的順序,CompletableFuture 自身還有一個問題:它既是一個 future 也是一個 promise。promise 可以讓您以非同步 (nonblocking,非阻塞) 的方式做出反應,但是,future 有一個特殊的 (blocking) 阻塞式函式:get(),這意味著,任何時間,您可以將非阻塞式的情境轉成轉成阻塞式的情境,即使是無意地,因為呼叫如 get() 這種類型 (例如:Optional) 公開的函式十分常見。

您可能會問,重點是什麼?只要我不呼叫 get 函式,一切會沒事,對吧?但這既是 future 也是 promise 的型別,可是無法保證 get 函式不會被下游的另一個 API 調用,若 promise 不再是一個 future 會更好,下個問題可能會是:如果我將 CompletableFuture 包裝成只有 promise 的 API?是的,這會奏效,但為何不改用現成的 promise 函式庫呢?我說的是 JDeferred。

介紹 JDeferred

JDeferred 是提供 promise 概念的函式庫,它的靈感來自 JQuery 及 Android Deferred Object,設計相容 JDK 1.6 及之後的版本,API 相當簡單,但別被它的簡潔給愚弄,您可以用它建立穩定、行為良好且易讀的程式碼,讓我們用 JDeferred 重新檢視先前的例子,如果您想進一步研究,可以在 GitHub 找到完整的程式碼,JDeferred 可以用 Maven 加到您的專案中:

若您偏好使用 Gradle,可以用:

compile ‘org.jdeferred:jdeferred-core:1.2.5’

JDeferred 提供一基礎型別:org.jdeferred.Promise 可以用來註冊動作或 callbacks,一個 Promise 可以在完成時回傳一個值;若錯誤發生時,拋出一個 Object (任何 Object,不只是 Throwable),並回傳計算期間的中間結果。CompletableFuture 無法提供最後這二個選項,Deferred 允許您依責任將 callbacks 群組在一起,因此免去剛剛討論 CompletableFuture時提到的順序問題,promise 常用另一個元件 DeferredManager 建立,這樣一來,函式庫將任務的建立機制與 promise 本身脫鉤,因為它們是完全不同的二個概念,讓我們看一下用 JDeferred 改寫先前 GitHub 服務的實作。

這程式的功能與先前的程式一樣,但明顯簡潔許多,任務以這方式執行能受益於 DeferredManager 的自動例外處理,這是為什麼您不需要如先前那樣明確處理溝通與解析錯誤,這些錯誤將 promise 的狀態設為失敗,並以這樣的方式記錄錯誤,處理錯誤的 callcacks 會接收它們。

這個例子沒有中間結果,所以 Promise 的第三個參數設為 Void

現在,可以用下列的方式使用 promise 的結果:

和先前相同的功能,但程式碼相當簡潔,能依您認定的特殊,以任何順序定義 SUCCESS、FAILURE 和 ALWAYS 的 callbacks,最後,沒有任何方式可以讓 promise 以阻塞式的方式等待結果出來,API 就是不允許。

如果您想,使用 DeferredObject,您亦可切換到手動的實作產生 promise,這型別允許您設定計算後或是拒絕的數值,或是發布中間的結果。如果您曾使用 SwingWorker 的 API,那您會知道這行為是如何發生的,關鍵的區別是 DeferredObject 在背景的執行緒傳送通知,SwingWorker 則是在 UI 執行去中,下例是 DeferredObject 如何被使用設定 promise 結果或是觸發失敗:

這次,您必須處理所有的溝通與錯誤的解析,並明確用 Executor 或類似的方式排程背景的任務,這 DeferredObject 特殊的用法在撰寫測試時十分方便,因為您可以在任何時間解決或拒絕一個 promise,下面的測試案例顯示在這一情境 (撰寫測試) 下如何使用 JDeferred、Mockito 和關係注入的方式實作:

我們從這可以看到如何以 DeferredObject 作為預期的結果和 Github 的偽實體 (mocked instance)一起使用,這特定測試檢查正常流程是否如預期運作,您可以呼叫 rejected() 設定失敗的流程,檢查預期的例外是否發生。

結論

Promise 讓您能以延遲或非同步的方式處理計算的結果,Java 8 提供一個名為 CompletableFuture 的型別作為一個 promise,它能用來處理結果、轉換結果成其他值、結合其他結果成為新的結果,以及處理例外當錯誤發生時。

無論如何,您須注意附加在 promise 上動作的順序,同樣,這樣的 promise 有可能因簡單地呼叫 get() 被阻塞,JDeferred 實作一簡單的 API 提供同樣的能力但沒有這些缺點,它同樣允許您在背景執行的任何時間點發布中間的結果,這種行為的例子可以在 GitHub 看到。

Learn more

JDeferred library
tutorial on futures, promises, and JDeferred
Java 8 CompletableFuture (Javadoc)
tutorial on Java 8 CompletableFuture

譯者的告白

這文章讓我想到先前也曾在一個 API 是否以同步或非同步的方式設計,有過一段小小的爭執,不過爭執的核心原因不是同步或非同步就是了。最近,我常覺得現有 API 有點濫用非同步的設計,結果引起 callback hall 的問題,然後又設計了其他的方式處理非同步計算,一直把問題複雜化,回到原點,真的有必要把所有的 API 設計成非同步嗎?其實沒有必要。對 API 的使用者來說,同步的 API 想以同步方式使用,不用任何改變就可以直接用,想以非同步方式使用,加上 JDeferredCompletableFuture 就非常好用了,但若一開始就把 API 設計成非同步式,想變回同步式呼叫,反而麻煩,而且還可能因為 lock 使用不當導致效能不佳甚至是 dead lock,而且測試也很難寫,所以我還是比較喜歡同步的 API,參閱《閒談軟體架構:Async everything?》。

--

--