本文作者结合实际工作中的一些应用经验,来全面解析一下防御性编程。
本文主要内容:
- 什么是防御性编程
- 防御性编程的重要性
- 输入检查
- 断言的应用
- 错误处理
- 隔离
- 防御策略及建议
1. 什么是防御性编程
关于防御性编程,这一概念开始来自于汽车的防御性驾驶技术,意为你永远无法确定另一位司机将要做什么,才能确保他人做出危险动作时不会伤害到你。
防御性编程应用过程中,并不是指让你从保护自身,对他人持有“批判或攻击”的态度,而是将保护的意识落地到自身程序上,通过一些防御手段让你的代码程序不因传入的错误数据而出错崩溃。大家通常会说,“代码有问题很正常的呀”,的确是这样,那更应该在编写程序的时候提高防御性的重要性,尤其核心程序能力,做好程序错误影响的包容性。
2. 防御性编程的重要性
随着目前互联网已渗透到各行各业,每个细微的风险问题都可能会被放大,足以影响整个行业。
- 1996年6月4日,欧洲航天局的 Ariane 5 Flight 501 在起飞后 40 秒被引爆。因为导航软件里的一个 bug,这个价值 10 亿美金的运载火箭不得不自毁。
- 2019年1月,拼多多被爆出现重大BUG,用户可领100元无门槛券,造成大批用户开始‘薅羊毛’,一晚上200多亿都是话费充值。
- 2019年5月时候,部分用户反映其支付宝出现网络故障,账号无法登录或支付。支付宝官方表示,该故障是由于杭州市萧山区某地光纤被挖断导致,这一事件造成部分用户无法使用支付宝。
系统服务的稳定性对于企业来说非常重要,不仅仅会对企业带来直接的经济损失,甚至会对行业、人们的生活造成非常严重的影响。
3. 输入检查
在学习编码的时候,估计大家都听过“不要相信用户的输入”,指的就是对用户输入做检查的必要性。谈到输入,常见Web开发主要包括以下两个方面:
3.1 检查所有来自系统外部的数据
在系统建设过程中,我们经常会需要跟外部系统做数据交互处理,这里包括:文件、接口、消息队列、表单用户输入等等,对于来自系统外部输入的数据内容,我们需要明确做到:
- 数据格式是否准确
- 数据类型是否准确
- 数据长度是否准确
对数据做预期准确性检查,保证输入数据在我们程序的可接受范围以内。其实,所有的安全问题的本质都是信任的问题。数据检查,这个跟车站、机场的安全检查相似。通过一个安全检查(过滤,净化)的过程,可以梳理未知的人或物,使其变得可信任。被划分出来的具有不同信任级别的区域,我们称为信任域,划分两个不同信任域之间的边界,我们称之为信任边界。对于异常数据处理情况,做好防御检查的,同时需要做好日志记录,以防追后账呢,哈哈~
3.2 检查接口API的参数值
对于系统内部接口API请求,需要检查程序的输入参数的值。这个跟检查来自外部系统的数据一样。
public class CommonRequest {
@NotBlank(message="参数str不能为空")
private String str;
@NotNull(message = "参数i不能为空")
private Integer i;
@Min(value =0,message = "最小值不能小于0")
private int min;
@Max(value=100,message = "最大值不能大约100")
private int max;
}
通常情况下,需要验证如下几项:
- 字段必传和非必传
- 字段类型是否一致
- 参数值是否合法
- 长度是否符合要求
对于接口参数/字段异常情况,大家可以按照以下思路来验证问题:
- Q1:如果参数缺失或者漏传,会有默认值么?
- Q2:如果参数问题,业务逻辑会发生哪些不合理的情况?
- Q3:字段缺失、不合法情况,对于写操作,是否会造成垃圾数据的产生?
注意:补充一个关键情况,需要结合业务场景来评估可能的影响范围。
必要情况,设置白名单而不是黑名单。
举个栗子,在你设置图像扩展名的时候,不要设置无效的类型,而是检查有效的类型并排除其他类型。在 PHP 有无数的开源校验库,让你的工作更简单。
要记住:进攻是最好的防守。总而言之,不要将代码外部的函数调用或方法调用想得太过美好。请确保你调用外部的API和库之前理解并测试了错误。
4. 断言的应用
4.1 何谓断言?
所谓断言,是指在开发期间使用的,让程序在运行时进行自检的代码。通常是一个子程序或者宏。
断言的目的为了表示与验证软件开发者预期的结果,当程序执行到断言的位置时,对应的断言应该为真;若断言不为真时,程序会中止执行,并给出错误信息。
举个例子:
如果系统假定一份数据信息文件所包含的记录数不超过20000,那么程序中可以设置一个断定记录数<=20000 的断言。只要 记录数<= 20000,这一断言都不会触发,然而一旦记录数超过20000,它就会断言程序存在一个错误。
4.2 断言的形式
断言可以有两种形式:
- assert Expression1
- assert Expression1:Expression2
其中 Expression1 应该总是一个布尔值,Expression2是断言失败时输出的失败消息的字符串。
如果Expression1为假,则抛出一个 AssertionError,这是一个错误,而不是一个异常,也就是说是一个不可控制异常(unchecked Exception),AssertionError由于是错误,所以可以不捕获,但不推荐这样做,因为那样会使你的系统进入不稳定状态。
public class TestAssert{undefined
public static void main(String[] args){undefined
String name = "abner chai";
//String name = null;
assert (name!=null):"变量name为空null";
System.out.println(name);
}
}
5. 错误处理
根据前面的介绍,断言可以用于处理代码中不应该发生的错误,那又该如何处理那些预料中可能要发生的错误呢?
异常和错误处理是防御性编程的一个组成部分。
想象一下,启动了一个异步操作,运行并输出结果,没有异常,这是一个理想的情况。如果在执行过程中发生错误怎么办?与任何未处理的异常一样,应用程序通常会崩溃。假设任何异步操作都会成功运行而没有任何错误,那么可能会失败。
高级语言中一般会采用try catch方式捕获异常处理,如下示例:
try {
//逻辑代码
} catch (exception e){
//异常处理代码
}
try{
//逻辑代码
} finally {
//一定要执行的代码
}
try {
//逻辑代码
} catch (exception e){
//异常处理代码
} finally{
//一定要执行的代码
}
而Golang的错误处理规范也是Go语言的最大亮点之一。
- error接口
标准库把error定义为接口类型, 以便于自己定义错误类型。
type error interface{
Error() string
}
- Painc
golang的内置方法,能够改变程序的控制流。当函数调用了panic,函数会停止运行,但是defer函数会运行,程序会在当前panic的goroutine全部退栈以后crash。
- Recover
recover也是golang的内置方法,用于恢复发生panic的goroutine的控制,recover只在defer函数中生效。如果当前goroutine将要发生panic的话, recover会捕获这个panic,并恢复正常执行。
- Defer
聊到panic和recover,需要聊聊defer这个关键字,后面会看到defer在异常处理机制中起到的作用。go的defer是用来延迟执行函数的,延迟的发生是在调用函数的returen之后。
6. 隔离
所谓隔离,是指程序可以包容由错误造成的损害,称为一种容损策略。这个在软件行业中最常见的方案,就是多机房建设。实现服务双机房部署建设,承受单机房故障,保障用户体验。这个实现的难点主要有如下三点:
- 跨机房网络延迟和带宽限制导致的数据层面一致性问题
- 机房之间流量调度
- 业务改动不能太大
各个大厂对于核心服务多机房部署的实现,简单列举以下几种实现方案:
- 高德
基于地理位置单元化,不同服务集群间双向数据复制,内部调用路由。
- 微博
MySQL多机房同步(写入时写但机房,有专门的组件负责同步写入到另一个机房)。
隔离的应用,同时体现了在架构设计上规定应该如何应用如何处理错误的价值。
7. 防御策略及建议
在防御性编程的路上,没有银弹。在产品中保留过多的防御性代码,则会与精简代码实现产生相矛盾的地方。从产品本身出发,在不影响用户体验的使用的情况下,使程序能够稳定的运行,梳理了如下几项建议:
- 保留重要错误检查的代码,去掉检查细微错误的代码
- 保留让程序稳妥地崩溃的代码,去掉会导致程序硬性崩溃的代码
- 确认代码中的错误消息是友好的,为技术支持人员做好错误信息记录
其实,对于防御性编程,我们其实是要在保障程序稳定和程序不过于臃肿之间找到一个合理的平衡。防御式编程技术可以让错误更容易发现,更容易修改,并减少错误对代码的破坏,断言可以帮助人们更早的发现错误,关于如何处理错误输入的决策是一项关键的错误处理决策,也是一项关键的高层设计决策。防范看似微小的错误,收益价值可能远远超出你的想象。