Techniques

Mongo

学习如何在 NestJS 中集成 MongoDB 数据库,包括 Mongoose 的使用、模型注入、连接管理、会话处理、多数据库配置、中间件、插件、判别器、测试和异步配置等。

Mongo

Nest 支持两种与 MongoDB 数据库集成的方法。您可以使用内置的 TypeORM 模块(在这里描述),它有一个 MongoDB 连接器,或者使用 Mongoose,这是最流行的 MongoDB 对象建模工具。在本章中,我们将描述后者,使用专用的 @nestjs/mongoose 包。

首先安装所需的依赖项

$ npm i @nestjs/mongoose mongoose

安装过程完成后,我们可以将 MongooseModule 导入到根 AppModule 中。

@@filename(app.module)
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';

@Module({
  imports: [MongooseModule.forRoot('mongodb://localhost/nest')],
})
export class AppModule {}

forRoot() 方法接受与 Mongoose 包中的 mongoose.connect() 相同的配置对象,如这里所述。

模型注入

使用 Mongoose,一切都源于 Schema。每个模式映射到一个 MongoDB 集合,并定义该集合中文档的形状。模式用于定义模型。模型负责从底层 MongoDB 数据库创建和读取文档。

模式可以使用 NestJS 装饰器创建,也可以手动使用 Mongoose 本身创建。使用装饰器创建模式大大减少了样板代码并提高了整体代码可读性。

让我们定义 CatSchema

@@filename(schemas/cat.schema)
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';

export type CatDocument = HydratedDocument<Cat>;

@Schema()
export class Cat {
  @Prop()
  name: string;

  @Prop()
  age: number;

  @Prop()
  breed: string;
}

export const CatSchema = SchemaFactory.createForClass(Cat);

提示 请注意,您也可以使用 DefinitionsFactory 类(来自 nestjs/mongoose)生成原始模式定义。这允许您手动修改基于您提供的元数据生成的模式定义。这对于某些边缘情况很有用,在这些情况下,用装饰器表示所有内容可能很困难。

@Schema() 装饰器将类标记为模式定义。它将我们的 Cat 类映射到同名的 MongoDB 集合,但在末尾添加了一个额外的 "s" - 因此最终的 mongo 集合名称将是 cats。此装饰器接受一个可选参数,即模式选项对象。将其视为您通常作为 mongoose.Schema 类构造函数的第二个参数传递的对象(例如,new mongoose.Schema(_, options))。要了解有关可用模式选项的更多信息,请参阅章节。

@Prop() 装饰器在文档中定义属性。例如,在上面的模式定义中,我们定义了三个属性:nameagebreed。由于 TypeScript 元数据(和反射)功能,这些属性的模式类型会自动推断。但是,在更复杂的场景中,类型无法隐式反映(例如,数组或嵌套对象结构),必须明确指示类型,如下所示:

@Prop([String])
tags: string[];

或者,@Prop() 装饰器接受选项对象参数(阅读更多关于可用选项)。通过这个,您可以指示属性是否必需,指定默认值,或将其标记为不可变。例如:

@Prop({ required: true })
name: string;

如果您想指定与另一个模型的关系,稍后用于填充,您也可以使用 @Prop() 装饰器。例如,如果 CatOwner,它存储在名为 owners 的不同集合中,该属性应该有类型和引用。例如:

import * as mongoose from 'mongoose';
import { Owner } from '../owners/schemas/owner.schema';

// 在类定义内部
@Prop({ type: mongoose.Schema.Types.ObjectId, ref: 'Owner' })
owner: Owner;

如果有多个所有者,您的属性配置应如下所示:

@Prop({ type: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Owner' }] })
owners: Owner[];

如果您不打算总是填充对另一个集合的引用,请考虑使用 mongoose.Types.ObjectId 作为类型:

@Prop({ type: { type: mongoose.Schema.Types.ObjectId, ref: 'Owner' } })
// 这确保字段不会与填充的引用混淆
owner: mongoose.Types.ObjectId;

然后,当您需要稍后有选择地填充它时,您可以使用指定正确类型的存储库函数:

import { Owner } from './schemas/owner.schema';

// 例如在服务或存储库内部
async findAllPopulated() {
  return this.catModel.find().populate<{ owner: Owner }>("owner");
}

提示 如果没有要填充的外部文档,类型可能是 Owner | null,这取决于您的 Mongoose 配置。或者,它可能会抛出错误,在这种情况下类型将是 Owner

最后,原始模式定义也可以传递给装饰器。这在例如属性表示未定义为类的嵌套对象时很有用。为此,使用来自 @nestjs/mongoose 包的 raw() 函数,如下所示:

@Prop(raw({
  firstName: { type: String },
  lastName: { type: String }
}))
details: Record<string, any>;

或者,如果您不喜欢使用装饰器,您可以手动定义模式。例如:

export const CatSchema = new mongoose.Schema({
  name: String,
  age: Number,
  breed: String,
});

cat.schema 文件位于 cats 目录中的一个文件夹中,我们还在其中定义了 CatsModule。虽然您可以将模式文件存储在您喜欢的任何地方,但我们建议将它们存储在相关对象附近,在适当的模块目录中。

让我们看看 CatsModule

@@filename(cats.module)
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
import { Cat, CatSchema } from './schemas/cat.schema';

@Module({
  imports: [MongooseModule.forFeature([{ name: Cat.name, schema: CatSchema }])],
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {}

MongooseModule 提供 forFeature() 方法来配置模块,包括定义应在当前作用域中注册哪些模型。如果您还想在另一个模块中使用模型,请将 MongooseModule 添加到 CatsModuleexports 部分,并在其他模块中导入 CatsModule

注册模式后,您可以使用 @InjectModel() 装饰器将 Cat 模型注入到 CatsService 中:

@@filename(cats.service)
import { Model } from 'mongoose';
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Cat } from './schemas/cat.schema';
import { CreateCatDto } from './dto/create-cat.dto';

@Injectable()
export class CatsService {
  constructor(@InjectModel(Cat.name) private catModel: Model<Cat>) {}

  async create(createCatDto: CreateCatDto): Promise<Cat> {
    const createdCat = new this.catModel(createCatDto);
    return createdCat.save();
  }

  async findAll(): Promise<Cat[]> {
    return this.catModel.find().exec();
  }
}
@@switch
import { Model } from 'mongoose';
import { Injectable, Dependencies } from '@nestjs/common';
import { getModelToken } from '@nestjs/mongoose';
import { Cat } from './schemas/cat.schema';

@Injectable()
@Dependencies(getModelToken(Cat.name))
export class CatsService {
  constructor(catModel) {
    this.catModel = catModel;
  }

  async create(createCatDto) {
    const createdCat = new this.catModel(createCatDto);
    return createdCat.save();
  }

  async findAll() {
    return this.catModel.find().exec();
  }
}

连接

有时您可能需要访问原生 Mongoose Connection 对象。例如,您可能想要在连接对象上进行原生 API 调用。您可以使用 @InjectConnection() 装饰器注入 Mongoose Connection,如下所示:

import { Injectable } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Connection } from 'mongoose';

@Injectable()
export class CatsService {
  constructor(@InjectConnection() private connection: Connection) {}
}

会话

要使用 Mongoose 启动会话,建议使用 @InjectConnection 注入数据库连接,而不是直接调用 mongoose.startSession()。这种方法允许与 NestJS 依赖注入系统更好地集成,确保正确的连接管理。

以下是如何启动会话的示例:

import { InjectConnection } from '@nestjs/mongoose';
import { Connection } from 'mongoose';

@Injectable()
export class CatsService {
  constructor(@InjectConnection() private readonly connection: Connection) {}

  async startTransaction() {
    const session = await this.connection.startSession();
    session.startTransaction();
    // 您的事务逻辑在这里
  }
}

在此示例中,@InjectConnection() 用于将 Mongoose 连接注入到服务中。一旦注入连接,您可以使用 connection.startSession() 开始新会话。此会话可用于管理数据库事务,确保跨多个查询的原子操作。启动会话后,请记住根据您的逻辑提交或中止事务。

多数据库

某些项目需要多个数据库连接。这也可以通过此模块实现。要使用多个连接,首先创建连接。在这种情况下,连接命名变为强制性的

@@filename(app.module)
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';

@Module({
  imports: [
    MongooseModule.forRoot('mongodb://localhost/test', {
      connectionName: 'cats',
    }),
    MongooseModule.forRoot('mongodb://localhost/users', {
      connectionName: 'users',
    }),
  ],
})
export class AppModule {}

注意 请注意,您不应该有多个没有名称的连接,或具有相同名称的连接,否则它们将被覆盖。

通过此设置,您必须告诉 MongooseModule.forFeature() 函数应该使用哪个连接。

@Module({
  imports: [
    MongooseModule.forFeature([{ name: Cat.name, schema: CatSchema }], 'cats'),
  ],
})
export class CatsModule {}

您也可以为给定连接注入 Connection

import { Injectable } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Connection } from 'mongoose';

@Injectable()
export class CatsService {
  constructor(@InjectConnection('cats') private connection: Connection) {}
}

要将给定的 Connection 注入到自定义提供者(例如,工厂提供者),请使用 getConnectionToken() 函数,将连接名称作为参数传递。

{
  provide: CatsService,
  useFactory: (catsConnection: Connection) => {
    return new CatsService(catsConnection);
  },
  inject: [getConnectionToken('cats')],
}

如果您只是想从命名数据库注入模型,您可以使用连接名称作为 @InjectModel() 装饰器的第二个参数。

@@filename(cats.service)
@Injectable()
export class CatsService {
  constructor(@InjectModel(Cat.name, 'cats') private catModel: Model<Cat>) {}
}
@@switch
@Injectable()
@Dependencies(getModelToken(Cat.name, 'cats'))
export class CatsService {
  constructor(catModel) {
    this.catModel = catModel;
  }
}

钩子(中间件)

中间件(也称为前置和后置钩子)是在异步函数执行期间传递控制的函数。中间件在模式级别指定,对于编写插件很有用(来源)。在编译模型后调用 pre()post() 在 Mongoose 中不起作用。要在模型注册之前注册钩子,请使用 MongooseModuleforFeatureAsync() 方法以及工厂提供者(即 useFactory)。通过这种技术,您可以访问模式对象,然后使用 pre()post() 方法在该模式上注册钩子。请参见下面的示例:

@Module({
  imports: [
    MongooseModule.forFeatureAsync([
      {
        name: Cat.name,
        useFactory: () => {
          const schema = CatsSchema;
          schema.pre('save', function () {
            console.log('Hello from pre save');
          });
          return schema;
        },
      },
    ]),
  ],
})
export class AppModule {}

像其他工厂提供者一样,我们的工厂函数可以是 async 的,并且可以通过 inject 注入依赖项。

@Module({
  imports: [
    MongooseModule.forFeatureAsync([
      {
        name: Cat.name,
        imports: [ConfigModule],
        useFactory: (configService: ConfigService) => {
          const schema = CatsSchema;
          schema.pre('save', function() {
            console.log(
              `${configService.get('APP_NAME')}: Hello from pre save`,
            ),
          });
          return schema;
        },
        inject: [ConfigService],
      },
    ]),
  ],
})
export class AppModule {}

插件

要为给定模式注册插件,请使用 forFeatureAsync() 方法。

@Module({
  imports: [
    MongooseModule.forFeatureAsync([
      {
        name: Cat.name,
        useFactory: () => {
          const schema = CatsSchema;
          schema.plugin(require('mongoose-autopopulate'));
          return schema;
        },
      },
    ]),
  ],
})
export class AppModule {}

要一次为所有模式注册插件,请调用 Connection 对象的 .plugin() 方法。您应该在创建模型之前访问连接;为此,请使用 connectionFactory

@@filename(app.module)
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';

@Module({
  imports: [
    MongooseModule.forRoot('mongodb://localhost/test', {
      connectionFactory: (connection) => {
        connection.plugin(require('mongoose-autopopulate'));
        return connection;
      }
    }),
  ],
})
export class AppModule {}

判别器

判别器是模式继承机制。它们使您能够在同一个底层 MongoDB 集合之上拥有具有重叠模式的多个模型。

假设您想在单个集合中跟踪不同类型的事件。每个事件都将有一个时间戳。

@@filename(event.schema)
@Schema({ discriminatorKey: 'kind' })
export class Event {
  @Prop({
    type: String,
    required: true,
    enum: [ClickedLinkEvent.name, SignUpEvent.name],
  })
  kind: string;

  @Prop({ type: Date, required: true })
  time: Date;
}

export const EventSchema = SchemaFactory.createForClass(Event);

提示 mongoose 区分不同判别器模型的方式是通过"判别器键",默认情况下是 __t。Mongoose 向您的模式添加一个名为 __t 的字符串路径,它用来跟踪此文档是哪个判别器的实例。 您也可以使用 discriminatorKey 选项来定义判别路径。

SignedUpEventClickedLinkEvent 实例将与通用事件存储在同一集合中。

现在,让我们定义 ClickedLinkEvent 类,如下所示:

@@filename(click-link-event.schema)
@Schema()
export class ClickedLinkEvent {
  kind: string;
  time: Date;

  @Prop({ type: String, required: true })
  url: string;
}

export const ClickedLinkEventSchema = SchemaFactory.createForClass(ClickedLinkEvent);

SignUpEvent 类:

@@filename(sign-up-event.schema)
@Schema()
export class SignUpEvent {
  kind: string;
  time: Date;

  @Prop({ type: String, required: true })
  user: string;
}

export const SignUpEventSchema = SchemaFactory.createForClass(SignUpEvent);

有了这个,使用 discriminators 选项为给定模式注册判别器。它在 MongooseModule.forFeatureMongooseModule.forFeatureAsync 上都有效:

@@filename(event.module)
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';

@Module({
  imports: [
    MongooseModule.forFeature([
      {
        name: Event.name,
        schema: EventSchema,
        discriminators: [
          { name: ClickedLinkEvent.name, schema: ClickedLinkEventSchema },
          { name: SignUpEvent.name, schema: SignUpEventSchema },
        ],
      },
    ]),
  ]
})
export class EventsModule {}

测试

在对应用程序进行单元测试时,我们通常希望避免任何数据库连接,使我们的测试套件更简单设置和更快执行。但是我们的类可能依赖于从连接实例中提取的模型。我们如何解决这些类?解决方案是创建模拟模型。

为了使这更容易,@nestjs/mongoose 包公开了一个 getModelToken() 函数,该函数基于令牌名称返回准备好的注入令牌。使用此令牌,您可以使用任何标准自定义提供者技术轻松提供模拟实现,包括 useClassuseValueuseFactory。例如:

@Module({
  providers: [
    CatsService,
    {
      provide: getModelToken(Cat.name),
      useValue: catModel,
    },
  ],
})
export class CatsModule {}

在此示例中,每当任何消费者使用 @InjectModel() 装饰器注入 Model<Cat> 时,都会提供硬编码的 catModel(对象实例)。

异步配置

当您需要异步而不是静态传递模块选项时,请使用 forRootAsync() 方法。与大多数动态模块一样,Nest 提供了几种处理异步配置的技术。

一种技术是使用工厂函数:

MongooseModule.forRootAsync({
  useFactory: () => ({
    uri: 'mongodb://localhost/nest',
  }),
});

像其他工厂提供者一样,我们的工厂函数可以是 async 的,并且可以通过 inject 注入依赖项。

MongooseModule.forRootAsync({
  imports: [ConfigModule],
  useFactory: async (configService: ConfigService) => ({
    uri: configService.get<string>('MONGODB_URI'),
  }),
  inject: [ConfigService],
});

或者,您可以使用类而不是工厂来配置 MongooseModule,如下所示:

MongooseModule.forRootAsync({
  useClass: MongooseConfigService,
});

上面的构造在 MongooseModule 内部实例化 MongooseConfigService,使用它来创建所需的选项对象。请注意,在此示例中,MongooseConfigService 必须实现 MongooseOptionsFactory 接口,如下所示。MongooseModule 将在提供的类的实例化对象上调用 createMongooseOptions() 方法。

@Injectable()
export class MongooseConfigService implements MongooseOptionsFactory {
  createMongooseOptions(): MongooseModuleOptions {
    return {
      uri: 'mongodb://localhost/nest',
    };
  }
}

如果您想重用现有的选项提供者而不是在 MongooseModule 内部创建私有副本,请使用 useExisting 语法。

MongooseModule.forRootAsync({
  imports: [ConfigModule],
  useExisting: ConfigService,
});

连接事件

您可以使用 onConnectionCreate 配置选项监听 Mongoose 连接事件。这允许您在建立连接时实现自定义逻辑。例如,您可以为 connectedopendisconnectedreconnecteddisconnecting 事件注册事件监听器,如下所示:

MongooseModule.forRoot('mongodb://localhost/test', {
  onConnectionCreate: (connection: Connection) => {
    connection.on('connected', () => console.log('connected'));
    connection.on('open', () => console.log('open'));
    connection.on('disconnected', () => console.log('disconnected'));
    connection.on('reconnected', () => console.log('reconnected'));
    connection.on('disconnecting', () => console.log('disconnecting'));

    return connection;
  },
}),

在此代码片段中,我们正在建立到 mongodb://localhost/test 的 MongoDB 数据库的连接。onConnectionCreate 选项使您能够设置特定的事件监听器来监控连接的状态:

  • connected:连接成功建立时触发。
  • open:连接完全打开并准备好进行操作时触发。
  • disconnected:连接丢失时调用。
  • reconnected:连接在断开后重新建立时调用。
  • disconnecting:连接正在关闭过程中时发生。

您也可以将 onConnectionCreate 属性合并到使用 MongooseModule.forRootAsync() 创建的异步配置中:

MongooseModule.forRootAsync({
  useFactory: () => ({
    uri: 'mongodb://localhost/test',
    onConnectionCreate: (connection: Connection) => {
      // 在这里注册事件监听器
      return connection;
    },
  }),
}),

这提供了管理连接事件的灵活方式,使您能够有效地处理连接状态的变化。

子文档

要在父文档中嵌套子文档,您可以按如下方式定义模式:

@@filename(name.schema)
@Schema()
export class Name {
  @Prop()
  firstName: string;

  @Prop()
  lastName: string;
}

export const NameSchema = SchemaFactory.createForClass(Name);

然后在父模式中引用子文档:

@@filename(person.schema)
@Schema()
export class Person {
  @Prop(NameSchema)
  name: Name;
}

export const PersonSchema = SchemaFactory.createForClass(Person);

export type PersonDocumentOverride = {
  name: Types.Subdocument<Types.ObjectId> & Name;
};

export type PersonDocument = HydratedDocument<Person, PersonDocumentOverride>;

如果您想包含多个子文档,您可以使用子文档数组。重要的是要相应地覆盖属性的类型:

@@filename(name.schema)
@Schema()
export class Person {
  @Prop([NameSchema])
  name: Name[];
}

export const PersonSchema = SchemaFactory.createForClass(Person);

export type PersonDocumentOverride = {
  name: Types.DocumentArray<Name>;
};

export type PersonDocument = HydratedDocument<Person, PersonDocumentOverride>;

虚拟属性

在 Mongoose 中,虚拟属性是存在于文档上但不持久化到 MongoDB 的属性。它不存储在数据库中,而是在访问时动态计算。虚拟属性通常用于派生或计算值,如组合字段(例如,通过连接 firstNamelastName 创建 fullName 属性),或用于创建依赖于文档中现有数据的属性。

class Person {
  @Prop()
  firstName: string;

  @Prop()
  lastName: string;

  @Virtual({
    get: function (this: Person) {
      return `${this.firstName} ${this.lastName}`;
    },
  })
  fullName: string;
}

提示 @Virtual() 装饰器从 @nestjs/mongoose 包导入。

在此示例中,fullName 虚拟属性从 firstNamelastName 派生。即使它在访问时表现得像普通属性,它也永远不会保存到 MongoDB 文档中。

示例

一个工作示例可在这里获得。