Java 设计了一套久经考验的异常处理机制,为开发高质量的程序提供了可靠保障。但是随着现在软件日趋复杂,特别是异步编 程的发展,语法级的异常处理机制已经不足使用。
我模仿 Scala 的 Try 类型,为 Java 编写了一组 Try 和 Tuple 方法,用于简化复杂应用逻辑中的异常管理和数据传递。 根据最新的 Java 21 和目前仍在广泛使用的 Java 8,分别在 jaskell-rocks 和 jaskell-java8 工具库中实现。
首先,这些工具并非内置异常语法的替代品,相反,它们依附于 try/catch 机制上,辅助我们更方便的使用异常管理。因此, 介绍它们的时候,也会涉异常机制的知识运用。
异常语法的局限
异常语法的限制往往不来自关键字本身,而是来自 Java 标准库。例如,stream api中通常只接受不抛出异常的函数对象,
但是在日常的操作中,异常是非常常见的,例如我们如果需要对一个 List<String>
中的每一个字符串做 json 解析,
不可避免的要面对可能出错的情况。那么 Stream 的 map 方法就很难满足要求。如果我们在 lambda 中明确的捕获异常,
这个代码块就会多出好几行。如果应用项目里经常要做类似的操作,那么这种高度重复的 try catch会遍地都是,而且难以
抽象。有些 JSON 库会吃掉这种错误直接返回 null。要我说这也不是很好的策略,至少不应该只有这么一种策略。我希望
将异常处理延后到某一个时刻,而不是在表达业务逻辑的代码中散落的到处都是。
与此类似的是,异步任务的各种抽象,例如各种 Future 类型,也需要无异常的代码块。在这些场景,能携带正常或异常数 据的 Try 封装,成为了一种可行的方案。
Scala 的 for yield
首先,在介绍 Jaskell Try 类型之前,我们先看一下启发了这组设计的原型,Scala 强大的 for yield 语法:
for {
a <- self(s)
b <- p(s)
} yield f(a, b))
这个例子来自 jaskell core 项目,它很简单,但是展示了 for yield 的重要特性。
首先,对于所有实现了 map 或 flatMap 方法,或者 iterator trait 的对象,可 以自动的经由 <- 运算符映射到左边的变量,然后这些变量能够被 yield 子句的代码 引用。这其中隐含的逻辑,相当于对 for 表达式中的各个引用项聚合为一个 tuple, 再使其传递到 yield 子句(其中还包含了 tuple 的隐式拆箱,或者类似 c sharp 的 var 类型的能力)。
当然我们在 java 的语法限制下,无法做到完美的复刻这种强大的能力,但是可以借助 一组 tuple,有限的模仿。
在这里讨论 for yield 和 tuple ,似乎有些跑题,但是这种 for yield 能力是 我非常关注的能力,它们为 Try 类型提供了实用价值,使其从我最初只是出于形式一 致性开始的基础工具设计实验,在多年后有了让我满意的实用性。
Try 和 Triable
我们现在着重介绍一下 jaskell-rocks 项目中的 Try 类型。它充分利用了 java 21 的新语法。而 jaskell-java8 的版本,基本上就是对应的朴素的 POJO 版本。
首先,我们定义 Try<T>
接口:
public sealed interface Try<T> permits Failure, Success {
T get() throws Exception;
boolean isOk();
boolean isErr();
// ...
}
这里仅给出了最基本的三个方法,其它工具方法我们在后续的内容中介绍。
从定义我们可以看到,Try 是一个封闭接口,它只有 Failure 和 Success 两个实现。下面是 Success 类型:
public record Success<T>(T item) implements Try<T> {
@Override
public T get() throws Exception {
return item;
}
@Override
public boolean isOk() {
return true;
}
@Override
public boolean isErr() {
return false;
}
// ...
}
和 Failure 类型:
public record Failure<T>(Exception err) implements Try<T> {
@Override
public T get() throws Exception {
throw err;
}
@Override
public boolean isOk() {
return false;
}
@Override
public boolean isErr() {
return true;
}
// ...
}
同样,我们暂且只给出最基础的三个方法。
这三个方法定义了 Try 的基本内核。它是可以携带异常状态的数据封装。因此,Success 和 Failure 对应的取值方法,分别只是 简单的返回包装值或者抛出包装的异常。对应的逻辑判定也是非常简单的固定逻辑。
注意观察,get 方法的声明提示了一个重要的问题,它必须声明为 throws Exception 。而我设计 Try 时,一个重要的动机就是 尽量少的显式处理异常,把异常处理集中在必要的位置。
所以,我们有必要设计一组方法,使我们可以不脱离 try 环境的前提下,执行正常的逻辑代码,允许它们抛出异常。
首先,我们定义一个不同于标准库的 Supplier<T>
抽象:
public interface Supplier<T> extends Triable<T> {
T get() throws Exception;
@Override
default Try<T> collect() {
try {
return Try.success(get());
} catch (Exception err) {
return Try.failure(err);
}
}
}
这个抽象和 java.util.function.Supplier<T>
一样,也有一个 get 方法,但是它们的关键不同在
于,这个 get 带有 throws Exception
签名。
作为一个 SAM 类型,这个方法允许我们任意传递可能抛出异常的代码,因为实际使用时,我们尽量不直接调用 它,而是调用无异常抛出的 collect 方法,它总是返回已经求值的 Try 类型。
读者可能注意到,我们在这里还引用了一个称为 Triable<T>
的 interface:
public interface Triable<T> {
Try<T> collect();
// ...
}
和 Try 一样,Triable 也有大量的工具方法,但是它的核心只有这么一个抽象方法,其它都是基于它实 现(是的,都有 default 实现)的。
回到 Try,有了能够自动处理异常的 Supplier,我们可以为 Try 添加一个静态的构造方法:
static <T> Try<T> tryIt(Supplier<? extends T> supplier) {
try {
return Try.success(supplier.get());
} catch (Exception err) {
return Try.failure(err);
}
}
于是,我们可以安全的使用可能抛出异常的代码作为lambda:
Try<Map<String, Object>> result = Try.tryit(() -> objectMapper.readValue(content));
这行代码简单的调用了 jackson 的 ObjectMapper 解析 json 字符串,转为对象字典。至于异常,那不是 现在需要担心的事。即使它失败了,那也合法的呆在 result 里呢,让下游代码去操心吧。
说到这个,我在写这篇文章的时候,才想来我还没有实现过 stream 方法。是的,我用 Try 已经很长时间了, 大规模改造它,并深度的在工作使用,也有了差不多三周,但是,处处不允许出现异常的 Stream,已经不那么 重要了。
只要有了类似 Supplier 的,可以管理异常的 Function,我完全可以自己实现一套对应的函数式抽象。于是,
我实现了 jaskell 版本的 Function<T, U>
:
public interface Function<T, U> {
U apply(T arg) throws Exception;
default Try<U> collect(T arg) {
try {
return Try.success(apply(arg));
} catch (Exception err) {
return Try.failure(err);
}
}
default <R> Function<T, R> andThen(Function<? super U, ? extends R> other) {
return (T arg)-> other.apply(apply(arg));
}
}
现在,我们可以任性的定义 map 和 flatMap:
<U> Try<U> map(Function<T, U> mapper);
<U> Try<U> flatMap(Function<? super T, Try<U>> mapper);
Success 的实现:
@Override
public <U> Try<U> map(Function<T, U> mapper) {
try {
return new Success<>(mapper.apply(item));
} catch (Exception err) {
return new Failure<>(err);
}
}
@Override
public <U> Try<U> flatMap(Function<? super T, Try<U>> mapper) {
try {
return mapper.apply(item);
} catch (Exception e) {
return Try.failure(e);
}
}
Failure 的版本比较简单,直接返回异常就可以:
@Override
public <U> Try<U> map(Function<T, U> mapper) {
return new Failure<>(err);
}
@Override
public <U> Try<U> flatMap(Function<? super T, Try<U>> mapper) {
return new Failure<>(err);
}
其它类似的方法,例如 filter 和 recover ,就不在这里讨论了,它们的实现都比较朴素。而下面介绍的这些功 能,对 Try 类型来说更为重要。
如前所述,for yield 语法一个非常重要的能力是将 monad 中的值 destruct 之后传递到 yield 中继续求值 和封装。这个工作在 jaskell 中,我是通过 try 类型的 join map/flatMap 和 tuple 类型实现的。
我们假设有这样一种场景,用户用纯文字的方式提交一个地址,我们的程序从中提取出省、市等信息(类似快递系统常见的
智能地址簿),这有可能成功,也有可能失败,而我们的下一步流程是需要将这些地址信息发送到当前环境中的
Try<Order> post(String provinces, String city, String street, String house)
。
class Address {
private String origin;
public Address(String origin) {
this.origin = origin;
}
public Try<String> provinces() {
// ...
}
public Try<String> city() {
// ...
}
public Try<String> street() {
// ...
}
public Try<String> house() {
// ...
}
}
那么,使用 Try 类型的 joinFlatMap 就是:
Try.joinFlatMap4(address.provinces(), address.city(), address.street(), address.house(),
this::post);
如果其中某一个try求值失败,会直接返回一个 failure,只有四个参数都成功,才会执行 post。
如果我们需要将它们一起传递给下一步流程处理,那么可以使用 tuple 的 liftA 操作:
return Tuple4.liftA(address.provinces(), address.city(), address.street(), address.house())
如果传入的 Try 对象都成功,那么 liftA 返回 Success<Tuple4<T, U, V, W>>
类型对象,否则会返回第一个得
到的 failure。这符合我们使用 try catch 语法时的习惯,它就是在第一个异常发生时中断并抛出。但是从形式上,我
将异常处理和业务代码分离开,不必在每一个函数调用时就关注它的异常。
类似的,如果我们有一个 Tuple4<String, String, String, String> tuple4
,也可以
tuple4.uncurry(this::post)
有时候,我们需要在 Try map/flatMap 中传递多个不同的 Triable 求值结果,Tuple 可以帮助我们很方 便的传递成组的信息。很奇怪的是,如此常用的功能,一直没有进入标准库,Apache Commons 有 Pair 和 TripleTuple ,有很多同行也实现了自己的 tuple 或 pair。我从我自己的工程经验出发,做了 tuple2 到 tuple8 共7个类型,目前看它们用作传递变量是足够了。
因为 Success 和 Failure 是两个 record,因此我们可以充分利用 java 21的模式匹配:
return switch(result){
case Success(var result) -> {
log.info("...");
yield post(result)
case Failure(var error) -> {
log.error("...", error);
rolback(....)
yield ...
}
}
注意事项
由于 Try 包装了 try catch 过程,如果我们可以忽略异常处理,程序也会正常运行,但这是有危险的,有可 能我们会因此错过发现问题的机会。我自己的业务代码中也遇到过这样的问题,在核查一个记账结果时,我发现 两份账目一直无法对齐,进一步检查发现某个返回 Try 的数据库写入一直失败,但是我没有用到那个 save 操作的返回值,也就没有发现它其实是 failure 。
Try 类型是用来简化异常捕获管理的,并不是用来替代 try catch,如果遇到用 try catch 更为有效的情况, 那么仍然应该使用 try catch。而 try 语法还兼顾资源自动回收和 finally 操作,这些因为作用域的关系, 在 Try 类型中实现抽象并不容易,目前还没有做到。