为了账号安全,请及时绑定邮箱和手机立即绑定

异步调用栈,让它更清晰!

Neal Beeken(BlogGitHub)所写

在最近发布的 MongoDB Node.js 驱动程序(v6.5.0)中,团队已经确保所有异步操作都能准确地报告异步调用栈,以帮助更准确地定位错误源头。在这里,我将向您介绍这个 JavaScript 功能及其零成本的获取方式。

如何组合调用 📚

首先,什么是调栈?调栈是一种隐藏的数据结构,用于存储程序中未完成的函数调用的信息——未完成的函数调用是指已被调用但尚未完成并返回控制权给调用者的函数。调栈的主要功能是记录每个正在运行的函数在完成时需要返回的执行位置。

我们来看一个例子。举一个程序的例子,它从参数中解析一个类似“2+2”的字符串作为方程,并计算该方程的结果。

    主过程()
      -> 解析字符串()
        -> 分割字符串()
          -> 获取字符串长度()
        -> 字符串转数字()
      -> 加法()
      -> 输出结果()
    -> 返回;

我们大多数人都熟悉上述过程范例(无论来自JavaScript、C、Java还是Python),其中每一步都是同步执行的,因此我们的调用栈是一系列依赖的清晰顺序。例如,如果 stringLength 失败,调用栈将包含 stringLengthsplitStringparseStringmain 作为尚未结束的活动过程。我们的运行时错误系统会利用此调用栈生成有用的错误信息。

    file://addNumbers.mjs:35  
        throw new Error('无法获取字符串的长度')  
              ^  
    错误: 无法获取字符串的长度  
        在 stringLength 中 (file://addNumbers.mjs:35:11)  
        在 splitString 中 (file://addNumbers.mjs:17:17)  
        在 parseString 中 (file://addNumbers.mjs:11:19)  
        在 main 中 (file://addNumbers.mjs:4:5)
异步工具 🔧

当我们切换到异步编程模型时,一切都会改变。这意味着异步工作的引入意味着我们不再有严格依赖的过程。本质上,异步编程是关于设置任务,并添加会在任务完成后被调用的回调处理程序。

让我们在程序中加入从标准输入读取的功能,看看这会如何影响我们的调用堆栈。

main():
    -> readStdin(handleUserInput)  
    // 当用户完成输入时  
    handleUserInput()  
    -> parseString()  
      -> splitString()  
        -> stringLength()

现在,主函数(main)的唯一任务是让运行时从标准输入读取,并在读取完成后调用我们选择的函数。这意味着主函数不再是一个主动的步骤;它返回,由运行时负责在获得标准输入数据后继续运行,并将数据传递给我们的函数handleUserInput

如下是堆栈追踪的样子:

file://addNumbers.mjs:42  
    throw new Error('无法获取字符串的长度')  
    ^  
Error: 无法获取字符串的长度  
    at stringLength (file://addNumbers.mjs:42:11)  
    at splitString (file://addNumbers.mjs:24:17)  
    在 stringLength 处 (file://addNumbers.mjs:42:11)  
    at parseString (file://addNumbers.mjs:18:19)  
    在 parseString 处 (file://addNumbers.mjs:18:19)  
    at ReadStream.handleUserInput (file://addNumbers.mjs:11:5)  
    在 ReadStream.handleUserInput 处 (file://addNumbers.mjs:11:5)  
    at ReadStream.emit (node:events:511:28)  
    at addChunk (node:internal/streams/readable:332:12)  
    at readableAddChunk (node:internal/streams/readable:305:9)  
    at Readable.push (node:internal/streams/readable:242:10)  
    at TTY.onStreamRead (node:internal/stream_base_commons:190:23)

只有 handleUserInput,没有 main

这是异步编程中一个常见的陷阱:你总是记录你的活跃过程并替换它们,因为它们都在进行任务设置。设置完成后,创建的回调会在稍后由运行时调用。

JavaScript 心动 💚

异步一直是JavaScript的核心特性,也是在使用Node.js时的一大卖点。

在2015年,第一个Node.js的长期支持版本发布,随之而来的是一套处理异步任务的通用模式,该模式被广泛采用。所有异步任务都会接受一个作为最后一个参数的回调,该回调至少接收两个参数:一个表示错误,另一个表示任务的结果。如果第一个参数是非空的(即表示错误的对象),任务就失败了;反之,第二个参数则包含任务的结果。

这里有一个简化版的读取文件的函数例子,

读取文件('filename.txt', (error, data) => {  
  if (error) {  
    console.error(error);  
    return;  
  }  
  console.log('文件内容为:', data);  
})

Node.js的回调模式非常常见且熟悉,因此,许多流行的库,例如MongoDB Node.js驱动程序,也采用了这种方式。

不抛错,只用回调 🐕

感谢 cupcakelogic (链接: 原文链接)

这种模式的一个挑战就是开发者需要自己记住预期的执行顺序。不然的话,可能会导致混乱的执行顺序。

通常来说,这通常应该被抽象到运行时或语言层面,具体来说可以这样分解:

错误解决

正确地使用回调模式意味着错误会被作为变量传递给一系列的处理函数,最终到达发起异步操作的顶层。这时不能再用 throw/try/catch 来控制流程。

    try {  
      readFile('filename', (error, data) => {  
        if (error) { /* ? */ }  
      })  
    } catch (error) {  
      // 这到底是怎么回事?  
    }

运行顺序:

回调还要求开发者确保执行顺序的一致性。如果文件成功读取并将内容通过传递给 readFile 的回调返回,那么该回调总会在 readFile 下一行代码之后执行。但是,如果 readFile 接收到一个无效参数,比如路径用数字而不是字符串,当它用无效参数错误调用回调时,我们仍然期望代码按照成功情况相同的顺序执行:

    function readFile(filename, callback) {  
       if (typeof filename !== 'string') {  
           callback(new Error('文件名无效'))  
           return;  
       }  
       // 打开并读取文件 ...  
    }  

    readFile(0xF113, (error, data) => {  
       if (error) {  
           console.log('无法读取文件,因为', error)  
           return;  
       }  
       console.log('文件内容为:', data)  
    })  
    console.log('开始读取文件...')

下面的代码输出:

无法读取文件:无效参数错误,开始尝试重新读取文件。

当我将 readFile 更改为调用一个不存在的路径时:

    开始读取文件时
    无法读取文件。错误:/notAPath.txt 不存在。

这太出人意料了!readFile 的实现者在遇到无效类型时同步调用了回调,因此 readFile 只有在这个回调完成之后才会返回。也就是说,很容易写出这样的函数,它们以这种方式不一致地处理回调。

承诺 🤞

Promise 是一个处理异步操作结果或失败的对象,解决了上述问题,允许将多个异步操作链接起来,而无需在每个 API 中显式传递一个表示所有任务已完成的回调函数。

    // 回调
    client.connect((error) => {
     if (error) {
       return done(error);
     }
     client
       .db()
       .collection('test')
       .findOne({}, (error, document) => {
         if (error) {
           return done(error);
         }
         console.log(document);
         return done();
       });
    });
    // 使用回调处理连接错误和查询操作

    // Promise
    client
     .connect()
     .then(() => client.db().collection('test').findOne({})) // 查询测试集合中的第一条记录
     .then(document => console.log(document))
     .catch(error => console.error(error));
    // 使用Promise处理连接错误和查询操作

注意,在 Promise 代码中只有一个错误处理步骤,而回调代码中有两个错误处理步骤。Promise 的链式处理使我们能够将多个异步操作当作一个整体来处理。无论是 connect 还是 find 方法抛出错误,都会触发 catch 处理器。虽然链式处理很方便,但在今天编写 JavaScript 时,我们有更好的选择,那就是使用专门处理 Promise 的语法。

让我们来看看 async/await 🔁

2017年中期,JavaScript引擎开始支持async/await语法,使得程序员可以以熟悉的顺序方式编写异步操作。使用async/await可以让程序员直接在代码中表达逻辑上的异步依赖。

让我们回到用户输入的例子,我们现在可以“等待”用户的输入,这使得main保持为主要处理过程。

“在 await 时,挂起点和恢复点一致,因此我们不仅知道继续的地方,而且还知道是从哪里开始的。”

源:零成本异步调用堆栈

当有可用输入时,readStdin 函数完成后,我们就可以继续解析。

    async 主函数()  
      -> 读取标准输入 await  
      -> 解析字符串()
    file://addNumbers.mjs:43  
        throw new Error('无法获取字符串的长度')  
              ^  
    Error: 无法获取字符串的长度  
        at stringLength (file://addNumbers.mjs:43:11)  
        at splitString (file://addNumbers.mjs:25:17)  
        at parseString (file://addNumbers.mjs:19:19)  
        at main (file://addNumbers.mjs:9:5)  
        at processTicksAndRejections (node:internal/process/task_queues:95:5)  
        at async file://addNumbers.mjs:62:1

当 JavaScript 引擎遇到 “await” 时,main 会被暂停。此时,引擎可以自由处理其他任务,而读取操作则等待用户输入。我们可以在函数语法中添加,使其暂停直到其他任务完成。继续执行时,它会保留从开始时所有已定义的上下文。

try {
 await client.connect();
 const document = await client.db().collection('test').findOne({});
 console.log(document);
} catch (error) {
 console.error(error);
}

await 和手动构建的 Promise 之间的根本区别在于,await X() 会暂停了当前的函数执行,而 promise.then(X) 则会在添加 X 调用到回调链之后继续执行当前的函数。在调用栈追踪的上下文中,这种差异在调用栈追踪中相当明显。”

_来源:为什么 await 胜过 Promise#then() · Mathias Bynens

示例堆栈跟踪

在完成将 async/await 转换到驱动内部网络层之前,我们的错误栈会从服务器错误消息的转换开始,例如:

MongoServerError: 通过 'failCommand' 失败点失败的命令  
    在 Connection.onMessage (./mongodb/lib/cmap/connection.js:231:30)  
    在 MessageStream.<anonymous> (./mongodb/lib/cmap/connection.js:61:60)  
    在 MessageStream.emit (node:events:520:28)  
    在 processIncomingData (./mongodb/lib/cmap/message_stream.js:125:16)  
    在 MessageStream._write (./mongodb/lib/cmap/message_stream.js:33:9)  
    在 writeOrBuffer (node:internal/streams/writable:564:12)  
    在 _write (node:internal/streams/writable:493:10)  
    在 Writable.write (node:internal/streams/writable:502:10)  
    在 Socket.ondata (node:internal/streams/readable:1007:22)  
    在 Socket.emit (node:events:520:28)  
                        ^-- 哎,这代码不是我写的...

现在,自 v6.5.0 版本后,堆栈追踪直接指向操作的起始点(我们看到你了!main.js):

MongoServer 错误: 通过 'failCommand' 错误点失败的命令执行
    at Connection.sendCommand (./mongodb/lib/cmap/connection.js:290:27)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at Connection.command (./mongodb/lib/cmap/connection.js:313:26)
    at Server.command (./mongodb/lib/sdam/server.js:167:29)
    at FindOperation.execute (./mongodb/lib/operations/find.js:34:16)
    at tryOperation (./mongodb/lib/operations/execute_operation.js:192:20)
    at executeOperation (./mongodb/lib/operations/execute_operation.js:69:16)
    at FindCursor._initialize (./mongodb/lib/cursor/find_cursor.js:51:26)
    at FindCursor.cursorInit (./mongodb/lib/cursor/abstract_cursor.js:471:27)
    at FindCursor.fetchBatch (./mongodb/lib/cursor/abstract_cursor.js:503:13)
    at FindCursor.next (./mongodb/lib/cursor/abstract_cursor.js:228:13)
    at Collection.findOne (./mongodb/lib/collection.js:274:21)
    at main (./mongodb/main.js:19:3)
                       ^-- 哇,这是我写的代码!

想要用 MongoDB 和 Node.js 建些酷炫的东西吗?看看一些用法示例或看看我们的教程,今天就开始吧!

更多资源

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消