工具平台组/一辆
有经验的司机在驾驶过程中会谨慎地观察周围环境和其他车辆,提前预防可能的事故,确保自己不会受到伤害,即使问题可能是其他车辆引起的。
类似地,防御性编程的核心理念是:
如果一段程序接收到错误数据,即使这个错误数据是另一段程序的问题,该程序也不会受到损害。
先要确定 “边界”,才能进行“防御”
在编程中,这个 “边界” 是什么呢?
// 内部输入1:传入的参数 key
function updateConfigByKey(key: string) {
// 内部输入2:调用另一个内部接口的返回值
const value = getConfigValueByKey(key);
// 外部输入1:post 接口的返回值
axios.post('/api/update-config', {
key,
value
}).then((result) => {
//
});
}
有些时候,接口实现方和调用方对预期行为并没有真正达成共识:
这种情况下,”防御“就如空中楼阁
以调用浏览器 API 为例,我们是否与浏览器的开发者达成了一致:
设计契约涉及的范围越小,需要防御的范围就越小,防御就越容易。
提供 TS 公共包时,使用 dts-rollup 对编译出的 .d.ts 打包,可以控制对外暴露的接口
使用 dts-rollup 对编译出的 .d.ts 打包,可以控制对外暴露的接口
使用 dts-rollup 对编译出的 .d.ts 打包,可以控制对外暴露的接口
怪异的难以理解的规则是很难被遵守的
尽量遵循所在平台约定俗成的概念和规则。例如 nodejs 里的 callback 参数顺序是有惯例的(error 总是第一个参数)。
const fs = require('node:fs');
fs.readFile('/file.json', (err, data) => {
if (err) {
// handle error
console.log(err);
return;
}
// no errors, process data
console.log(data);
});
不同的概念应该让它差异得更明显,例如接口命名,参数类型等
虽然接口文档对其各自作用有说明,但调用者仍然很容易误用
断言能够让开发者更快地发现接口输入输出与预期不匹配的问题。
function assert(condition: boolean, msg: string) {
if (!condition) {
throw new Error(msg);
}
}// 输入经度,维度,海拔,计算当地线速度的函数
function velocity(
latitude: number,
longitude: number,
elevation: number
): number {
// 前提条件
assert(latitude >= -90 && latitude <= 90, 'Latitude must be between -90 and 90.');
assert(longitude >= 0 && longitude < 360, 'Longitude must be between 0 and 360.');
assert(elevation >= -500 && elevation <= 75000, 'Elevation must be between -500 and 75000.');
const returnVelocity = /* 计算逻辑 */;
// 后置条件
assert(returnVelocity >= 0 && returnVelocity <= 600, 'Return velocity must be between 0 and 600.');
// 返回值
return returnVelocity;
}使用断言可以记录代码中的预期行为,并发现意外情况,例如:
输入或输出的值是否符合预期
接口是否存在非预期的副作用
例如输入的只读变量的值在代码中是否未被更改
接口中间步骤是否符合预期
通常,断言只用于开发和测试期间
function onOpenContactWindow() {
const url = getHelpLink();
if (!url) {
if (CONFIG.IS_DEV_OR_TESTING) {
throw new Error('Help link not config');
}
console.error('Help link not config');
return;
}
const height = 632;
const width = 842;
const top = (window.screen.availHeight - 30 - height) / 2;
const left = (window.screen.availWidth - 10 - width) / 2;
window.open(url, '', `toolbar=no,menubar=no,location=no,status=no,height=${height},width=${width},left=${left},top=${top}`);
}断言是“可执行的文档”:
既可以表达当前代码的预期(与代码注释类似)
又能验证这一预期
避免在断言中放入有副作用的代码,因为它会在生产环境被移除
尽可能精准地描述类型,并且保证值与类型一致,可以让 TS 类型检查发挥最大作用。
让 TS 类型只能表达合法状态
更多内容参考之前的 TS 主题分享:
错误处理与断言的使用场景不同
// 输入经度,维度,海拔,计算当地线速度的函数
function velocity(
latitude: number,
longitude: number,
elevation: number
): number {
// 前提条件
assert(latitude >= -90 && latitude <= 90, 'Latitude must be between -90 and 90.');
assert(longitude >= 0 && longitude < 360, 'Longitude must be between 0 and 360.');
assert(elevation >= -500 && elevation <= 75000, 'Elevation must be between -500 and 75000.');
const returnVelocity = /* 计算逻辑 */;
// 后置条件
assert(returnVelocity >= 0 && returnVelocity <= 600, 'Return velocity must be between 0 and 600.');
// 返回值
return returnVelocity;
}function velocity(
latitude: number,
longitude: number,
elevation: number
): number {
// 对输入数据进行处理
if (latitude < -90) {
latitude = -90;
} else if (latitude > 90) {
latitude = 90;
}
if (longitude < 0) {
longitude = 0;
} else if (longitude >= 360) {
longitude = 360 - 0.000001; // 确保 longitude 小于 360
}
if (elevation < -500) {
elevation = -500;
} else if (elevation > 75000) {
elevation = 75000;
}
const returnVelocity = /* 计算逻辑 */;
// 返回结果
return returnVelocity;
}如果变量 latitude、longitude 和 elevation 来自外部,则应由错误处理代码检查并处理无效值
如果变量来自可信赖的内部源,并且代码的设计是基于输入都是有效的这一假设,则适合使用断言
有时对错误数据的最佳响应是继续运行并返回一个已知的无害的值。
在游戏软件中,绘制程序可能使用默认的背景色来处理错误的颜色输入。
web 应用中,使用写在代码中的一份默认配置作为后端接口异常时的替代值是常见的做法。
在显示癌症患者的 X 光数据时,如果显示“中性值”,则可能会导致不准确的诊断,因此关闭程序会更好。
在处理连续输入时,有些情况下可以简单地返回下一个有效数据。
如果从数据库中读取记录时遇到损坏的记录,可以继续读取直到找到有效记录。
如果每秒从温度计读取 100 次数据,有一次未获得有效读取,可以等待 1/100 秒再读取。
如果温度计读取软件某一次没有获得读取数据,可以简单地返回上次的值。因为温度在 1/100 秒内通常变化不大。
在游戏软件中,如果检测到无效颜色,可能会简单地返回上一帧使用的颜色。
如果是在 ATM 机上进行交易授权,则可能不希望使用上次的结果,这会导致使用上一个用户的银行账户号码。
比如,温度计有效值在 0 到 100 摄氏度之间:
如果检测到小于 0 的读数,可以替代为 0
如果检测到大于 100 的值,可以替代为 100
在字符串操作中:
如果获取到的字符串长度异常地小于 0,可以替代为 0
将错误通知给其他模块处理,具体机制包括:
设置状态变量的值
将状态码作为函数的返回值返回
使用语言的内置异常机制抛出异常
将错误处理集中在一个全局错误处理模块中:
好处是可以集中处理错误责任,方便调试
缺点是整个程序都将了解这个模块并与其耦合
最大限度地减少了错误处理的开销
可能会导致 UI 与逻辑分离的难度加大
这种方法在安全关键应用程序中非常常见。
例如,当控制癌症治疗的放射设备的软件收到错误的辐射剂量输入,关闭程序是最佳选择。此时宁愿重新启动机器,也不冒错误输送剂量的风险。
例如,默认情况下,即使安全日志已满 Windows 也会继续运行,但用户可以配置 Windows 在安全日志变满时停止服务器,这在安全关键环境中可能是更合理的。
function velocity(
latitude: number,
longitude: number,
elevation: number
): number {
// 前提条件检查
assert(latitude >= -90 && latitude <= 90, 'Latitude must be between -90 and 90.');
assert(longitude >= 0 && longitude < 360, 'Longitude must be between 0 and 360.');
assert(elevation >= -500 && elevation <= 75000, 'Elevation must be between -500 and 75000.');
// 对输入数据进行消毒
if (latitude < -90) {
latitude = -90;
} else if (latitude > 90) {
latitude = 90;
}
if (longitude < 0) {
longitude = 0;
} else if (longitude >= 360) {
longitude = 360 - 0.000001; // 确保 longitude 小于 360
}
if (elevation < -500) {
elevation = -500;
} else if (elevation > 75000) {
elevation = 75000;
}
const returnVelocity = /* 计算逻辑 */;
// 后置条件检查
assert(returnVelocity >= 0 && returnVelocity <= 600, 'Return velocity must be between 0 and 600.');
// 返回结果
return returnVelocity;
}正确性:从不返回不准确的结果,结束程序比返回不准确的结果更好
健壮性:总是尝试让软件继续运行,即使这有时会导致结果不准确
取决于错误发生的模块的具体类型和使用场景
安全关键应用程序往往更倾向于正确性,结束程序比返回不准确的结果更好
医疗放射机控制软件是一个很好的例子
消费级程序往往更倾向于健壮性,任何结果通常比直接关闭软件更好
文字处理软件偶尔在屏幕底部显示不完整,如果检测到这种情况,软件可以不必关闭,因为下次滚动页面时屏幕会刷新,显示将恢复正常。
越底层越通用的接口,对业务场景越没有感知,通常难以判断返回不准确的结果会有什么后果。可以考虑:
对于酷家乐工具前端来说,常见的业务模块单元包括:
React 模块(健壮性)
场景视图模块(健壮性)
场景交互事件处理模块,即 Tool 模块(健壮性或正确性)
数据模块,即 model 层(健壮性或正确性)
异常是一种将异常事件传递给调用它的代码的手段
异常事件是指一段代码遇到它无法处理的意外情况
本质上是对错误上下文没有感知的代码将控制权交还给调用者,调用者可能更理解业务场景并采取有效的行动
基本结构是一个使用 throw 来抛出异常对象,在上层调用代码中通过 try-catch-finally 捕获这些异常并处理
仅对无法通过其他编码方式解决的情况抛出异常
异常是在处理意外和增加复杂性之间的权衡
异常要求接口的调用者了解可能在接口内部抛出的具体异常,而这削弱了接口的封装
如果错误情况可以在本地处理,就在本地处理而不要抛出(不要使用异常推卸责任)
抛出的异常是接口的一部分,应该与接口常规的输入输出呈现一致的抽象层级。以下面的 Java 代码为例:
class Employee {
// ...
public TaxId GetTaxId() throws EOFException {
// ...
}
// ...
}class Employee {
// ...
public TaxId GetTaxId() throws EmployeeDataNotAvailable {
...
}
// ...
}在 TS 里没有类似 Java 的受检异常机制,只能通过接口注释告知调用者:
try {
...
// lots of code
...
} catch (error) { // 这里,error 的类型是 unknown
// empty
}try {
...
// lots of code
...
} catch ( ExceptionA exception ) {
// handle ExceptionA 类型的错误
} catch ( ExceptionB exception ) {
// handle ExceptionB 类型的错误
}
// 其他这里未声明的错误类型会继续向上抛出为了避免异常带来巨大的心智负担,需要为项目制定规范:
在什么情况下可以抛出异常
在什么情况下可以使用 throw-catch 在本地执行错误处理
哪些值能够被 throw(在 JS 里可以 throw 任何值)
创建项目的公共异常基类
从而支持集中和标准化的日志记录、错误报告等
是否使用全局统一的错误上报模块
指定某些接口作为“安全区域”的边界
在数据穿越边界时,检查数据的有效性,并在数据无效时进行处理,确保错误不会扩散到整个系统中。例如:
类的公共方法假定数据不安全,并负责检查数据和对其进行消毒
一旦数据被类的公共方法接受,私有方法就可以假定数据是安全的
关键是如何决定哪部分代码在安全区域内,哪部分在外部
这是一个架构决策,需要根据具体场景决定
“阻隔” 不是一种具体的编程机制,而是一种抽象概念。
通过阻隔能够清楚地区分断言和错误处理的使用场景:
阻隔之外的代码应该使用错误处理,因为无法对数据做出任何假设。
阻隔之内的代码应该使用断言,因为传递给它们的数据应该在传递过阻隔之前已经过消毒。
如果阻隔内的代码检测到错误数据,那是程序内部的错误,而不是数据的错误。
阻隔和断言之间的关系
生产版本不应该向用户暴露危险操作,而开发版本可以添加危险的调试接口
生产版本必须节约资源,而开发版本允许大量使用资源
生产版本必须运行得快,而开发版本可能可以运行得慢
例如在调试模式下,Microsoft Word 在空闲循环中每隔几秒检查一次文档对象的完整性。这有助于快速检测数据损坏,并使错误诊断更加容易。
在开发版本牺牲性能和资源换取调试便利性,从而更容易发现问题
在开发版本牺牲性能和资源换取调试便利性
在开发版本牺牲性能和资源换取调试便利性
使用断言,在遇到非法输入时中止程序
将错误日志发送到企业微信
开发和测试期间,使用“进攻式”编程:让问题变得足够明显和痛苦
在开发和生产之间灵活地切换调试辅助工具,并在发布前移除不必要的调试代码,确保程序的性能和质量:
使用构建工具可以从相同的源文件中构建程序的不同版本
生产环境移除不必要的调试代码
过度的防御性编程会产生问题
优先考虑核心业务和关键路径
谢谢