在异步Java代码中解救已检测异常

jopen 9年前

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 - 丛一