20

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

现在这就是我遇到麻烦的地方。

由于第 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 中。

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

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

谢谢你的帮助!

4

3 回答 3

15

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

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

域不能解决问题,因为它们不能确保资源不泄露

阅读问题#5114#5149中的更多信息。

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

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

让我们假设我们的资源被表示为具有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)
      )
    );
}

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

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

注意:终止基本上是带外抛出,因此承诺无法捕获它,例如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()在内部回调中,如果它抛出仍然会使进程崩溃,即使unwrapped在另一个 Promise 中调用.then。这些类型的错误不会被典型的promisify包装器捕获,并且会像往常一样继续导致进程崩溃。

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

我们可以编写一个偏执的版本,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 的回调中使用 Promisified 函数.then会导致进程崩溃,如果解包抛出,则退回到 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's 的回调收到错误时,代码会忘记释放资源。

上下文管理器不会发生此类问题。您不能忘记调用 dispose:您不必这样做,因为using它为您服务!

参考:为什么我要切换到 promises上下文管理器和事务

于 2013-10-23T11:52:26.883 回答
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)。如果您了解异常流程的工作原理,您会发现它是一种通常使用的反模式.then(fnSuccess, fnFail)

关键不是要完成(IE 它.then(success, fail)不是, function(fail, success)附加回调的替代方法),而是使编写的代码看起来几乎与编写同步代码时的外观相同:

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 没有键入捕获)

于 2013-10-23T09:16:46.847 回答
1

承诺拒绝只是失败抽象的一种。节点样式的回调(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,因此您使用拒绝来处理错误。

于 2013-10-22T21:41:20.893 回答