☰
Mongoose返回的一大串是什么鬼?用lean()和schema看清find()结果
2026/10/4 13:03:11 网站建设 项目流程

1. 为什么 find() 打印出来一大串看不懂的东西

你写 Node.js 连 MongoDB,用 Mongoose 查一条数据,console.log(docs[0])之后终端刷出几百行:$__、InternalCache、activePaths、StateMachine、_doc、$init……你只是想看projectName和accountPermissions,结果像把整个数据库连接都倒出来了。更诡异的是,你明明在 schema 里没写accountPermissions,但docs[0].accountPermissions居然能取到值;而另一个字段取出来却是undefined。这不是玄学,是 Mongoose 的 Document 实例机制在起作用。

先把结论放前面:find()默认返回的不是普通 JS 对象,而是 Mongoose 的Document 实例(内部叫model实例)。它身上挂了两层东西——一层是你数据库里真实存的字段(放在_doc里),另一层是 Mongoose 为了做校验、变更追踪、中间件、类型转换而附加的内部状态($__、$init、activePaths等)。你console.log时看到的一大串,就是这两层混在一起被打印出来的结果。

那为什么docs[0].accountPermissions有时能取到、有时是undefined?关键在于schema 里有没有声明这个字段。Mongoose 的 Document 实例在取值时,会走 getter。如果 schema 里定义了这个 path,Mongoose 会为它生成 getter/setter,你直接.accountPermissions就能拿到;如果 schema 里没定义,这个字段只存在于_doc里,没有对应的 getter,直接点属性就可能拿不到,得用docs[0].get('accountPermissions')或docs[0].toObject().accountPermissions。

所以这个问题的本质是三个层次:

第一层,schema 是 Mongoose 的“灵魂”。每个 schema 实例映射一个 MongoDB 集合,它决定了哪些字段被识别、被类型转换、被校验。schema 里没声明的字段,Mongoose 默认不会主动帮你挂到实例上(除非开了strict: false)。

第二层,model 是 schema 编译出来的构造函数。mongoose.model('ProjectInfo', ProjectSchema)之后,你拿到的ProjectInfo是一个 Model,它的实例就是 Document。Document 不是 plain object,它的原型链是model → Model → Document → Object,所以docs[0].__proto__不是{},而是Model.prototype。

第三层,lean() 是“降级”开关。加上.lean()后,Mongoose 跳过 Document 实例化,直接把 MongoDB 驱动返回的 BSON 转成普通 JS 对象。这时候docs[0].__proto__就是{},你看到的就是干净的{ _id, projectName, accountPermissions }。

我试过在同一个查询里分别打印docs[0]、docs[0]._doc、docs[0].toObject()、docs[0].toJSON(),四个结果长度差了好几倍。_doc最接近原始数据,toObject()会做一层转换,toJSON()还会受 schema 的toJSON配置影响。理解这几个方法的区别,比死记lean()什么时候用更重要。

下面按“schema 定义 → model 编译 → find() 查询 → 打印验证 → lean() 对比 → 排错”的顺序,把每一步都拆开,代码可以直接复制到你的项目里跑。

2. 前置准备:schema、model 与连接配置

在动手之前,先把环境搭好。你需要一个能连上的 MongoDB(本地或云端都行),以及 Node.js 项目里装好mongoose。这里不涉及任何网络工具,直接用官方 npm 包即可。

先建一个db.js,负责连接和导出 model。注意 schema 的定义方式,这直接决定了后面find()返回的 Document 里哪些字段能直接点出来。

// db.js const mongoose = require('mongoose'); // 连接本地 MongoDB,库名 demo mongoose.connect('mongodb://127.0.0.1:27017/demo', { useNewUrlParser: true, useUnifiedTopology: true, }); const projectSchema = new mongoose.Schema({ projectName: { type: String, unique: true }, // 注意:这里故意先不声明 accountPermissions // 用来复现“schema 没定义字段时取不到”的现象 owner: { type: String }, createdAt: { type: Date, default: Date.now }, }); const ProjectInfo = mongoose.model('ProjectInfo', projectSchema); module.exports = { ProjectInfo, mongoose };

上面这段 schema 里,projectName、owner、createdAt都声明了,accountPermissions故意没写。这样你就能看到两种字段的差异:声明过的字段,Document 实例上有 getter,直接点能拿到;没声明的字段,只能通过_doc或get()拿。

再写一个seed.js,往集合里插一条测试数据,包含accountPermissions数组:

// seed.js const { ProjectInfo, mongoose } = require('./db'); async function seed() { await ProjectInfo.deleteMany({}); await ProjectInfo.create({ projectName: 'aaa', owner: 'tester', accountPermissions: [ '5e09a25cd77ad90b2f63cee7', '5c0f150f9325280349519b20', '5df2f8b330750c2b8f2b8725', ], }); console.log('seed done'); await mongoose.disconnect(); } seed();

跑node seed.js,看到seed done就说明数据进去了。这一步很关键,因为后面所有对比都基于这条数据。如果你用的是已有数据库,把字段名换成你自己的即可,逻辑一样。

这里有个容易忽略的点:mongoose.connect返回的是 Promise,但很多人不await就直接查询,导致查询在连接建立前发出,报MongooseError: Operation buffering timed out。建议在启动脚本里await mongoose.connect(...),或者用.then()包一层。我在项目里习惯把连接封装成一个connectDB()函数,在应用启动时调用一次,避免每个 model 文件都连一遍。

另外,useNewUrlParser和useUnifiedTopology在新版 Mongoose(6.x 以上)里已经默认开启,写不写都行,但写上兼容性更好。如果你用的是 Mongoose 7/8,这两个选项会被忽略,不会报错。

3. 可复制配置:find() 与 lean() 对比代码

现在进入核心部分。新建query.js,把下面这段完整代码复制进去,逐段运行,观察输出差异。

// query.js const { ProjectInfo, mongoose } = require('./db'); async function run() { // 1. 普通 find(),返回 Document 实例 const docs = await ProjectInfo.find( { projectName: 'aaa' }, { projectName: 1, accountPermissions: 1, owner: 1 } ).exec(); console.log('===== docs[0] 直接打印 ====='); console.log(docs[0]); console.log('===== docs[0]._doc ====='); console.log(docs[0]._doc); console.log('===== docs[0].toObject() ====='); console.log(docs[0].toObject()); console.log('===== docs[0].__proto__ 是不是 {} ====='); console.log(docs[0].__proto__ === Object.prototype); // 2. 加 lean(),返回普通 JS 对象 const leanDocs = await ProjectInfo.find( { projectName: 'aaa' }, { projectName: 1, accountPermissions: 1, owner: 1 } ).lean().exec(); console.log('===== leanDocs[0] 直接打印 ====='); console.log(leanDocs[0]); console.log('===== leanDocs[0].__proto__ 是不是 {} ====='); console.log(leanDocs[0].__proto__ === Object.prototype); await mongoose.disconnect(); } run();

运行node query.js,你会看到两组输出。第一组docs[0]会刷出$__、InternalCache、activePaths、_doc等一大堆;第二组leanDocs[0]只有干净的{ _id, projectName, accountPermissions, owner }。

这里有几个参数值得说明。find()的第一个参数是查询条件,第二个参数是 projection(投影),{ projectName: 1, accountPermissions: 1, owner: 1 }表示只返回这三个字段加默认的_id。如果你不想返回_id,加_id: 0。projection 写错会导致字段缺失,比如写成{ projectName: true }和{ projectName: 1 }效果一样,但混用1和0会报错,MongoDB 不允许同时包含包含和排除(_id除外)。

.exec()是可选的,await ProjectInfo.find(...)本身就能执行。但显式写.exec()能返回真正的 Promise,方便链式调用和错误捕获。.lean()的位置必须在.exec()之前,写成find().lean().exec(),顺序反了不生效。

如果你用的是 TypeScript,lean()的返回类型需要额外处理,因为默认推断还是 Document 类型。可以用ProjectInfo.find(...).lean<{ projectName: string; accountPermissions: string[] }[]>()手动指定泛型,或者定义type ProjectLean = { ... }再断言。

再补充一个toObject()的配置写法。如果你不想每次都手动调toObject(),可以在 schema 里设置:

const projectSchema = new mongoose.Schema( { projectName: { type: String, unique: true }, owner: { type: String }, createdAt: { type: Date, default: Date.now }, }, { toObject: { virtuals: true, getters: true }, toJSON: { virtuals: true, getters: true }, } );

这样docs[0].toObject()会自动带上 virtual 字段和 getter 转换。但注意,这仍然不会让docs[0]本身变成普通对象,只是toObject()的结果更完整。

4. 验证请求与成功结果:打印结构逐层拆解

跑完上面的query.js,我们来逐层看输出到底代表什么。

先看docs[0]直接打印。你会看到顶层有$__、isNew、errors、_doc、$init。其中$__是InternalCache,里面存了strictMode、selected、activePaths、emitter等。selected就是你传的 projection,activePaths是StateMachine,用来追踪哪些字段被修改过。_doc才是真正从数据库取回来的数据,里面是accountPermissions、projectName、_id。

再看docs[0]._doc,输出是:

{ accountPermissions: [ '5e09a25cd77ad90b2f63cee7', '5c0f150f9325280349519b20', '5df2f8b330750c2b8f2b8725' ], projectName: 'aaa', owner: 'tester', _id: new ObjectId('...') }

这就是原始数据。注意accountPermissions在这里是普通数组,但如果你在 schema 里声明了它是[String],它会被包装成 MongooseArray,带push、pull、addToSet等方法。这就是为什么有时候你打印数组会看到一堆函数——它已经不是原生数组了。

再看docs[0].toObject(),输出和_doc接近,但会做一层深拷贝和类型转换,ObjectId会保留,Date会保留。toJSON()则会在toObject()基础上再应用 schema 的toJSON配置,通常用于res.json()返回给前端。

最后看docs[0].__proto__ === Object.prototype,结果是false。因为 Document 实例的原型链是model.prototype → Model.prototype → Document.prototype → Object.prototype,中间隔了好几层。而leanDocs[0].__proto__ === Object.prototype是true,说明它就是普通对象。

验证成功的标志很简单:leanDocs[0]打印出来只有你投影的字段,没有$__、没有_doc、没有activePaths。你可以直接leanDocs[0].accountPermissions.map(...),也可以JSON.stringify(leanDocs[0])得到干净的 JSON 字符串。

这里有个实测细节:lean()之后,ObjectId仍然是ObjectId类型,不是字符串。如果你要返回给前端,通常还需要.toString()或JSON.stringify自动转。Date也仍然是Date对象。如果你希望连这些都转成字符串,可以在lean()后手动 map,或者用JSON.parse(JSON.stringify(leanDocs))。

另外,lean()不会触发 virtual 字段和 getter。如果你 schema 里定义了fullName这种 virtual,lean()后拿不到。需要 virtual 的话,要么不用lean(),要么在lean({ virtuals: true })里开启(Mongoose 5.10+ 支持)。

5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth

这一节把你在接入和查询过程中可能撞到的报错集中列一下。虽然本篇主题是 Mongoose 查询结构,但很多同学是在调 AI 接口或远程数据库时顺带踩到这些坑,排查思路是相通的。

报错一:MongooseError: Operation buffering timed out after 10000ms

这不是 401,但经常和连接配置一起出现。原因是mongoose.connect还没完成,查询就发出了。Mongoose 默认会缓冲操作 10 秒,超时就报这个。解决:在启动时await mongoose.connect(...),或者把bufferCommands设为false让错误立刻暴露。如果你连的是远程 MongoDB,检查连接字符串里的 host、port、库名是否正确,以及网络是否可达。

报错二:MongoServerError: Authentication failed或 401

这是认证失败,不是权限问题就是密码错。检查连接字符串格式:mongodb://user:pass@host:port/db?authSource=admin。authSource很关键,很多云数据库要求指定认证库。如果你用的是 API Key 方式访问某些托管服务,确认 Key 没有过期、没有多余空格。401 的本质是“身份没通过”,和 Mongoose 的 Document 结构无关,但会卡在连接阶段,让你误以为是查询写错了。

报错三:local proxy failed或连接被拒绝

这类报错通常出现在你配置了本地转发或自定义网络层时。排查顺序:先确认目标地址和端口是否可达,再确认本地配置有没有冲突。如果是公司内网,检查是否需要走特定的出口。注意,这里不涉及任何网络工具的使用建议,只做通用排查:ping目标主机、telnet host port看端口通不通、检查环境变量里有没有残留的代理配置覆盖了你的连接。

报错四:Cannot read properties of undefined (reading 'choices')

这个报错常见于调用大模型接口后解析响应。choices是 OpenAI 兼容接口返回结构里的字段,如果响应体不是预期格式,或者你访问的路径不对,就会读到undefined。排查:先console.log(JSON.stringify(response, null, 2))看完整结构,确认choices在哪一层。如果是流式响应,choices在chunk里,不是顶层。如果是错误响应,可能只有error字段,没有choices。

报错五:OAuth 相关报错,如invalid_grant、redirect_uri_mismatch

这类报错出现在用 OAuth 方式接入服务时。redirect_uri_mismatch说明回调地址和注册时填的不一致,检查协议(http/https)、域名、端口、路径是否完全匹配。invalid_grant通常是授权码过期或已被使用,重新走一遍授权流程。OAuth 的排查核心是“比对配置”,把请求参数和平台后台的配置逐项对照。

报错六:docs[0].accountPermissions是undefined

回到本篇主题。如果你 schema 里没声明accountPermissions,直接点属性可能拿不到。三种解法:一是在 schema 里补上accountPermissions: [String];二是用docs[0].get('accountPermissions');三是用docs[0].toObject().accountPermissions或.lean()。推荐第一种,因为 schema 声明后,Mongoose 会帮你做类型转换和校验,后续维护更省心。

报错七:lean()后 virtual 字段消失

前面提过,lean()默认不触发 virtual。如果你依赖 virtual 做计算属性,要么不用lean(),要么用lean({ virtuals: true })。但注意,virtuals: true在lean()里需要 Mongoose 5.10+,低版本不支持。

把这些报错按“连接阶段 → 查询阶段 → 响应解析阶段”分类,排查时先定位阶段,再看具体错误信息,效率会高很多。

6. 语义一致 CTA:把查询结构调清楚之后

把find()返回的 Document 和lean()返回的普通对象分清楚之后,你在写接口、做数据转换、返回 JSON 时就不会再被那一大串内部字段干扰了。核心判断标准就一条:需要 Document 的方法(save、validate、populate 链式、virtual)就用默认 find();只需要读数据、要性能、要干净 JSON,就加 lean()。

如果你在调 AI 接口或做后端集成时,需要快速验证模型返回结构、对比不同模型的输出格式,可以用模型对话功能直接试:https://taotoken.net/api 配合模型对话入口 https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=model_chat 先把响应体看清楚,再写解析代码,能少踩很多reading 'choices'的坑。

长期做编码和 Agent 集成的,可以看 Coding Plan:https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=coding_plan 把 Key 和 Base URL 配好,后面调接口、查文档、排错都在一个地方。

需要生成和管理 API Key 的,直接进控制台:https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=console 接入文档在 https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=doc 里面有完整的 Base URL、Key、Model ID 三件套说明。

最后留一个实用习惯:每次写完查询,先console.log一下docs[0]._doc和docs[0].toObject(),确认字段都在,再决定要不要lean()。这个动作花不了几秒,但能帮你避开“打印一大串”和“取不到字段”两个高频坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询