Mongoose实战指南:从基础用法到高级查询优化技巧

UE丶玉卿 安全 阅读 1,612
赞 15 收藏
二维码
手机扫码查看
反馈

又踩坑了,Mongoose 的默认值居然没生效?

上周改一个老项目,加了个新字段 isPublished,类型是 Boolean,默认值设成 false。结果测试的时候发现,有些文档里这个字段压根没有!查数据库一看,空的,不是 false,是根本不存在这个 key。我当时就懵了——Mongoose 的 default 不是应该自动补上吗?怎么还能漏掉?

Mongoose实战指南:从基础用法到高级查询优化技巧

折腾了半天,试了一堆方法,最后才发现问题出在一个我完全没想到的地方。

一开始我以为是 schema 写错了

先贴一下最开始的 schema(简化版):

const postSchema = new mongoose.Schema({
  title: String,
  content: String,
  isPublished: {
    type: Boolean,
    default: false
  }
});

看起来没问题吧?官方文档也是这么写的。我新建文档的时候也没传 isPublished,按理说应该自动填 false。但实际存进 MongoDB 的文档长这样:

{
  "_id": "65f1a...",
  "title": "测试文章",
  "content": "内容..."
}

确实没有 isPublished 字段。我第一反应是:是不是 default 只在 new Model() 的时候生效,而用 Model.create() 或者 insertMany 就不生效?于是我把代码改成显式 new 一个实例再 save,结果还是一样。

后来我又怀疑是不是 Mongoose 版本问题(我们用的是 6.x),去 GitHub issues 搜了一圈,发现不少人遇到类似问题,但原因五花八门。有人说是 strict 模式的问题,有人说是 setDefaultsOnInsert 没开…… 我一个个试,都没解决。

关键线索:findOneAndUpdate 的锅

直到我注意到——这个字段是在调用 findOneAndUpdate 的时候才被创建的!而我的业务逻辑里,很多地方用的是 upsert 操作:

Post.findOneAndUpdate(
  { slug: 'test-post' },
  { title: '更新标题', content: '新内容' },
  { upsert: true, new: true }
)

问题就出在这儿!Mongoose 的 default 值在普通 save 时会生效,但在 findOneAndUpdate 这类原子操作中默认不会触发。这是个经典坑点,但文档写得特别隐晦。

官方其实提过一句:default 值只在 document 实例化时应用,而 findOneAndUpdate 是直接发命令给 MongoDB,绕过了 Mongoose 的中间件和默认值处理流程。所以即使你设置了 default,在 upsert 的时候也不会自动加上。

解决方案:setDefaultsOnInsert

好在 Mongoose 提供了一个选项叫 setDefaultsOnInsert,专门用来解决这个问题。你得在查询选项里显式开启它:

Post.findOneAndUpdate(
  { slug: 'test-post' },
  { title: '更新标题', content: '新内容' },
  {
    upsert: true,
    new: true,
    setDefaultsOnInsert: true // 👈 关键!
  }
)

加上这行之后,upsert 时就会把 schema 里定义的 default 值自动补上。亲测有效。

不过这里有个细节要注意:setDefaultsOnInsert 只对 完全缺失 的字段生效。如果你传了 isPublished: undefined,它不会帮你转成 default 值;只有字段压根没出现在 update 对象里,才会触发 default。

更彻底的做法:配合 runValidators

既然都改到这儿了,索性把校验也加上。因为默认情况下,findOneAndUpdate 也不会跑 schema 的 validator。所以完整配置应该是:

Post.findOneAndUpdate(
  { slug: 'test-post' },
  { title: '更新标题', content: '新内容' },
  {
    upsert: true,
    new: true,
    setDefaultsOnInsert: true,
    runValidators: true
  }
)

这样既能保证默认值,又能确保数据符合规则。不过要注意,validator 在 upsert 时的行为可能和普通 save 有点差异,比如 required 字段如果没传,在 upsert 时可能会报错——得根据业务判断是否需要调整。

那能不能全局开启?

我一开始想:每个地方都写 setDefaultsOnInsert: true 太麻烦了,能不能全局设置?查了下,Mongoose 确实支持在 schema 级别设置默认选项:

const postSchema = new mongoose.Schema({
  title: String,
  content: String,
  isPublished: {
    type: Boolean,
    default: false
  }
}, {
  // 全局设置 findOneAndUpdate 的默认行为
  defaults: {
    setDefaultsOnInsert: true,
    runValidators: true
  }
});

但很遗憾,这个写法不生效。Mongoose 并不支持通过 schema 配置来自动附加这些选项到查询方法上。你必须在每次调用 findOneAndUpdatefindOneAndReplacefindByIdAndUpdate 等方法时手动传入。

后来我干脆封装了一个工具函数:

function safeUpsert(Model, filter, update, options = {}) {
  return Model.findOneAndUpdate(
    filter,
    update,
    {
      upsert: true,
      new: true,
      setDefaultsOnInsert: true,
      runValidators: true,
      ...options
    }
  );
}

// 使用
safeUpsert(Post, { slug: 'test' }, { title: 'xxx' });

虽然多一层封装,但至少不用到处复制粘贴那几行配置了。

顺便提一嘴:default 函数的陷阱

还有一个小坑,跟 default 本身有关。比如你这样写:

createdAt: {
  type: Date,
  default: Date.now()
}

注意这里是 Date.now() 而不是 Date.now。前者会在 schema 定义时就执行一次,导致所有文档的 createdAt 都是同一个时间戳!正确写法应该是传函数引用:

createdAt: {
  type: Date,
  default: Date.now // 不加括号!
}

这个我早年踩过,现在看到 default 就条件反射检查有没有括号。不过这次的问题跟这个无关,只是顺带提醒下。

总结一下

这次的问题根源在于:**Mongoose 的 default 值机制不适用于原子更新操作(如 findOneAndUpdate)**,除非你显式开启 setDefaultsOnInsert。这个设计其实有道理——MongoDB 本身的 upsert 就不处理应用层逻辑,Mongoose 默认保持一致。但对开发者来说确实容易踩坑,尤其当项目里既有 save 又有 findOneAndUpdate 的时候,行为不一致很容易引发 bug。

改完之后,大部分情况都正常了。不过我发现如果 update 对象里显式传了 isPublished: null,还是会存成 null 而不是 default 值。这其实是预期行为,因为 null 被视为“已提供值”,不算缺失字段。如果业务上不允许 null,得靠 validator 或 pre middleware 来拦截。暂时没改,因为影响不大,后续再看。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。这个技巧的拓展用法还有很多,比如配合 timestamps、virtuals 等,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论