在NodeJS中重新抛出异常而不丢失堆栈跟踪

85

我该如何在nodejs/javascript中转发错误或异常并包含自定义消息。

我有以下代码:

var json = JSON.parse(result);

我希望在出现任何解析错误时,将result中的内容包含在异常消息中。 就像这样。

1.  try {
2.    var json = JSON.parse(result);
3.    expect(json.messages.length).to.be(1);
4.  } catch(ex) {
5.    throw new Error(ex.message + ". " + "JSON response: " + result);
6.  }
这里的问题是我丢失了我的堆栈跟踪。 是否有一种类似于Java的方法可以实现这一点?

问题在于我丢失了我的堆栈跟踪。

是否有一种类似于 java 的方法可以做到这一点?

throw new Error("JSON response: " + result, ex);

1
不是直接回答,但你真的想学习这个:https://www.joyent.com/node-js/production/design/errors - Paul
@Paul 虽然这是一个很好的介绍,但我认为它现在有点过时了,因为它没有提到 Promise 和错误类,这些可以解决 JS/Node.js 错误处理中的许多模糊问题。 - Matt
5个回答

95
ES2022Error 对象中添加了 .cause 非可枚举属性,该属性可以从构造函数中设置。
function doThing(){
  try {
    throw new Error('Internal Error')
  }
  catch (err) {
    throw new Error('Do thing failed!', { cause: err })
  }
}

try {
  doThing()
}
catch (err) {
  console.log('Error: ', err)
  console.log('Cause: ', err.cause)
}

支持的语言:

Chrome  > 93
Firefox > 91
Safari  > 15
Node.js > v16.9.0

tc39提案中,可以看到一个es-shim Error实现以及如何设置cause属性
更多堆栈跟踪细节请参见Scotty Jamison的答案。


2022年之前的回答

我不知道有没有像Java那样的本地方法,也没有找到一个优雅的解决方案来包装错误。

创建一个new Error的问题是你可能会丢失附加到原始Error的元数据,堆栈跟踪和类型通常是重要的丢失项。

对已经抛出的错误进行修改会更快,但仍然有可能将错误的数据修改得无影无踪。在其他地方创建的错误中进行探索感觉也不太对。


创建一个新的错误和新的堆栈

一个新的Error.stack属性是一个普通的字符串,可以在抛出之前修改为你想要的内容。但是完全替换错误的stack属性可能会导致调试时非常混乱。

当原始抛出的错误和错误处理程序位于不同的位置或文件中(这在使用Promise时很常见),你可能能够追踪到原始错误的来源,但无法追踪到实际捕获错误的处理程序所在的位置。为了避免这种情况,最好在stack中保留对原始错误和新错误的引用。如果原始错误中还存储了其他元数据,那么访问完整的原始错误也会非常有用。

下面是一个捕获错误、将其包装在新错误中并添加原始stack并存储error的示例:

try {
  throw new Error('First one')
} catch (error) {
  let e = new Error(`Rethrowing the "${error.message}" error`)
  e.original_error = error
  e.stack = e.stack.split('\n').slice(0,2).join('\n') + '\n' +
            error.stack
  throw e
}

哪个抛出:
/so/42754270/test.js:9
    throw e
    ^

Error: Rethrowing the "First one" error
    at test (/so/42754270/test.js:5:13)
Error: First one
    at test (/so/42754270/test.js:3:11)
    at Object.<anonymous> (/so/42754270/test.js:13:1)
    at Module._compile (module.js:570:32)
    at Object.Module._extensions..js (module.js:579:10)
    at Module.load (module.js:487:32)
    at tryModuleLoad (module.js:446:12)
    at Function.Module._load (module.js:438:3)
    at Module.runMain (module.js:604:10)
    at run (bootstrap_node.js:394:7)
    at startup (bootstrap_node.js:149:9)

所以我们创建了一个新的通用错误。不幸的是,原始错误的类型在输出中被隐藏了,但是错误已经作为“.original_error”附加了起来,因此仍然可以访问。新的堆栈已经大部分被移除,除了生成行是重要的,还有原始错误的堆栈被附加上去。
任何试图解析堆栈跟踪的工具可能无法与此更改配合使用,或者最好的情况下,它们会检测到两个错误。
使用ES2015+错误类重新抛出
将其转换为可重用的ES2015+错误类:
class RethrownError extends Error {
  constructor(message, error){
    super(message)
    this.name = this.constructor.name
    if (!error) throw new Error('RethrownError requires a message and error')
    this.original_error = error
    this.stack_before_rethrow = this.stack
    const message_lines =  (this.message.match(/\n/g)||[]).length + 1
    this.stack = this.stack.split('\n').slice(0, message_lines+1).join('\n') + '\n' +
                 error.stack
  }
}

throw new RethrownError(`Oh no a "${error.message}" error`, error)

结果为

/so/42754270/test2.js:31
    throw new RethrownError(`Oh no a "${error.message}"" error`, error)
    ^

RethrownError: Oh no a "First one" error
    at test (/so/42754270/test2.js:31:11)
Error: First one
    at test (/so/42754270/test2.js:29:11)
    at Object.<anonymous> (/so/42754270/test2.js:35:1)
    at Module._compile (module.js:570:32)
    at Object.Module._extensions..js (module.js:579:10)
    at Module.load (module.js:487:32)
    at tryModuleLoad (module.js:446:12)
    at Function.Module._load (module.js:438:3)
    at Module.runMain (module.js:604:10)
    at run (bootstrap_node.js:394:7)
    at startup (bootstrap_node.js:149:9)

那么,你知道每当你看到一个 RethrownError 时,原始错误仍然可以在 .original_error 中找到。

这种方法并不完美,但它意味着我可以将底层模块中已知的错误重新输入到通用类型中,以便更容易处理,通常使用 bluebirds 的 filtered catch .catch(TypeError, handler)

注意 这里的 stack 变为可枚举。

带有修改堆栈的相同错误

有时候你需要保留原始错误的大部分内容。

在这种情况下,你可以将新信息追加/插入到现有堆栈中。

file = '/home/jim/plumbers'
try {
   JSON.parse('k')
} catch (e) {
   let message = `JSON parse error in ${file}`
   let stack = new Error(message).stack
   e.stack = e.stack + '\nFrom previous ' + stack.split('\n').slice(0,2).join('\n') + '\n'
   throw e
}

返回的是

/so/42754270/throw_error_replace_stack.js:13
       throw e
       ^

SyntaxError: Unexpected token k in JSON at position 0
    at Object.parse (native)
    at Object.<anonymous> (/so/42754270/throw_error_replace_stack.js:8:13)
    at Module._compile (module.js:570:32)
    at Object.Module._extensions..js (module.js:579:10)
    at Module.load (module.js:487:32)
    at tryModuleLoad (module.js:446:12)
    at Function.Module._load (module.js:438:3)
    at Module.runMain (module.js:604:10)
    at run (bootstrap_node.js:394:7)
    at startup (bootstrap_node.js:149:9)
From previous Error: JSON parse error in "/home/jim/plumbers"
    at Object.<anonymous> (/so/42754270/throw_error_replace_stack.js:11:20)

请注意,堆栈处理是简单的,并假设错误消息是单行的。如果遇到多行错误消息,您可能需要查找\n at 来终止消息。

为什么需要captureStackTrace?我没有找到任何场景它会有所区别。 - Bogdan Gusiev
嗯,可能已经不相关了。自从早期ES6编写以来,我没有太多更改基础内容。如果我没记错的话,它是一个ES5转译工具,用于在v8浏览器中从堆栈顶部删除自定义的Error代码。这里有一些相关讨论:https://dev59.com/KXM_5IYBdhLWcg3wcCnc。 - Matt
这是一个在其时非常优秀的解决方案。但自2021年以来,已经有了v8本地机制--请参见@scotty-jamison在下面的答案:https://dev59.com/-1gQ5IYBdhLWcg3wcjgs#73545352 - jsalvata

26

如果您只想更改消息,您可以直接更改消息:

try {
  throw new Error("Original Error");
} catch(err) {
  err.message = "Here is some context -- " + err.message;
  throw err;
}

更新:

如果消息属性为只读,则可以创建一个新对象,使用原始错误作为原型,并分配一个新消息:

try {  // line 12
  document.querySelectorAll("div:foo");  // Throws a DOMException (invalid selector)
} catch(err) {
  let message = "Here is some context -- " + err.message;
  let e = Object.create( err, { message: { value: message } } );
  throw e;  // line 17
}

不幸的是,对于异常捕获的日志信息仅包含"Uncaught exception",并未提供异常本身的具体信息。因此,创建一个错误(Error)并赋予相同的堆栈信息可能会更有帮助,这样记录的日志信息将包括错误消息:

try {  // line 12
  document.querySelectorAll("div:foo");  // Throws a DOMException (invalid selector)
} catch(err) {
  e = new Error( "Here is some context -- " + err.message );
  e.stack = err.stack;
  throw e;  // line 17
}

由于片段输出显示了重新抛出的行号,这证实了堆栈被保留:

try {  // line 12
  try {  // line 13
    document.querySelectorAll("div:foo");  // Throws a DOMException (invalid selector)
  } catch(err) {
    console.log( "Stack starts with message: ", err.stack.split("\n")[0] );
    console.log( "Inner catch from:", err.stack.split("\n")[1] );
    e = new Error( "Here is some context -- " + err.message );  // line 18
    console.log( "New error from:", e.stack.split("\n")[1] );
    e.stack = err.stack;
    throw e;  // line 21
  }
} catch(err) {
  console.log( "Outer catch from:", err.stack.split("\n")[1] );
  throw err;  // line 25
}


1
一些错误,例如DOMException,具有只读的消息属性。 - Aaronius
@Aaronius,我从未见过这种情况,而且DOMException听起来像是浏览器的事情,而不是Node的事情。但如果这是个问题,我接下来会做的是创建一个新对象,将原始错误作为其原型;我没有测试过,但类似于e = Object.create(err); e.message = "这里有一些上下文 -- " + err.message; throw e的东西。 - ShadSterling
2
这会导致 TypeError: Cannot set property message of which has only a getter。也许有一种方法可以解决,但我还没有深入研究过。你提到 DOMException 不在 Node 中的观点是正确的。我会取消我的反对票。可能还有其他类型的错误在 Node 中,它们的 message 属性同样是只读的。 - Aaronius
1
我刚刚看到了你的最新更新。我需要试一下。 - Aaronius

8
JavaScript 已经引入了创建新错误并附加“原因”的能力,从而导致原始堆栈跟踪得以保留(如 此处 所述)。它看起来像这样:
try {
  someDangerousLogic();
} catch (originalError) {
  throw new Error(
    'Some additional, useful information',
    { cause: originalError }
  );
}

完整示例:

function someDangerousLogic() {
  throw new Error('Whoops!');
}

function main() {
  try {
    someDangerousLogic();
  } catch (originalError) {
    throw new Error(
      'Some additional, useful information',
      { cause: originalError }
    );
  }
}

main();

不幸的是,当错误未被捕获时,不是所有浏览器都会显示错误原因。如果我在Chrome v110中运行上面的“完整示例”,然后查看错误的开发工具,我只会找到新的堆栈跟踪(“原因”仍可通过.cause属性访问)。另一方面,当Node未被捕获时,它会同时显示错误和原因:

Error: Some additional, useful information
    at main (/home/me/temp.js:9:11)
    at Object.<anonymous> (/home/me/temp.js:16:1)
    ... 5 lines matching cause stack trace ...
    at node:internal/main/run_main_module:17:47 {
  [cause]: Error: Whoops!
      at someDangerousLogic (/home/me/temp.js:2:9)
      at main (/home/me/temp.js:7:5)
      at Object.<anonymous> (/home/me/temp.js:16:1)
      at Module._compile (node:internal/modules/cjs/loader:1112:14)
      at Module._extensions..js (node:internal/modules/cjs/loader:1166:10)
      at Module.load (node:internal/modules/cjs/loader:988:32)
      at Module._load (node:internal/modules/cjs/loader:834:12)
      at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:77:12)
      at node:internal/main/run_main_module:17:47
}

2

你也可以继续将错误传递到 try 链中。如果你想修改任何内容,请在 b 中的 throw 语句之前进行修改。

function a() {
    throw new Error('my message');
}

function b() {
    try {
        a();
    } catch (e) {
        // add / modify properties here
        throw e;
    }
}

function c() {
    try {
        b();
    } catch (e) {
        console.log(e);
        document.getElementById('logger').innerHTML = e.stack;
    }
}
c();
<pre id="logger"></pre>


0
你可能需要查看来自Joyent的verror模块,它提供了一种简单的方法来包装错误:
var originError = new Error('No such file or directory');
var err = new VError(originError, 'Failed to load configuration');
console.error(err.message);

这将打印:

Failed to load configuration: No such file or directory

2
这是否保留了调用堆栈? - Flimm

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接