十、应对失败

在本书的这一点上,我们认为我们的架构是合理的。 我们考虑了可伸缩性,并做出了所有适当的权衡,为可配置性牺牲了性能,等等。 可伸缩 JavaScript 架构的一个方面我们还没有深入探讨,那就是人为因素。 尽管我们很聪明,但我们是最薄弱的环节,因为我们设计应用,编写代码——我们非常擅长犯微妙的错误。

在我们完全脱离软件开发的等式之前,我们必须在设计组件时考虑到失败。 这涉及到思考失败模式——我们是快速失败,还是试图从错误中恢复? 它涉及到思考我们错误的质量——有些错误比其他错误更容易处理。 但同时也要理解我们的局限性; 我们不可能从每一个可能的错误中检测和恢复。

当我们扩展我们的应用时,我们处理失败的方法也需要扩展。 这是我们需要在许多其他规模影响中做出的另一个权衡。 让我们从快速失败模式开始。

快速失败

快速失效的系统或组件在出现故障时停止运行。 这听起来可能不是一个理想的设计特征,但请考虑另一种选择:系统或组件出现故障,但无论如何仍能继续运行。 这些组件可能以错误的状态运行,然而,如果系统或组件停止,则不可能。

有时我们会想要恢复一个失败的组件,我们将在本章的后面讨论这个主题。 在本节中,我们将介绍一些用于确定 JavaScript 组件是否应该快速失败的标准,以及对用户的影响。 有时,甚至我们的快速失败机制也会让我们失败,这也是我们需要考虑的。

使用质量约束

当我们的组件快速失效时,通常是由于已知的错误状态。 另一方面,一些完全意想不到的事情可能会发生。 在任何一种情况下,它都可能使我们的组件处于糟糕的状态,我们不希望应用像一切正常一样运行。 让我们专注于当质量限制没有得到满足时的快速失败。 这些是关于应用行为的断言。 例如,我们不应该尝试发送 API 请求超过三次; 我们不会等待超过 30 秒的响应—模型的这个属性应该总是有一个非空字符串,等等。

当这些断言被证明为 false 时,就该停止执行了——要么是一个组件,要么是整个系统。 我们这样做并不是为了惹恼用户。 就像任何失败一样,我们希望它们尽可能少发生。 想想失败——就像汽车事故中安全气囊迅速打开一样——一旦发生,我们的车就不能再开了。

在某些情况下,做出一个组件或整个系统快速失效的决定,不应该掉以轻心。 例如,如果我们在某个地方快速失败,因为某个特性团队因此实现了它,而其他团队不知道原因,那么整个应用就会开始失败。 同时,事实证明,这是设计好的,是预期的行为。 这种失效模式需要有严格的理论基础。 真正有助于讨论快速失败场景的是,如果应用不受阻碍地继续下去,可能会发生灾难性的结果。

Using quality constraints

当违反这些约束时,会导致组件快速失败,可能会导致整个应用快速失败

提供有意义的反馈

我们不想让用户或我们开发团队的其他成员错误地理解为什么我们的软件不能在某些情况下运行。 这意味着我们必须区分快速失败和完全无法控制的失败。 后者会破坏我们的应用,并可能导致浏览器选项卡崩溃。 或者更糟的是,它还活着,在地板上爬来爬去,给用户一种它还在工作的印象,一直在造成伤害。

这意味着当我们快速失败时,我们必须让用户清楚地知道某些东西已经停止工作了,他们不应该继续使用它。 无论是单个组件失败,还是整个应用失败,我们都必须使消息传递清晰而简洁。 用户并不总是需要知道哪里出了问题; 他们只需要知道组件或应用当前是坏的,他们所做的任何事情都不能工作。

这实际上是在我们的体系结构中引入快速失败的一个重要结果——我们在某些条件下获得了响应性。 我们从不让用户猜测。 当然,在我们面前出现一个坏了的软件是很烦人的,但等待、尝试和等待更多的时间,才发现它坏了更烦人。 有了一个明确的信息,说明应用不工作,或者它的一部分不工作,我们可能想要从物理上阻止用户与它交互。 例如,通过在元素上抛出一个div覆盖层或关闭 DOM 事件处理程序。

下面的示例显示了两个错误处理程序。 第一种方法通过禁用按钮隐式地处理错误。 另一个回调做同样的事情,但也显式地显示一个错误消息:

// The DOM elements...
var error = document.getElementById('error'),
    fail1 = document.getElementById('fail1'),
    fail2 = document.getElementById('fail2');

// The first event merely disables the button.
function onFail1(e) {
    e.target.disabled = true;
}

// The second event disables the button, but
// also explicitly informs the user about what
// went wrong.
function onFail2(e) {
    e.target.disabled = true;
    error.style.display = 'block';
}

// Setup event handlers...
fail1.addEventListener('click', onFail1);
fail2.addEventListener('click', onFail2);

当我们不能快速失败时…

我们可以在组件中设计快速失效机制。 我们不能保证这些机制本身不会失败。 也就是说,我们为了保护自己而编写的代码是由我们自己编写的。 等等,等等。 我们可以继续编写一层又一层的错误处理代码,当它下面的一层出现故障时,这些代码会快速而优雅地失败。 但目的何在?

我们所面临的挑战之一是,我们不能总是预见到失败。 因为,在某种程度上,我们必须专注于我们真正想要提供的功能,而不是支撑它的脚手架。 无关的故障处理代码不会让我们的产品变得更好,它只是以代码的形式增加了批量。 如果我们试图专注于我们正在构建的功能,那么我们想要快速失败的明显情况就会暴露出来。

故障检测代码的问题是,它需要与应用的其余部分一起伸缩,外部伸缩影响者指导其发展。 例如,更多的用户意味着更多的后端需求。 这意味着我们的故障检测代码很有可能永远不会到达——我们如何解释这种情况? 我们没有。 因为试图解决这样的问题,是无法规模化的。 从一开始就试图阻止它们的发生是一种更有成效的努力。

容错

具有容错能力的系统能够在组件发生故障时存活下来。 这可以通过纠正组件中的错误,或者用一个新实例替换有缺陷的组件来实现。 把容错能力想象成一架只使用一个引擎降落的飞机——乘客是我们的用户。

通常,我们在大型服务器环境的上下文中听到容错。 如果有足够的复杂性,这在前端开发中也是可行的概念。 在本节中,我们将首先考虑如何将组件划分为关键组件和非关键组件。 然后,我们将继续讨论检测错误,以及如何处理错误,以便应用能够继续运行。

对关键行为进行分类

就像有些关键的代码片段不能被其他线程中断一样,在我们的应用中也有一些组件不能优雅地失败。 有些组件无论如何都必须工作,如果它们不能工作,那么它们需要快速失效,以避免造成进一步的损害。 这就是为什么我们需要对组件进行分类。 虽然给定的组件必须按预期运行,这似乎是显而易见的,但以某种方式对它们进行一致的分类是有意义的。 在整个组织中推广这样的想法是个好主意。

当我们知道哪些组件是关键的,我们就知道它们必须工作,而且不存在它们需要恢复的可能情况。 如果这些组件失败了,那么就有一个需要修复的 bug。 我们还可以通过单元测试来瞄准这些关键组件。

为组件设置关键层并不是一个好主意。 例如,一个级别是绝对关键的组件,下一个级别是不关键但太重要而不能被视为常规的组件,等等——这违背了目的。 没有这个成分我们可以生存,也可以不生存。 这种简单性让我们可以将组件分为两类,给它们贴标签要比累死它们简单得多。 任何不重要的东西都有可能容忍故障,所以我们可以开始考虑这些组件的故障检测和恢复设计。

Classifying critical behavior

关键组件,相对于容忍错误的其他组件

检测和包含错误行为

我们的组件应该彼此分离,如果我们设计的架构可以很好地伸缩。 这种脱钩的部分原因是错误。 导致一个组件失败的错误不应该导致另一个组件失败。 如果我们能接受这个咒语,其他的事情就会变得简单。 因为如果一个部件发生故障,我们可以有把握地说,故障不是由另一个部件引起的。 从这里开始,找出原因并提供解决方案基本上就更简单了。

如果我们有类似事件代理这样的东西,那么将一个组件中的错误从其他组件中解耦就会简单得多。 如果所有组件间的通信都是代理的,那么就是实现检测错误和防止错误传播到其他组件的机制的好地方。 例如,如果一个组件接收到一个事件并运行一个失败的回调函数,它可能会对整个应用产生副作用,甚至可能导致它完全失败。

相反,事件代理将检测此错误,例如抛出异常或回调函数返回的错误状态代码。 在异常的情况下,它不会找到它的方式上的调用堆栈,因为它被捕获。 事件队列中的下一个处理程序然后可以接收关于失败处理程序的信息——因此它们可以决定要做什么,也许什么也不做。 重要的是,包含错误,并将其发生的情况传达给其他组件。

下面的例子展示了一个事件代理,它能够检测错误并将错误转发给事件的下一个回调:

// events.js
// The event broker...
class Events {

    // Trigger an event...
    trigger(name, data) {
        if (name in this.listeners) {
            // We need to know the outcome of the previous handler,
            // so each result is stored here.
            var previous = null;

            return this.listeners[name].map(function(callback) {
                var result;

                // Get the result of running the callback. Notice
                // that it's wrapped in an exception handler. Also
                // notice that callbacks are passed the result
                // of the "previous" callback.
                try {
                    result = previous = callback(Object.assign({
                        name: name
                    }, data), previous);
                } catch(e) {
                    // If the callback raises an exception, the
                    // exception is returned, and also passed to
                    // the next callback. This is how the callbacks
                    // know if their predecessor failed or not.
                    result = previous = e;
                }

                return result;
            });
        }
    }

}

var events = new Events();

export default events;

// main.js
import events from 'events.js';

// Utility for getting the error message from
// the object. If it's an exception, we can return
// the "message" property. If it has an "error"
// property, we can return that value. Otherwise,
// it's not an error and we return "undefined".
function getError(obj) {
    if (obj instanceof Error) {
        return obj.message;
    } else if (obj && obj.hasOwnProperty('error')) {
        return obj.error;
    }
}

// This callback will be executed first, since it's
// the first to subscribe to the event. It'll randomly
// throw errors.
events.listen('action', (data, previous) => {
    if (Math.round(Math.random())) {
        throw new Error('First callback failed randomly');
    } else {
        console.log('First callback succeeded');
    }
});

// This callback is second in line. It checks if the
// "previous" result is an error. If so, it will exit
// early by returning the error. Otherwise, it'll randomly
// throw its own error or succeed.
events.listen('action', (data, previous) => {
    var error = getError(previous);
    if (error) {
        console.error(`Second callback failed: ${error}`);
        return previous;
    } else if (Math.round(Math.random())) {
        throw new Error('Second callback failed randomly');
    } else {
        console.log('Second callback succeeded');
    }
});

// The final callback function will check for errors in
// the "previous" result. What's key here is that only
// one of the preceding callbacks will have failed. Because
// the second callback doesn't do anything if the first
// callback fails.
events.listen('action', (data, previous) => {
    var error = getError(previous);
    if (error) {
        console.error(`Third callback failed: ${error}`);
        return previous;
    } else {
        console.log('Third callback succeeded');
    }
});

events.trigger('action');

Detecting and containing errant behavior

包含错误意味着一个组件发出的错误不会影响其他组件

禁用有缺陷的部件

当我们在整个应用中快速失败时,是因为我们试图避免更糟糕的问题出现。 但是,如果一个组件与系统中的其他组件完全解耦有问题怎么办? 我们可以尝试从失败中恢复,但这不总是可能的——如果有错误,唯一的恢复选择是修补代码。 与此同时,我们可以在没有恢复选项时禁用该组件。

这样做有两个目的。 首先,错误组件在系统中传播其问题的可能性更小。 其次,禁用组件或完全隐藏它,将阻止任何用户交互。 这意味着用户反复尝试最终导致其他错误的机会更少。 它不应该,因为组件是孤立的,但我们仍然不总是知道我们的设计哪里有缺陷。

在解决了问题组件后,我们可以感到些许安慰,即用户并非完全没有运气。 只是这个系统有一个方面是它们不能相互作用的。 这给了我们一点时间来诊断问题和修补问题组件。

设计问题是谁负责禁用组件——是组件本身,还是某个核心组件负责检测问题? 一方面,组件关闭自己是一个好主意,因为安全关闭可能涉及几个步骤,以保持其余组件平稳运行。 另一方面,让事件代理之类的东西在遇到有问题的组件时关闭它们,将错误处理放在一个地方。 我们采取的方法实际上取决于最简单的可能的解决方案。 如果事件代理可以安全地做到这一点,那么可能是最好的选择。

Disabling defective components

禁用的组件不会与系统的其他部分交互,这降低了有问题的组件引起问题的可能性

优雅地降级功能

当检测到错误时禁用组件是一回事。 处理失败的组件并从 UI 中优雅地删除它是另一回事。 尽管我们努力保持组件之间的松散耦合,但当涉及到 DOM 时,这完全是另一个问题。 例如,我们是否可以在不干扰周围元素的情况下删除失败组件的 DOM 元素? 或者,我们是否最好保留元素的位置,但在视觉上禁用它们并关闭任何 JavaScript 事件处理程序?

我们采用的方法取决于我们构建的是什么,也就是说,我们的应用的性质。 有些应用很容易添加和删除特性,部分原因在于组件的组合,但也在于 UI 的总体布局。 不要认为视觉设计只是一个皮肤,可以与应用的其余部分分离,不会产生任何后果。 理论上,它应该与系统的其他部分脱钩,但在实践中,这个概念不能伸缩。 如果我们想要规模,页面上元素的布局是相关的,比如组件失败的原因,以及在其他地方禁用或删除它们而不产生副作用的能力。

我们应该将处理失败的组件视为关闭它们,因为通常需要执行一些操作——这样我们就可以优雅地降低用户体验。 很少会出现整个功能失效的情况——只有一个组件,比如路由,才会导致某个功能失效。 因此,如果我们关闭给定组件的路由处理程序,我们将需要关闭其他组件,以便从 UI 中删除该特性,并为用户显示错误消息,等等。 对于我们构建的任何给定特性,都需要考虑和测试这些关闭语义。 我们想要保护的并不是功能本身; 相反,我们正在保护系统的其余部分不受该特性的影响。

Gracefully degrading functionality

一个集合组件失败,导致整个特性退出服务; 但是整个应用仍然是功能性的

故障恢复

在前一节中,我们开始考虑前端代码中的容错问题。 也就是说,我们的应用需要在丢失失败的组件后存活下来——至少在短期内是这样。 但如果我们可以从某些错误中恢复呢? 因此,我们不会在检测到错误后关闭组件,而是采取一些替代的行动; 一个仍然能让用户满意的。

在本节中,我们将研究组件从失败操作中恢复的各种方法。 例如,我们可以重试操作,或者通过重新启动组件来清除组件的坏状态。 有时,从用户那里获得关于他们希望在恢复工作中如何进行的输入是有意义的。

重试失败的操作

如果我们的组件执行了一个失败的操作,它可以重试该操作。 这个操作甚至不需要是组件的组成部分。 但是由于组件依赖于这个操作,如果它失败了,那么组件也会失败。 例如,后端 API 调用可能失败,使进行调用的组件处于不确定状态。 API 调用是在失败时重试的很好的候选者。

无论是我们正在重试的 API 调用,还是关于另一个组件的操作,我们都必须确保它是等幂。 这意味着首次手术调用后,后续调用无副作用。 换句话说,连续多次调用该操作不会对系统的其他地方产生负面影响。 获取请求(在不改变任何后端资源状态的情况下向 API 请求数据的请求)是很好的重试对象。 例如,如果我们的获取请求失败,因为后端占用的时间太长,可能是由于来自其他用户的竞争请求,我们可以再次尝试请求,并获得立即的结果。 我们可能不想继续等待,但如果我们决定重试,我们是安全的。 下面的例子展示了一个模型,它将重试失败的获取尝试:

// api.js
// Simulate an API call by returning a promise.
function fetch() {
    return new Promise((resolve, reject) => {

        // After one second, randomly resolve or
        // reject the promise.
        setTimeout(() => {
            if (Math.round(Math.random())) {
                resolve();
            } else {
                reject();
            }
        }, 1000);

    });
}

export default fetch;

// model.js
import fetch from 'api.js';

// An entity model that's fetched from the API.
export default class Model {

    // Initialized with a "retries" count and an
    // "attempts" counter, used when the requests fail.
    constructor(retries=3) {
        this.attempts = 0;
        this.retries = retries;
    }

    // Returns a new promise where "fetchExecutor()"
    // attempts, and possibly re-attempts to call the API.
    fetch() {
        return new Promise(this.fetchExecutor.bind(this));
    }

    fetchExecutor(resolve, reject) {
        // Call the API and resolve the promise. Also reset the
        // "attempts" counter.
        fetch().then(() => {
            this.attempts = 0;
            resolve();
        }).catch(() => {
            // Make another API request attempt, unless
            // we've already made too many, in which case
            // we can reject the promise.
            if (this.attempts++ < this.retries) {
                console.log('retrying', this.attempts);
                this.fetchExecutor(resolve, reject);
            } else {
                this.attempts = 0;
                reject(`Max fetch attempts 
                    ${this.retries} exceeded`);
            }
        });
    }

};

// main.js
import Model from 'model.js';

var model = new Model();

// Fetch the model, and look at the logging
// output to see how many attempts were made.
model.fetch()
    .then(() => {
        console.log('succeeded');
    })
    .catch((e) => {
        console.error(e);
    });

注释

我们必须意识到我们正在执行的操作类型,以及我们正在接收的失败类型。 例如,提交创建新资源的表单可能会以多种方式失败。 如果我们尝试这个操作,但它返回一个 503 错误,那么我们就知道重试是安全的,因为后端没有实际接触到任何资源。 另一方面,我们可以得到 500,这意味着我们不知道后台发生了什么。

对于获取请求,我们不必担心失败的类型,因为我们不需要改变任何东西的状态。 这意味着在重试操作之前,我们需要考虑操作的类型,如果它修改了资源,则需要考虑错误响应的类型。

重启组件

根据组件的类型,组件通常有一个生命周期——启动、关闭,以及在此之间存在的几个阶段。 通常,这个生命周期需要由创建组件的启动。 当组件在其生命周期中移动时,它会改变其内部状态。 这种状态可能是稍后组件出现故障的根源。

例如,如果一个组件处于繁忙状态,并且不处理来自外部组件的任何外部请求,那么我们可能会在系统的其他地方看到问题。 可能组件是正常繁忙的,或者可能发生了其他事情使它错误地陷入这种状态。 如果是这种情况,那么可能重新启动生命周期就足以解决任何问题,并使组件处于运行状态,能够再次处理外部请求。

实际上,重新启动组件是从错误中恢复的最后一搏。 这意味着我们不知道组件出了什么问题,只知道有什么东西不能工作,它正在整个应用中造成严重破坏。 当出现问题时重新启动组件的主要复杂性是,一旦我们清除了坏的内部状态,组件仍然需要从它停止的地方恢复。 例如,如果我们有一个组件,它的集合是从后端获取的,我们重启它,由于组件的状态有问题,然后它需要再次获取该集合。

因此,在开始将重新启动功能设计到组件之前,我们需要考虑几件事情。 首先,我们如何知道何时重新启动组件? 这通常是一个特定于应用的决策,但它们大多集中在组件失败的边缘情况。 如果有漏洞,重新启动可能不会有帮助,但尝试一下也无妨。 另一个方面是数据源的恢复—不是内部状态,而是应用使用的数据源。 这是两个独立的东西——内部状态是由组件计算的东西,而数据是作为输入提供的外部源。

我们不希望将组件重启功能实现为一种掩盖代码中其他问题的机制。 这是设计组件的好方法。 它迫使我们考虑组件在环境中可能被丢弃的各种方式。 即使只是问这个问题也是值得的—如果我重新启动这个组件,或者在运行时用一个新的实例替换它,会发生什么? 我们可能永远不会真正做这些事情,即使我们想做,也可能不可行。 然而,通过这些练习意味着我们将开始设计组件,使其在这些场景中更具弹性。

Restarting components

组件状态循环的一个非常高级的视图

手动用户干预

如果导致问题的组件能够重新启动自己,从而努力摆脱错误状态,那么我们可能希望让用户控制何时发生错误。 例如,如果一个组件产生了一个错误,那么我们可以禁用该特性,告诉用户该特性出了问题,并询问他们是否愿意重新加载该特性。

对于重试失败的操作也可以采用相同的方法—询问用户是否希望再次尝试。 当然,我们必须为用户处理更普通的重试/重启尝试。 当用户显然希望这个操作成功时,并且他们没有等待太久,那么我们不应该用重试操作的问题来打扰他们。 这违背了我们的目的——当我们的软件遇到不允许它工作的情况时,通过将控制权交还给用户来响应。

我们可能希望声明某种阈值,在寻求用户输入之前,我们的重新启动/重试尝试必须满足该阈值。 例如,我们试图获取的 API 数据已经超时两次,用户可能会越来越不耐烦。 我们就停在这里,告诉用户发生了什么——我们没有从后台得到响应。 我们应该继续努力,还是就此打住? 因为当我们的组件遇到像这样的不确定性情况时,最好将控制传递给人,他们可能比我们的代码有更多的洞察力。

我们的组件将愉快地重新启动和重试,但只有在用户同意的情况下。 但如果使用者放弃了,他们已经经历了足够多的折磨,想要采取平权行动,而不是让轮子转动,会发生什么呢? 然后我们可能需要为用户提供一些指导。 除了让他们的应用反复尝试相同的事情之外,他们还能做什么呢? 我们的组件是否知道关于错误的任何信息,以便为用户翻译? 例如,如果通过更改用户首选项来修复某个特定错误的原因,该怎么办? 那么,在这里展示一个友好的、有指导意义的信息,告诉他们如何着手解决问题,就有意义了。

提示

最好将故障排除建议描述为可能的解决方案,而不是确定的赌注。 只是为了避免令人讨厌的支持请求。

当我们无法从失败中恢复过来时……

如果我们已经到了失败的地步,而用户仍然不能从我们的软件中得到他们需要的东西,那我们就无能为力了。 正如该节的标题所暗示的,并不是所有内容都是可恢复的。 后端 API 并不总是可访问的。 我们的组件在生产环境中会有 bug,有时在它们被发现之前已经有好几年了。

像这些史诗般的失败是类似于我们的应用在一群人面前做脸部植物。 重试操作只是返回相同的结果。 重新启动组件没有效果。 要求用户输入不会有帮助,因为可能不可能重试失败的特定操作,或者我们在这里没有实现任何类型的用户输入。

在这两种情况下,解决方案都是恢复到故障快速模式,即在组件上拔插头,或者在特殊情况下对整个应用拔插头。 如果我们只禁用失败的组件,我们必须确保应用在没有它的情况下也能正常运行。 这又回到了单引擎飞机着陆的类比——这能做到吗? 如果没有,那么我们必须停止整个应用。

乍一看,这一切听起来可能有点极端。 然而,这样做可以消除我们的支持团队不需要担心的其他缺陷。 由于带有 bug 的组件的副作用,新缺陷被引入到活动系统的可能性更小。

我们正在使用可扩展的错误处理,当我们不试图在恢复活动上太聪明时,这种可能性对我们有利。

When we can't recover from failures...

失效组件的两种失效模式选择; 可以在运行时做出选择,但它不一定是预先的设计决策

性能和复杂性

在实现了健壮的故障检测和恢复之后,是时候将我们的注意力转向它们所引入的性能和复杂性了。 对于任何大规模的 JavaScript 应用来说,没有什么是免费的——每获得一个好处,都有一个新的扩展挑战。 故障处理只是这些收获之一。

与故障处理相关的两个密切相关的可伸缩性因素是性能和复杂性。 我们的软件以有趣的方式失败,而且没有优雅的方法来处理它们,导致复杂的实现。 复杂的代码通常对性能不是很好。 因此,我们首先看看是什么使异常处理代码变慢。

异常处理

当我们在 JavaScript 中处理异常时,我们通常会捕获抛出的所有错误。 无论它是我们预期会被抛出的东西,还是出乎意料的东西,都要由异常处理程序来确定如何处理错误。 例如,它是关闭组件,还是重试操作? try/catch语句的好处在于,我们可以确保在给定的代码段中没有任何东西不被捕获。 因为那时我们开始看到其他组件的副作用。

实现这一点的一种方法是在事件代理中,将其作为不允许错误通过的首要异常处理机制。 在这里,我们将在try/catch块中包装对任何事件回调的调用。 这样,不管调用事件回调函数的结果如何,异常处理代码都可以检查异常并确定要做什么。

这里有一个问题——在异常处理程序中运行的代码会付出性能代价。 JavaScript 引擎非常擅长及时优化我们的代码。 某些事情会阻止这些优化的发生,异常处理程序就是其中之一。 当存在多个级别的异常处理程序时,问题会被放大,直到调用堆栈。

就用户可感知的延迟而言,这种影响有多明显? 这取决于我们应用的规模——更多的组件意味着更多的代码可能没有得到优化。 但一般来说,这不是决定我们的应用是否慢的因素。 然而,与其他决定因素结合起来,它可能是重要的。 在事件代理级别使用精益异常处理是一个合理的权衡。 我们的所有代码都在这里运行 try 块,但是,我们得到了很多回报——只有适当地处理失败,我们才能加快速度。

发生在每个组件内部的嵌套异常处理可能会导致更多的性能和复杂性问题。 例如,如果我们的事件回调函数捕获了错误,但却没有很好地处理它们,那么我们很可能是弊大于利。 让异常在相同的位置捕获通常更好。 还有前面提到的性能影响。 我们可以在更高的水平上受到冲击,但我们不想在每个组件上受到进一步的冲击,特别是因为这些组件的数量会增加。

状态检查

除了异常处理之外,我们还有在执行操作之前检查组件状态的逻辑。 如果当前状态不适合该操作,则不会执行该操作,因为这样做可能会导致问题。 这是一种主动的异常处理,我们在尝试做任何事情之前处理任何潜在的错误,而异常处理则更为乐观。

组件自身的状态可能很简单,但当我们的代码必须检查边缘情况时,它通常包括检查它所在的组件的状态,但也包括其他组件的状态。 不一定是直接的——因为我们的组件是解耦的——而是间接的,比如向主应用发出一个查询。 这可能会变得相当复杂。 当我们添加更多的组件时,就会有更多的状态检查要做,同时我们现有的状态检查代码很有可能变得更加复杂。

如果将简单的状态检查编码为if语句或类似的语句,则可以。 但是,随着测试失败,这些边缘用例会增加,更多的边缘用例处理被添加到混乱中。 如果我们把应用的状态看作一个整体,我们会发现它只是所有组成状态的总和。 考虑到有许多组件,每个组件都有自己独特的状态和在什么情况下可以执行什么操作的约束,难怪我们无法预测应用将如何失败。 当我们沿着这条道路开始时,很容易给系统引入更多问题。 这是复杂性的代价——以前没有问题的地方,现在有了,多亏了我们在其他地方添加的一些错误处理。

提示

为了方便错误处理,减轻组件状态检查复杂性的一种方法是声明地将我们的操作绑定到必须满足的条件。 例如,我们可以有某种与操作名称的映射,以及要检查的所有条件的集合。 然后,一个通用机制可以查看这个映射,并确定我们是否可以执行这个动作。 在组件之间一致地使用这样的东西将减少有问题的if语句的数量。

通知其他组件

作为 JavaScript 架构师,我们面临的另一个挑战是在组件分离的系统中处理故障。 我们希望组件彼此分离,因为这意味着它们是可互换的,而且系统更容易构建和扩展。 在错误处理的环境中,这种分离充当了故障组件和系统其余部分之间的安全网。 这是所有的好消息,但我们也需要通信组件故障,以及所有其他发生在愉快路径上的事件。 我们如何在保持现有松耦合的同时做到这一点?

让我们首先考虑事件代理—所有组件间通信的仲裁者。 如果它可以交付我们所有的组件事件,那么它当然也可以交付错误通知? 假设代理执行了一个函数回调,它引发了一个异常。 异常被代理捕获,关于错误的详细信息被作为事件的下一个回调函数的参数包含。

在正常情况下,回调函数会收到一个错误参数,因此需要检查这个参数——一个具有较小开销的小障碍。 如果函数不关心在它之前发生了什么,那么这个参数可以被安全地忽略。 或者,如果传递了一个错误,回调可以查看错误并确定下一步要做什么。 如果是这种类型的错误——检查这个的状态,否则,执行那个,等等——它可能选择不做任何事情。 重要的是要通信错误,因为如果我们不希望一个组件中的错误产生副作用,那么有时需要在其他组件中采取纠正行动,但它需要知道错误发生了。

日志和调试

在大规模 JavaScript 应用中,应对失败的一部分是生成正确的信息。 最明显的开始位置是错误控制台,其中记录了未捕获的异常,或者只是使用console.error()生成的简单错误消息。 一些错误消息会导致快速修复,而另一些错误消息则会让程序员徒劳无功。

除了记录发生的错误外,我们可能还希望记录即将发生错误的情况。 这些是警告消息,它们在前端应用中使用得并不多。 警告在诊断代码中更隐蔽的问题时特别有用,因为它们会在失败后留下线索。

如果用户没有打开开发工具窗口,那么他们不一定会看到这些日志,一般用户可能也不会。 相反,我们只向他们显示与他们在应用中所做的相关的错误。 因此,我们不能只是发表声明,我们必须遵循它们的下一步。

有意义的错误日志

有意义的错误消息有很长的路要走。 考虑到错误消息的有效性直接影响开发人员及时解决问题的能力,这确实是一个可伸缩性问题。 考虑不包含有用信息的错误消息。 当我们调查这些失败时,我们花了更多的时间来拼凑出哪里出了问题。 我们可以在浏览器中使用开发人员工具来跟踪错误的起源,但这只能得到位置。 我们需要更好地指导哪里出了问题。

有时候,这些含糊不清的错误消息并不是什么大问题,因为当我们在代码中跟踪它们的起源时,哪里出了问题马上就很明显了。 通常这只是我们忽略的一个边缘情况,用几行代码就可以修复。 其他时候,问题比这更严重。 例如,如果这个错误实际上是由另一个组件所做的事情的副作用引起的,该怎么办? 这是否意味着我们可能想要修复设计问题,因为我们假设我们没有任何副作用?

考虑以下错误信息:Uncaught TypeError: component.action is not a function。 要破译它需要做大量的工作——除非我们对代码非常熟悉,因为我们每天都在与它互动。 问题是,随着应用的扩展,我们对代码的熟悉程度越来越低,因为添加了更多的组件。 这意味着我们花在他们身上的时间更少了,当他们崩溃时,很难快速恢复过来。 除非我们能从错误中得到帮助。 如果上面的错误被更改为:ActionError: The "query" component does not support the "save" action

诚然,在我们生成的错误消息中包含这种特定的细节确实增加了代码的复杂性。 然而,如果我们能够在提供特定的检查和让代码自然失败之间取得平衡,那么这些好处将被证明是有用的。 例如,花时间和精力编码错误检查和从未发生过的详细消息是完全没有意义的。 只关注那些有很大收益的场景。 这意味着,如果错误发生的可能性很大,那么该消息可以指向一个快速的解决方案。

当我们快速失败时,我们应该抛出自己的异常。 这使得错误在控制台中显示出来,我们可以提供有意义的信息,帮助开发人员诊断问题。 抛出异常是快速失败的一种简单方法,因为一旦抛出,当前的执行堆栈就会停止运行。

潜在故障警告

错误消息和警告消息之间的区别是,后者意味着系统仍然正常运行,尽管不是最佳状态。 例如,如果我们有一些数量限制,比如给定集合中的项目数量,那么当接近该限制时,我们可以发出警告。 这种功能与增强的错误消息功能一样,需要更多的代码和复杂性。

那么,如果我们有强大的错误处理,那还有什么意义呢? 警告很好,因为它们在显示的开发人员工具控制台中具有视觉上的区别。 错误有一个功能失调的含义,然而,这不是我们警告的重点。 我们试图说明一些不好的事情可能会发生。 例如,如果我们加速汽车引擎,我们会注意到转速表指针进入红色区域。 这是一个警告,意思是如果这种行为继续下去,可能会发生“不好”的事情。

警告背后的模糊性实际上是有帮助的,但对于错误,我们的目标是特异性。 我们希望警告是通用的,以便它们可以是关于应用状态的广泛断言。 这意味着我们的日志不会被开始重复的小警告信息填满。 在这一点上他们失去了所有的意义。 如果它们是通用的,它们可以帮助我们诊断错误的病理。 大多数情况下,它们可以作为线索,用来判断几秒钟后发生的错误是由什么原因造成的。 如果我们正在与更有经验的用户进行故障排除,他们可能打开了开发工具,他们可以向我们传递这些警告。 对于较少参与的用户,我们需要一种更友好的方法来进行故障排除。

告知、指导用户

到目前为止,我们在本节中讨论的错误和警告通常在开发人员工具控制台结束。 这意味着我们并不太关心用户是否看到它。 对于我们想让用户看到的消息,他们需要成为 ui 的一部分——我们根本不能依赖开发人员工具是开放的或呈现的。 有些相同的错误消息原则也适用于显式显示给用户的消息。 例如,我们想要通知用户发生了错误。 这取决于我们对信息的具体理解。 在这里,我们必须让听众记住,在调用方法之前告诉他们组件的状态必须是这样或那样是没有帮助的。

然而,如果我们能够将错误的名词转化为用户看到并直接与之交互的功能,那么它就会立即对用户产生意义。 现在他们拥有的是无法运转的东西。 他们可能不关心为什么没有,他们要用这些信息做什么? 最好按照指示去做。 这个坏了,所以这里是你需要做的。 这是值得努力实现的,因为就规模而言,软件处理了许多我们需要人工干预的问题,而这些问题是不可伸缩的。 它还能让用户继续使用我们的软件——这是一个很大的影响。

有时没有好的指导。 也就是说,用户需要的功能就是无法工作,他们对此无能为力。 然而,我们仍然可以瞄准一个消息,告诉他们这个功能已经停止工作。 开发人员工具控制台中的错误消息可能有更多与出错有关的信息。 然而,我们希望避免在不同时在 UI 中做一些用户友好的事情的情况下引发异常。 然后我们将同时服务于用户——开发者和用户。

改进架构

如果我们的架构要扩展,我们需要健壮的方法来处理失败的组件。 但这只能让我们在未来走这么远——因为一次又一次地处理同样的失败是无法扩大规模的。 在可能的情况下,消除失败的可能性是有规模的。 添加新组件会引入我们需要考虑的新失效模式,我们需要通过从方程中消除旧的失效模式来抵消这些失效模式。

这是通过设计实现的; 特别是修改后的设计。 这种变化可以是很小的,也可以是方向上的彻底转变。 这取决于发病频率、严重程度和增长速度。 把所有这些因素都考虑在内,我们就能在设计上做出取舍,使我们能够继续前进。

有很多方法可以帮助我们做到这一点。 例如,当我们遇到新的失败场景时,我们需要一种方法来一致地记录它们,我们需要更好地将组件划分为关键和非关键类别。 和往常一样,我们需要保持事情简单。

记录故障场景

端到端测试是记录场景的一种很好的方法。 特别是导致我们的软件失败的场景。 我们可以在设计和实现功能的同时,快速想出其中的一些方法。 但是端到端测试的亮点在于重现生产环境中发生的实际故障。 这些测试不仅对重现错误是必要的,以便我们知道它是固定的,而且对历史保存也是必要的。

随着时间的推移,我们将积累模拟真实生活场景的端到端测试; 我们的一个客户确实这么做了,结果失败了。 这使我们的软件更强大,但只是在实现级别。 在某种程度上,我们的软件在设计上是有缺陷的,因为我们需要对每个端到端测试进行解释。 这个想法是要改进架构,使某些故障根本不可能发生。

假设我们有一些在获取给定集合期间失败的端到端测试。 事实证明,我们对每个请求发送参数的方式实际上是不需要的。 此外,我们解析响应的方式也可以修复——某些部分是静态的。 这些是架构上的改进,因为它们适用于我们的数据模型,并且它们消除了某些失败,因为生成失败的代码不再存在。

改进部件分类

关键组件不会失败,它们是我们核心应用的组成部分——如果它们失败了,那么应用也会失败。 这就是为什么我们有这么少的人; 可能有少数组件涉及到每个组件,并且完全需要按预期运行。 另一方面,不是关键的组件可能会失败,而不会导致整个应用崩溃。 或者,他们可以尝试从故障中恢复,为用户保持一切顺利运行。

虽然我们的关键组件的分类是相对静态的,但情况并非总是如此。 例如,我们可能有一个我们认为不重要的特性组件,应用可以在没有它的情况下生存。 这在过去可能是正确的,但现在我们的应用已经发展壮大了,结果是这个组件以不明显的方式接触了所有其他组件—因此它不会失败是至关重要的。

关键组件是否会失去它们的临界性? 它们更有可能被从设计中完全移除,而不是降级为非关键组件。 然而,我们需要确保始终对关键组件有扎实的理解。 这是我们体系结构的一个重要属性——拥有不会失败的组件。 如果出现这种情况,就会被认为是整个应用的失败。 在扩展过程中,我们必须保持这种架构属性的完整性,这通常意味着我们必须在引入新的关键组件时识别它们。

复杂性导致失败

复杂组件有许多内部部件,它们以许多方式与环境相连接。 对于复杂性,我们拥有隐式状态,这些状态通常在组件失败后才会被发现。 我们只是无法从精神上把握复杂的设计。 当设计者自己不能掌握设计时,他们也不可能掌握所有的失效模式。

复杂性会以两种方式伤害我们。 第一种方法是首先触发失败。 由于所有的移动部件,我们忽略了在简单部件中很明显的边缘情况。 我们不得不引入大量的错误处理代码来考虑复杂性,使组件更加复杂,并触发更多的故障。 这样的循环往复。

复杂性对我们的第二种伤害是当失败真的发生时,我们在处理失败。 例如,只有很少运动部件的简单部件会以明显的方式出现故障。 即使是我们错过的,需要稍后去修复的,也不需要花时间去修复。 这是由于一个简单的事实,我们几乎没有精神上的穿越。 简单促进安全。

小结

本章向我们介绍了大型 JavaScript 应用的各种失败模式。 快速失效模式意味着,一旦我们发现了问题,我们会立即停止一切,以防止进一步的损害。 当应用的一个关键组件失败时,这通常是需要的。

容错是一种架构属性,它意味着系统能够检测错误,并防止它们干扰常规操作。 在 JavaScript 上下文中,这通常意味着捕获异常并防止它们干扰其他组件。 组件可以通过几种方法从错误中恢复,包括重新尝试操作或重新启动自己以清除坏状态。

错误处理增加了代码的复杂性,如果不小心处理,还会影响性能。 为了避免这些问题,我们必须瞄准不操纵状态的简单组件,并避免过度的异常处理。 错误消息可以帮助程序员和用户获得他们需要的信息,以更好地处理失败。 最终目标是将失败转化为改进的设计,完全消除违规代码。

JavaScript 在规模上确实是可以实现的,尽管有时它看起来像是一个不可逾越的障碍。 为了得到正确的答案,我们首先需要问正确的问题。 我希望这本书为您提供了必要的知识,帮助您制定有关扩展 JavaScript 应用的问题。 在正确的环境中,在正确的时间寻找合适的影响者,这将为你提供答案。