MongooseのVirtualsを使いこなす!

この投稿は、弊社が提供するWESEEK TECH通信の一環です。
WESEEK TECH通信とは、WESEEKのエンジニアがキャッチアップした技術に関する情報を、techブログを通じて定期的に発信していくものです。

はじめに

こんにちは、システムエンジニアのかおりです。本記事では、mongoose の virtual関数についてお話しします。この記事で取り上げる内容は以下になります。

Mongoose とは

mongooseの基礎知識と使い方に関してはこちらの記事を参照ください。また、呼び方は「マングース」です。

Virtuals とは

Mongooseが提供するvirtual(仮想)関数を使用することで、仮想的にフィールドを定義することができます。これはMongoDBに永続化(保存)されませんが、ドキュメント取得時には付与されます。

【例】

// (1)userSchema を定義する
  const userSchema = new Schema({
    name: {
      first: String,
      last: String
    }
  });

// 上記で定義したschemaを用いて、Userモデルを定義する
const User = mongoose.model('User', userSchema);

// ドキュメント(Userインスタンス)を生成する
 const user1 = new User({
    name: { first: 'Hanako', last: 'Yamada' }
  });

この時、userのフルネームを出力したい場合、以下のようにします。

// "Hanako Yamada" と出力される
console.log(user1.name.first + ' ' + user1.name.last);

ただし、上記のように毎回連結するのは、面倒な場合があります。
この時、Virtual関数を使用して、仮想フィールドを作成することができます。
(fullNameがMongoDBに永続化されることはありません)

// スキーマを定義した後にvirtual関数を使い、仮想プロパティfullNameを定義
userSchema.virtual('fullName').get(function() {

// firstName とlastNameを連結させた値を返す
  return this.name.first + ' ' + this.name.last;
});

こうすることによって、mongooseはfullNameプロパティにアクセスする度に、getter関数を呼び出します。
これは、結婚して名字が変わる場合など、動的なデータを取得したい時に便利です。

console.log(user1.fullName); // Hanako Yamada

Virtuals の利点

では、virtualの利点とはなんでしょうか?
ここでは主に二つあげます。

一つ目は、「toJSON / toObject に反映されないプロパティの実現」です。
mongooseでは、ドキュメントをオブジェクトまたはJSONに変換するときにデフォルトではvirtualsを含みません。
実装者によってvirtualsを含めるか含めないかを選ぶことができます。

含みたい場合、以下のようにスキーマのオプションで設定する必要があります。

schema.set("toJSON", { virtuals: true });
schema.set("toObject", { virtuals: true });

二つ目は、「保護されたプロパティの実現」です。
Vitualsで定義された仮想プロパティは、保護されており書き換えができません。
getter と setter を分けて定義する機能をもっています。
これは defineProperty によって実現されていますが、mongoose からは離れるので、この記事では割愛します。詳しくは以下のサイトをご参照ください。

Virtuals を populate する

Virtuals の populate とはどういうことでしょうか?
Mongooseの公式ドキュメントを例に説明します。

Author と BlogPost という二つのモデルがあるとします。

// Authorスキーマは、著者名と複数のブロク投稿を保持する
const AuthorSchema = new Schema({
  name: String,
  posts: [{ type: mongoose.Schema.Types.ObjectId, ref: 'BlogPost' }]
});

// BlogPostスキーマは、タイトルと複数のコメントを保持する
const BlogPostSchema = new Schema({
  title: String,
  comments: [{
    author: { type: mongoose.Schema.Types.ObjectId, ref: 'Author' },
    content: String
  }]
});

const Author = mongoose.model('Author', AuthorSchema, 'Author');
const BlogPost = mongoose.model('BlogPost', BlogPostSchema, 'BlogPost');

上記の例は悪いスキーマのデザインです。なぜでしょうか?
1万件を超えるブログ投稿を書いている非常に多作な著者がいる場合、その作成者のドキュメントは巨大なものになり、サーバーとクライアントの両方でパフォーマンスの問題を引き起こします。
カーディナリティの原則では、作成者からブログ投稿へのような1対多の関係は、「多」側に保存する必要があると述べています。 つまり、ブログの投稿には「作成者」を保存する必要があり、作成者は「すべての投稿」を保存する必要はありません。

そこで、以下のように書き換えます。

// Authorドキュメントから「ブログ投稿」を省き、「著者名」のみを保持する
const AuthorSchema = new Schema({
  name: String
});

// BlogPostドキュメントに「著者名」を追加する
const BlogPostSchema = new Schema({
  title: String,
  author: { type: mongoose.Schema.Types.ObjectId, ref: 'Author' },
  comments: [{
    author: { type: mongoose.Schema.Types.ObjectId, ref: 'Author' },
    content: String
  }]
});

ただし、これら2つのスキーマは、著者のブログ投稿リストへの入力をサポートしていません。 そこで、仮想populateが登場します。仮想populateとは、以下に示すように、refオプションを持つ仮想プロパティでpopulate()を呼び出すことを意味します。

// `ref`プロパティでvirtualのpopulateを有効にします
AuthorSchema.virtual('posts', {
  ref: 'BlogPost', // 参照するモデル(`BlogPost`)
  localField: '_id', // 参照元のプロパティ(Authorの`_Id`プロパティ)
  foreignField: 'author' // 参照先モデルのプロパティ(BlogPostの`author`プロパティ)
});

const Author = mongoose.model('Author', AuthorSchema, 'Author');
const BlogPost = mongoose.model('BlogPost', BlogPostSchema, 'BlogPost');

そうすることによって、以下に示すように、作成者の投稿をpopulateできます。

const author = await Author.findOne().populate('posts');

author.posts[0].title; // 最初に投稿したblogのタイトル

まとめ

Virtualsとは

  • MongoDBには保存されない仮想フィールドをつくることができる
  • ドキュメント取得時に参照することができる
  • ref, localField, foreignFieldを指定することによって、virtualsのpopulateが可能になる

いかがでしたでしょうか。
最後まで読んでくださりありがとうございました!

参考にさせていただいた記事