如何应用服务器端的防御式编程

在我们平时的业务开发中,我们一定不想写出 Bug,都想尽全力写出高质量、没有缺陷的代码,然后让代码稳定地运行在线上环境。那么,你知道在业务代码中面对大量的 null,什么场景下应该走判断逻辑,什么场景下应该抛出异常吗?你是怎样尽可能地保证开发逻辑是符合预期的,如果有 Bug,怎样才能最快发现,而不是依赖于测试呢?防御式编程就是解决这些问题的利剑,下面我们就来看下这两个问题的具体解决方法。

我们先来看第一个问题,关于 null 的处理。我自己见过两例因为对 null 处理不当,导致系统大规模 crash 给公司业务造成损失的案例。在日常高强度的开发工作中,我们几乎每一个方法甚至每隔两行都要处理一个 null。这样高强度的处理频率让我们不得不重新审视:自己的代码到底对不对,我对 null 的处理真的到位吗?

首先 null 可以是一个有意义的值,也可以是一个业务上正确合理的返回。我以微信的设置为例子,我们可以选择不看别人的朋友圈,或者不让别人看我们的朋友圈。这是 privacy setting。隐私设置这个 entity,如果用户不设置,那么从数据库取出就是 null,我们可以用 if != null 来判断;再比如,如果用户从来没购买过任何商品,他的订单列表页就是空的,也可以用 null 来判断,这都很合理。

那什么场景下处理 null 才抛出异常呢?我们来看这样一个例子:有关联关系的一组数据写入。例如一个事务,先写 A 表,再写 B 表。在读取数据的时候,先查出 A,再用 A 的 id 去查 B,这时候如果判断 B !=null,就是错误的写法。我见过太多这种错误的写法,这种代码实际上是用一种错误的"保护"吃掉了 Bug,非常危险。代码看起来有 !=null 保护,其实是把潜在的数据不一致问题吃掉了,而数据不一致造成的 Bug,将会在另一些场景下暴露出来。你可以看看这段代码片段:

python 复制代码
A a = aDao.get(aId);
B b = bDao.get(a.getBid());
// 错误的代码,这种写法会把数据不一致的bug吃掉,程序不会报错,没有异常抛出
if (b != null) {
  //做一些业务逻辑
}
// 正确的代码
if (b == null) {
  throw new NullPointerException("b is null");
}
// 在实际工作中,我们多用封装好的类库,比如google guava中的Preconditions
// checkNotNull里其实也是抛出NullPointerException
Preconditions.checkNotNull(b);

在没有 Bug 的前提下,A 表和 B 表的数据同时存在,数据是一致的,这也是开发者写代码的本意。所以根据 A 的 id 取出 B,如果 B 为 null,说明有 Bug,可能的原因是事务没起作用或者写 B 表之前有其他问题引发了 crash 导致 B 没写入。这种情况,我们应该抛出 NullPointerException 而不是用 if B != null 吃掉 Bug,这一点你一定要警惕。

我在一家创业公司工作的时候,CTO 团队来自 Google,他们对代码 review 非常苛刻。当时我自己的代码就会出现我们刚才说的问题,"用 if 吃掉了 bug"。CTO 在 review 意见中说"if it is null,let it crash"证明你的代码出现了 Bug,数据已经不一致了。

现在我们解决了第一个问题,紧接着来了解第二个问题,如果有 Bug,怎样才能最快发现,而不是依赖于测试?

一个有效的解决方案就是对程序逻辑进行自检,这是什么意思呢?

在防御式编程中,有一个重要的概念是对程序逻辑进行自检,就是 self check。可能你看过一些防御式编程的资料提到自检,但是并没有案例。这个应该怎么理解呢?

自检是在程序代码中的某段逻辑中,按照正确的预期检查函数的返回。为了更好地理解这个概念,我们举个例子来说明。

比如有一个大的 Task 表 (总任务表),里面有总任务信息,还有标记总任务完成状态的字段。跟 Task 关联的是 SubTask 表,相当于 Task 的子任务表,Task 和 SubTask 是一对多的关系。

一个 Task 所关联的 SubTask 全部完成后,整个 Task 才算最终完成(标记 Task 状态为已完成)。所以,当我们读取出 Task 表的记录,发现 Task 状态变成已完成,在逻辑上又需要根据 taskId 取 SubTask 表记录进行处理的时候,我们就可以根据预期遍历 SubTask,检查每个元素都必须是已完成。如果某个 SubTask 不是已完成,就说明发生了 Bug,程序在之前的处理逻辑中有缺陷。导致 SubTask 中有记录还没完成,但是整个 Task 确被标记成已完成。这就是自检。

python 复制代码
Task task = taskService.getTask(taskId);
Preconditions.checkNotNull(task);
if (task.isCompleted()) {
  List<SubTask> subTasks = taskService.getSubTasks(taskId);
  // 自检,因为task状态是completed,那么按照正确的预期,必定有子任务
  // 如果此处抛出异常,证明入库逻辑有bug,造成数据不一致
  // 此处用google guava提供的Preconditions类库,其实是会抛出IllegalStateException
  Preconditions.checkState(!Collections.isEmpty(subTasks));
  for (SubTask subTask : subTasks) {
    //自检,因为总任务已完成,如果代码符合预期,每个subTask必定已完成
    Preconditions.checkState(subTask.isCompleted());
    //其他业务逻辑
  }
}

再举个例子,我们在电商网站下单的时候,一个订单里可以包含多个商品,可以分批退款。这时候订单状态主要有支付成功,部分退款,全部退款(我们忽略其他状态)。同时存储上还记录了购买数量,退款数量。在逻辑编写正确的前提下,如果订单当前状态是全部退款,那么购买数量必然等于退款数量。这时我们可以自检,如果抛出异常,说明退款逻辑有 Bug,你就需要定位 Bug 了。

python 复制代码
Order order = orderService.getOrder(orderId);
Asserts.assertNotNull("订单 id 不存在:" + orderId, order);
if (order.isFullyRefunded()) {
   // 自检,全部退款,购买数量必然等于购买数量
  Preconditions.checkState(order.getPurchaseCnt() == order.getRefundedCnt());
}

这里需要注意的是,自检一旦发生失败,也就是抛出 Exception,说明你的系统有逻辑上的缺陷,数据已经不一致了。要知道有问题和 Bug,迟早会被发现,你也不要试图去"保护"这些 Bug。所以,使用自检的代码,使不符合预期的程序无处遁形,让我们立刻定位和修复 Bug,让系统变得越来越好。

尤其是现在很多互联网公司,大多业务代码都是基于微服务架构的。在这样的体系下,很多同学在处理业务逻辑的时候,总是出于这样那样的"担心"和"顾虑",对一些事实上必须存在的数据加 if 保护。这种做法看起来没有问题,至少不会抛出系统异常,可一旦发生 Bug,比如测试环境没有测出来的 Bug,就会非常难以排查。因为在业务上并没有 crash 的日志,一般都会由用户投诉或者运营反馈出来。

好,今天的分享到这里就结束了,最后我来给你总结一下。在这一讲,我们首先讲解了服务器端开发过程中关于 null 的正确处理方式。然后,我们一起学习了如何对业务逻辑进行正确的自检,以及处理自检的时机。这两个部分的关键点,我做了一张思维导图,可以供你参考。你可以牢记一个宗旨,就是有问题的代码,一定会出问题,区别只是在什么场景下被谁发现而已。而对代码进行自检,可以让程序在测试阶段最大程度暴露出 Bug(如果有),让系统更健康,让应用数据更干净,不被写脏。同时,我也希望你能理解防御式编程的要义,能够运用在自己平时的工作中,让自己的系统更加健壮和健康。

相关推荐
我爱娃哈哈4 分钟前
微服务拆分粒度,拆得太细还是太粗?一线架构师实战指南!
后端·微服务
终是蝶衣梦晓楼7 分钟前
HiC-Pro Manual
java·开发语言·算法
泉城老铁13 分钟前
EasyPoi实现百万级数据导出的性能优化方案
java·后端·excel
斜月14 分钟前
Spring 自动装配原理即IOC创建流程
spring boot·后端·spring
贰拾wan23 分钟前
抛出自定义异常
java
weisian15127 分钟前
Prometheus-3--Prometheus是怎么抓取Java应用,Redis中间件,服务器环境的指标的?
java·redis·prometheus
界面开发小八哥28 分钟前
「Java EE开发指南」如何用MyEclipse创建企业应用项目?(二)
java·ide·java-ee·开发工具·myeclipse
有追求的开发者29 分钟前
基于Django和APScheduler的轻量级异步任务调度系统
后端
CF14年老兵30 分钟前
📝 如何在 MySQL 中创建存储过程:从基础到实战
java·sql·trae
泉城老铁33 分钟前
Spring Boot 整合 EasyPoi 实现复杂多级表头 Excel 导出的完整方案
java·后端·excel