写点什么

如何优雅的处理错误逻辑

  • 2022 年 2 月 15 日
  • 本文字数:3653 字

    阅读完需:约 12 分钟

如何优雅的处理错误逻辑

程序的健壮性

程序在运行的时候总是不可避免地遇到各种错误。这些错误有一些是包含在原有的逻辑判断中的。而有一些是被程序描述了,但是我们并不认为它是正常逻辑的一部分。不论是什么形式的问题,我们在进行我们预期的业务逻辑编程的同时,不可避免地要为程序的异常进行编程。

为了在异常中保护程序的流程的正确性,以至于不会出现“过分”的功能不可用,我们要为了异常编程。从而保证了程序的健壮性。

一般来说,如果我们能覆盖系统所有的异常,那么我们的程序就将变得十分的坚不可摧。显然,这时候程序的健壮性就得到了体现。

不可放弃的可读性

程序的健壮性是十分的必要的,因为这关系到系统的稳定性。所以,系统的健壮性是我们不能抛弃的。但不得不说的是,如果我们只关心系统的健壮性,那我们很容易就会让代码变得十分的糟糕,难以阅读。

举个例子,不论是不是出自自己的手里,我们有时候会看见这种代码:

A a = getA();if(a.status != xxx ){  B b = a.getB();  if(b.status != xxx ){    doSomething(b);  }}
复制代码

从程序的角度上判断,当程序的返回结果不满足主要流程的需要的时候,我们就不执行我们的逻辑。从程序的健壮性的角度上来说,上面的这部分代码已经满足了业务的需要。但是他有什么问题呢?如果我们将状态的判断去除,就可以简化为以下逻辑:

A a = getA();B b = a.getB();doSomething(b);
复制代码

这个逻辑就非常清晰了,分为三个部分:获取变量 a,然后获取 a 的属性 b,然后根据 b 进行某种操作。尽管上下两段代码在正常流程上的处理是一样的,而上面的代码更是对异常流程进行了处理。但是我们很容易就能发现其中的问题:

不优雅的处理异常逻辑,会导致程序变得难以阅读。

尽管我们处理了异常,但是如果代价是让我们的代码逻辑混乱难以阅读。那么我认为这就不是一个的处理方式。

优雅的处理错误逻辑

我们希望在保证不损失程序可读性的同时保证系统的健壮性。而一些编程的准则能帮助我们达到目标。

使用异常

我们有很多重的方法来描述程序是否满足我们的预期。比如上文中的第一个例子,我们是通过 A、B 对象中的状态字段来体现逻辑是否按照我们的预期来实现的。这个例子里面的字段并不是那么合理,但有时这种处理的方式是因为对象本身的属性包含这种描述状态的字段,而我们就直接复用了这些字段用于在后续逻辑中对非期望数据进行处理。但是这样就存在问题:

  1. 我们将处理异常的逻辑和处理正常逻辑的代码混合编排。

  2. 由于状态分散在不同的对象中,我们需要在对象被使用前为每个对象进行判断。

第一个问题导致代码的可读性变得比较差。第二个问题导致到进行上有编码的时候,会依赖下层的细节。如果编码人不知道下层异常逻辑,则会错过对异常的处理。

而如果我们用异常来处理则代码就可以变成:

try{  A a = getA();  B b = a.getB();    doSomething(b);  } catch (AStatusException e) {    e.printStackTrace();  } catch (BStatusException e) {    e.printStackTrace();  } catch (Exception e) {    e.printStackTrace();}
复制代码

可以看到这种处理失败流程的方式可以可以让“正常业务逻辑”保持完整,其中 try 中包裹的部分和上文中不做异常判断的部分是一模一样的,所以:

推荐使用异常(Exception)代替失败状态,可以保证正常业务逻辑的完整性。

进一步的,在《如何写好一个方法》一文中,我们提到:可以将异常单独地封装为一个方法,从而让一个方法中只关注一件事,所以我们可以调整如下:

try{  tryDoSomething();} catch (AStatusException e) {  e.printStackTrace();} catch (BStatusException e) {  e.printStackTrace();} catch (Exception e) {  e.printStackTrace();}
...
private void tryDoSomething(){  A a = getA();  B b = a.getB();  doSomething(b);}
复制代码

这样调整之后,我们就可以将处理异常的部分,单独剥离出来。这样,如果我们只关心正常业务逻辑,就只关心 tryDoSomething()就可以了。

封装异常

观察上一小节中的代码,我们可以发现对于 tryDoSomething()的方法,我们进行了两种指定类型的 catch 操作,但是在当前代码中,这两个类型的 catch 的处理动作逻辑并没有差别,也就是说当前的业务逻辑并不关心异常的类型。而进一步地说,异常的类型实际上是底层方法的实现细节。所以,就会出现一个相互排斥的问题:

  1. 底层实现(或者公共 API)希望提供足够信息的异常场景信息。

  2. 上层实现并不对所有的异常信息关心。

针对这两种情况,我们可以通过使用异常进行封装的方法来对下层的异常进行抽象,从而对上层调用屏蔽细节,方法如下:

try{  tryDoSomethingWithSameException();} catch (StatusException e) {  e.printStackTrace();}...
private void tryDoSomethingWithSameException(){  try{    tryDoSomething();  } catch (AStatusException e) {    throw new StatusException(e);  } catch (BStatusException e) {    throw new StatusException(e);  } catch (Exception e) {    throw new StatusException(e);  }}
复制代码

tryDoSomethingWithSameException()这个方法可能是在一个单独的代理类中定义的,或者是通过其他方式定义的,但是总的来说,通过使用 tryDoSomethingWithSameException()方法,我们在最外层实际调用的时候就只用关心 StatusException 的这个方法就可以了。

同时,由于使用了 tryDoSomethingWithSameException() 方法,如果当我们调整 tryDoSomething();中的业务逻辑而产生新的异常的时候,我们就不需要调整主业务逻辑的文件了,而只用调整异常封装类就可以了,就让我们可以更少的修改业务主流程。

非受检异常

你会发现,在上文的方法中无论是 tryDoSomething(),还是 tryDoSomethingWithSameException() 我们都没有使用 throw。也就是说,我们使用的是“非受检异常”。那么如果如果我们使用“受检异常”会怎么样呢,代码会变成这样:

private void tryDoSomething() throws AStatusException, BStatusException {  A a = getA();  B b = a.getB();  doSomething(b);}
复制代码

在方法上需要对方法内抛出的异常进行定义。或许有的人认为这种方式十分好,因为足够明确,一眼就知道会出现什么异常。并且在上层进行使用的时候我们也可以直观地知道方法可能出现的异常。

但是这种方式的优点也同样成为了缺点,因为异常的描述直接变成了方法签名中的一部分。而且由于是受检异常,所以会逐级地向上传递,直到上层那里进行了捕获处理。也就是说,如果不想在当前方法中处理异常的话,就要将异常添加到方法签名上。从而使得调整一个底层逻辑新增一个异常的时候,会导致所有调用该方法的方法都需要进行调整,而这显然是不符合开闭原则的。

当然,对于一下关键的逻辑,你可能会需要让开发人员明确地知道可能会存在的异常。但是对于更多的一般情况,非受检异常的使用,会更适合代码的可维护性。

特殊对象代替异常

当我们尝试获取一个列表的时候,可能会使用如下的方法:

List<File> files = getFileByPath("xxx");
复制代码

我们会通过 getFileByPath()尝试获取文件列表。那么针对“xxx”的这个变量,如果他不是一个有效的路径,那就有可能存在异常逻辑。我们可以通过抛出一个"FileNotFoundException"的异常来描述这种的异常情况,但这就需要上层对异常逻辑进行处理,这回导致增加额外的逻辑。

所以,当上层对于下层的异常不敏感的时候,我们可以调整数据的返回值,让他成为一种不会影响业务逻辑的特别返回值,从而减少整体的业务维护代码。

以本节的例子举例,就是当异常的时候,在方法内部进行捕获,然后使用"Collections.emptyList()"返回一个空的列表。这样后续的处理逻辑就可以正常执行。当然要保证这样的处理和你的业务是吻合的。

出入不欢迎 null

不要用 null!不要用 null!不要用 null!

不论出入都不用用 null 作为入参或者出参。原因很简单,一旦你的代码中中出现了返回 null 的代码风格,那么你所有的处理逻辑中都要对 null 做出判断。即便 java 可以使用 Optional 简化连续为空的处理,但这是给自己增加没有必要的工作量。假如本文中第一个例子的返回值可能为空,那么这个代码就是有问题的,因为几个判断里面都可能会抛出"NullPointerException"而本方法中没有捕获,上层要是也没有的话,这次逻辑执行就会直接以失败告终。但是如果对 null 进行判断,代码就会变成如下:

A a = getA();if(a != null && a.status != xxx ){  B b = a.getB();if(b != null && b.status != xxx ){  doSomething(b);}
复制代码

但起来没有多少增加的逻辑,但是要知道当所有的判断中都需要处理这个情况的时候,就很可怕了。更可怕的是如果你忘记了其中的一个判断,代码就会在下一次的时候从不知道哪里抛出一个"NullPointerException"。

所以,为了减少不必要的业务逻辑维护,不要让 null 成为你“正常逻辑”中的一种返回。

最后

在进行业务编码的过程中,我们不可避免地需要处理正常逻辑之外的异常逻辑。但如果不处理好异常逻辑,那么异常逻辑的维护就会侵占正常逻辑的位置,让系统整体的理解成本增高。所以,优雅的处理异常可以让系统的可维护性大大提高。


发布于: 刚刚阅读数: 2
用户头像

人肉bug制造机 2020.06.26 加入

欢迎关注同名公众号!

评论

发布
暂无评论
如何优雅的处理错误逻辑