文章目录
简单来说,防御性编程就是"不信任"的艺术。
一、什么是防御性编程
1.1 引言
面对复杂多变的运行环境、不可预测的用户输入以及潜在的编程错误,如何确保软件在遭遇异常情况时依然能够稳定运行,是每位开发者必须面对的挑战。防御性编程(Defensive Programming)正是为解决这一问题而生的一种编程范式,它强调在编程过程中预见并防范潜在的错误和异常情况,从而增强软件的健壮性和稳定性。
1.2 基本概念
防御性编程的核心思想在于承认程序总会存在问题和需要修改,因此聪明的程序员会提前考虑并防范可能的错误。它强调在编程过程中不仅要实现功能,还要确保程序在面对错误输入、异常情况和并发操作时能够稳定运行。
-
一种编程思想:假定数据都是有问题的,防御性编程就是去处理预防这些问题。而且通过这种设计的程序可极大的避免因传入错误数据而被破坏。
-
对外部数据的防护措施:对外部错误数据提前设置防护,通过有效的防护措施将异常数据隔离在程序之外。
-
内部正常运作的保障:保护程序内部正常运转,保障内部数据的可靠性、稳定性。
1.3 作用
降低墨菲定律带来的影响:
你永远不知道会接收到什么错误而导致程序被破坏
-
2019年1月,拼多多被爆出重大BUG,用户可领100元无门槛券,造成大批用户开始“薅羊毛”,一晚上200多亿都是话费充值。
-
2019年5月,部分用户反应支付宝出现网络故障,账号无法登录或支付,该故障是由于杭州市萧山区某光纤被挖断导致,这一事件造成部分用户无法使用支付宝。
1.4 防御原则
-
假设输入总是错误的:不依赖外部输入的绝对正确性,对所有输入进行验证和清理。
-
最小化错误的影响范围:通过异常处理、错误隔离等措施,限制错误对系统整体的影响。
-
使用断言进行内部检查:在代码的关键位置加入断言,确保程序状态符合预期。
-
代码清晰易懂:编写易于理解和维护的代码,便于团队成员发现潜在问题。
-
持续测试:通过单元测试、集成测试等手段,不断验证软件的正确性和稳定性。
二、对输入输出不信任
2.1 对输入不信任
检查所有输入参数的值,包括:
-
数据格式是否正确
eg:对空指针的检查,不只是输入,只有是使用到指针的地方,都应该先判断指针是否为NULL,而内存释放后,应当将指针设置为NULL。
【案例】注册系统某段逻辑,正常使用情况下,都有对指针做检查,在某个错误分支,打印日志时,没检查就使用了该字符串;结果可正常运行,但当访问某个依赖模块超时走到该分支,触发bug,导致coredump。
-
数据类型是否正确
-
数据长度是否正确
【案例】某系统后运行数月表现良好,突然有一天,发生了coredump,经查,是某个业务不按规定请求包中填写了超长长度,导致memcpy时发生段错误,根本原因,还是没有做好长度检查。
-
数据内容是否正确
eg:某些场景下,没有对数据内容做检查就直接使用,可能导致意想不到的结果。
【案例】sql注入注入和xss攻击都是利用了服务端没有对数据内容做检查的漏洞。
-
边界条件检查
你永远不知道用户怎么用你的产品,守护好边界。
【案例】域名长度超256字符,正则表达式无法解析,导致访问不通。
【案例】修改账号密码,使用特殊字符,部分字符具有特殊含义,导致输入新密码后,特殊字符组合被特殊解析,密码修改失败。
2.2 如何处理错误的输入数据
2.2.1 抛出异常
异常使用建议:
- 通知程序的其他部分发生了不可忽略的错误。
- 只有真正例外的情况下抛出异常。
- 避免在构造函数中抛出异常。
- 避免使用空的catch语句。
- 考虑创建一个集中的异常报告机制。
- 在异常消息中加入关于异常发生的全部信息。
2.2.2 错误处理
选用合适的错误处理方式对错误数据进行处理,常用方法包括:
- 返回中立值:处理错误时继续执行操作并简单的返回一个没有危害的值。
- 换用下一个正确的数据(实时系统):在处理轮询查询状态的子程序时,如果某次查询出的输出数据错误或有误,大可以忽略本次错误的数据,继续等待下一次轮询时读取正确的数据。
- 返回与前次相同的数据(数据处理)。
- 换用最接近的合法数据(预先设置)。
- 警告信息写入日志。
- 设置相应的错误码并返回。
- 调用集中的错误处理子程序或对象:把错误处理都集中在一个全局的错误处理子程序或对象中,这种方法优点在于能把错误处理的职责集中到一起,从而让调试变得更简单。
- 局部处理错误。
- 关闭程序。
2.2.3 创建栅格
外部接口数据假定是肮脏的不可信的,中间这些类(子程序)构成隔栏,负责清理和验证数据并返回可信的数据,最右侧的类(子程序)全部在假定数据干净(安全)的基础上工作,这样可以让大部分的代码无须再担负检查错误数据的职责!
船体外壳上装备隔离舱,如果船只与冰山相撞导致船体破裂,隔离舱就会被封闭起来,从而保护船体的其余部分不会受到影响。在发生火灾的时候,建筑物里的防火墙横沟阻止火势从建筑物的一个部位向另外一个部位蔓延。
2.2.4 充分利用开发者测试
充分利用开发者测试,在UT,自动化测试,联调等环节,使用最小的代价,覆盖繁多的异常场景表现,确保代码的各个部分都能正常工作,并能正确地处理各种输入和边缘情况。
2.3 对输出(变更)不信任
变更的影响一般体现在输出,有时候输出的结果并不能简单的判断是否正常,如输出是加密信息,或者输出的内容过于复杂。
所以,对于每次修改代码时,采用不信任编码,正确的不一定是“对”的,再小的修改也应确认其对后续逻辑的影响,有些修正可能改变原来错误时的输出,而输出的改变,就会影响到依赖该改变字段的业务。
【案例】全覆盖测试一切正常,现网实施出现问题,经分析,该场景为复杂叠加场景,测试未能覆盖。
【案例】工步执行成功后,执行结果不是预期结果,创建升级工程,预期一次完成版本升级和打补丁的动作,工程执行成功后,某服务只完成了版本升级,未完成打补丁动作,工程未拦截该问题。
2.4 如何处理异常输出
2.4.1 全发布流程的质量监控
-
发布前,应该对涉及到的场景进行测试和验证,测试可以有效的发现潜在的问题,这是众所周知的。
-
发布过程,应该采用灰度发布策略,因为测试并非总是能发现问题,灰度发布,可以减少事故影响的范围。常见灰度发布的策略有机器灰度、IP灰度、用户灰度、按比例灰度等,各有优缺点,需要根据具体场景选择,甚至可以同时采用多种的组合。
-
发布后,全面监控是有效发现问题的一种方法。因为测试环境和正式环境可能存在不一致的地方,也可能测试不够完整,导致上线后有问题,所以需采取措施补救
A:如使用Monitor监控请求量、成功量、失败量、关键节点等
B:使用DLP告警监控成功率
C:发布完,在正式环境测试一遍
2.4.2 通过断言保护前后条件
断言是在开发和维护期间使用的、让程序运行时自检的代码,断言为真说明程序运行正常,断言为假则说明程序发生错误进而程序退出,如Java中关键字assert。对于大型复杂程序或可靠性要求极高的程序,可以快速的定位程序中的关键错误。
【案例】某工步需要调整分布式系统的角色部署形态,调整完成后,增加终态规则校验,不满足后置条件,不允许继续向后执行。
【案例】某服务连接每1分钟连接一次kafka,如果连接结果异常,就上报告警,正常就清除告警,导致有误检测,出现告警上报1分钟后自动清除,引起恐慌,最好调整为连续三次检测失败才上报告警,防止偶发错误输出,引起结果判断错误。
三、对服务不信任
一般的系统,都会有上下游的存在,正如下图所示
而上下游的整个链路中,每个点都是不能保证绝对可靠的,任何一个点都可能随时发生故障,让你措手不及。
因此,不能信任整个链路中的任何一个点,需进行设防。
3.1 对服务本身不信任
主要措施如下:
(1)服务监控
前面所述的请求量、成功量、失败量、关键节点、成功率的监控,都是对服务环节的单点监控。
在此基础上,可以加上自动化测试,自动化测试可以模拟应用场景,实现对于流程的监控。
(2)进程秒起
人可能在程序世界里是不可靠的因素(大牛除外),所以,coredump还是有可能发生的,这时,进程秒起的实现,就可以有效减少coredump的影响,继续对外提供服务。
3.2 对依赖系统不信任
可采用柔性可用策略,对于根据模块的不可或缺性,区分关键路径和非关键路径,并采取不同的策略
(1)对于非关键路径,采用柔性放过策略
当访问非关键路径超时时,简单的可采取有限制(一定数量、一定比重)的重试,结果超时则跳过该逻辑,进行下一步;复杂一点的统计一下超时的比例,当比例过高时,则跳过该逻辑,进行下一步
(2)对于关键路径,提供弱化服务的柔性策略
关键路径是不可或缺的服务,不能跳过;某些场景,可以根据目的,在关键路径严重不可用时,提供弱化版的服务。举例如派票系统访问票据存储信息严重不可用时,可提供不依赖于存储的纯算法票据,为弥补安全性的确实,可采取缩短票据有效期等措施。
(3)减少代码依赖
比如JDBC接口标准中的获取连接Connection接口,无论是什么厂商的数据库,什么类型的数据库连接,只要实现统一的JDBC标准,即可对外提供统一的获取连接调用。所以在做程序设计时,我们就需要考虑到业务逻辑和底层数据调用的解耦。
3.3 对请求不信任
(1)对请求来源的不信任
有利可图的地方,就会有黑产时刻盯着,伪造各种请求,对此,可采取如下措施
A:权限控制:如ip鉴权、模块鉴权、白名单、用户登录态校验等
B:安全审计
权限控制仅能打击一下非正常流程的请求,但坏人经常能够成功模拟用户正常使用的场景;所以,对于一些重要场景,需要加入安全策略,打击如IP、号码等信息聚集,频率过快等机器行为,请求重放、劫持等请求)
(2)对请求量的不信任
前端的请求,不总是平稳的;有活动时,会暴涨;前端业务故障恢复后,也可能暴涨;前端遭到恶意攻击时,也可能暴涨;一旦请求量超过系统负载,将会发生雪崩,最终导致整个服务不可用,对此种种突发情况,后端服务需要有应对措施
A:频率限制,控制各个业务的最大请求量(业务根据正常请求峰值的2-3倍申请,该值可修改),避免因一个业务暴涨影响所有业务的情况发生。
B:过载保护,虽然有频率限制,但业务过多时,依然有可能某个时间点,所有的请求超过了系统负载,或者到某个IDC,某台机器的请求超过负载,为避免这种情况下发生雪崩,将超过一定时间的请求丢弃,仅处理部分有效的请求,使得系统对外表现为部分可用,而非完全不可用。
四、对运营不信任
4.1 对机器不信任
机器故障时有发生,如果服务存在单点问题,故障时,则服务将完全不可用,而依赖人工的恢复是不可预期的,对此,可通过以下措施解决
(1)容灾部署
即至少有两台以上的机器可以随时对外提供服务。
(2)心跳探测
用于监控机器是否可用,当机器不可用时,若涉及到主备机器的,应做好主备机器的自动切换;若不涉及到主备的,禁用故障机器对外提供服务即可。
4.2 对机房不信任
现实生活中,整个机房不可用也是有发生过的,如2015年的天津滨海新区爆炸事故,导致腾讯在天津的多个机房不能对外提供正常服务,对此采取的措施有:
(1)异地部署
不同IDC、不同城市、不同国家等部署,可用避免整个机房不可用时,有其他机房的机器可以对外提供服务
(2)容量冗余
对于类似QQ登陆这种入口型的系统,必须保持两倍以上的冗余;如此,可以保证当有一个机房故障时,所有请求迁移到其他机房不会引发系统过载。
4.3 对电力不信任
虽然我们越来越离不开电力,但电力却不能保证一直在为我们提供服务。断电时,其影响和机器故障、机房故障类似,机器会关机,数据会丢失,所以,需要对数据进行备份。
(1)磁盘备份
来电后,机器重启,可以从磁盘中恢复数据,但可能会有部分数据丢失。
(2)远程备份
机器磁盘坏了,磁盘的数据会丢失,使用对于重要系统,相关数据应当考虑采用远程备份。
4.4 对网络不信任
(1)不同地方,网络时延不一样
一般来说,本地就近的机器,时延要好于异地的机器, 所以,比较简单的做法就是近寻址,如CMLB。
也有部分情况,是异地服务的时延要好于本地服务的时延,所以,如果要做到较好的最优路径寻址,就需要先做网络探测,如Q调
(2)常有网络有波动或不可用情况
和机器故障一样处理,应当做到自动禁用;但网络故障和机器故障又不一样,经常存在某台机器不可用,但别的机器可以访问的情况,这时就不能在服务端禁用机器了,而应当采用本地回包统计策略,自动禁用服务差机器;同时需配合定时探测禁用机器策略,自动恢复可正常提供服务机器。
4.5 对人不信任
人的因素在运营的世界里其实是不稳定的因素(大牛除外),所以,不能对人的操作有过多的信任。
(1)操作备份
每一步操作都有记录,便于发生问题时的回溯,重要的操作需要review,避免个人考虑不周导致事故。
(2)效果确认
实际环境往往和测试环境是存在一些差异,所有在正式环境做变更后,应通过视图review和验证来确认是否符合预期。
(3)变更可回滚
操作前需对旧程序、旧配置等做好备份,以便发生故障时,及时恢复服务。
(4)自动化部署
机器的部署,可能有一堆复杂的流程,如各种权限申请,各种客户端安装等,仅靠文档流程操作加上测试验证时不够的,可能某次部署漏了某个步骤而测试又没测到,上线后就可能发生事故若能所有流程实现自动化,则可有效避免这类问题。
(5)一致性检查
现网的发布可能因某个节点没同步导致漏发,也就是不同的机器服务不一样;对此,有版本号的,可通过版本号监控发现;没版本号的,则需借助进程、配置等的一致性检查来发现问题。
备注:以上提到的不信任策略,有的不能简单的单条使用,需要结合其他的措施一起使用的。
五、防御式编程的挑战
过度防御性编程带来的问题 |
---|
1、过多异常处理的情况下异常被吞没,没有将异常正常输出。 |
2、代码臃肿,内部充满大量的判断和非业务代码,代码难以维护。 |
3、预防性代码的引入可能会严重影响系统的性能。 |
4、增加软件的开发时间和复杂度。 |
5、防御性代码同样会有缺陷,增加维护时间。 |
正确的态度 |
---|
尽管防御式编程增加了开发时间,但还是利大于弊的,多花点时间开发出安全稳定高质量的软件是物超所值的! |
1、采取正确的防御姿态。 |
2、考虑好什么地方要用防御。 |
3、根据实际情况调整防御编程的优先级。 |
六、总结
防御性编程是一种积极主动的编程策略,它要求开发者在编写代码时,不仅要关注功能的实现,更要关注代码的健壮性和稳定性。通过预见并防范潜在的错误和异常情况,提升软件的质量,减少因外部因素导致的程序崩溃,提升系统稳定性。
作为一名优秀的开发者,不能将希望完全寄托于测试,而是在设计、开发阶段,对系统的异常和边界有充分的认知和考量,这是防御式编程带给我们的思考。