在 Mongoose 中使用 Async/Await:查询、Promise 与可等待对象的完整指南
【免费下载链接】mongooseMongoDB object modeling designed to work in an asynchronous environment.项目地址: https://gitcode.com/GitHub_Trending/mo/mongoose
Mongoose 的查询、保存等操作全部是异步的,而 async/await 是消除回调地狱、以同步风格编写异步代码的最直接手段。本文以 Mongoose 官方文档 docs/async-await.md 为主线,结合仓库源码(lib/query.js、lib/model.js)与官方测试(test/docs/promises.test.js),系统讲解 async/await 在 Mongoose 中的三种典型用法、Mongoose Query 作为 thenable 的特殊语义,以及"查询被重复执行"这一核心陷阱的成因与规避方法。读完本文,你将能写出既简洁又安全、可完全掌控查询执行时机的异步数据访问代码。
一、基本用法:从 Promise 链到 async/await
Async/await 让我们可以像写同步代码一样书写异步代码。对于 Mongoose 这类以大量连续异步操作为主的库而言,这一点尤为重要——它能把"查记录 → 改字段 → 保存 → 打印结果"这类多步流程从层层嵌套的then中解放出来。
下面两个函数做的是同一件事:从数据库中取出一个记录、修改它、再把更新后的结果打印到控制台。第一个版本使用 Promise 链式调用:
// Using promise chaining function thenUpdate() { MyModel.findOne({ firstName: 'franklin', lastName: 'roosevelt' }) .then(function(doc) { doc.middleName = 'delano'; return doc.save(); }) .then(console.log) .catch(function(err) { handleError(err); }); }第二个版本使用 async/await,逻辑完全一致,但结构上是扁平的:
// Using async/await async function awaitUpdate() { try { const doc = await MyModel.findOne({ firstName: 'franklin', lastName: 'roosevelt' }); doc.middleName = 'delano'; console.log(await doc.save()); } catch (err) { handleError(err); } }两个版本的关键差异在于错误处理与流程控制:
- Promise 链版本中,每一环都必须显式
return,否则下一步拿到的将是undefined; - async/await 版本中,
await之后的代码天然按顺序执行,异常统一由try/catch捕获,不再需要逐环传递.catch; - 从源码角度看,
doc.save()与查询方法返回的对象类型不同(详见下文"查询与 Promise 的区别"一节),这决定了await作用于两者时的行为差异。
需要特别说明的是:不同 Mongoose 方法的履行值(fulfillment value)各不相同,且可能受配置影响。例如findOne()解析为单个文档或null,find()解析为文档数组,save()解析为保存后的文档本身(见 lib/model.js 中Model.prototype.save的return _this;)。具体方法的返回语义请查阅 API 文档 docs/api.md。
二、async 函数始终返回 Promise
给 JavaScript 函数加上async关键字后,该函数无论函数体里写了什么 return 语句,返回值都会被包装成一个原生的 JavaScript Promise。这是 async 函数的基本语义,也是初学者最容易踩的第一个坑:
async function getUser() { // Inside getUser, we can await an async operation and interact with // foundUser as a normal, non-promise value... const foundUser = await User.findOne({ name: 'bill' }); console.log(foundUser); // Prints '{name: 'bill', admin: false}' return foundUser; } // However, because async functions always return a promise, // user is a promise. const user = getUser(); console.log(user); // Oops. Prints '[Promise]'上面的user不是用户文档,而是一个 Promise,因此直接console.log只能看到[Promise]。正确做法是:把 async 函数的返回值当成普通 Promise 来处理——要么在另一个 async 函数中await它,要么用.then链式消费它:
async function getUser() { const foundUser = await User.findOne({ name: 'bill' }); return foundUser; } async function doStuffWithUser() { // Await the promise returned from calling getUser. const user = await getUser(); console.log(user); // Prints '{name: 'bill', admin: false}' }这个例子的意义在于:async 函数是 Promise 生态的一部分,它内部可以await任何 Promise(包括 Mongoose 的查询与保存),对外则以统一 Promise 的形式暴露结果。这也意味着你可以把 Mongoose 操作封装成 async 函数,再在 Express 路由、测试用例等任意 async 上下文中消费它。
三、Async/Await 与 Mongoose 查询:thenable 与 Promise 的本质区别
从语言层面看,async/await 只是 Promise API 之上的语法糖。JavaScript 的await关键字会尝试"解包"任何带有名为then、值为函数属性的对象——这类对象统称为 thenable。如果解包的是真正的 Promise(即Promise构造函数的实例),我们可以获得关于then行为的一系列保证;但 Mongoose 的多个静态辅助方法(如find()、findOne()、findById())返回的并不是 Promise,而是一种特殊的 thenable 对象——Query。Query 不是 Promise(详见 docs/queries.md 中 "Queries are not promises" 一节)。
因为 Query 也是 thenable,所以await一个查询与await一个真正的 Promise 在写法上并无二致,但二者存在一个决定性的行为差异:
观察一个真正 Promise 的履行值,无论如何都不可能改变该值本身;但重复观察同一个 Query 的履行值,可能导致该查询被重新执行。
用instanceof Promise验证身份
function isPromise(thenable) { return thenable instanceof Promise; } // The fulfillment value of the promise returned by user.save() will always be the same, // regardless of how, or how often, we observe it. async function observePromise() { const user = await User.findOne({ firstName: 'franklin', lastName: 'roosevelt' }); user.middleName = 'delano'; // Document.prototype.save() returns a *genuine* promise const realPromise = user.save(); console.log(isPromise(realPromise)); // true const awaitedValue = await realPromise; realPromise.then(chainedValue => console.log(chainedValue === awaitedValue)); // true } // By contrast, the value we receive when we try to observe the same Query more than // once is different every time. The Query is re-executing. async function observeQuery() { const query = User.findOne({ firstName: 'leroy', lastName: 'jenkins' }); console.log(isPromise(query)); // false const awaitedValue = await query; query.then(chainedValue => console.log(chainedValue === awaitedValue)); // false }源码层面的验证
这段行为差异在仓库源码中有直接体现。查询执行入口 lib/query.js 的Query.prototype.exec是一个async函数,它内部用_execCount记录了执行次数:
if (this._execCount > 0) { let str = this.toString(); if (str.length > 60) { str = str.slice(0, 60) + '...'; } throw new MongooseError('Query was already executed: ' + str); } this._execCount++;也就是说,同一 Query 对象被提交执行(真正发出数据库请求)最多只有一次。而 Query 的 thenable 实现如下(lib/query.js):
Query.prototype.then = function(resolve, reject) { return this.exec().then(resolve, reject); }; Query.prototype.catch = function(reject) { return this.exec().then(null, reject); }; Query.prototype.finally = function(onFinally) { return this.exec().finally(onFinally); };then/catch/finally都直接调用exec()。因此每次对 Query 调用.then(await底层正是调用.then)都会触发一次exec(),而重复触发exec()会抛出MongooseError: Query was already executed——这正是 observeQuery 示例中两次观察得到不同结果、且第二次以报错告终的根源。
与之对照,Model.prototype.save(lib/model.js)是一个async function,返回的是真正的原生 Promise:无论被await、被.then消费多少次,其履行值都恒定不变。
四、最容易踩坑的场景:回调与 async/await 混用
官方文档特别指出,最可能在将回调风格与 async/await 混用时无意间重复执行查询,例如:
// 反面示例:query 既是 thenable 又被回调消费,极易重复执行 const query = User.findOne({ name: 'bill' }); query.then(doc => { /* ... */ }); const doc = await query; // 再次触发 exec(),抛错或产生意外行为这种写法从来都不是必须的,应当避免。设计原则是:一个查询只消费一次。如果你确实需要把一个 Query 变成"货真价实"的 Promise(例如要传给只接受 Promise 的第三方工具),可以使用 Query#exec():
const promise = User.findOne({ name: 'bill' }).exec(); // 返回真正的 Promise需要说明的是:在 lib/query.js 中,exec()已经不再接受回调参数(传入回调会直接抛出MongooseError),它的唯一职责就是执行查询并返回一个 Promise。
五、await query还是await query.exec()?
这是使用 async/await 时最常见的二选一问题。官方测试 test/docs/promises.test.js 给出了明确结论:
const doc = await Band.findOne({ name: 'Guns N\' Roses' }); // works // 与下面这行功能上等价 const doc = await Band.findOne({ name: 'Guns N\' Roses' }).exec();- 功能上两者完全等价,
await Band.findOne()更简洁,await Band.findOne().exec()更显式,且能确保你await的一定是货真价实的 Promise; - 历史背景:Mongoose 6 时代官方推荐使用
.exec(),因为它在类型转换失败等错误发生时能提供更好的调用栈(stack trace);到了 Mongoose 7+,V8 引擎的 async stack traces 已经足够完善,两者在堆栈信息上的差异不复存在,因此你可以按个人偏好任选其一。
测试中还展示了实际堆栈差异:不带exec()时,CastError的堆栈止于Query.then;带exec()时,堆栈会包含你在代码中调用exec()的位置,更便于定位问题。若你的环境不支持 V8 async stack traces(如 Deno),则仍需依赖.exec()获取完整调用栈。
六、最佳实践小结
结合以上分析与源码证据,在使用 async/await 编写 Mongoose 代码时建议遵循以下规则:
- 函数体加
async前先想清楚:async 函数必然返回 Promise,调用方必须await或.then消费它; - 一个查询只观察一次:不要对同一个 Query 对象既
await又.then(或多次await),以免触发 lib/query.js 中的重复执行保护逻辑; - 需要真 Promise 时调用
.exec():Query.prototype.exec返回原生 Promise,适合与 Promise.all、async 工具库等协作; - 区分"履行值"语义:
findOne()解析为文档或null,find()解析为文档数组,save()解析为保存后的文档本身;多文档操作的解析结果可参考 docs/queries.md; - 把
try/catch作为默认错误策略:async/await 版本中所有异步异常都会汇聚到最近的catch,无需像 Promise 链那样逐环注册拒绝处理器; - 保持单一风格:不要在同一个流程中混用回调、Promise 链与 async/await,这是官方文档中明确指出、也是最容易诱发查询重复执行的写法。
Async/await 与 Promise 是 Mongoose 异步模型的基石:Query作为 thenable 提供了顺滑的await体验,而exec()与真正的 Promise(如save()的返回值)则提供了确定性的观察语义。理解二者的边界,就能既享受 async/await 的简洁,又避开"查询被重复执行"的隐性陷阱。
【免费下载链接】mongooseMongoDB object modeling designed to work in an asynchronous environment.项目地址: https://gitcode.com/GitHub_Trending/mo/mongoose
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考