有(出)应用程序崩溃,拜托!
已发表: 2020-02-12程序员试图避免他们的代码崩溃。 如果有人使用他们的应用程序,它不应该意外中断或退出。 这是最简单的质量衡量标准之一——如果一个应用程序经常崩溃,它可能做得不好。
当程序即将执行未定义或错误的操作(例如将值除以零或访问机器上的受限资源)时,就会发生应用程序崩溃。 它也可能由编写应用程序的程序员显式完成。 “那永远不会发生,所以我会跳过它”——这是很普遍的想法,也不是完全不合理的想法。 有些情况是不可能发生的,永远不会发生,直到……它确实发生了。
失信的诺言
我们知道某事不可能发生的最常见情况之一是 API。 我们在后端和前端之间达成了一致——这是您可以针对此请求获得的唯一服务器响应。 这个库的维护者已经记录了这个函数的行为。 该函数不能做任何其他事情。 这两种思维方式都是正确的,但都可能导致问题。
当您使用库时,您可以依靠语言工具来帮助您处理所有可能的情况。 如果您使用的语言缺少任何形式的类型检查或静态分析,您必须自己处理。 不过,您可以在运送到生产环境之前检查一下,所以这没什么大不了的。 这可能很困难,但是您在更新依赖项和编写单元测试之前阅读了变更日志,对吗? 无论是使用库还是创建库,您都可以为您的代码和其他程序员提供更好的类型。
后端 - 前端通信有点困难。 它通常是松散耦合的,因此可以轻松地在一侧进行更改,而无需意识到它将如何影响另一侧。 后端的更改通常会打破您对前端的假设,并且两者通常是分开分布的。 它必须以糟糕的方式结束。 我们只是人类,有时我们不了解对方或忘记告诉他们那个小小的变化。 同样,这对于正确的网络处理来说并不是什么大问题——解码响应将失败,我们知道如何处理它。 即使是最好的解码代码也会受到不良设计的影响……
部分函数。 糟糕的设计。
“我们这里会有两个布尔变量:‘isActive’和‘canTransfer’,当然你不能在它不活跃的时候进行转账,但这只是一个细节。” 从这里开始,我们的糟糕设计可能会受到重创。 现在有人会用这两个参数创建一个函数并根据它处理一些数据。 最简单的解决方案是……只是在无效状态下崩溃,它不应该发生,所以我们不应该关心。 我们有时甚至会关心并留下一些评论以供稍后修复或询问应该发生什么,但最终可以在不完成该任务的情况下发货。
// 伪代码 函数 doTransfer(Bool isActive, Bool canTransfer) { If ( isActive 和 canTransfer ) { // 为可用的传输做一些事情 } else if ( 不是 isActive 也不是 canTransfer ) { // 做一些转移不可用的事情 } else if ( isActive 而不是 canTransfer ) { // 做一些转移不可用的事情 } else { // aka (不是 isActive 和 canTransfer ) // 有四种可能的状态 // 这不应该发生,传输不应该在不活动时可用 碰撞() } }
这个例子可能看起来很傻,但有时你可能会陷入那种比这更难发现和解决的陷阱。 你最终会得到一个叫做偏函数的东西。 这是一个仅针对某些可能的输入定义的函数,忽略或与其他输入发生冲突。 您应该始终避免使用部分函数(请注意,在动态类型语言中,大多数函数都可以被视为部分函数)。 如果您的语言无法确保类型检查和静态分析的正确行为,它可能会在一段时间后以意想不到的方式崩溃。 代码在不断发展,昨天的假设今天可能无效。
快速失败。 经常失败。
你怎么能保护自己? 最好的防守就是进攻! 有这样一句好话:“快速失败。 经常失败。” 但是我们不是刚刚同意我们应该避免应用程序崩溃、部分功能和糟糕的设计吗? Erlang OTP 为程序员提供了一个神话般的优势,即它会在意外状态后自我修复并在运行时更新。 他们负担得起,但并不是每个人都有这种奢侈。 那么我们为什么要快速而频繁地失败呢?
首先,要找到那些意想不到的状态和行为。 如果您不检查您的应用程序状态是否正确,则可能导致比崩溃更糟糕的结果!
其次,帮助其他程序员在相同的代码库上进行协作。 如果你现在一个人在一个项目中,可能会有其他人在你之后。 您可能会忘记一些假设和要求。 在一切正常之前不阅读提供的文档或根本不记录内部方法和类型是很常见的。 在这种状态下,有人调用了一个具有意外但有效值的可用函数。 例如,假设我们有一个“等待”函数,它接受任何整数值并等待该秒数。 如果有人将'-17'传递给它怎么办? 如果这样做后它没有立即崩溃,则可能会导致一些严重的错误和无效状态。 它是永远等待还是根本不等待?

故意崩溃最重要的部分是优雅地进行。 如果您的应用程序崩溃,您必须提供一些信息以进行诊断。 当你使用调试器时这很容易,但你应该有一些方法来报告没有它的应用程序崩溃。 您可以使用日志系统在应用程序启动之间保留该信息或在外部查看它。
故意崩溃的第二个最重要的部分是避免在生产环境中发生这种情况......
不要失败。 曾经。
您最终将发布您的代码。 您无法使其完美,甚至考虑做出正确性保证通常都太昂贵了。 但是,您应该确保它不会出现异常或崩溃。 既然我们已经决定快速且频繁地崩溃,你怎么能做到这一点?
故意崩溃的一个重要部分是只在非生产环境中进行。 您应该使用在应用程序的生产版本中被剥离的断言。 这将在开发过程中有所帮助,并允许在不影响最终用户的情况下发现问题。 但是,有时最好还是崩溃以避免无效的应用程序状态。 如果我们已经制作了部分函数,我们如何实现这一点?
使未定义和无效状态无法表示,否则回退到有效状态。 这听起来很容易,但需要大量的思考和工作。 不管它有多少,它总是比寻找错误、进行临时修复和……烦人的用户少。 它会自动使某些部分功能不太可能发生。
// 伪代码 函数doTransfer(状态状态){ 开关(状态){ 案例状态.canTransfer { // 为可用的传输做一些事情 } case State.cannotTransfer { // 做一些转移不可用的事情 } 案例状态.notActive { // 做一些转移不可用的事情 } // 如果没有激活,就不可能表示可用的传输 // 只有三种可能的状态 } }
如何使无效状态成为不可能? 让我们选择前面的两个例子。 在我们的两个布尔变量“isActive”和“canTransfer”的情况下,我们可以将这两个变量更改为单个枚举。 它将详尽地表示所有可能和有效的状态。 即使这样,也有人可以发送未定义的变量,但这更容易处理。 这将是一个无效值,不会被导入我们的程序,而不是在内部传递无效状态,这会使一切变得更加困难。
我们的等待功能也可以在强类型语言中得到很好的改进。 我们可以让它在输入时只使用无符号整数。 仅此一项就可以解决我们所有的问题,因为无效的参数将被编译器删除。 但是如果你的语言没有类型怎么办? 我们有一些可能的解决方案。 首先——只是崩溃,这个函数对于负数是未定义的,我们不会做无效或未定义的事情。 我们将不得不在测试期间发现它的无效使用。 单元测试(无论如何我们都应该这样做)在这里非常重要。 第二——这可能是有风险的,但取决于上下文可能是有用的。 我们可以回退到有效值,在非生产构建中保持断言,以尽可能修复无效状态。 对于这样的函数,这可能不是一个好的解决方案,但如果我们将整数设为绝对值,我们将避免应用程序崩溃。 根据具体的语言,抛出/引发一些错误/异常也可能是一个好主意。 如果可能的话,回退可能是值得的,即使用户看到错误,这也比崩溃要好得多。
让我们在这里再举一个例子。 如果您的前端应用程序中的用户数据状态在某些情况下即将失效,最好强制注销并再次从服务器获取有效数据,而不是崩溃。 无论如何,用户可能会被迫这样做,或者可能会陷入无休止的崩溃循环。 再一次——我们应该在非生产环境中的这种情况下断言和崩溃,但不要让你的用户成为外部测试人员。
概括
没有人喜欢崩溃和不稳定的应用程序。 我们不喜欢制造或使用它们。 在开发和测试期间提供有用诊断的断言快速失败将及早发现很多问题。 生产中有效状态的回退将使您的应用程序更加稳定。 使无效状态不可表示将排除一整类问题。 在开发之前给自己多一点时间思考如何去除和回退无效状态,并在编写过程中多花一点时间来包含一些断言。 您可以从今天开始改进您的应用程序!
阅读更多:
- 合同设计
- 代数数据类型