【问题标题】:exception handling, thrown errors, within promises异常处理,抛出的错误,在 Promise 中
【发布时间】:2020-05-08 07:07:57
【问题描述】:

我正在运行外部代码作为 node.js 服务的第 3 方扩展。 API 方法返回承诺。已解决的 Promise 表示操作已成功执行,失败的 Promise 表示执行操作时出现问题。

现在我遇到了麻烦。

由于第 3 方代码未知,可能存在错误、语法错误、类型问题以及任何可能导致 node.js 引发异常的事情。

但是,由于所有代码都包含在 Promise 中,因此这些抛出的异常实际上会以失败的 Promise 的形式返回。

我尝试将函数调用放在 try/catch 块中,但从未触发:

// worker process
var mod = require('./3rdparty/module.js');
try {
  mod.run().then(function (data) {
    sendToClient(true, data);
  }, function (err) {
    sendToClient(false, err);
  });
} catch (e) {
  // unrecoverable error inside of module
  // ... send signal to restart this worker process ...
});

在上面的伪代码示例中,当抛出错误时,它会出现在失败的 promise 函数中,而不是在 catch 中。

根据我的阅读,这是一个功能,而不是问题,具有承诺。但是,我很难理解为什么您总是希望以完全相同的方式对待异常和预期的拒绝。

一种情况是代码中的实际错误,可能无法恢复——另一种情况可能是缺少配置信息、参数或可恢复的东西。

感谢您的帮助!

【问题讨论】:

  • 设计是没有预期的拒绝(我不喜欢它),实际的特点是即使在异步代码中抛出异常也会报告
  • @Bergi 因为必须重新启动服务器更可取?笏
  • @Esailija:未知异常应该如何处理?我更愿意手动捕获已知异常并手动触发拒绝,并将 domain/window.onerror/process.onuncaughtexceptions 用于其他所有内容。
  • @Bergi 通过关闭任何打开的资源并向用户报告错误?即使您使用未捕获的异常,您仍然必须使服务器崩溃,因为您不知道哪些资源处于打开状态。
  • @Esailija:当然也可以,但是it's recommended 之后仍要重新启动该过程

标签: javascript node.js promise


【解决方案1】:

崩溃和重新启动进程不是处理错误甚至错误的有效策略。在 Erlang 中会很好,其中一个进程很便宜并且只做一件孤立的事情,比如为单个客户端提供服务。这不适用于节点,其中一个流程的成本要高出几个数量级,并且一次为数千个客户提供服务

假设您的服务每秒处理 200 个请求。如果其中 1% 的代码在你的代码中遇到了抛出路径,那么你每秒将获得 20 次进程关闭,大约每 50 毫秒一次。如果您有 4 个内核,每个内核有 1 个进程,那么您将在 200 毫秒内丢失它们。因此,如果一个进程启动和准备服务请求的时间超过 200 毫秒(对于不加载任何模块的节点进程,最低成本约为 50 毫秒),我们现在有一个成功的 total 拒绝服务.更不用说遇到错误的用户往往会做一些事情,例如反复刷新页面,从而使问题更加复杂。

域名无法解决问题,因为它们cannot ensure that resources are not leaked

阅读问题#5114#5149 了解更多信息。

现在您可以尝试对此“聪明”一点,并根据一定数量的错误制定某种进程回收策略,但无论您采用何种策略,它都会严重改变 node.js 的可伸缩性配置文件。我们说的是每个进程每秒几十个请求,而不是几千个。

然而,promise 捕获所有异常,然后以与同步异常向上传播堆栈的方式非常相似的方式传播它们。此外,他们通常提供一个方法finally,它相当于try...finally。由于这两个功能,我们可以通过构建“上下文管理器”来封装清理逻辑(类似于@中的with 987654324@, using in C#try-with-resources in Java) 总是清理资源。

假设我们的资源被表示为具有acquiredispose 方法的对象,这两个方法都返回承诺。调用函数时没有建立连接,我们只返回一个资源对象。此对象稍后将由using 处理:

function connect(url) {
  return {acquire: cb => pg.connect(url), dispose: conn => conn.dispose()}
}

我们希望 API 像这样工作:

using(connect(process.env.DATABASE_URL), async (conn) => {
  await conn.query(...);
  do other things
  return some result;
});

我们可以轻松实现这个API:

function using(resource, fn) {
  return Promise.resolve()
    .then(() => resource.acquire())
    .then(item => 
      Promise.resolve(item).then(fn).finally(() => 
        // bail if disposing fails, for any reason (sync or async)
        Promise.resolve()
          .then(() => resource.dispose(item))
          .catch(terminate)
      )
    );
}

在 using 的 fn 参数中返回的承诺链完成后,资源将始终被处置。即使在该函数中抛出错误(例如来自JSON.parse)或其内部.then闭包(如第二个JSON.parse),或者如果链中的promise被拒绝(相当于回调调用错误) .这就是为什么承诺捕获错误并传播它们如此重要的原因。

如果处理资源真的失败了,那确实是一个很好的终止理由。在这种情况下,我们极有可能泄漏了资源,并且开始结束该过程是一个好主意。但是现在我们崩溃的机会被隔离到我们代码的一小部分 - 实际处理可泄漏资源的部分!

注意:终止基本上是抛出带外,因此承诺无法捕获它,例如process.nextTick(() => { throw e });。哪种实现有意义可能取决于您的设置 - 基于 nextTick 的实现类似于回调保释的方式。

如何使用基于回调的库?它们可能不安全。让我们看一个示例,看看这些错误可能来自哪里以及哪些可能导致问题:

function unwrapped(arg1, arg2, done) {
  var resource = allocateResource();
  mayThrowError1();
  resource.doesntThrow(arg1, (err, res) => {
    mayThrowError2(arg2);
    done(err, res);
  });
}

mayThrowError2() 在一个内部回调中,如果它抛出,仍然会导致进程崩溃,即使在另一个 Promise 的 .then 中调用了 unwrapped。这些类型的错误不会被典型的promisify 包装器捕获,并且会像往常一样继续导致进程崩溃。

但是,如果在.then 内调用mayThrowError1(),则承诺会捕获mayThrowError1(),并且内部分配的资源可能会泄漏。

我们可以编写promisify 的偏执版本,以确保任何抛出的错误都无法恢复并使进程崩溃:

function paranoidPromisify(fn) {
  return function(...args) {
    return new Promise((resolve, reject) =>   
      try {
        fn(...args, (err, res) => err != null ? reject(err) : resolve(res));
      } catch (e) {
        process.nextTick(() => { throw e; });
      }
    }
  }
}

在另一个 Promise 的 .then 回调中使用 promisified 函数现在会导致进程崩溃,如果解包 throws,则退回到 throw-crash 范式。

一般希望随着您使用越来越多的基于 Promise 的库,它们会使用上下文管理器模式来管理其资源,因此您不必让进程崩溃。

这些解决方案都不是万无一失的 - 甚至不会因抛出错误而崩溃。尽管不抛出,但很容易意外编写泄漏资源的代码。例如,这个节点样式的函数即使不抛出也会泄漏资源:

function unwrapped(arg1, arg2, done) {
  var resource = allocateResource();
  resource.doSomething(arg1, function(err, res) {
    if (err) return done(err);
    resource.doSomethingElse(res, function(err, res) {
      resource.dispose();
      done(err, res);
    });
  });
}

为什么?因为当doSomething的回调收到错误时,代码会忘记释放资源。

上下文管理器不会发生这种问题。您不能忘记调用 dispose:您不必这样做,因为 using 会为您完成!

参考:why I am switching to promisescontext managers and transactions

【讨论】:

  • 感谢您的解释,它开始变得更有意义了。但是,由于这些“模块”是第 3 方代码,我无法确保它们正确清理资源,这是问题的症结所在。有没有办法清除模块中使用的资源而不知道它们是什么? (它们可能是 websocket 连接、http 请求或服务器,在这些模块之一中可能发生任何数量的事情)。
  • 另外,是否有一些节点模块实现了这种清理管理?
  • 如果模块是使用 Promise 编写的,则不必确保它。如果您正在包装遵循 throw-crash 范式的(基于回调的)模块,并且不希望 promise 破坏它,我想您的包装器可以使用 process.nextTick 来逃避 promise 错误传播。
  • 当然,promise 仍然不会在库的内部节点样式回调中捕获错误,因此这些会很高兴地继续使进程崩溃。我可能应该写一篇更详细地扩展这一切的文章。
  • 如果模块使用 Promise,为什么你不应该确保他们已经清理了他们的连接或对象?不确定如何实现这一飞跃,在您的示例中,您编写了自定义代码来清理您的连接——我不能确定该模块是否写得很好。如果我失败了,我需要能够把石板擦干净。
【解决方案2】:

这几乎是 Promise 最重要的特性。如果它不存在,您不妨使用回调:

var fs = require("fs");

fs.readFile("myfile.json", function(err, contents) {
    if( err ) {
        console.error("Cannot read file");
    }
    else {
        try {
            var result = JSON.parse(contents);
            console.log(result);
        }
        catch(e) {
            console.error("Invalid json");
        }
    }

});

(在你说 JSON.parse 是 js 中唯一抛出的东西之前,你是否知道即使将变量强制为数字,例如 +a 也会抛出 TypeError

但是,上面的代码可以用 Promise 更清楚地表达,因为只有一个异常通道而不是 2 个:

var Promise = require("bluebird");
var readFile = Promise.promisify(require("fs").readFile);

readFile("myfile.json").then(JSON.parse).then(function(result){
    console.log(result);
}).catch(SyntaxError, function(e){
    console.error("Invalid json");
}).catch(function(e){
    console.error("Cannot read file");
});

请注意,catch.then(null, fn) 的糖。如果您了解异常流程的工作原理,您会发现它有点像anti-pattern to generally use .then(fnSuccess, fnFail)

重点不在于, function(fail, success) 上执行.then(success, fail)(即,它不是附加回调的替代方法),而是使编写的代码看起来几乎与它看起来一样编写同步代码时:

try {
    var result = JSON.parse(readFileSync("myjson.json"));
    console.log(result);
}
catch(SyntaxError e) {
    console.error("Invalid json");
}
catch(Error e) {
    console.error("Cannot read file");
}

(同步代码实际上会更丑,因为 javascript 没有键入捕获)

【讨论】:

  • 那么,如何区分抛出的错误和承诺失败?
  • @NickJennings 如果像console.log(resalt) 这样的同步代码中存在程序员错误(错字),同步代码会发生什么情况?
  • 会抛出错误。但是,承诺会导致承诺被拒绝。理想情况下,我想在 uncaughException 中捕获它
  • @NickJennings 仅当您没有包装同步代码的任何 try catch 时才会抛出错误,在这种情况下,即使出现预期错误,您的服务器也会崩溃。我指的是我的同步代码示例,在没有 try-catch 的情况下调用 readFileSync 会很疯狂。
  • 我明白了,但我无法控制模块代码,我需要一个防弹(或接近防弹)来捕获尽可能接近模块的任何和所有错误可能的。但是 - 我需要区分 promise.reject 和抛出的错误。似乎没有办法做到这一点......
【解决方案3】:

Promise 拒绝只是一个失败的抽象。节点样式的回调(err、res)和异常也是如此。由于 Promise 是异步的,因此您不能使用 try-catch 来实际捕获任何内容,因为错误可能不会发生在事件循环的同一滴答声中。

一个简单的例子:

function test(callback){
    throw 'error';
    callback(null);
}

try {
    test(function () {});
} catch (e) {
    console.log('Caught: ' + e);
}

在这里我们可以捕获一个错误,因为函数是同步的(虽然是基于回调的)。另一个:

function test(callback){
    process.nextTick(function () {
        throw 'error';
        callback(null); 
    });
}

try {
    test(function () {});
} catch (e) {
    console.log('Caught: ' + e);
}

现在我们无法捕捉到错误!唯一的选择是在回调中传递它:

function test(callback){
    process.nextTick(function () {
        callback('error', null); 
    });
}

test(function (err, res) {
    if (err) return console.log('Caught: ' + err);
});

现在它的工作方式与第一个示例一样。同样适用于 Promise:您不能使用 try-catch,因此您可以使用拒绝来处理错误。

【讨论】:

    猜你喜欢
    • 2011-04-07
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-03-19
    • 2018-08-19
    • 2018-03-23
    • 2011-12-12
    • 2013-07-24
    相关资源
    最近更新 更多