为防御性编程辩护
[注:本文中我引用了我编写的一个名为 `allow` 的验证库allow。它现在已打包成 NPM 包,可以在这里找到:https://www.npmjs.com/package/@toolz/allow ]
我的老读者(就他们俩)都知道,我写过很多关于应用程序不同部分之间传递的值的完整性问题。有时,我们会添加手动验证。有时,这些值根本不会检查。有时,我们会在编译时检查它们,但我们假设它们在运行时是正确的(我说的就是你,TypeScript)。
无论采用何种方法,我最近才意识到,“防御性编程”这个词在很多程序员眼中通常带有贬义。我的印象是,“防御性编程”常常被解读为“为了验证数据而费尽心思,而这些数据可能根本就不需要验证”。我并不完全反对这种看法。但我担心有些人可能对防御性编程的概念过于反感,以至于他们意识不到自己代码中存在的其他漏洞。
基本假设
我们先确保大家理解一致。我确信防御性编程有多种定义。所以,为了本文的讨论,我将采用以下定义:
防御性编程:将程序的所有输入都视为“未知”甚至“敌对”数据。这种做法会将这些输入与主应用程序流程隔离开来,直到它们被验证符合“预期”的类型/值/格式。
我关注的是输入数据。虽然可以在定义数据的同一代码块内验证数据,这种做法当然是一种防御性措施,但也过于极端,甚至有些愚蠢。
但输入数据恰恰是防御性编程最强有力的论据。因为输入数据来自……其他地方。你不希望这个程序为了执行任务而了解其他程序的内部运作机制。你希望这个程序是一个独立的单元。但如果这个程序是独立的,那么它也必须假定任何输入都可能带有敌意。
验证地狱
这就是“防御性编程”一词变得令人反感的原因。当我们谈到验证所有输入时,我们担心这会导致类似这样的结果:
const calculatePassAttemptsPerGame = (passAttempts = 0, gamesPlayed = 0) => {
if (isNaN(passAttempts)) {
console.log('passAttempts must be a number.');
return;
}
if (isNaN(gamesPlayed)) {
console.log('gamesPlayed must be a number.');
return;
}
if (gamesPlayed === 0) {
console.log('Cannot calculate attempts-per-game before a single game has been played.');
return;
}
return passAttempts / gamesPlayed;
}
该函数有输入参数。而且该函数不应该知道这些输入参数的来源。因此,从函数的角度来看,所有输入参数都可能存在风险。
这就是为什么这个函数本身就存在一些重大缺陷。我们不能完全相信 `a`passAttempts或gamesPlayed`b` 是数字,因为 ` passAttemptsa` 和 ` gamesPlayedb` 是这个程序的输入。如果我们觉得有必要“防御性地”编写程序,最终就会在程序中塞入额外的验证代码。
说实话,在我看来,上面展示的验证方法甚至都不够充分。因为虽然我们确保输入的是数字,但我们并没有验证它们是否是正确类型的数字。
想想看:如果我们记录的是每场比赛的传球尝试次数,那么其中任何一个数值为负数合理吗?如果其中任何一个数值是小数,合理吗?我不记得上次有球员在一场比赛中传球 19.32 次是什么时候了。我也不记得上次有球员打了 -4 场比赛是什么时候了。如果我们想确保我们的函数始终能够提供最合理的返回值,我们也应该确保它始终接收到最合理的输入。所以,如果我们真的想全面采用防御性编程技术,我们就需要添加更多验证,以确保输入是非负整数。
但谁真的想做这么多呢?我们想要的只是一个简单的函数,返回除以passAttempts某个数的结果gamesPlayed,结果却写出了一堆臃肿不堪的代码。编写所有这些防御性验证感觉既费力又毫无意义。
那么,我们如何避免防御型编程带来的种种麻烦呢?以下是我最常遇到的一些方法(借口)。
只见树木不见森林
上面的图片是一片树林吗?还是一片森林?当然,这取决于你的参照物,它可能两者都是。但如果就此断定上面的图片中没有“树”而只有一片“森林”,那就很危险了。
同样地,当你查看这样的代码时,你看到了什么?
const calculatePassAttemptsPerGame = (passAttempts = 0, gamesPlayed = 0) => {
//...
}
const calculateYardsPerAttempt = (totalYards = 0, passAttempts = 0) => {
//...
}
const getPlayerName = (playerId = '') => {
//...
}
const getTeamName = (teamId = '') => {
//...
}
这是一个单一的程序(一片“森林”),还是由许多独立的程序(几棵树)组成?
一方面,它们都出现在同一个代码示例中。而且它们似乎都与某种中心化的玩家/团队/运动应用程序有关。这些函数很可能只会在一次运行时被调用。所以……它们都是同一个程序(一个“森林”)的一部分,对吧?
如果我们跳出这个过于简单的例子来思考,就会发现一个简单的事实是,我们应该始终努力使我们的函数尽可能地“通用”。
这意味着该函数可能只会在本示例中使用。但该函数也可能在整个应用程序中被引用数十次。事实上,有些函数非常实用,以至于我们最终会在多个应用程序中使用它们。
这就是为什么最好的函数都是独立运行的原子单元。它们自成一体。因此,它们应该能够独立于调用它们的应用程序之外运行。正因如此,我坚信:
每个函数都是一个程序。
当然,并非所有人都同意我的观点。他们认为每个功能都像一棵树,而他们只需要关注输入到整个程序(森林)中的所有数据即可。
这为开发者提供了一种便捷的方式来避免代码测试带来的种种麻烦。他们查看上面的例子后会说:“没有人会向其中传递布尔值,getPlayerName()因为它只会在我的程序内部getPlayerName()调用,而我知道我永远不会向其中传递像布尔值这样愚蠢的值。”或者他们会说:“没有人会向其中传递负数,因为它只会在我的程序内部调用,而我知道我永远不会向其中传递像负数这样愚蠢的值。”calculateYardsPerAttempt()calculateYardsPerAttempt()
如果你熟悉逻辑谬误,这些反驳论点基本上都属于诉诸权威的范畴。这些开发者把程序本身当作“权威”。他们想当然地认为,只要输入来自同一程序内部的其他部分,就不会出现任何问题。换句话说,他们说:“这个函数的输入没问题,因为‘程序’说它们没问题。”
这当然没问题——只要你的应用还很小。但一旦你的应用发展到真正强大、功能完善的程度,这种吸引力就荡然无存了。我不知道有多少次,我不得不排查代码故障(通常是我自己的代码),因为我发现某个函数出错的原因是传递了错误“类型”的数据——即使这些数据来自同一个程序内部的其他地方。
如果项目中有(或将来会有)两个或更多开发人员,这种“逻辑”就远远不够了。因为它依赖于一个愚蠢的想法:任何其他参与项目的人都永远不会以“错误”的方式调用函数。
如果项目规模庞大(或者将来会如此),以至于指望单个开发人员完全掌握整个程序是不切实际的,那么这种“逻辑”就再次显得远远不够。如果最终用户可以在表单字段中输入荒谬的值,那么其他程序员也同样可以尝试以荒谬的方式调用你的函数。如果你的函数内部逻辑如此脆弱,以至于一旦接收到错误数据就会崩溃——那么你的函数就糟糕透顶。
所以在我们继续之前,我想把话说清楚:如果你不验证函数输入的理由仅仅是认为你知道应用中所有调用函数的方式,那么我们真的没必要在同一个开发团队。因为你的编码方式不利于团队开发。
测试骗局
我发现很多开发者并不试图通过编写大量的防御性代码来解决输入不稳定的问题,而是通过编写海量的测试用例来“解决”这个问题(这是个技术术语)。
他们会写类似这样的内容:
const calculatePassAttemptsPerGame = (passAttempts = 0, gamesPlayed = 0) => {
return passAttempts / gamesPlayed;
}
然后,他们耸耸肩,否认该函数的脆弱性,并指出他们编写了大量的集成测试,以确保该函数只能以“正确”的方式调用。
需要明确的是,这种方法本身并非错误。但它只是将确保应用程序正常运行的真正工作转移到了运行时并不存在的那一组测试上。
例如,`maybe` 函数可能只会calculatePassAttemptsPerGame()在组件内部调用。因此,我们可以尝试编写一系列集成测试,以确保该函数永远不会使用“正确”数据以外的任何数据进行调用。PlayerProfile
但这种方法存在严重的局限性。
首先,正如我之前提到的,测试在运行时并不存在。它们通常只在部署之前运行/检查。因此,它们仍然需要开发人员的监督。
说到开发人员的监管……试图通过集成测试来彻底检验这个函数,意味着我们可以想到所有可能调用该函数的方式/位置。这很容易导致目光短浅。
在需要验证数据的地方直接加入验证代码要简单得多(在代码层面)。这意味着,当我们在函数签名内部或之后直接加入验证代码时,通常可以减少疏忽。让我简单解释一下:
测试固然重要,但它们永远无法完全替代数据验证。
显然,我并不是建议你完全放弃单元测试/集成测试。但如果你编写大量测试仅仅是为了确保函数在输入“错误”时功能正常,那么你的验证逻辑就如同玩障眼法。你试图通过把所有验证都塞进测试中来保持应用程序的“干净”。随着应用程序复杂性的增加(意味着每个函数的调用方式越来越多),你的测试也必须跟上步伐——否则你的测试策略最终会出现明显的盲点。
TypeScript 的错觉
Dev.to 的读者中有很大一部分人会带着自负的微笑读到这里,心想:“嗯,很明显——这就是你使用 TypeScript 的原因!” 而对于这些自负的开发者,我会说:“嗯……算是吧。”
我的两个老读者都知道,过去半年左右的时间里,我使用 TypeScript 经历了不少“冒险”。我并不反对TypeScript,但我对 TypeScript 拥趸们那些夸张的承诺也心存疑虑。在你给我贴上“TypeScript 死忠粉”的标签之前,让我先说说 TypeScript 的优势所在。
在应用内部传递数据时,TypeScript 非常有用。例如,如果你有一个辅助函数,它只在特定应用内部使用,并且你知道数据(及其参数)也只来自该应用内部,那么 TypeScript 就非常强大。它几乎可以捕获应用中每次调用该辅助函数时可能出现的所有关键错误。
它的实用性显而易见。如果辅助函数需要一个类型为 `T` 的输入number,而你在应用程序的任何其他位置尝试使用类型为 `T` 的参数调用该函数string,TypeScript 会立即报错。如果你使用的是任何类型的现代 IDE,这意味着你的编码环境也会立即报错。因此,当你尝试编写一些“无法正常工作”的代码时,你很可能会立即知道。
很酷吧?
但是……当数据来自应用外部时,情况就不同了。如果是处理 API 数据,你可以编写所有你想要的 TypeScript 类型定义——但如果接收到错误的数据,运行时仍然可能出错。处理用户输入也是如此。处理某些类型的数据库输入也是如此。在这些情况下,你仍然只能选择:A) 编写脆弱的函数,或者 B) 在函数内部添加额外的运行时验证。
这并非贬低 TypeScript。即使是像 Java 或 C# 这样的强类型面向对象语言,如果没有适当的错误处理,也容易出现运行时故障。
我注意到的问题是,太多 TypeScript 开发者把数据“定义”写在函数签名里——或者接口里——然后……就完事了。仅此而已。他们觉得自己“完成了工作”——即使那些漂亮的类型定义在运行时根本不存在。
TypeScript 的定义也(严重)受限于 JavaScript 本身提供的基本数据类型。例如,在上面的代码中,没有原生的 TypeScript 数据类型规定passAttempts必须是非负整数。你可以将其表示passAttempts为 `int` number,但这是一种弱验证——仍然容易受到函数调用方式“错误”的影响。因此,如果你真的想确保它passAttempts是“正确”的数据类型,最终还是需要编写额外的手动验证。
尝试接球的万福玛丽
我们还可以探索另一种避免防御性编程的方法:尝试-捕获。
try-catch 在 JS/TS 编程中显然有其用武之地。但作为防御性编程工具,它在验证输入方面却相当有限。这是因为 try-catch 只有在 JS 本身抛出错误时才真正有意义。但当我们处理异常输入时,经常会出现这样的情况:这些“错误”数据并不会导致直接的错误,而只是产生一些意料之外或不期望的输出。
请看以下示例:
const calculatePassAttemptsPerGame = (passAttempts = 0, gamesPlayed = 0) => {
try {
return passAttempts / gamesPlayed;
} catch (error) {
console.log('something went wrong:', error);
}
}
const attemptsPerGame = calculatePassAttemptsPerGame(true, 48);
console.log(attemptsPerGame); // 0.0208333333
try-catch 语句永远不会被触发,因为true / 48不会抛出错误。JS“贴心地”将其解释true为,1并且该函数返回的结果1 / 48。
其实没那么难
此时,还在阅读的读者们可能在想:“好吧……这个问题没有好的答案。防御性编程既繁琐又缓慢。其他技术又容易出现疏漏和失败。那么……该怎么办呢???”
我的答案是,防御性编程其实没那么难。有些人把“防御性编程”理解为“验证所有输入”,然后就想当然地认为验证所有输入必然是件苦差事。但事实并非如此。
我之前写过文章,介绍如何对所有接受输入的函数进行运行时验证。对我来说,这很简单。(如果您想了解更多信息,可以阅读这篇文章:https://dev.to/bytebodger/better-typescript-with-javascript-4ke5)
关键在于让内联验证快速、简单、简洁。没人愿意在每个函数里都塞进30行额外的验证代码。但——你完全不必这么做。
为了让您更直观地了解我的方法,请考虑以下例子:
import allow from 'allow';
const calculatePassAttemptsPerGame = (passAttempts = 0, gamesPlayed = 0) => {
allow.anInteger(passAttempts, 0).anInteger(gamesPlayed, 1);
return passAttempts / gamesPlayed;
}
该函数的所有运行时验证都在一行代码中完成:
passAttempts必须是整数,最小值为0。gamesPlayed也必须是整数,最小值为1。
就是这样。无需 TypeScript,无需复杂的库,也无需在每个函数中塞满冗杂的代码来手动验证所有参数。只需一次调用allow,如果函数需要两个或多个参数,还可以链式调用。
必须明确一点,这并非是我那简陋的、自研的验证库的(冗长)广告。我根本不在乎你用哪个库——或者你是否自己写一个。重点是,运行时验证不必那么复杂,也不必冗长繁琐。而且,它能为你的应用提供比任何仅在编译时进行验证的工具都更强大的整体安全性。
根深蒂固者的傲慢
那么,你是否应该重新考虑你对“防御性编程”的任何反感呢?嗯……可能不必。
我明白,你可能已经有一份靠编程谋生的工作了。而且在那份工作中,你可能已经和一些程序员共事,他们几年前就把所有的编程理念都固化下来了。那些陈词滥调早已深深扎根于他们的灵魂。如果你质疑这些,很可能会被驳斥——甚至被默默地鄙视。
不信?看看我上面链接的那篇文章就知道了。评论里有很多不错的反馈。但是,有一位……嗯……“先生”却只回复了一句:“真恶心……”
就这些。没有建设性反馈,没有理性逻辑,只有一句:“真恶心……”
而这基本上就是如今编程的本质。你甚至可以只用 JavaScript 代码就实现核聚变。但总会有人不加任何解释地跳出来说:“呸……”
所以……我明白了。真的明白了。继续写你的 TypeScript 文档吧。继续写你大量的测试用例吧。继续拒绝验证你的函数输入吧。因为那会是“防御性编程”。而防御性编程是不好的,懂吗?
我将继续编写容错性更高、代码行数更少的应用程序。
文章来源:https://dev.to/bytebodger/in-defense-of-defective-programming-k45







