在異步Java代碼中解救已檢測異常

jopen 9年前發布 | 14K 次閱讀 Java Java開發

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方法并將當前方法的輸出作為參數傳入。

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

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

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

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

來源:InfoQ - 叢一

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!