Chapter 23

techniques-mongo

kisihara.c
kisihara.c
2021.03.18に更新

Mongo

NestはMongoDBと2つの方法で連携できる。前章で紹介したTypeORMの組み込みモジュール(MongoDB用のコネクタがある)を使うか、MongoDBのオブジェクトモデリングツールとして最も人気のあるMongooseを使うか。本章では後者について、専用の@nestjs/mongooseパッケージを使って説明する。

まず必要なdependenciesをインストールしよう。

$ npm install --save @nestjs/mongoose mongoose

インストールが終わったら、ルートのAppModuleMongooseModuleをインストールする。

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

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

fooRoot()メソッドは、ここで説明しているMongooseパッケージのmongoose.connect()と同じ設定オブジェクトを受け取る。

モデルインジェクション

Mongooseにおいては、全てがスキーマから派生する。各スキーマはMongoDBのコレクションに対応しており、そのコレクション内のドキュメントの形状を定義する。スキーマはモデルを定義するために使われる。モデルは、MongoDBデータベースからドキュメントを生成したり読み込んだりする役割を果たす。

スキーマはNestJSのデコレータを使って作る事もできるし、Mongoose自体に手動で作らせる事もできる。デコレータを使うと定型文が大幅に減り、コード全体が読みやすくなる。

CatSchemaを定義してみよう。

schemas/cat.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type CatDocument = Cat & Document;

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

  @Prop()
  age: number;

  @Prop()
  breed: string;
}

export const CatSchema = SchemaFactory.createForClass(Cat);

HINT
nestjs/mongooseDeifinitiosFactoryクラスを使えば生のスキーマ定義を生成できる。そして用意したメタデータに基づいて生成したスキーマ定義を手動で修正できる。これはデコレータで全てを表現するのが難しいようなエッジケースに有効だ。

@Schema()デコレータは、クラスをスキーマ定義として印付ける。これは、CatクラスをMongoDBの同名のコレクションに対応させる。ただし最後に"s"を追加する為、最終的なmongoのコレクション名はcatsとなる。このデコレータはスキーマオプションオブジェクトという省略可能な引数をひとつだけ受け取る。これは通常のmongoose.Schemaクラスのコンストラクタ(例:new mongoose.Schema(_,options))の第二引数として渡すオブジェクトと考えてほしい。利用可能なスキーマオプションについてはこちら

@Prop()デコレータは、ドキュメントのプロパティを定義する。例えば上記のスキーマ定義では、nameagebreedの3つのプロパティを定義している。これらのプロパティのスキーマタイプは、TypeScriptのメタデータ(及びリフレクション)機能によって自動推論される。しかし暗黙のうちに型が反映されない複雑な状況(配列やネストしたオブジェクト構造等)では、以下のように型を明示する必要がある。

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

また、@Prop()デコレータはオプションオブジェクトの引数を受け取る(利用可能なオプションはこちら)。これにより、プロパティが必須か否か示したり、デフォルト値を指定したり、不変な値である事を示したりできる。例:

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

また、他のモデルとの関係を指定して後で入力する場合にも、@Prop()デコレータを使用する事ができる。例えば、CatOwnerを持ち、それがOwnersという別のコレクションに格納されている場合、プロパティはtyperefを持つ必要がある。例:

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

// クラス定義の中
@Prop({ type: mongoose.Schema.Types.ObjectId, ref: 'Owner' })
owner: Owner;

複数のownersがある場合、プロパティ設定はこうなる。

@Prop({ type: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Owner' }] })
owner: 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.schemacatsディレクトリ内のフォルダに格納される。CatModuleも同じ場所で定義される。スキーマファイルはどこにでも保存できるが、関連するドメインオブジェクトの近く、適切なモジュールディレクトリに保存する事を勧める。

CatsModuleを見てみよう。

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()メソッドが用意されていて、どのモデルを現在のスコープに登録するのか等モジュールの設定を行う。別のモジュールでもモデルを使いたい場合は、CatsModuleexportsセクションにMongooseModuleを追加して、別のモジュールでCatsModuleimportする。

スキーマを登録したら、@InjectModel()デコレータを使ってCatsServiceCatモデルをインジェクションする。

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

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

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

  async findAll(): Promise<Cat[]> {
    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) {}
}

マルチプルデータベース

プロジェクトによっては、複数のデータベース接続が必要な場合がある。これもこのモジュールで実現可能。まず接続を作成しよう。この場合名前をつける必要がある。

app.module.ts
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 {}

NOTICE
名無しの接続や複数の同名の接続があると上書きされる。

このセットアップでは、どの接続を使うかMongooseModule.foreFeature()関数で指定する必要がある。

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

また、指定した接続に対して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をカスタムプロバイダ(例:ファクトリプロバイダ)にインジェクションするには、引数にConnectionの名前を入れこんでgetConnectionToken()関数を使用する。

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

フック(ミドルウェア)

ミドルウェア(プリ・ポストフックとも呼ばれる)は、非同期関数の実行中に制御を渡す関数。ミドルウェアはスキーマレベルで指定され、プラグインを書く際に有用だ(ソース)。モデルをコンパイルした後でpre()post()を呼んでもMongooseでは上手くいかない。モデルの登録前にフックを登録するには、MongooseModuleforeFeartureAsync()メソッドをファクトリプロバイダ(つまり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 {}

他のファクトリープロバイダと同様、このファクトリー関数は非同期で使えるし、依存関係をインジェクションできる。

@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を使用する。

app.module.ts
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のコレクションを基礎として、その上に重複するスキーマを持つ複数のモデルを持つことが出来る(have multiple models with overlapping schemas on top of the same underlying MongoDB collection)。

さまざまな種類のイベントを一つのコレクションで管理したいとしよう。全てのイベントにはタイムスタンプがある。

event.schema.ts
@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);

HINT
mongooseがディスクリミネータモデルを区別する方法は「ディスクリミネータキー」、デフォルトの値は_tだ。Mongooseはスキーマに_tという名前の文字列pathを追加して、このドキュメントがどのディスクリミネータのインスタンスであるかを追跡する為に使う。discriminatorKeyオプションで判別用のpathを定義する事もできる。

SignedUpEventClickedLinkEventインスタンスは、一般的なイベントと同じコレクションに格納される。

では、ClickedLinkEventクラスを以下のように定義してみよう。

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

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

export const ClickedLinkEventSchema = SchemaFactory.createForClass(ClickedLinkEvent);

SignUpEventクラスも。

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

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

export const SignUpEventSchema = SchemaFactory.createForClass(SignUpEvent);

以上を前提に、discriminatorsオプションを使って使いたいスキーマ用のディスクリミネータを登録する。これはMongooseModule.forFeatureでもMongooseModule.forFeatureAsyncでも動く。

event.module.ts
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 {}

テスト

アプリケーションのユニットテストを行う場合、通常はデータベースへの接続を避け、テストスイートの設定を単純にし、実行速度を早くしたいと考える。けれど我々の作ったクラスがconnectionインスタンスから引き出されたモデルに依存しているかもしれない。これらのクラスをどうテストすればよいだろう。解決策はモックモデルを作ることだ。

@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',
  }),
});

他のファクトリープロバイダのように、このファクトリー関数も非同期にできる。

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

別の方法として、クラスを使ってMongooseModuleを設定する事もできる。

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

上記のコードではMongooseModuleの中でMongooseConfigServiceをインスタンス化し、それを使って必要なオプションオブジェクトを作る。この例では、MongooseConfigServiceMongooseOptionsFactoryインターフェイスを実装する必要がある事に注意(下記参照)。MongooseModuleは、提供されたクラスのインスタンス化されたオブジェクトに対してcreateMongooseOptions()メソッドを呼び出す。

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

MongooseModuleの中にprivateなコピーを作るのではなく、既存のオプションプロバイダを再利用したい場合はuseExisting構文を使う。

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

サンプル

実際に動くサンプルはこちら