MongoDB Academy · レッスン

Mongooseミドルウェア:PreとPostフック

保存前のパスワードのハッシュ化や検索後のログ記録などを行う、ドキュメントおよびクエリのミドルウェアフックを記述します。

レッスン 4/413 ステップ

「Mongooseミドルウェア:PreとPostフック」はCoddyKit上の無料MongoDB Academyレッスンです。 これはレッスン4/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはMongoDB Academy学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 MongoDB Academyコースには全4レッスンが含まれています。

Mongoose ミドルウェアとは

Mongoose のミドルウェア(フックとも呼ばれます)は、save、find、updateOne、deleteOne などの特定の操作の前または後に実行される関数です。これにより、ルートハンドラーを複雑にすることなく、ドキュメントやクエリ操作のライフサイクルに独自の処理を組み込めます。よくある用途には、保存前のパスワードのハッシュ化、クエリ時間の記録、ソフトデリートの強制、find 後の関連データの populate などがあります。

2 種類のミドルウェア: Document と Query

Mongoose のミドルウェアには、明確に異なる 2 つの種類があります。Document ミドルウェアは、特定の Document インスタンスに対する操作(save、validate、remove、init)にフックします。Query ミドルウェアは、Model で呼び出されるクエリ操作(find、findOne、updateOne、deleteOne など)にフックします。重要な違いは this が何を参照するかです。Document ミドルウェアでは this はドキュメントを、Query ミドルウェアでは this は Query オブジェクトを参照します。

// Document middleware: 'this' = the document
userSchema.pre('save', function () {
  console.log('Saving document:', this.email);
});

// Query middleware: 'this' = the Query object
userSchema.pre('find', function () {
  console.log('Running query:', this.getQuery());
});

保存前処理: パスワードのハッシュ化

pre-save フックは、最も一般的な Document ミドルウェアです。ドキュメントが MongoDB に保存される前に実行されます。代表的な用途はパスワードのハッシュ化です。ユーザードキュメントが新しいパスワード、または変更されたパスワードとともに保存されるとき、保存する前に bcrypt でハッシュ化します。this.isModified('password') のチェックにより、関係のない更新で保存する際に、すでにハッシュ化されたパスワードを再度ハッシュ化するのを防げます。

const bcrypt = require('bcrypt');

userSchema.pre('save', async function () {
  // 'this' is the User document being saved
  if (!this.isModified('password')) {
    return; // skip if password hasn't changed
  }
  const saltRounds = 12;
  this.password = await bcrypt.hash(this.password, saltRounds);
  // The hashed value replaces the plain password before MongoDB stores it
});

isNew と isModified のヘルパー

Document ミドルウェアでは、変更追跡用のヘルパーを利用できます。this.isNew は、ドキュメントが初めて挿入されるとき(更新ではないとき)に true になります。this.isModified(path) は、指定したフィールドが最後に保存または取得されてから変更されている場合に true を返します。これらのヘルパーを使うと、作成時だけ、または特定のフィールドが変更されたときだけフックを実行するなど、条件付きで処理を実行できます。

userSchema.pre('save', async function () {
  if (this.isNew) {
    // Only runs when creating a new user, not on updates
    this.verificationToken = crypto.randomBytes(32).toString('hex');
    this.verificationExpires = new Date(Date.now() + 24 * 60 * 60 * 1000);
  }

  if (this.isModified('email')) {
    // Only runs when the email field specifically changed
    this.emailVerified = false; // reset verification on email change
  }
});

保存後処理: 保存後の副作用

保存後フックは、ドキュメントが正常に永続化された後に実行されます。保存されたドキュメントを第1引数として受け取り、(古いバージョンの Mongoose では)nextコールバックも受け取ります。保存が正常に完了した後に実行すべき副作用(ウェルカムメールの送信、検索インデックスの更新、メッセージキューへのイベント発行、キャッシュの消去など)には、ポストフックが適しています。ポストフックで発生したエラーによって、保存がロールバックされることはありません。

userSchema.post('save', async function (doc) {
  // 'doc' is the saved document
  if (doc.isNew) {
    // Note: 'isNew' is false here (doc was just saved, so it's no longer new)
    // Track this with a flag set in pre-save:
  }
});

// Pattern: set a flag in pre-save, read it in post-save
userSchema.pre('save', function () {
  this._wasNew = this.isNew; // save state before it changes
});

userSchema.post('save', async function (doc) {
  if (doc._wasNew) {
    await sendWelcomeEmail(doc.email, doc.name);
    await analyticsTracker.track('user_created', { userId: doc._id });
  }
});

クエリミドルウェア:ソフト削除のための Pre-find

クエリミドルウェアの典型的な用途として、ソフト削除の実装があります。ドキュメントを削除する代わりに、deletedAtフィールドを設定します。そのうえで、すべての検索クエリに { deletedAt: null } を自動的に追加する pre('find')フックを登録します。これにより、削除済みのドキュメントがデフォルトで返されることはありません。監査証跡を残しながら、アプリケーションの他の部分からソフト削除の処理を意識せずに済むようになります。

const postSchema = new mongoose.Schema({
  title: String,
  content: String,
  deletedAt: { type: Date, default: null }
});

// Automatically exclude soft-deleted documents from all find queries
postSchema.pre(/^find/, function () {
  // 'this' is the Query object
  this.where({ deletedAt: null });
  // /^find/ matches find, findOne, findOneAndUpdate, etc.
});

// Now Post.find({}) never returns deleted posts
// To explicitly query deleted posts, you'd call Post.find({}).bypassMiddleware() or use .lean() with the native driver

クエリミドルウェア:自動 Populated のための Pre-find

クエリミドルウェアを使うと、すべての検索で参照フィールドを自動的に populateできます。これにより、呼び出し側がすべてのクエリに .populate() を追加しなくても、参照先のドキュメントが常に解決されます。ただし便利な一方で、自動 populate は検索のたびに2回目のクエリを発生させるため、参照先が大きい場合や常に必要とは限らない場合には、パフォーマンスが低下することがあります。

const reviewSchema = new mongoose.Schema({
  productId: { type: mongoose.Schema.Types.ObjectId, ref: 'Product' },
  userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
  rating: Number,
  comment: String
});

// Always populate author info on find
reviewSchema.pre(/^find/, function () {
  this.populate({
    path: 'userId',
    select: 'name avatar'
  });
});

// Now Review.find() always includes user name and avatar

Pre-deleteOne:カスケード削除

Mongoose は、親ドキュメントの削除時に関連ドキュメントも削除するカスケード削除を自動的には適用しません。ドキュメントミドルウェアを使ってカスケード動作を実装できます。User モデルに pre-deleteOne フックを設定し、そのユーザーに属するすべての投稿、コメント、セッションを、ユーザードキュメント自体を削除する前に削除できます。これにより、外部キー制約がなくても参照整合性を維持できます。

userSchema.pre('deleteOne', { document: true, query: false }, async function () {
  // 'this' is the User document being deleted
  const userId = this._id;

  // Cascade delete related documents
  await Promise.all([
    Post.deleteMany({ authorId: userId }),
    Comment.deleteMany({ userId: userId }),
    Session.deleteMany({ userId: userId }),
    Notification.deleteMany({ userId: userId })
  ]);

  console.log('Cascade deleted data for user:', userId);
});

// Trigger:
// const user = await User.findById(id);
// await user.deleteOne(); // triggers pre-deleteOne above

ミドルウェアのエラーハンドリング

pre フック関数がエラーをスローするか、Promise を rejectすると、その後に実行されるはずだった操作は中止されます。これにより、ミドルウェアでバリデーションや認可チェックを実行し、エラーをスローして保存やクエリを中断できます。たとえば、スキーマのバリデーションだけでは扱えないビジネスロジックを検証する pre-save フックでエラーをスローすると、そのエラーはアプリケーションコード内の .save() 呼び出しにある catch ブロックまで伝播します。

orderSchema.pre('save', async function () {
  if (this.total <= 0) {
    throw new Error('Order total must be positive');
  }

  // Check inventory synchronously before saving the order
  const product = await Product.findById(this.productId).lean();
  if (!product || product.stock < this.quantity) {
    throw new Error('Insufficient inventory for this order');
  }
});

// In route handler:
try {
  const order = new Order({ productId, quantity, total });
  await order.save(); // throws if pre-save hook rejects
} catch (err) {
  res.status(400).json({ error: err.message });
}

Aggregate ミドルウェア

Mongoose は、集約パイプライン用のミドルウェアもサポートしています。pre-aggregate フックを使うと、パイプラインが MongoDB に送信される前に、その配列へアクセスできます。これにより、ステージ(ソフト削除されたドキュメントのフィルタリングなど)を先頭に追加したり、ステージ(デフォルトの上限の注入など)を末尾に追加したりできます。フック関数内では this.pipeline() を使ってパイプラインにアクセスします。

postSchema.pre('aggregate', function () {
  // 'this' is the Aggregate object
  // Add a $match stage at the beginning to exclude soft-deleted documents
  this.pipeline().unshift({
    $match: { deletedAt: null }
  });
});

// Now Post.aggregate([...]) automatically excludes deleted posts
// at the start of every aggregation pipeline

ミドルウェアの注意点:フックを回避するクエリメソッド

すべての書き込み操作がドキュメントミドルウェアを実行するわけではありません。Model(インスタンスではありません)に対して呼び出す updateMany()、findOneAndUpdate()、replaceOne() は、ドキュメントの save フックを回避します。これらはクエリミドルウェアなので、処理を介入させたい場合は別途フックが必要です。たとえば、User.updateOne({}, { $set: { password: plain } }) を呼び出しても、pre-save のパスワードハッシュ化フックは実行されません。クエリベースの更新では、必ずアプリケーションコード側でハッシュ化してください。

// WRONG: password NOT hashed — bypasses pre-save hook
await User.updateOne({ _id: userId }, { $set: { password: plainPassword } });

// RIGHT for query-level updates: hash before calling updateOne
const hashed = await bcrypt.hash(plainPassword, 12);
await User.updateOne({ _id: userId }, { $set: { password: hashed } });

// Or: fetch, modify, save — triggers pre-save hook
const user = await User.findById(userId);
user.password = plainPassword; // hook will hash it
await user.save();

理解度チェック

このレッスンで学んだ MongoDB & NoSQL Databases の概念について、理解度を確認しましょう。

レッスンのまとめ

このレッスンでは、pre フックは操作の前に実行され、エラーをスローして操作を中止できること、post フックは操作の後に実行され、結果を引数として受け取ること、ドキュメントミドルウェア(pre-save、pre-deleteOne)では 'this' がドキュメントを指し、クエリミドルウェアでは 'this' が Query オブジェクトを指すこと、そしてクエリレベルの書き込みメソッド(updateOne、updateMany、findOneAndUpdate)はドキュメントミドルウェアを回避するため、操作の種類ごとにどのフックが実行されるかを常に把握する必要があることを学びました。これで MongoDB & NoSQL Databases のコーストラックは完了です。

無料で開始

AI チューターと学ぶ JavaScript — 無料

ブラウザでリアルコードを書いて実行し、24/7 の AI チューターから瞬時にサポートを受け、ウェブまたはアプリで続きから学習できます。

コース
30
レッスン
120

よくある質問

「Mongooseミドルウェア:PreとPostフック」レッスンは無料ですか?

はい。「Mongooseミドルウェア:PreとPostフック」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、MongoDB Academyコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 MongoDB Academyコースには全4レッスンが含まれています。

「Mongooseミドルウェア:PreとPostフック」で何を学びますか?

保存前のパスワードのハッシュ化や検索後のログ記録などを行う、ドキュメントおよびクエリのミドルウェアフックを記述します。 ブラウザで直接実行するハンズオンコードでMongoDB Academyを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

MongoDB Academyを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのMongoDB Academyは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン4/4です。

「Mongooseミドルウェア:PreとPostフック」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このMongoDB Academyレッスンでコードを書いて実行できますか?

はい。すべてのMongoDB Academyレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. 公式Node.jsドライバーによる接続
  2. Mongooseのスキーマ、モデル、仮想プロパティ
  3. Mongooseのクエリ、チェーン、Leanドキュメント
  4. Mongooseミドルウェア:PreとPostフック
← MongoDB Academyに戻る