防御性编程,就是通过技术手段,把错误扼杀在萌芽阶段,或者把错误直观的暴露给开发者快速修复错误继续聚焦于业务。七年前,我们开始在项目中践行了全面的防御性编程,从多年的坚持下来看,稇载而归。今天翻出了以前总结的一篇文章,内容感觉还挺硬核的,很多做法非常有效,并且可以照搬,于是发出来与大家分享。
总览:
我们在防御性编程实践中主要落地点有5个,分别为:
- 1.推广使用基础的防御性编程宏
- 2.在错误收集器查看错误和警告
- 3.框架上规避不安全的用法,提供安全的函数支持
- 4.编译期间进行规避,开启警告视为错误
- 5.让程序自己发现错误
接下来,我们依次展开聊聊。
1.推广使用基础的防御性编程宏:
防御性编程是我们非常重视的开发习惯,一个新同学入职,一定会经过多轮严格的codereview,不断的培养防御性编程的习惯,直到满足我们的要求为止。目前我们在防御性编程常使用的API有:
名称 | 代码 | 作用 | 是否中断 | 代码中出现次数 |
前置条件检查 | EDU_REQUIRE_RETURN(exp, ret) EDU_REQUIRE_RETURN_QUIET(exp, ret) | 检查传入参数,自身状态是否符合预期,不符合预期返回ret。 | 是,QUIET版本不中断 | 1070 |
指针保护-警告版 | EDU_SAFE_POINTER(p) EDU_SAFE_POINTER_ERROR(p) | 对p求解一次,检查使用指针p是否为空,不为空才执行调用。 | ERROR版本会中断 | 3350 |
指针保护-无临时变量版 | SAFE_POINTER_NO_TEMP_VAR(p) SAFE_POINTER_ERROR _NO_TEMP_VAR(p) | 对p进行多次求解,检查使用指针p是否为空,不为空才执行调用。 | ERROR版本会中断 | 75 |
跨平台-平台不支持 | EDU_PLATFOMR_NOT_SUPPORT | 是 | 20 | |
断言 | EDU_ASSERT_ERROR EDU_ASSERT_WARRING | ERROR版本会中断 | 1546 | |
主动报错 | TraceError/TraceWarring | 主动输出错误或警告信息 | 错误信息会中断 | 592 |
合计 | 6653 |
排除第三方库,生成的源文件、头文件,注释,声明等,项目有效函数体逻辑代码约为7W行,平均每10.5行代码包含一行防御性代码。在我们的代码规范要求中,也强调防御性宏使用的重要性,其中一句是这么说的:“如果你写的函数没有一行防御性编程宏,你都不好意思提交代码”。
以上统计不包括日志等常规信息输出,其中80%的防御性宏为中断宏。也就是说,当程序运行发生任何异常,大部分都会中断,警告等可以恢复的异常会记录到错误收集器中。
丰富的防御性编程代码,可以让开发者快速完成自测和联调,自测时间大大缩短,定位问题效率大大提高。
摘取一段代码片段提现我们对防御性编程的执念:
上面的例子中,向下调用SDK返回失败时也会中断,因为我们认为这个API不应该失败,如果失败必须彻查。
如果项目的代码质量本身不高,在初期引入大量的中断防御性编程宏时,会有一阵短痛期(因为开发在调试过程中可能会频繁遇到非自己正在编写的逻辑的中断,从而影响开发效率)。在实践时,我们建议逐步增加或者按模块增加防御性编程代码,用“攻城略地”的方式逐步覆盖到整个项目。
2.在错误收集器查看错误和警告:
错误收集器是内置在客户端中,用于观察客户端运行状态,以及和后台,web之间的接口交互,输出警告和错误信息的地方。我们的错误收集器长成这样:
错误收集器最开始是为了给测试同学使用,测试当发现问题后,可以直接通过错误收集器了解问题的原因,如果是协议返回报错会提单给后台,如果是web问题会提单给前端,可以避免客户端同学分发bug单的工作量。有的时候,测试同学在测试时发现错误收集器输出了错误,也会把错误截图放到bug单中,这样开发同学甚至不需要下载日志就可以知道问题发生在哪了。
注意,错误收集器不是日志查看工具,这个是要特别说明的。错误收集器主要关注于警告和错误,以及客户端和其他端的数据流,因此其打印量比日志量小非常多。甚至不需要过滤也可以直接查看到主要的逻辑。后面随着错误收集器越来越完善,产品和数据同学也可以查看上报方便数据同学进行验证。错误收集器的另一大特色是这个是客户端内的置工具,无使用门槛且操作简单,一键唤出。只有这样随手可得的工具大家才愿意用,才能成为工作效率提高神器。
3.框架上规避不安全的用法,提供安全的函数支持:
我们对业务框架的易用性非常重视,目的是为开发者提供友好高效的开发体验,因此我们会对所有开发者的可能存在的误用都使用了中断进行强提醒。也把所有可以在编译期发现的问题都提前到编译器,把不安全的函数都进行了屏蔽。比如:
- 删除/禁用不安全的API,比如C语言的scanf/sprinft等类型不安全的字符串格式化全部删除,取而代之的是类型安全的c++的strfmt库,并且对常用的对象做了特例化支持。
- 对编译器扩展,不符合标准的语法禁用:开启vs的符合模式(permissive-),这样可以确保项目代码在其他平台可以正确的编译。
- 业务框架API提供丰富的中断报错,凡是传入字符串动态模型的API,都可能因为传错导致无效,我们针对所有无法在编译期发现的错误都做了防御性报错,比如连接信号槽,链接失败会中断报错,比如FIND_CONTROLLER寻找模块找不到会中断报错,甚至FindUIWidgetByName失败也会中断。总之,业务框架不断完善,为的是提高业务开发效率。把低级错误扼杀在编译或者自测阶段。
- 所有的异步,回调,都需要传入生命周期管理对象,在传入的对象析构后不会继续出发回调,这样可以防止生命周期管理不善导致的已析构对象的访问。
- 模态弹框等不安全坑多要注意的AIP显式加入UnSafe关键字、编译警告等,推荐开发者使用安全版本。
例如,在框架中,UIController的创建是约定以CT_开始的字符串为name,如果开发者没有使用CT_开头的字符串,并且名字中含有Controller,那么大概率是开发者忘记了,所以会对这种场景进行检查:
类似的,项目中会对这类无法使用编译期暴露的问题,在运行时为开发者提供安全检查。
4.编译期间进行规避,开启警告视为错误:
我们非常重视错误可以在编译期间就被发现,这样可以极大的提高研发效率,因此我们开启了比较多的应该引起开发者重视的警告视为错误,表格中是我们认为应该视为错误的警告,后续还会继续添加。具体表格见文章最后(列表较长影响观看)。
关于警告的完整列表可以参考微软MSVC警告list:编译器警告 C4000 - C5999 | Microsoft Docs。
最理想的状态是清空项目所有警告,我们也会定期清理让整个项目的警告数保持在0的状态;同时我们认为警告视为错误也是很有必要的,这可以让编译器为开发者提高编码效率。
5.让程序自己发现错误:
我们非常重视每一次外网问题,每一个外网问题都反映出我们的质量漏洞。我们会思考,如果让程序像开发者那样去自动定位bug,于是我们发现开发同学定位问题是通过日志,观察资源使用等方法。于是每次出现新的外网问题,我们都会把这样的问题让程序自己检测出来的逻辑。比如:
1.优化编译过程中,增加了签名文件的缓存的逻辑,其中的BUG导致打出的安装包的TRTC SDK使用了上一次的二进制(DLL)文件。
面对这个问题,我们首先修复了签名文件缓存逻辑的问题,为了后续让程序自己发现这个错误,我们增加了如下逻辑:在客户端加载SDK时,对SDK版本号进行校验,彻底规避类似的问题:
2.客户端在使用win32 API时,如果忘记了Close句柄就非常容易引入句柄泄露,单次的泄露尚可接受,持续的句柄泄露容易让程序长时间运行后资源不足而异常。我们观察句柄的增长规律,发现正常情况下句柄不会持续增长,于是我们启动程序后会有个Timer一直检测自身的句柄数量,从此之后再也没出现过句柄泄露的问题流出外网了:
类似的例子非常多,每当出现了新的外网问题,我们都会尝试通过程序自动的检测出同一类的问题。
小结:
综上,我们从推广使用基础的防御性编程宏;在错误收集器查看错误和警告;框架上规避不安全的用法,提供安全的函数支持;开启警告视为错误,尽可能保持项目无警告;让程序自己发现错误;来体现出我们对防御性编程的落地。
在实际的实践过程中,我们还有许多配套的理论和工具提高免测的质量,以及对风险的把控,日后有机会还会继续发出来。
其实只要在开发过程中不放过任何一个新的问题,只要发现一个问题,对这个问题进行分析,上升为出一类问题进行对待,尝试在项目中修复所有此类问题,然后尝试寻找可靠的方法对这类问题进行规避,确保后续不再出现,这就是防御性编程的核心思想。
坚定不移的践行防御性编程,除了可以大大提高工作效率,也会极大减少外网反馈,只有这样才能在内卷中卷胜对手(手动狗头)。
我们团队是我们部门中唯一一个得到测试团队充分的信任和肯定后,和测试团队达成一致,需求不需要经过测试直接发布的团队(因为测试团队需要对外网问题担主要责任)。于是,我们成为鹅厂第一支客户端常态化免测的研发团队。
即使免测,我们的外网问题数量在部门中的研发团队里,也是最少的:
附表:早期开启的警告视为错误 列表(如果可能,可以开启所有警告视为错误):
编译选项 | 警告含义 | 备注 |
/we4305 | 如果将值转换为较小类型的初始化或构造函数参数,则会发出此警告,导致信息丢失 | 修复x86/x64交叉编译问题 |
/we4309 | 转换:对常量值的截断:类型转换导致常数超出为其分配的空间。 可能需要对常数使用较大的类型。 | 修复x86/x64交叉编译问题 |
/we4311 | 此警告检测 64 位指针截断问题。 例如,如果为 64 位体系结构编译了代码,则指针 (64 位) 的值将被截断(如果将其分配给 int (32 位) ) | 修复x86/x64交叉编译问题 |
/we4312 | 此警告检测到将32位值分配给64位指针类型的尝试,例如,将32位 int 或转换 long 为64指针 | 修复x86/x64交叉编译问题 |
/we4334 | 32 位移位的结果已隐式转换为 64 位,编译器怀疑有 64 位移位 | 修复x86/x64交叉编译问题 |
/we4333 | 右移量过大,数据丢失,右移操作太大。 所有有效位都将移出,结果将始终为零。 | 修复x86/x64交叉编译问题 |
/we4319 | ~ (按位求补) 运算符的结果是无符号的,然后在转换为更大的类型时进行零扩展 | 修复x86/x64交叉编译问题 |
/we4311 | 此警告检测 64 位指针截断问题。 例如,如果为 64 位体系结构编译了代码,则指针 (64 位) 的值将被截断(如果将其分配给 int (32 位) ) | 修复x86/x64交叉编译问题 |
/we4310 | 常数值强制转换为较小的类型。 编译器执行转换,这会截断数据 | 修复x86/x64交叉编译问题 |
/we4308 | 表达式将负整数常量转换为无符号类型。 表达式的结果可能毫无意义。 | 修复x86/x64交叉编译问题 |
/we4302 | 编译器检测到从较大类型到较小类型的转换。 信息可能会丢失。 | 修复x86/x64交叉编译问题 |
/we4306 | 标识符是类型强制转换到更大的指针。 新类型的未填充高位将填充零。此警告可能表示不需要的转换。 生成的指针可能无效 | 修复x86/x64交叉编译问题 |
/we4473 | 没有为格式字符串传递足够的参数 | 规避格式化字符串可能遇到的问题 |
/we4477 | 编译器检测到满足格式字符串中的占位符所需的参数类型与提供的参数类型不匹配 | 规避格式化字符串可能遇到的问题 |
/we4474 | ||
/we4717 | 通过函数的每个路径都包含对函数的调用。 由于无法先以递归方式自行调用函数而退出函数,因此该函数将永远不会退出。 | |
/we4715 | 指定的函数可能不会返回值 | 避免函数返回分支没有返回值 |
/we4533 | 程序中的指令更改了控制流,因此,未执行初始化变量的指令。 | |
/we4129 | 以下 \ 字符或字符串常量中的反斜杠 () 未被识别为有效的转义序列 | |
/we4172 | 函数返回局部变量或临时对象的地址。 当函数返回时,将销毁局部变量和临时对象,因此返回的地址无效。 | |
/we4013 | 编译器遇到对未定义的函数的调用。 | |
/we4431 /we4430 | 缺少类型说明符 - 假定为 int。 注意: C 不再支持默认的 int | |
/we4133 | "type":不兼容的类型 - 从"type1"到"type2" 此警告可能是由于尝试减去两个不同类型的指针导致的。 | |
/we4716 /we4715 | “function”必须返回值,给定的函数未返回值。 | |
/we4456 /we4457 /we4458 /we4459 | "identifier" 的声明隐藏了函数参数; 局部范围内的 标识符 声明隐藏类范围内具有相同名称的 标识符 的声明。 | 避免C++语言特性让开发者陷入低级错误中 |
/we4700 /we4701 /we4703 | 局部变量 名称 已被 使用,即在分配值之前从中读取;可能使用了 局部 变量名称,但没有分配值。 这可能导致不可预知的结果。 | |
/we4706 | 条件表达式中的赋值 | 避免if中==写成了= |
/we4047 | ||
/we4702 | 无法访问的代码 | |
/we4709 | 数组索引表达式中的逗号运算符 | 避免低级错误 |
/we4739 | 对变量“var”的引用超过了其存储空间 将值赋给了变量,但是该值的大小超过变量的大小。 。 | |
/we4744 | 在两个文件中引用或定义的外部变量在这些文件中具有不同的类型,并且编译器确定 file1 中变量的大小与 file2 中变量的大小不同。 | |
/we4747 | ||
/we4754 | 比较中的算术运算的转换规则意味着无法执行一个分支。 | |
/we4756 | 编译器在编译期间执行常量算术时生成了异常。 | |
/we4789 | 缓冲区大小 为 N 个字节的"identifier"将被溢出; 从 偏移量 L 开始写入 M 字节 | |
/we4001 /we4002 /we4003 /we4004 /we4005 | 使用了非标准扩展 "单行注释";宏的实参不足;宏的实参不足;宏重定义 | |
/we4804 /we4805 /we4806 /we4807 | 操作中类型 "type1" 和类型 "type2"的不安全 混合 | |
/we4819 /we4821 | 该文件包含不能在当前代码页(数字)中表示的字符。 以 Unicode 格式保存文件以防止数据丢失 | |
/we4838 | 从 "type_1" 到 "type_2" 的转换需要收缩转换 | |
/we4906 | 将宽字符串文本强制转换到 LPSTR ' ';字符串文本强制转换到' LPWSTR ' |