中介軟體

中介軟體(也稱為 pre 和 post hooks)是在執行非同步函式時將控制權傳遞給的函式。中介軟體是在綱要層級指定,對於編寫 外掛程式很有用。

中介軟體類型

Mongoose 有 4 種類型的中介軟體:文件中間件、模型中間件、聚合中間件和查詢中間件。

以下文件函數支援文件中間件。在 Mongoose 中,文件是 Model 類別的實例。在文件中間件函數中,this 指的是文件。若要存取模型,請使用 this.constructor

以下 Query 函式支援查詢中介軟體。當您在 Query 物件上呼叫 exec()then(),或在 Query 物件上呼叫 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

所有中介軟體類型都支援 pre 和 post hook。以下將更詳細地說明 pre 和 post hook 的運作方式。

注意: Mongoose 預設會在 Query.prototype.updateOne() 上註冊 updateOne 中介軟體。這表示 doc.updateOne()Model.updateOne() 都會觸發 updateOne hook,但 this 指的是查詢,而不是文件。若要將 updateOne 中介軟體註冊為文件中間件,請使用 schema.pre('updateOne', { document: true, query: false })

注意:updateOne 類似,Mongoose 預設會在 Query.prototype.deleteOne 上註冊 deleteOne 中介軟體。這表示 Model.deleteOne() 會觸發 deleteOne hook,而 this 會指的是查詢。然而,由於舊有原因,doc.deleteOne()不會觸發 deleteOne 查詢中介軟體。若要將 deleteOne 中介軟體註冊為文件中間件,請使用 schema.pre('deleteOne', { document: true, query: false })

注意: create() 函數會觸發 save() hook。

注意: 查詢中介軟體不會在子文件上執行。

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
});

Pre

當每個中介軟體呼叫 next 時,會依序執行 Pre 中介軟體函數。

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

mongoose 5.x 中,您可以使用傳回 promise 的函數,而不是手動呼叫 next()。特別是,您可以使用 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');
});

使用案例

中介軟體對於模型邏輯原子化非常有用。以下是一些其他想法

  • 複雜的驗證
  • 移除相依的文件(移除使用者會移除其所有部落格文章)
  • 非同步預設值
  • 特定動作觸發的非同步工作

Pre Hook 中的錯誤

如果任何 pre hook 發生錯誤,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() 無作用。如果您呼叫 next() 並傳入錯誤 err1,然後拋出錯誤 err2,則 mongoose 會報告 err1

Post 中介軟體

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);
});

非同步 Post Hook

如果您的 post hook 函數採用至少 2 個參數,則 mongoose 會假設第二個參數是您將呼叫以觸發序列中下一個中介軟體的 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()之前加入所有中介軟體和外掛程式。以下指令碼將列印出「來自 pre save 的 Hello」

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 Hook

save() 函數會觸發 validate() hook,因為 mongoose 有一個內建的 pre('save') hook 會呼叫 validate()。這表示所有 pre('validate')post('validate') hook 都會在任何 pre('save') hook之前被呼叫。

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

在 Middleware 中存取參數

Mongoose 提供兩種方式來取得有關觸發中介軟體之函數呼叫的資訊。對於查詢中介軟體,我們建議使用 this,它會是 Mongoose Query 實例

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() 的第 1 個參數當作 pre('save') 回呼的第 2 個引數傳遞。您應該使用第 2 個引數來存取 save() 呼叫的 options,因為 Mongoose 文件不會儲存您可以傳遞給 save() 的所有選項。

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() 都有查詢和文件 hook。

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(),以切換 Mongoose 是否對 Document.prototype.deleteOne()Query.prototype.deleteOne() 呼叫您的 deleteOne() hook。請注意,在這裡您需要在傳遞的物件中設定 documentquery 屬性。

// 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() 有查詢和文件 hook。與 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() 和查詢中介軟體的注意事項

update()findOneAndUpdate() 等不會執行 Pre 和 Post save() hook。您可以在 此 GitHub 問題中看到更詳細的討論原因。Mongoose 4.0 為這些函數引入了不同的 hook。

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 指的是查詢物件,而不是正在更新的文件。

例如,如果您想要將 updatedAt 時間戳記加入每個 updateOne() 呼叫,您會使用以下 pre hook。

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') 文件中間件會掛鉤到 Document#updateOne(),而不是 Query#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() 並傳入錯誤時停止。但是,有一種特殊類型的 post 中介軟體稱為「錯誤處理中介軟體」,會在發生錯誤時特別執行。錯誤處理中介軟體對於報告錯誤並讓錯誤訊息更具可讀性非常有用。

錯誤處理中介軟體定義為採用一個額外參數的中介軟體:以函數的第一個參數形式發生的「錯誤」。然後,錯誤處理中介軟體可以隨心所欲地轉換錯誤。

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

錯誤處理中介軟體也適用於查詢中介軟體。您也可以定義一個 post update() hook,來捕捉 MongoDB 重複金鑰錯誤。

// 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() 而未傳入錯誤(如上所示),函數呼叫仍然會發生錯誤。

彙總 Hook

您也可以為 Model.aggregate() 函數定義 hook。在彙總中介軟體函數中,this 指的是 Mongoose Aggregate 物件。例如,假設您在 Customer 模型上實作軟刪除,方法是新增 isDeleted 屬性。若要確保 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 彙總管線。它對於從中介軟體在管線開頭新增階段很有用。

同步 Hook

某些 Mongoose hook 是同步的,這表示它們支援傳回 promise 或接收 next() 回呼的函數。目前,只有 init hook 是同步的,因為 init() 函數是同步的。以下是使用 pre 和 post init hook 的範例。

[require:post init hooks.*success]

若要報告 init hook 中的錯誤,您必須拋出一個同步錯誤。與所有其他中介軟體不同,init 中介軟體會處理 promise 拒絕。

[require:post init hooks.*error]

下一步

既然我們已經介紹過中介軟體,接下來讓我們看看 Mongoose 如何使用其查詢 population 協助程式來偽造 JOIN。