防御性编程前端实践
工具平台组/一辆
有经验的司机在驾驶过程中会谨慎地观察周围环境和其他车辆,提前预防可能的事故,确保自己不会受到伤害,即使问题可能是其他车辆引起的。

“防御性驾驶”
类似地,防御性编程的核心理念是:
如果一段程序接收到错误数据,即使这个错误数据是另一段程序的问题,该程序也不会受到损害。
防御性编程“防御”的是什么?

先要确定 “边界”,才能进行“防御”
防御性编程“防御”的是什么?
-
以驾驶为例:
-
“边界” 就是交通法规
- 交通法规是所有交通参与者之间的契约,规定了每个参与者的权利和义务
- 需要“防御”的是不遵守交通法规的行为
-
“边界” 就是交通法规
在编程中,这个 “边界” 是什么呢?
-
编程中的“防御边界”是设计契约(design contract),包括:
-
接口的输入需要满足什么条件
- 是接口调用者在调用前需要保证的
-
接口的输出需要满足什么条件
- 是接口实现者在执行完毕后需要保证的
- 注意:性能标准也属于输出条件的一部分
-
接口的输入需要满足什么条件
防御性编程“防御”的是什么?
- ”防御“的对象显而易见:不符合设计契约的输入
防御性编程“防御”的是什么?
// 内部输入1:传入的参数 key
function updateConfigByKey(key: string) {
// 内部输入2:调用另一个内部接口的返回值
const value = getConfigValueByKey(key);
// 外部输入1:post 接口的返回值
axios.post('/api/update-config', {
key,
value
}).then((result) => {
//
});
}
“防御”的总体策略
-
检测:如何更容易地检测到非预期输入
- 接口设计原则
- 编程技术、工具
-
处理:如何更合理地处理非预期输入
- 保证健壮性
- 保证正确性
有些时候,接口实现方和调用方对预期行为并没有真正达成共识:
- 接口缺乏文档说明,调用者只能猜测其对输入的要求
- 调用者没有耐心阅读接口文档,盲目调用接口
- 接口设计复杂,调用者理解成本过高
“设计契约”被双方确认过吗?
这种情况下,”防御“就如空中楼阁
以调用浏览器 API 为例,我们是否与浏览器的开发者达成了一致:
-
该浏览器版本并没有实现该接口
- 确认该接口在我们的支持浏览器版本内广泛支持,或添加 polyfill
- 项目引用正确的类型声明库和正确版本,从而让 TS 类型检查发挥作用(通过类型验证契约):
- 不要引用 node 类型声明(这些接口在浏览器环境并不存在)
- 不要引用太新版本的类型声明(这些接口部分浏览器并不支持)
-
浏览器对某些接口的额外限制没有显著的文档说明,如跨域,跨站等限制
- 了解浏览器相关政策和常见的相关 API
- 不同浏览器表现可能会有差异
-
浏览器并不能保证某些接口一定成功
- 例如,sendBeacon 不能确保一定成功
“设计契约”被双方确认过吗?
设计契约涉及的范围越小,需要防御的范围就越小,防御就越容易。
-
减小输入的范围(减轻接口实现者的防御负担)
- 让接口的职责单一,从而只需更少的输入
-
减小输出的范围(减轻接口调用者的防御负担)
- 让类、方法、成员、函数尽可能私有
- 即使是不小心暴露出去的接口,也可能被使用
-
提供 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);
});
接口应该易于理解
-
“看起来是一样的,但实际却不同”是非常危险的
-
不同的概念应该让它差异得更明显,例如接口命名,参数类型等
-
- Object3D.updateMatrixWorld
- Object3D.updateWorldMatrix
虽然接口文档对其各自作用有说明,但调用者仍然很容易误用
使用断言
断言能够让开发者更快地发现接口输入输出与预期不匹配的问题。
- 断言通常接受两个参数:
- 一个描述预期条件的布尔表达式
- 条件不成立时要抛出的异常消息
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 类型验证与断言验证的关系
- 类型系统
- 静态的,且表达能力有限。例如无法表达 ”数组长度大于2“,”字符串不为空“ 这样的条件。
- 在编译时验证,不依赖具体功能触发
- 断言
- 运行时验证,理论上可以验证任何预期行为
- 必须在运行时达到特定的触发条件(函数需要被执行)才能验证
TS 类型验证与断言验证的关系
- 优先使用 TS 类型对业务数据进行尽可能精确的建模,从而利用它在编译环节进行验证
- 对于 TS 类型系统无法描述和验证的行为,则可以利用断言进行描述和运行时验证
TS 类型系统
尽可能精准地描述类型,并且保证值与类型一致,可以让 TS 类型检查发挥最大作用。
-
让 TS 类型只能表达合法状态
- 始终保证值与类型一致,不要使用 'as', '!' 等方式强制转换类型
TS 类型系统

- 构造函数声明可以不传参数
- 属性 x 和 y 类型声明是不为空的
- 如果构造时不传参数,得到的实例实际处于非法状态(x,y 为空)
TS 类型系统
- 构造 Vector2dData 时未传入参数,导致该实例的 x, y 属性为空
- 序列化库对为空的字段抛出错误


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 来自外部,则应由错误处理代码检查并处理无效值
-
如果变量来自可信赖的内部源,并且代码的设计是基于输入都是有效的这一假设,则适合使用断言
错误处理的具体方式
方式 1:使用一个中性值
有时对错误数据的最佳响应是继续运行并返回一个已知的无害的值。
-
在游戏软件中,绘制程序可能使用默认的背景色来处理错误的颜色输入。
-
web 应用中,使用写在代码中的一份默认配置作为后端接口异常时的替代值是常见的做法。
-
在显示癌症患者的 X 光数据时,如果显示“中性值”,则可能会导致不准确的诊断,因此关闭程序会更好。
错误处理的具体方式
方式 2:使用下一个有效数据
在处理连续输入时,有些情况下可以简单地返回下一个有效数据。
-
如果从数据库中读取记录时遇到损坏的记录,可以继续读取直到找到有效记录。
-
如果每秒从温度计读取 100 次数据,有一次未获得有效读取,可以等待 1/100 秒再读取。
错误处理的具体方式
方式 3:使用上一次的结果替代
-
如果温度计读取软件某一次没有获得读取数据,可以简单地返回上次的值。因为温度在 1/100 秒内通常变化不大。
-
在游戏软件中,如果检测到无效颜色,可能会简单地返回上一帧使用的颜色。
-
如果是在 ATM 机上进行交易授权,则可能不希望使用上次的结果,这会导致使用上一个用户的银行账户号码。
错误处理的具体方式
方式 4:使用最接近的合法值
-
比如,温度计有效值在 0 到 100 摄氏度之间:
-
如果检测到小于 0 的读数,可以替代为 0
-
如果检测到大于 100 的值,可以替代为 100
-
-
在字符串操作中:
-
如果获取到的字符串长度异常地小于 0,可以替代为 0
-
错误处理的具体方式
方式 5:记录异常信息
- 当检测到错误数据时,可以将异常信息记录到文件或服务器,然后继续运行。
- 可以与其他技术结合,如使用最接近的合法值或下一个有效数据替代。
错误处理的具体方式
方式 6:返回错误码
将错误通知给其他模块处理,具体机制包括:
-
设置状态变量的值
-
将状态码作为函数的返回值返回
-
使用语言的内置异常机制抛出异常
错误处理的具体方式
方式 7:调用专门的错误处理接口
将错误处理集中在一个全局错误处理模块中:
-
好处是可以集中处理错误责任,方便调试
-
缺点是整个程序都将了解这个模块并与其耦合
错误处理的具体方式
方式 8:在错误发生的地方显示错误信息
-
最大限度地减少了错误处理的开销
-
可能会导致 UI 与逻辑分离的难度加大
错误处理的具体方式
方式 9:关闭系统
这种方法在安全关键应用程序中非常常见。
-
例如,当控制癌症治疗的放射设备的软件收到错误的辐射剂量输入,关闭程序是最佳选择。此时宁愿重新启动机器,也不冒错误输送剂量的风险。
-
例如,默认情况下,即使安全日志已满 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;
}正确性与健壮性
-
正确性:从不返回不准确的结果,结束程序比返回不准确的结果更好
- 注意,这里的“结束程序”不意味着结束整个程序(进程),也可能是结束某个相对独立的子模块
-
健壮性:总是尝试让软件继续运行,即使这有时会导致结果不准确
正确性与健壮性
取决于错误发生的模块的具体类型和使用场景
-
安全关键应用程序往往更倾向于正确性,结束程序比返回不准确的结果更好
-
医疗放射机控制软件是一个很好的例子
-
-
消费级程序往往更倾向于健壮性,任何结果通常比直接关闭软件更好
-
文字处理软件偶尔在屏幕底部显示不完整,如果检测到这种情况,软件可以不必关闭,因为下次滚动页面时屏幕会刷新,显示将恢复正常。
-
决定应该使用哪种策略
通用模块
越底层越通用的接口,对业务场景越没有感知,通常难以判断返回不准确的结果会有什么后果。可以考虑:
- 通过异常机制,将无法处理的情况通知给上层调用者,由他们根据当前业务场景决定如何处理(确保调用者提前知晓可能抛出的异常)
- 提供配置项,在接口调用前由调用者决定优先采用什么策略,例如:抛出异常,返回默认值,重试等等
- 例如,数据请求接口允许设置超时时间,如果不设置则不超时,否则在超时后抛出异常
决定应该使用哪种策略
业务模块
在理解业务场景,理解用户预期的基础上,尽可能保证健壮性:
- 区分核心依赖和非核心依赖
- 非核心依赖出错时一般都可以降级、恢复从而保证健壮性
- 核心依赖出错时一般难以降级,需要保证正确性
- 对非核心依赖选取合适的降级方案
- 给降级的情况添加监控
决定应该使用哪种策略
业务模块
如果必须保证正确性,要考虑:
- 要结束的模块的边界
对于酷家乐工具前端来说,常见的业务模块单元包括:
- IdleToolFeature
- Tool
- React Component
- 较独立的 UI 单元,例如弹窗,面板
- Entity Drawable
- Entity
- 由 StateManager 的多次方法调用组合出的一个 action
- 由响应式机制驱动的数据联动逻辑
- DocumentSource
- app mode
- 整个应用
决定应该使用哪种策略
业务模块
如果必须保证正确性,要考虑:
- 优雅地结束程序或模块,清理模块的非法状态
- 有的模块容易以模块粒度结束,有的则很难,只能将整个应用结束
- 让用户理解发生了什么
- 通过交互提示告诉用户发生了什么问题,当前系统是否仍然可用
酷家乐工具的一般策略
React 模块(健壮性)
- 视图
-
在正常情况下,任一 component 的生命周期回调抛出异常会导致整个页面渲染失败(白屏),但工具内做了兜底处理。即使业务方不添加任何容错逻辑:
- render 及 render 前的生命周期回调内的异常只会让该组件渲染空内容
- render 后的生命周期回调内的异常则只会影响该回调
- 业务可以更进一步,通过 React 的 error-boundary 机制 拦截模块下 component 子树下生命周期回调里的异常,根据具体情况选择处理方案,提供更好的降级方式。
-
在正常情况下,任一 component 的生命周期回调抛出异常会导致整个页面渲染失败(白屏),但工具内做了兜底处理。即使业务方不添加任何容错逻辑:
- 事件处理
- React 的事件回调里的异常只会导致后续的同步代码不执行,不会导致渲染空内容。
- 如果有错误的预期,一般只需要考虑清理交互状态和添加监控。
酷家乐工具的一般策略

酷家乐工具的一般策略
场景视图模块(健壮性)
- MeshComponent,PixiComponent
- 与常规 React 视图模块类似,区别在于 mesh 是在 componentDidMount 和 componentDidUpdate 内更新的
- 如果 mesh 更新逻辑有出现异常的可能,则应该对具体的更新函数添加 try-catch 并处理
- 如果业务代码不添加任何容错逻辑,一般会导致该 component 实例对应的 mesh 渲染异常,但不会导致大规模错误
- Entity Drawable
- KAF 会拦截 create, update, dispose 回调内的异常,如果业务代码不添加任何容错逻辑,该 drawable 对应的 mesh 创建或更新会失败,但不会导致大规模错误
酷家乐工具的一般策略
场景交互事件处理模块,即 Tool 模块(健壮性或正确性)
- 交互事件回调内的错误一般会让该回调内部代码不再执行,这可能产生非法的交互状态
- 如果这些交互状态非常短暂,会在下一次回调触发时修正,则采取健壮性策略,让该模块继续执行
- 如果这些交互状态会用于修改 model 层,则采取正确性策略,结束该模块(退出本次交互功能)
酷家乐工具的一般策略
数据模块,即 model 层(健壮性或正确性)
- 如果能够实现事务机制,确保数据逻辑要么完整更新要么完整失败,那么可以采取健壮性策略,以事务为粒度将其取消后继续运行
- 如果难以做到这一点,出现异常后数据处于难以修正的非法状态,此时应采用正确性策略,阻止继续编辑
酷家乐工具的一般策略
- CSS 也需要考虑不同情况,确保健壮性,例如:
- 文本超长
- 不同图片比例
- 不同颜色主题
- 容器尺寸变化
- 内容为空或内容过多
- z-index 如何设置,避免被遮挡
异常机制(throw, try-catch)
什么是异常
-
异常是一种将异常事件传递给调用它的代码的手段
-
异常事件是指一段代码遇到它无法处理的意外情况
-
本质上是对错误上下文没有感知的代码将控制权交还给调用者,调用者可能更理解业务场景并采取有效的行动
-
基本结构是一个使用 throw 来抛出异常对象,在上层调用代码中通过 try-catch-finally 捕获这些异常并处理
异常机制(throw, try-catch)
什么时候应该抛出异常
-
仅对无法通过其他编码方式解决的情况抛出异常
-
异常是在处理意外和增加复杂性之间的权衡
-
异常要求接口的调用者了解可能在接口内部抛出的具体异常,而这削弱了接口的封装
-
-
如果错误情况可以在本地处理,就在本地处理而不要抛出(不要使用异常推卸责任)
异常机制(throw, try-catch)
在正确的抽象层级抛出异常
-
抛出的异常是接口的一部分,应该与接口常规的输入输出呈现一致的抽象层级。以下面的 Java 代码为例:
- EOFException 暴露了方法的内部实现细节,即需要从文件读取数据,而这是不应该对外暴露的实现细节
- EmployeeDataNotAvailable 则更符合该方法的抽象层级,与它的语义是匹配的
异常机制(throw, try-catch)
在正确的抽象层级抛出异常
class Employee {
// ...
public TaxId GetTaxId() throws EOFException {
// ...
}
// ...
}class Employee {
// ...
public TaxId GetTaxId() throws EmployeeDataNotAvailable {
...
}
// ...
}异常机制(throw, try-catch)
在 TS 中如何注明函数可能抛出的异常
在 TS 里没有类似 Java 的受检异常机制,只能通过接口注释告知调用者:


异常机制(throw, try-catch)
TS 中 catch 的参数类型
- 在 TS 中,使用 try-catch 捕获到的参数类型是 unknown,这是因为 JS 的错误捕获机制会捕获 try block 下的所有错误,而不是像 Java 那样只捕获 catch 声明的错误类型。
- 调用者需要对其进行识别和筛选,只处理自己能够处理的异常。而不要使用强制类型转换,避免产生新的问题。
try {
...
// lots of code
...
} catch (error) { // 这里,error 的类型是 unknown
// empty
}异常机制(throw, try-catch)
- 在 Java 等语言中,可以通过下面的方式分别处理不同类型的错误,而 JS 没有这样的机制。
try {
...
// lots of code
...
} catch ( ExceptionA exception ) {
// handle ExceptionA 类型的错误
} catch ( ExceptionB exception ) {
// handle ExceptionB 类型的错误
}
// 其他这里未声明的错误类型会继续向上抛出规范化项目中异常的使用
- 在 TS 或 JS 生态中,异常处理的语言机制和标准库都比较薄弱。也没有形成广泛使用的异常处理惯例或最佳实践。还需要更多探索和实践。
- 要避免滥用异常机制:
- 对接口调用者来说如果每个接口都声明自己会抛出异常,编程过程会非常痛苦。
规范化项目中异常的使用
为了避免异常带来巨大的心智负担,需要为项目制定规范:
-
在什么情况下可以抛出异常
-
在什么情况下可以使用 throw-catch 在本地执行错误处理
-
哪些值能够被 throw(在 JS 里可以 throw 任何值)
-
创建项目的公共异常基类
-
从而支持集中和标准化的日志记录、错误报告等
-
-
是否使用全局统一的错误上报模块
阻隔错误
-
指定某些接口作为“安全区域”的边界
-
在数据穿越边界时,检查数据的有效性,并在数据无效时进行处理,确保错误不会扩散到整个系统中。例如:
-
类的公共方法假定数据不安全,并负责检查数据和对其进行消毒
-
一旦数据被类的公共方法接受,私有方法就可以假定数据是安全的
-
-
-
关键是如何决定哪部分代码在安全区域内,哪部分在外部
-
这是一个架构决策,需要根据具体场景决定
-
“阻隔” 不是一种具体的编程机制,而是一种抽象概念。
阻隔错误
-
通过阻隔能够清楚地区分断言和错误处理的使用场景:
-
阻隔之外的代码应该使用错误处理,因为无法对数据做出任何假设。
-
阻隔之内的代码应该使用断言,因为传递给它们的数据应该在传递过阻隔之前已经过消毒。
-
如果阻隔内的代码检测到错误数据,那是程序内部的错误,而不是数据的错误。
-
-
阻隔和断言之间的关系
调试辅助
-
生产版本不应该向用户暴露危险操作,而开发版本可以添加危险的调试接口
-
生产版本必须节约资源,而开发版本允许大量使用资源
-
生产版本必须运行得快,而开发版本可能可以运行得慢
-
例如在调试模式下,Microsoft Word 在空闲循环中每隔几秒检查一次文档对象的完整性。这有助于快速检测数据损坏,并使错误诊断更加容易。
-
在开发版本牺牲性能和资源换取调试便利性,从而更容易发现问题
调试辅助
在开发版本牺牲性能和资源换取调试便利性

调试辅助
在开发版本牺牲性能和资源换取调试便利性

调试辅助
-
使用断言,在遇到非法输入时中止程序
-
将错误日志发送到企业微信
开发和测试期间,使用“进攻式”编程:让问题变得足够明显和痛苦

调试辅助
-
在开发和生产之间灵活地切换调试辅助工具,并在发布前移除不必要的调试代码,确保程序的性能和质量:
-
使用构建工具可以从相同的源文件中构建程序的不同版本
-
生产环境移除不必要的调试代码
谨慎使用防御性编程
过度的防御性编程会产生问题
-
优先考虑核心业务和关键路径
- 如果在每一个地方以每一种可能的方式检查数据:
- 程序将变得臃肿且运行缓慢
- 增加了代码的复杂性,代码将难以维护
谢谢
防御性编程前端实践
By yiliang_wang
防御性编程前端实践
- 13