ミドルウェア

ミドルウェア(プリフックおよびポストフックとも呼ばれます)は、非同期関数の実行中に制御が渡される関数です。ミドルウェアはスキーマレベルで指定され、プラグインの記述に役立ちます。

ミドルウェアの種類

Mongooseには、ドキュメントミドルウェア、モデルミドルウェア、集約ミドルウェア、クエリミドルウェアの4種類のミドルウェアがあります。

ドキュメントミドルウェアは、次のドキュメント関数でサポートされています。Mongooseでは、ドキュメントはModelクラスのインスタンスです。ドキュメントミドルウェア関数では、thisはドキュメントを参照します。モデルにアクセスするには、this.constructorを使用します。

クエリミドルウェアは、次のクエリ関数でサポートされています。クエリミドルウェアは、クエリオブジェクトでexec()またはthen()を呼び出すか、クエリオブジェクトでawaitを使用すると実行されます。クエリミドルウェア関数では、thisはクエリを参照します。

集約ミドルウェアは、MyModel.aggregate()用です。集約ミドルウェアは、集約オブジェクトでexec()を呼び出すと実行されます。集約ミドルウェアでは、this集約オブジェクトを参照します。

モデルミドルウェアは、次のモデル関数でサポートされています。モデルミドルウェアとドキュメントミドルウェアを混同しないでください。モデルミドルウェアはModelクラスの静的関数にフックし、ドキュメントミドルウェアはModelクラスのメソッドにフックします。モデルミドルウェア関数では、thisはモデルを参照します。

pre()に渡すことができる文字列を以下に示します。

  • aggregate
  • bulkWrite
  • count
  • countDocuments
  • createCollection
  • deleteOne
  • deleteMany
  • estimatedDocumentCount
  • find
  • findOne
  • findOneAndDelete
  • findOneAndReplace
  • findOneAndUpdate
  • init
  • insertMany
  • replaceOne
  • save
  • update
  • updateOne
  • updateMany
  • validate

すべてのミドルウェアの種類で、プリフックとポストフックがサポートされています。プリフックとポストフックの動作については、以下で詳しく説明します。

注:MongooseはデフォルトでQuery.prototype.updateOne()updateOneミドルウェアを登録します。これは、doc.updateOne()Model.updateOne()の両方でupdateOneフックがトリガーされることを意味しますが、thisはドキュメントではなくクエリを参照します。updateOneミドルウェアをドキュメントミドルウェアとして登録するには、schema.pre('updateOne', { document: true, query: false })を使用します。

注:updateOneと同様に、MongooseはデフォルトでQuery.prototype.deleteOnedeleteOneミドルウェアを登録します。つまり、Model.deleteOne()deleteOneフックをトリガーし、thisはクエリを参照します。ただし、レガシーの理由から、doc.deleteOne()deleteOneクエリミドルウェアを実行しませんdeleteOneミドルウェアをドキュメントミドルウェアとして登録するには、schema.pre('deleteOne', { document: true, query: false })を使用します。

注:create()関数はsave()フックを実行します。

注:クエリミドルウェアはサブドキュメントでは実行されません。

const childSchema = new mongoose.Schema({
  name: String
});

const mainSchema = new mongoose.Schema({
  child: [childSchema]
});

mainSchema.pre('findOneAndUpdate', function() {
  console.log('Middleware on parent document'); // Will be executed
});

childSchema.pre('findOneAndUpdate', function() {
  console.log('Middleware on subdocument'); // Will not be executed
});

プリ

プリミドルウェア関数は、各ミドルウェアがnextを呼び出すと、順番に実行されます。

const schema = new Schema({ /* ... */ });
schema.pre('save', function(next) {
  // do stuff
  next();
});

mongoose 5.xでは、next()を手動で呼び出す代わりに、Promiseを返す関数を使用できます。特に、async/awaitを使用できます。

schema.pre('save', function() {
  return doStuff().
    then(() => doMoreStuff());
});

// Or, using async functions
schema.pre('save', async function() {
  await doStuff();
  await doMoreStuff();
});

next()を使用する場合、next()呼び出しは、ミドルウェア関数内の残りのコードの実行を停止しません。next()を呼び出すときに、ミドルウェア関数の残りの部分が実行されないようにするには、早期returnパターンを使用します。

const schema = new Schema({ /* ... */ });
schema.pre('save', function(next) {
  if (foo()) {
    console.log('calling next!');
    // `return next();` will make sure the rest of this function doesn't run
    /* return */ next();
  }
  // Unless you comment out the `return` above, 'after next' will print
  console.log('after next');
});

ユースケース

ミドルウェアは、モデルロジックをアトマイズするのに役立ちます。その他のアイデアを以下に示します。

  • 複雑なバリデーション
  • 依存ドキュメントの削除(ユーザーを削除すると、そのユーザーのブログ投稿がすべて削除されます)
  • 非同期デフォルト
  • 特定のアクションによってトリガーされる非同期タスク

プリフックでのエラー

プリフックでエラーが発生した場合、Mongooseは後続のミドルウェアまたはフックされた関数を実行しません。代わりに、Mongooseはエラーをコールバックに渡し、または返されたPromiseを拒否します。ミドルウェアでエラーを報告するには、いくつかの方法があります。

schema.pre('save', function(next) {
  const err = new Error('something went wrong');
  // If you call `next()` with an argument, that argument is assumed to be
  // an error.
  next(err);
});

schema.pre('save', function() {
  // You can also return a promise that rejects
  return new Promise((resolve, reject) => {
    reject(new Error('something went wrong'));
  });
});

schema.pre('save', function() {
  // You can also throw a synchronous error
  throw new Error('something went wrong');
});

schema.pre('save', async function() {
  await Promise.resolve();
  // You can also throw an error in an `async` function
  throw new Error('something went wrong');
});

// later...

// Changes will not be persisted to MongoDB because a pre hook errored out
myDoc.save(function(err) {
  console.log(err.message); // something went wrong
});

next()を複数回呼び出すことは、ノーオペレーションです。エラーerr1next()を呼び出してからエラーerr2をスローした場合、Mongooseはerr1を報告します。

ポストミドルウェア

postミドルウェアは、フックされたメソッドとそのすべてのpreミドルウェアが完了したに実行されます。

schema.post('init', function(doc) {
  console.log('%s has been initialized from the db', doc._id);
});
schema.post('validate', function(doc) {
  console.log('%s has been validated (but not saved yet)', doc._id);
});
schema.post('save', function(doc) {
  console.log('%s has been saved', doc._id);
});
schema.post('deleteOne', function(doc) {
  console.log('%s has been deleted', doc._id);
});

非同期ポストフック

ポストフック関数が少なくとも2つのパラメータをとる場合、Mongooseは2番目のパラメータがnext()関数であると仮定し、シーケンス内の次のミドルウェアをトリガーするために呼び出します。

// Takes 2 parameters: this is an asynchronous post hook
schema.post('save', function(doc, next) {
  setTimeout(function() {
    console.log('post1');
    // Kick off the second post hook
    next();
  }, 10);
});

// Will not execute until the first middleware calls `next()`
schema.post('save', function(doc, next) {
  console.log('post2');
  next();
});

非同期関数をpost()に渡すこともできます。少なくとも2つのパラメータをとる非同期関数を渡す場合、next()を呼び出す責任は依然としてあります。ただし、2つ未満のパラメータをとる非同期関数を渡すこともでき、MongooseはPromiseが解決されるのを待ちます。

schema.post('save', async function(doc) {
  await new Promise(resolve => setTimeout(resolve, 1000));
  console.log('post1');
  // If less than 2 parameters, no need to call `next()`
});

schema.post('save', async function(doc, next) {
  await new Promise(resolve => setTimeout(resolve, 1000));
  console.log('post1');
  // If there's a `next` parameter, you need to call `next()`.
  next();
});

モデルのコンパイル前にミドルウェアを定義する

モデルのコンパイル後にpre()またはpost()を呼び出すことは、一般的にMongooseでは機能しません。たとえば、以下のpre('save')ミドルウェアは実行されません。

const schema = new mongoose.Schema({ name: String });

// Compile a model from the schema
const User = mongoose.model('User', schema);

// Mongoose will **not** call the middleware function, because
// this middleware was defined after the model was compiled
schema.pre('save', () => console.log('Hello from pre save'));

const user = new User({ name: 'test' });
user.save();

つまり、mongoose.model()を呼び出すに、すべてのミドルウェアとプラグインを追加する必要があります。以下のスクリプトは「Hello from pre save」を出力します。

const schema = new mongoose.Schema({ name: String });
// Mongoose will call this middleware function, because this script adds
// the middleware to the schema before compiling the model.
schema.pre('save', () => console.log('Hello from pre save'));

// Compile a model from the schema
const User = mongoose.model('User', schema);

const user = new User({ name: 'test' });
user.save();

その結果、スキーマを定義するファイルと同じファイルからMongooseモデルをエクスポートすることについて注意する必要があります。このパターンを使用する場合は、モデルファイルでrequire()を呼び出すグローバルプラグインを定義する必要があります。

const schema = new mongoose.Schema({ name: String });

// Once you `require()` this file, you can no longer add any middleware
// to this schema.
module.exports = mongoose.model('User', schema);

保存/バリデーションフック

save()関数はvalidate()フックを実行します。これは、Mongooseにvalidate()を呼び出す組み込みのpre('save')フックがあるためです。これは、すべてのpre('validate')post('validate')フックが、すべてのpre('save')フックのに呼び出されることを意味します。

schema.pre('validate', function() {
  console.log('this gets printed first');
});
schema.post('validate', function() {
  console.log('this gets printed second');
});
schema.pre('save', function() {
  console.log('this gets printed third');
});
schema.post('save', function() {
  console.log('this gets printed fourth');
});

ミドルウェアでのパラメータへのアクセス

Mongooseは、ミドルウェアをトリガーした関数呼び出しに関する情報を取得するための2つの方法を提供します。クエリミドルウェアの場合、Mongooseクエリインスタンスになるthisを使用することをお勧めします。

const userSchema = new Schema({ name: String, age: Number });
userSchema.pre('findOneAndUpdate', function() {
  console.log(this.getFilter()); // { name: 'John' }
  console.log(this.getUpdate()); // { age: 30 }
});
const User = mongoose.model('User', userSchema);

await User.findOneAndUpdate({ name: 'John' }, { $set: { age: 30 } });

ドキュメントミドルウェア(pre('save')など)の場合、Mongooseはsave()に渡された最初の引数を、pre('save')コールバックの2番目の引数として渡します。Mongooseドキュメントには、save()に渡すことができるすべてのオプションが保存されていないため、2番目の引数を使用してsave()呼び出しのoptionsにアクセスする必要があります。

const userSchema = new Schema({ name: String, age: Number });
userSchema.pre('save', function(next, options) {
  options.validateModifiedOnly; // true

  // Remember to call `next()` unless you're using an async function or returning a promise
  next();
});
const User = mongoose.model('User', userSchema);

const doc = new User({ name: 'John', age: 30 });
await doc.save({ validateModifiedOnly: true });

名前の衝突

Mongooseには、deleteOne()のクエリフックとドキュメントフックの両方があります。

schema.pre('deleteOne', function() { console.log('Removing!'); });

// Does **not** print "Removing!". Document middleware for `deleteOne` is not executed by default
await doc.deleteOne();

// Prints "Removing!"
await Model.deleteOne();

Schema.pre()Schema.post()にオプションを渡して、Document.prototype.deleteOne()またはQuery.prototype.deleteOne()に対してMongooseがdeleteOne()フックを呼び出すかどうかを切り替えることができます。ここで、渡されたオブジェクトにdocumentプロパティとqueryプロパティの両方を設定する必要があることに注意してください。

// Only document middleware
schema.pre('deleteOne', { document: true, query: false }, function() {
  console.log('Deleting doc!');
});

// Only query middleware. This will get called when you do `Model.deleteOne()`
// but not `doc.deleteOne()`.
schema.pre('deleteOne', { query: true, document: false }, function() {
  console.log('Deleting!');
});

Mongooseには、validate()のクエリフックとドキュメントフックの両方もあります。deleteOneupdateOneとは異なり、validateミドルウェアはデフォルトでDocument.prototype.validateに適用されます。

const schema = new mongoose.Schema({ name: String });
schema.pre('validate', function() {
  console.log('Document validate');
});
schema.pre('validate', { query: true, document: false }, function() {
  console.log('Query validate');
});
const Test = mongoose.model('Test', schema);

const doc = new Test({ name: 'foo' });

// Prints "Document validate"
await doc.validate();

// Prints "Query validate"
await Test.find().validate();

findAndUpdate()とクエリミドルウェアに関する注意

プリおよびポストsave()フックは、update()findOneAndUpdate()などは実行されませんこのGitHubの問題で、その理由の詳細な説明を確認できます。Mongoose 4.0では、これらの関数専用のフックが導入されました。

schema.pre('find', function() {
  console.log(this instanceof mongoose.Query); // true
  this.start = Date.now();
});

schema.post('find', function(result) {
  console.log(this instanceof mongoose.Query); // true
  // prints returned documents
  console.log('find() returned ' + JSON.stringify(result));
  // prints number of milliseconds the query took
  console.log('find() took ' + (Date.now() - this.start) + ' milliseconds');
});

クエリミドルウェアは、ドキュメントミドルウェアとは微妙な点で異なります。ドキュメントミドルウェアでは、thisは更新されているドキュメントを参照します。クエリミドルウェアでは、Mongooseは必ずしも更新されているドキュメントへの参照を持っていないため、thisは更新されているドキュメントではなくクエリオブジェクトを参照します。

たとえば、すべてのupdateOne()呼び出しにupdatedAtタイムスタンプを追加する場合は、次のプリフックを使用します。

schema.pre('updateOne', function() {
  this.set({ updatedAt: new Date() });
});

pre('updateOne')またはpre('findOneAndUpdate')クエリミドルウェアでは、更新されているドキュメントにアクセスすることはできません。更新されるドキュメントにアクセスする必要がある場合は、ドキュメントの明示的なクエリを実行する必要があります。

schema.pre('findOneAndUpdate', async function() {
  const docToUpdate = await this.model.findOne(this.getQuery());
  console.log(docToUpdate); // The document that `findOneAndUpdate()` will modify
});

ただし、pre('updateOne')ドキュメントミドルウェアを定義した場合、thisは更新されているドキュメントになります。これは、pre('updateOne')ドキュメントミドルウェアがQuery#updateOne()ではなくDocument#updateOne()にフックするためです。

schema.pre('updateOne', { document: true, query: false }, function() {
  console.log('Updating');
});
const Model = mongoose.model('Test', schema);

const doc = new Model();
await doc.updateOne({ $set: { name: 'test' } }); // Prints "Updating"

// Doesn't print "Updating", because `Query#updateOne()` doesn't fire
// document middleware.
await Model.updateOne({}, { $set: { name: 'test' } });

エラー処理ミドルウェア

ミドルウェアの実行は、ミドルウェアが最初にエラーでnext()を呼び出すと通常停止します。ただし、「エラー処理ミドルウェア」と呼ばれる特別な種類のポストミドルウェアがあり、エラーが発生したときにのみ実行されます。エラー処理ミドルウェアは、エラーの報告とエラーメッセージの可読性の向上に役立ちます。

エラー処理ミドルウェアは、追加のパラメータ(関数への最初の引数として発生した「error」)をとるミドルウェアとして定義されます。エラー処理ミドルウェアは、エラーを必要に応じて変換できます。

const schema = new Schema({
  name: {
    type: String,
    // Will trigger a MongoServerError with code 11000 when
    // you save a duplicate
    unique: true
  }
});

// Handler **must** take 3 parameters: the error that occurred, the document
// in question, and the `next()` function
schema.post('save', function(error, doc, next) {
  if (error.name === 'MongoServerError' && error.code === 11000) {
    next(new Error('There was a duplicate key error'));
  } else {
    next();
  }
});

// Will trigger the `post('save')` error handler
Person.create([{ name: 'Axl Rose' }, { name: 'Axl Rose' }]);

エラー処理ミドルウェアは、クエリミドルウェアでも機能します。MongoDBの重複キーエラーをキャッチするポストupdate()フックを定義することもできます。

// The same E11000 error can occur when you call `updateOne()`
// This function **must** take 4 parameters.

schema.post('updateOne', function(passRawResult, error, res, next) {
  if (error.name === 'MongoServerError' && error.code === 11000) {
    next(new Error('There was a duplicate key error'));
  } else {
    next(); // The `updateOne()` call will still error out.
  }
});

const people = [{ name: 'Axl Rose' }, { name: 'Slash' }];
await Person.create(people);

// Throws "There was a duplicate key error"
await Person.updateOne({ name: 'Slash' }, { $set: { name: 'Axl Rose' } });

エラー処理ミドルウェアはエラーを変換できますが、エラーを削除することはできません。上記のようにエラーなしでnext()を呼び出しても、関数呼び出しは依然としてエラーになります。

集約フック

Model.aggregate()関数 のフックも定義できます。集約ミドルウェア関数内では、thisMongoose の Aggregate オブジェクト を指します。例えば、isDeleted プロパティを追加することで、Customer モデルにソフトデリートを実装しているとします。aggregate() の呼び出しでソフトデリートされていない顧客のみを参照するには、以下のミドルウェアを使用して、各集約パイプラインの先頭に$match ステージを追加できます。

customerSchema.pre('aggregate', function() {
  // Add a $match state to the beginning of each pipeline.
  this.pipeline().unshift({ $match: { isDeleted: { $ne: true } } });
});

Aggregate#pipeline()関数 を使用すると、Mongoose が MongoDB サーバーに送信する MongoDB 集約パイプラインにアクセスできます。これは、ミドルウェアからパイプラインの先頭にステージを追加する場合に役立ちます。

同期フック

特定の Mongoose フックは同期式であり、これはPromiseを返す関数やnext()コールバックを受け取る関数をサポートしないことを意味します。init()関数が同期式であるため、現在、同期式なのはinitフックのみです。以下は、pre initフックとpost initフックの使用例です。

[require:post init hooks.*success]

initフックでエラーを報告するには、同期式のエラーをスローする必要があります。他のすべてのミドルウェアとは異なり、init ミドルウェアはPromiseの拒否を処理しません

[require:post init hooks.*error]

次へ

ミドルウェアについて説明したので、クエリポピュレーションヘルパーを使用した、Mongoose の JOIN の擬似実装アプローチを見てみましょう。