在非同步Java程式碼中解救已檢測異常

InfoQ - 叢一發表於2015-01-02

Java語言通過已檢測異常語法所提供的靜態異常檢測功能非常實用,通過它程式開發人員可以用很便捷的方式表達複雜的程式流程。

實際上,如果某個函式預期將返回某種型別的資料,通過已檢測異常,很容易就可以擴充套件這個函式,將所提供的輸入不適於所請求的計算的各類情況都通知給呼叫者,以確保每種情況下都能夠觸發恰當的動作。而且由Java語言所提供的語法級的異常處理執行讓這些異常像返回型別的隱式擴充套件一樣,成為合理的函式簽名一部分。

這種異常的抽象對於具有分層結構的程式來說特別方便,呼叫層只需要知道呼叫內部層級會出現哪些情況,而不需要了解更多的資訊。然後,呼叫層只需要判定這些情況中的哪些需要其在自身範圍內跟進,哪些應該作為其作用範圍內的非法情況,遞迴通知到外部層級。

這種針對自上而下的流程,識別和處理特殊情況的抽象通常是程式規格最自然的非正式表述方式。因此已檢測異常的存在,能夠讓程式實現在視覺形態上可以儘可能的與最初的程式規格保持一致。

舉例來說,某個Internet服務的自上而下的規格說明可能會在多個層級中確定一個專用層級用於處理某個自定義的表示請求和響應的協議。可以用如下程式碼來描述這一層的正常行為:

String processMessage(String req) {
   MyExpression exp = parseRequest(req);
   MyValue val = elaborate(exp);
   return composeResponse(val);
}

除此之外,還需要能夠識別各種出錯的情況,每種情況可能都會導致不同的與客戶端的互動方式。假設:

  • parseRequest可能會識別出“語法問題”
    • 這種情況下,應該立即中斷通訊流;
  • 當某個請求所假定的可用資源不可用時,elaborate可能會識別出這個請求的“資源問題”
    • 在這種情況下,我們希望通過底層的傳輸協議(如HTTP 404錯誤)通知上層這種資源缺乏的情況
  • 假如某個使用者試圖執行她沒有許可權執行的操作時,elaborate可能還會識別出“授信問題”
    • 在這種情況下,在我們自定義的協議中,會給客戶端一個特定的響應

利用已檢測異常,我們可以用下面這種方式表示這一層級的程式碼:

程式碼片段1:

MyExpression parseRequest(String req) throws MySyntaxException { ... }
String composeResponse(MyValue val) { ... }
String composeErrorResponse(MyCredentialException ce) { ... }

MyValue elaborate(MyExpression exp) throws MyCredentialException, MyResourceException { ... }

String processMessage(String req) throws MySyntaxException, MyResourceException {
   MyExpression exp = parseRequest(req);
   try {
       MyValue val = elaborate(exp);
       return composeResponse(val);
   } catch (MyCredentialException ce) {
       return composeErrorResponse(ce);
   }
}

如果沒有已檢測異常,想要儲存同樣的資訊,我們就需要引入專用的型別表示每種可能出現的特殊情況的函式輸出。這些型別讓我們可以儲存所有可能的情況,包括在正常情況下所生成的值。

此外,為了達到和基於型別的執行相同的層次,我們必須要擴充套件輸出型別,封裝這些型別所有可用的操作,這樣才能將所有情況都考慮在內。

Unfortunately, Java seems not to supply ready-made mechanisms for defining aggregate outcome types of this kind, that is, something like:

不幸的是,Java看起來還沒有現成的機制來定義下面這種聚合輸出型別集合:

Outcome<T, Exc1, Exc2, Exc3>

在上面的例子中,T是正常的返回值,增加的Exc1,Exc2等則是可能會出現的錯誤情況,這樣這些輸出中只有一個能夠在返回時傳遞返回值。

Java中最類似的工具就是Java 8的CompletionStage<T>,它封裝了函式可能丟擲的異常並且負責保證在檢測到異常的情況下,跳過對前置輸出的進一步操作。但是這個介面旨在啟用“一元”風格的程式碼,將異常作為與正常工作流程完全分離的計算的某一方面隱藏。因此,這個工具是為了處理那些不需要恢復的異常而設計,並不適用於自定義已檢測異常,因為已檢測異常是工作流程不可分割的一部分。因此儘管CompletionStage<T> 可以在保持其他型別異常的同時,選擇性的處理某些型別的異常,這種處理並不能在任意特定的情景下執行。

因此,如果要用CompletionStage<T>對我們之前的情況建模並保持基於型別的執行,就需要在基礎型別T中包含我們的已檢測異常同時還要保留專用的輸出型別。

堅持原生方式並引入定製化的專用輸出型別後(同時仍然利用Java 8語法的優勢),程式碼展示如下:

程式碼片段2:

class ProcessingOutcome {
   private String resp;
   private MySyntaxErrorNotif se;
   private MyResourceErrorNotif re;

   ......
}

class ParsingOutcome {
   private MyExpression exp;
   private MySyntaxErrorNotif se;

   ......

   public ElaborationOutcome applyElaboration(
           Function<MyExpression,  ElaborationOutcome> elabFun) {
       if (se != null) {
           return new ExtendedElaborationOutcome(se);
       } else {
           return elabFun.apply(exp);
       }
   }
}

class ElaborationOutcome {
   private MyValue val;
   private MyCredentialErrorNotif ce;
   private MyResourceErrorNotif re;

   ......

   public ProcessingOutcome applyProtocol(
           Function<MyValue, String> composeFun,
           Function<MyCredentialErrorNotif, String> composeErrorFun) {
       if (re != null) {
           return new ProcessingOutcome(re);
       } else if (ce != null) {
           return new ProcessingOutcome(composeErrorFun.apply(ce));
       } else {
           return new ProcessingOutcome(composeFun.apply(val));
       }
   }
}

class ExtendedElaborationOutcome extends ElaborationOutcome {
   private MySyntaxErrorNotif se;

   ......

   public ProcessingOutcome applyProtocol(
           Function<MyValue, String> composeFun,
           Function<MyCredentialErrorNotif, String> composeErrorFun) {
       if (se != null) {
           return new ProcessingOutcome(se);
       } else {
           return super.applyProtocol(composeFun, composeErrorFun);
       }
   }
}

ParsingOutcome parseRequest(String req) { ... }
String composeResponse(MyValue val) { ... }
String composeErrorResponse(MyCredentialErrorNotif ce) { ... }

ElaborationOutcome elaborate(MyExpression exp) { ... }

ProcessingOutcome processMessage(String req) {
   ParsingOutcome expOutcome = parseRequest(req);
   ElaborationOutcome valOutcome = expOutcome.applyElaboration(exp -> elaborate(exp));
   ProcessingOutcome respOutcome = valOutcome.applyProtocol(
       val -> composeResponse(val), ce -> composeErrorResponse(ce));
   return respOutcome;
}

實際上,通過比較程式碼片段1程式碼片段2我們可以看到已檢測異常這個特性實際上只是一種語法糖,旨在用前一種較短的語法重寫之後這段程式碼,同時又保留了基於型別的執行的所有優點。

不過,這個特性有一個令人討厭的問題:它只能在同步程式碼中使用。

如果在我們的流程中,即使很簡單的子任務都可能會引入非同步的API呼叫並且可能有較大的延遲,那麼我們可能不希望讓處理執行緒一直保持等待直到非同步計算完成(僅考慮效能和可擴充套件性因素)。

因此,在每個呼叫層級中,可能會在非同步API呼叫之後執行的程式碼都不得不移到回撥函式中。這樣,就無法再用程式碼片段1中的簡單遞迴結構啟用靜態異常檢測。

造成的後果就是,在非同步程式碼中,能夠保證每種錯誤情況最終會被處理的唯一方法可能只有將各種函式輸出封裝到專用的返回型別中。

幸運的是,利用Java 8 JDK,我們可以以一種能夠保留程式碼結構的方式對在流程中引入非同步性負責。例如,假設elaborate函式需要非同步處理。那麼就可以將其重寫為返回一個CompletableFuture物件,程式碼將變成:

程式碼片段3:

class ProcessingOutcome {
   private String resp;
   private MySyntaxErrorNotif se;
   private MyResourceErrorNotif re;

   ......
}

class ParsingOutcome {
   private MyExpression exp;
   private MySyntaxErrorNotif se;

   ......

   public CompletableFuture<ElaborationOutcome> applyElaboration(
           Function<MyExpression, CompletableFuture<ElaborationOutcome>> elabFun) {
       if (se != null) {
           return CompletableFuture.completedFuture(new ExtendedElaborationOutcome(se));
       } else {
           return elabFun.apply(exp);
       }
   }
}

class ElaborationOutcome {
   private MyValue val;
   private MyCredentialErrorNotif ce;
   private MyResourceErrorNotif re;

   ......

   public ProcessingOutcome applyProtocol(
           Function<MyValue, String> composeFun,
           Function<MyCredentialErrorNotif, String> composeErrorFun) {
       if (re != null) {
           return new ProcessingOutcome(re);
       } else if (ce != null) {
           return new ProcessingOutcome(composeErrorFun.apply(ce));
       } else {
           return new ProcessingOutcome(composeFun.apply(val));
       }
   }
}

class ExtendedElaborationOutcome extends ElaborationOutcome {
   private MySyntaxErrorNotif se;

   ......

   public ProcessingOutcome applyProtocol(
           Function<MyValue, String> composeFun,
           Function<MyCredentialErrorNotif, String> composeErrorFun) {
       if (se != null) {
           return new ProcessingOutcome(se);
       } else {
           return super.applyProtocol(composeFun, composeErrorFun);
       }
   }
}

ParsingOutcome parseRequest(String req) { ... }
String composeResponse(MyValue val) { ... }
String composeErrorResponse(MyCredentialErrorNotif ce) { ... }
CompletableFuture<ElaborationOutcome> elaborate(MyExpression exp) { ... }
CompletableFuture<ProcessingOutcome> processMessage(String req) {
   ParsingOutcome expOutcome = parseRequest(req);
   CompletableFuture<ElaborationOutcome> valFutOutcome = expOutcome.applyElaboration(exp -> elaborate(exp));
   CompletableFuture<ProcessingOutcome> respFutOutcome = valFutOutcome.thenApply(outcome -> outcome.applyProtocol(
           val -> composeResponse(val), ce -> composeErrorResponse(ce)));
   return respFutOutcome;
}

在引入非同步呼叫的同時保留程式碼結構是一個非常理想的功能。實際上,底層的執行到底是在同一個執行緒內還是(一次或多次)切換到不同的執行緒也許並不總是那麼重要的方面。在我們最初的自上而下的規範中,並沒有提及執行緒相關的事宜而且我們只是假設了一個比較顯而易見的效率方面的潛在需求。在這裡,適當的錯誤處理當然是更加重要的一個方面。

如果我們能夠在有底層執行緒切換的情況下保留住程式碼片段1的程式碼結構,就像我們保留程式碼片段2的結構那樣,就可能會獲得最優的程式碼展示。

換句話說,既然程式碼片段2中的程式碼可以用更加簡單的基於可檢測異常的表示形式替換,為什麼程式碼片段3中稍作變化的程式碼就不可以呢?

我們並不是說要試圖正式面對問題,也不是說可以對語言做擴充套件以支援上述情況。我們只是先討論一下如果有這樣的擴充套件該多好。

為了闡明這個問題,假設Java可以識別一個函式是非同步的但仍然是順序執行的。例如,使用如下方式編寫函式(使用一個神奇的關鍵字seq

CompletableFuture<T> seq fun(A1 a1, A2 a2) { … }

我們可以讓JVM以某種方式強制返回的CompletableFuture物件只完成一次(通過丟棄後續的虛假呼叫);這會被看作是這個函式的“正式”終止,不管實際的執行緒呼叫情況如何。

然後,編譯器將允許我們使用好像由下述簡化的簽名所定義的fun函式(用另外一個神奇的關鍵字async):

T async fun(A1 a1, A2 a2);

有了這個簽名,我們就可以像同步函式那樣呼叫這個函式,不過JVM必須要負責提取fun函式之後所有制定的程式碼,並且在“正式”終止後(如,在CompletableFuture物件完成之後)在適當的執行緒中執行這些程式碼。

這種程式碼轉換將遞迴地應用到函式呼叫棧中的所有函式之上。實際上,如果在定義一個新的函式時使用了fun函式的簡化簽名,新函式就需要強制包含async關鍵字,以表明這一函式本質上是非同步的(雖然仍是順序執行)。

另外,呼叫如下簽名的方法後,遞迴的傳遞將會終止

void async fun(A1 a1, A2 a2);

以便呼叫執行緒(可能屬於某個ExecutorService物件)可以完成其他的工作。

可以很方便地擴充套件上述假想的功能以支援已檢測異常。在實踐中,通過如下形式的函式定義:

CompletableFuture<Outcome<T, E1, E2>> seq fun(A1 a1, A2 a2) { … }

其中,Outcome是某個返回型別和異常的標準包裝器,異常可以是一個或多個,編譯器會把它看做由下述經過簡化的簽名所定義,從而允許我們使用這個函式:

T async fun(A1 a1, A2 a2) throws E1, E2;

利用這個語法,程式碼片段3的等價版本可以簡化如下:

程式碼片段4:

MyExpression parseRequest(String req) throws MySyntaxException { ... }
String composeResponse(MyValue val) { ... }
String composeErrorResponse(MyCredentialException ce) { ... }

CompletableFuture<Outcome<MyValue, MyCredentialException, MyResourceException>> seq elaborate(MyExpression exp) { ... }
/*
   equivalent to:
   MyValue async elaborate(MyExpression exp) throws MyCredentialException, MyResourceException;
*/

String async processMessage(String req) throws MySyntaxException, MyResourceException {
   MyExpression exp = parseRequest(req);
   try {
       MyValue val = elaborate(exp);
       return composeResponse(val);
   } catch (MyCredentialException ce) {
       return composeErrorResponse(ce);
   }
}

而且,在elaborate中引入非同步性的基礎上,將程式碼片段1轉化為程式碼片段4是很自然的事情。

是否有什麼其他的方法能夠達成與可用的語法能達成的相似的目標(服從合理的妥協)?

我們需要實現一種機制,通過這種機制,某個非同步呼叫之後的所有程式碼都會在這個呼叫在其所在的執行緒中產生輸出後被分割(比如,通過將其轉入一個回撥)並執行。

作為一種直觀的方法,一種可能可行的嘗試(只要每一層級的非同步呼叫數量都比較少,這種嘗試就是可行的)包含如下步驟:

  1. 首先,從工作流程的同步展示開始(如程式碼片段1所示),然後識別出可能會變成非同步的函式(在這個例子中即指:evaluate以及相應的processMessage方法本身)
  2. 如果多個可能的非同步呼叫存在於同一個函式中,就需要合理安排程式碼,可以通過引入中間函式的方式,每個函式中間僅包含一個可能的非同步呼叫,所有其他的非同步呼叫則作為返回前的最後一步操作被呼叫。(在我們的簡單示例中,不需要做任何安排)
  3. 用這樣的方式轉化程式碼,每個可能成為非同步函式並且參與了內部(inner)函式呼叫的外部(outer)函式都將會被分割為“outerBefore”和“outerAfter”兩部分。outerBefore將包含所有在內部函式之前執行的程式碼,然後呼叫內部函式作為其最後一步操作;另一方面,outerAfter則將呼叫outerBefore作為其第一個操作,然後執行全部剩餘程式碼。需要注意的是,這樣造成的後果就是outerBeforeouterAfter將共享相同的引數。在我們的示例中,將會生成如下程式碼:程式碼片段5:
    MyExpression parseRequest(String req) throws MySyntaxException { ... }
    String composeResponse(MyValue val) { ... }
    String composeErrorResponse(MyCredentialException ce) { ... }
    
    String processMessage(String req) throws MySyntaxException, MyResourceException {
       return processMessageAfter(req);
    }
    String processMessageAfter(String req) throws MySyntaxException, MyResourceException {
       try {
           MyValue val = processMessageBefore(req);
           return composeResponse(val);
       } catch (MyCredentialException ce) {
           return composeErrorResponse(ce);
       }
    }
    
    MyValue processMessageBefore(String req)
           throws MySyntaxException, MyResourceException, MyCredentialException {
       MyExpression exp = parseRequest(req);
       return elaborate(exp);
    
    }
    
    MyValue elaborate(MyExpression exp) throws MyCredentialException, MyResourceException {
       return elaborateAfter(exp);
    }
    
    MyValue elaborateAfter(MyExpression exp) throws MyCredentialException, MyResourceException { ... }
    
    ......
  4. 引入專用的類用來包含由“xxxBefore”和“xxxAfter”組成的函式對,然後用一個臨時例項呼叫任意函式對。我們的程式碼可能會擴充套件成如下形式:程式碼片段6:
    String processMessage(String req) throws MySyntaxException, MyResourceException {
       return new ProtocolHandler().processMessageAfter(req);
    }
    
    class ProtocolHandler {
    
       MyExpression parseRequest(String req) throws MySyntaxException { ... }
       String composeResponse(MyValue val) { ... }
       String composeErrorResponse(MyCredentialException ce) { ... }
    
       String processMessageAfter(String req) throws MySyntaxException, MyResourceException {
           try {
               MyValue val = processMessageBefore(req);
               return composeResponse(val);
           } catch (MyCredentialException ce) {
               return composeErrorResponse(ce);
           }
       }
    
       MyValue processMessageBefore(String req)
               throws MySyntaxException, MyResourceException, MyCredentialException {
           MyExpression exp = parseRequest(req);
           return elaborate(exp);
       }
    }
    
    MyValue elaborate(MyExpression exp) throws MyCredentialException, MyResourceException {
       return new ExpressionHandler().elaborateAfter(exp);
    }
    
    class ExpressionHandler {
       MyValue elaborateAfter(MyExpression exp) throws MyCredentialException, MyResourceException { ... }
    
       ......
    
    }
  5. 用適當的代理物件替代前一步引入的示例;代理的工作包括集合所有“xxxAfter”函式然後只在相關的“xxxBefore”函式完成後再呼叫它們(在“xxxBefore”函式完成的執行緒中)。最後這一步主要考慮將最內部函式轉換為非同步函式。最終的程式碼如下所示:程式碼片段7:
    String processMessage(String req) throws MySyntaxException, MyResourceException {
       ProtocolHandler proxy = createProxy(new ProtocolHandler());
       return proxy.processMessageAfter(req);
    }
    
    class ProtocolHandler {
    
       MyExpression parseRequest(String req) throws MySyntaxException { ... }
       String composeResponse(MyValue val) { ... }
       String composeErrorResponse(MyCredentialException ce) { ... }
       String processMessageAfter(String req) throws MySyntaxException, MyResourceException {
           try {
               MyValue val = processMessageBefore(req);
               return composeResponse(val);
           } catch (MyCredentialException ce) {
               return composeErrorResponse(ce);
           }
       }
    
       MyValue processMessageBefore(String req)
               throws MySyntaxException, MyResourceException, MyCredentialException {
           MyExpression exp = parseRequest(req);
           return elaborate(exp);
       }
    
    }
    
    MyValue elaborate(MyExpression exp) throws MyCredentialException, MyResourceException {
       ExpressionHandler proxy = createProxy(new ExpressionHandler());
       return proxy.elaborateAfter(exp);
    }
    
    class ExpressionHandler {
    
       MyValue elaborateAfter(MyExpression exp) throws MyCredentialException, MyResourceException { ... }
    
       ......
    
    }

即使涉及到的轉化全部完成之後,最終生成的程式碼作為初始規範的自然對映仍然具有較強的可讀性。

作為附註,我們認為這種方法確實可行,特別是,對於代理工作的需求是可行的,本質上來說,代理用如下方式重寫了“xxxBefore”和“xxxAfter”方法。

(讓我們考慮一下示例中ProtocolHandler的代理)

  • Proxy.processMessageAfter:[此方法必須是這個代理的首次呼叫]
    • 記錄獲取到的引數
    • 查詢上一個被呼叫的代理(如果存在)並通知它;記錄查詢到的資訊;然後將當前代理設定為最後一個被呼叫的代理;
    • 用獲取到的引數呼叫ProtocolHandler.processMessageBefore
    • 如果某一方法已經呼叫了下一個代理並且傳送通知,不再做任何事情;
    • 否則同步終止該方法;呼叫onCompleted (如下所示)並將方法的結果傳遞給它。
  • Proxy.processMessageBefore:[必須要從ProtocolHandler.processMessageAfter內部呼叫此方法,這樣我們就會在onCompleted 方法內(如下所示)並且方法的結果也會被保留]
    • 回放儲存的輸出結果

除此之外:

  • Proxy.onCompleted:
    • 記錄作為引數獲取的方法結果;
    • 將當前方法設定為被呼叫的最後一個代理;
    • 用呼叫Proxy.processMessageAfter時獲取並儲存的引數呼叫ProtocolHandler.processMessageAfter方法;
    • 如果某一方法已經呼叫了下一個代理並且釋出通知,就不再做任何事情;不過,需要注意的是,要通知下一個代理它的前置代理並不是當前方法,而是當前方法的前置代理。
    • 其他情況下,這個方法將同步終止;如果有前置代理,則呼叫前置代理的onCompleted方法並將當前方法的輸出作為引數傳入。

以上只是一個不完全的概括。

我們試圖用這些理念用來創造一個完整的解決方案。目前的階段性成果是可以應用於具體場景的一種實驗性技術。

這一預想的技術隱含著在易用性方面的許多妥協,這可能會限制其在有限的一些場景下的吸引力。在我們的場景中,已經證明我們在這種技術上所花費的努力是值得的。

感興趣的讀者可以從這裡找到關於我們的技術的詳細介紹,除此之外還包含一個對易用性利弊的全面討論。

相關文章