Published on

ODM(Object Data Modeling)工具 - mongoose

Authors
  • avatar
    Name
    Shelton Ma
    Twitter

MongoDB 数据模型

  1. ITicketModel

    import mongoose, { Schema } from "mongoose";
    import mongoosePaginate from "mongoose-paginate-v2";
    import aggregatePaginate from "mongoose-aggregate-paginate-v2";
    import User from "./user";
    import SecurityEvent from "./securityEvent";
    
    // 枚举类型定义
    export const TicketStatusEnum = ["pending", "resolved", "ignored"] as const;
    export type TicketStatus = [typeof TicketStatusEnum](number); // 复用枚举值生成类型
    
    export interface ITicketModel {
      _id: string;
      content: string; // 工单内容
      status: TicketStatus; // 工单状态
      handler?: string; // 工单处理人
      creator: string; // 创建人
      securityEvent: string; // 关联安全事件
      attachments?: object; // 附件
      createdAt: Date;
      updatedAt: Date;
    }
    
    export type ITicketDocument = ITicketModel & mongoose.Document<string>;
    
    // 表结构
    const TicketSchema = new Schema<ITicketDocument>(
      {
        _id: {
          type: String,
          trim: true,
          required: true,
        },
        content: {
          type: String,
          required: true,
        },
        status: {
          type: String,
          enum: TicketStatusEnum,
          default: TicketStatusEnum[0],
        },
        handler: {
          type: String,
          ref: User, // 引用 User 表
        },
        creator: {
          type: String,
          ref: User, // 引用 User 表
          required: true, // 创建人为必填
        },
        securityEvent: {
          type: String,
          ref: SecurityEvent.modelName, // 引用 SecurityEvent 表
          required: true, // 关联安全事件为必填
        },
        attachments: {
          type: Object,
        },
      },
      { timestamps: true }
    );
    
    TicketSchema.plugin(mongoosePaginate);
    TicketSchema.plugin(aggregatePaginate);
    
    // 用于在安全事件列表显示工单
    TicketSchema.index({ securityEvent: 1, updatedAt: -1 });
    
    const Ticket = mongoose.model<
      ITicketDocument,
      mongoose.PaginateModel<ITicketDocument> &
        mongoose.AggregatePaginateModel<ITicketDocument>
    >("Ticket", TicketSchema);
    
    export default Ticket;
    

2. 最佳实践

  1. 建立索引, 包括复合索引 (Compound Index) , 同时对多个字段进行索引,适用于复杂查询条件.

    • TicketSchema.index({ securityEvent: 1, updatedAt: -1 });
    • 选择正确的索引顺序, 先写筛选条件 (如 securityEvent),再写排序字段 (如 updatedAt) 是最佳实践.
    • 避免“范围查询”阻止索引优化 如果 securityEvent 的查询条件为范围查询 (gt,gt, lt),则 updatedAt 排序可能无法充分利用索引.
  2. 插件功能 — 扩展 Schema 的能力

    • 分页插件, 为 Mongoose 模型提供 paginate() 方法,实现基于查询条件的分页.TicketSchema.plugin(mongoosePaginate);
    • 聚合查询(带分页) TicketSchema.plugin(aggregatePaginate);
  3. 通过 as constTicketStatusEnum 转化为字面量类型,以便 enum 直接匹配字符串字面量.

    export const TicketStatusEnum = ["pending", "resolved", "ignored"] as const;
    export type TicketStatus = [typeof TicketStatusEnum](number); // 复用枚举值生成类型
    
  4. 主键_id类型及自定义

    • MongoDB 默认使用 ObjectId,若使用 number 作为 _id,需确保 ID 生成机制不会出现冲突.

      creator: {
        type: String,
        ref: User, // 引用 User 表
        required: true, // 创建人为必填
      },
      
  5. 自动更新createdAt, updatedAt

    { timestamps: true }
    
  6. 执行顺序

    • .find() / .findOne() / .findById() —— 先执行查找
    • .where() —— 额外的过滤条件
    • .sort() —— 排序
    • .skip() / .limit() —— 分页
    • .select() —— 选择返回的字段
    • .populate() —— 关联查询
    • .lean() —— 转换为普通对象
    • .exec() —— 最后执行查询(可选)
  7. 查询封装(.lean(), .exec(), .populate(), .sort())

    1. 简单查询 ➝ 用 .find() 直接获取数据.

    2. 复用查询逻辑 ➝ 用 statics 封装,提高代码复用性和可读性.

      TicketSchema.static.findByQuery = function (query: Record<string, any>) {
        return this.find({ ...query, status: { $ne: "delete" } });
      };
      TicketSchema.static.findByPlatform = function (platform: string) {
        return this.find({ platform, status: { $ne: "delete" } });
      };
      
    3. 数据预处理 ➝ 在 statics 方法中预处理查询结果,例如 sort()、lean() 或 populate()

      TicketSchema.static.findByQuery = function (query: Record<string, any>) {
        return this.find({ ...query, status: { $ne: "delete" } })
          .sort({ createdAt: -1 }) // 时间倒序
          .skip((page - 1) * pageSize) // 跳过前面的数据
          .limit(pageSize) // 限制每页 10 条
          .lean()
          .exec();
      };
      
  8. 逻辑删除

    1. npm install mongoose-delete

      import mongooseDelete from "mongoose-delete";
      TicketSchema.plugin(mongooseDelete, { overrideMethods: "all", deletedAt: true });
      const Ticket = mongoose.model("Ticket", TicketSchema);
      
      Ticket.delete(function (err, result) { ... });
      Ticket.restore(function (err, result) { ... });
      
      Ticket.find(function (err, documents) {
        // will return only NOT DELETED documents
      });
      
      Ticket.findDeleted(function (err, documents) {
        // will return only DELETED documents
      });
      
      Ticket.findWithDeleted(function (err, documents) {
        // will return ALL documents
      });
      
  9. methods vs statics

    1. methods 适用于操作单个文档(如 deleteTicket()).
    2. statics 适用于批量查询或操作多个文档(如 findActiveTickets()).
    3. 在一个 Schema 中可以同时使用 methods 和 statics 来实现更加清晰的代码组织.

3. MongoDB nanoid vs ObjectId

1. ObjectId

MongoDB 内置的默认 ID 类型,生成时自动带有时间戳和唯一标识符.

  1. 结构 (12 字节 = 24 个十六进制字符):

    • 前 4 字节 → 时间戳 (精确到秒)
    • 接下来的 5 字节 → 随机值 (确保唯一性)
    • 最后 3 字节 → 计数器 (防止时间戳重复)
  2. 优点:

    • 有时间序列特性,便于按创建时间排序.
    • 自动生成,无需手动指定.
    • 内存占用更少 (12 字节),索引效率更高.

nanoid

nanoid 是一个轻量级的随机 ID 生成库,具有更快的生成速度和更短的 ID 长度,适用于 URL、客户端生成等场景

_id: { type: String, default: () => nanoid(16) },