LoginSignup
151
121

More than 1 year has passed since last update.

MongoDB(Mongoose)上級者への道

Last updated at Posted at 2020-05-04

MongoDBはNoSQLなため、列の定義の拡張が難しいRDBMSに対して柔軟に列定義の拡張が可能です。(マイグレーションが楽)
また一般的にNoSQLはRDBMSに対してパフォーマンス面で優位です。
NodeJS+Mongooseで使う前提でお話します。
セットアップから検索、書き込み、パフォーマンス向上テクニックからトランザクションまでよく使う機能に関してまとめました。
Node v12、MongoDB v4.2.5以上を前提に書いてます。

GitHubサンプル

セットアップ

MongoDBインストール

$ brew tap mongodb/brew
$ brew install mongodb-community
$ brew services start mongodb-community

mongooseインストール

$ npm install mongoose 
# もしくは
$ yarn add mongoose

mongooseでmongoに接続します。

const mongoose = require('mongoose')
mongoose.Promise = global.Promise
const dbname = 'dbname'
mongoose.connect(`mongodb://localhost/${dbname}`, {useCreateIndex: true, useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false})

モデルの作成

modelsフォルダを作成してmodels/simple.js内にシンプルなモデルを作成します。
Schemaにモデルのフィールドを定義します。
文字列型のフィールドはString, 数値型のフィールドはNumber,真偽型のフィールドはBooleanで定義します。
Schema.Types.ObjectId型はObjectId型を指定します、これは外部スキーマのObjectId(参照)を保存します。refには外部スキーマ名を指定します。
(実際に使う際はpopulateメソッドなどでJOINします。)
配列やオブジェクト、子スキーマもフィールドに指定することができます。
スキーマを定義し、mongoose.modelでスキーマモデル名を指定してスキーマを作成します。

models/simple.js
const mongoose = require('mongoose')
const Schema = mongoose.Schema

const subSchema = new Schema({
  str: String,
})

const schema = new Schema({
  str: {type: String},
  s: String, // {type: String}の省略記法、オプションなしの場合のみ可能
  num: {type: Number},
  m: Number, // {type: Number}の省略記法、オプションなしの場合のみ可能
  bool: {type: Boolean},
  b: Boolean, // {type: Boolean}の省略記法、オプションなしの場合のみ可能
  ref: {type: Schema.Types.ObjectId, ref: 'User'},
  arr: [{type: String}],
  obj: {
    a: {type: String},
    b: {type: Number},
  },
  refs: [{type: Schema.Types.ObjectId, ref: 'User'}],
  sub: subSchema,
})

module.exports = mongoose.model('Simple', schema)

modelsフォルダ以下のモデル定義を一括でmodule.exportsして外部から参照できるようにmodels/index.jsを作成します。

models/index.js
'use strict'

require('fs').readdirSync(__dirname).forEach(e => {
  const name = /^([a-z]+)\.js$/i.test(e) && RegExp.$1
  if (name && name !== 'index') {
    const model = require('./' + name)
    module.exports[model.modelName] = model
  }
})

フィールドのオプション

mongooseの各フィールドにはオプションをつけることができます。
フィールドオブジェクトにオプションを追加します。
よく使うものに関して説明してます。
他のオプションは公式を参照してください。

required

必須フィールドです。ドキュメント(レコード)作成時に該当フィールドが必須かのフラグです。
必須パラメータがない場合はドキュメントを作成できず、エラーになります。
デフォルト(無指定)はfalseです。

user.js
required: true

unique

全ドキュメントの中でユニーク(重複なし)かどうかのフラグです。
ドキュメント作成時、もしくは更新時に重複してる場合はエラーとなります。
使う際はrequiredオプションと組み合わせがほぼ必須です。(undefinedも重複対象となるため)
デフォルトはfalseです。

user.js
unique: true

select

find時に指定のフィールドを返却するか否かのフラグです。
falseにすることで明示的にselectメソッドで該当のフィールドを指定しない限り、取得できません。
ユーザの個人情報などを隠蔽する際に使えます。
デフォルトはtrueです。

user.js
select: false

validate

フィールドに保存する前にチェックするオプションです。
validatorにはチェックする関数を指定します。戻り値がtrueであれば正常に保存、falseの場合にエラーとなります。
messageにはエラー時のエラーメッセージを指定します。
デフォルトでは設定されていません。

user.js
validate: { 
  validator: (v) => validator.isEmail(v), 
  message: props => `${props.value}は正しいメールアドレスではありません。`
}

min,max

フィールドに指定できる最小値、最大値(Number型のみ可)です。
この値の範囲を超えて指定した場合はエラーとなります。
デフォルトでは設定されていません。

user.js
rating: {type: Number, required: true, min: 1, max: 5},

enum

フィールドに指定できる値の列挙(String型でよく使います)で、指定の値以外は保存できなくします。
デフォルトでは設定されていません。

user.js
enum: ['normal', 'super', 'ultra'],

default

デフォルトの値(Model.create時やnew Model時にフィールドの値が指定されていない場合でも入る値)です。
デフォルトでは設定されていません。

user.js
default: false

また、関数を指定することもできます。この場合、thisは操作対象のドキュメントそのものを指します。
ちなみにアロー関数にするとthisは取得できないので注意です。(スコープの巻き上げ)

user.js
default: function() {
  return jwt.sign(this._id.toString(), secret)
}

refPath

refと違い、refPathを使うことで複数のスキーマの参照(ObjectId)を同一フィールドに格納することができます。
ドキュメント作成時にスキーマのtypeと該当するObjectIdの指定が必須です。
(typeとセットでないとどのスキーマの参照か判別できないためJOINできない)

user.js
role: {
  model: { type: Schema.Types.ObjectId, refPath: 'role.model' },
  type: { type: String, enum: ['Programmer', 'ProductManager'] },
},

スキーマのオプション

スキーマ自体のオプションです。
Schemaオブジェクトの第2引数に指定します。

versionKey

バージョン管理情報を保存するか否かのフラグです。
mongooseのドキュメントオブジェクトは__vフィールドという
特殊なフィールドでバージョン管理を行っています。(デフォルトだと自動で生成される)
配列フィールドの要素を削除や追加してmodel.save()した場合に__vの値をインクリメントするという特性があります。
配列フィールドをスキーマに持つ場合などに、更新や削除の処理が並列に走ると要素がずれるという問題を防ぐ意図があります。
(といってもVersionErrorを発生させるだけなのでデータの不整合は防げるかもしれないけど、根本的な解決にはなりません)

参考:Mongooseのバージョニング

versionKeyフラグをfalseにすることで、バージョニングをせず__vフィールドは作成されません。
デフォルトはtrueです。

user.js
versionKey: false

どのみち処理順番が大事な場合はupdatedAtなどで更新順番を管理する実装をする必要があったり、
(管理画面とユーザで複数人が同じデータを更新し合う場合に発生する)
MongoDB v4から実装されたトランザクションを使ったほうが無難です。

ちなみにsaveメソッド以外のfindByOneAndUpdate(findByIdAndUpdate)やupdateManyやupdateOneの場合はバージョニングを無視して上書きします。
以上のような問題をはらんでいるため、上記のversionKeyを無効にするかいっそsaveメソッドを使わないほうが無難です。

timestamps

createdAt、updatedAtフィールドを自動的に作成します。
createdAtはドキュメントが作られた日時が一度のみ入ります。
updatedAtはドキュメントが更新する度に同時に日時が更新されます。
特に理由がなければ、どのスキーマも基本的にtrueにして運用することが多いです。
デフォルトはfalseです。

user.js
timestamp: true

toObject

モデルのtoObjectメソッドを呼ぶときのオプションです。
後述のfindなどでドキュメント取得する際に呼ばれます。
(ただし、後述のlean時は呼ばれません)
minimizedはフィールドが空のオブジェクトの場合でも{}の値を返却します。trueにしておくのをおすすめします。
virtualsはvirtualsメソッドのフィールドも返却するかのフラグです。スキーマにvirtualsなフィールドを作成した場合、trueにする必要があります。

user.js
toObject: {
  minimize: true,
  virtuals: true,
  transform : function(doc, user) {
   console.log('toObject')
   transform(doc, user)
  },
}

transformはドキュメント取得時にフィールドの値を変換してから返却する関数です。
例えば、ユーザの個人情報やパスワード情報などは、マスクして生の情報を返却しないようにします。

user.js
function transform(doc, user) {
  delete user.id
  user.password = !!user.password
  if (user.isDeleted) {
    delete user.token
    user.email = !!user.email
    user.phone = !!user.phone
  }

  return user
}

他のオプションに関して公式を参照してください

toJSON

ドキュメントのtoJSONメソッドを呼ぶときのオプションです。
指定できるオプションはtoObjectと同じです。

user.js
toJSON: {
  minimize: true,
  virtuals: true,
  transform : function(doc, user) {
   console.log('toObject')
   transform(doc, user)
  },
}

expressサーバなどでjsonレスポンスを返すタイミングでもtoJSONが呼ばれます。
(ただし、後述のlean時したオブジェクトはPOJO化されるため、呼ばれません)

app.js
app.get('/api/user', async (req, res) => {
  const user = await User.findOne()
  res.json(user)
})

virtuals

仮想的なフィールドを作成することができます。
MongoDBには直接保存されませんが、ドキュメント取得時に付与されます。
主にファイルのパスなどを保存する際に役立ちます。(ファイル保存場所を変更するとパスも変える必要があるため)

user.js
// 仮想的なフィールド
schema
  .virtual('image')
  .get(function() {
    const webServer = 'http://localhost' // 保存の場所を変えた場合に差し替えできるようにする
    return `${webServer}/images/${this._id}`
  })

methods

ドキュメントにメソッドを定義することができます。
取得したドキュメント毎に付与されます。
呼び出し元のオブジェクトのthisなため、アロー関数は使えません。

user.js
schema.method('showName', function() {
  console.log(this.name)
})

使い方はnewしたドキュメントもしくはfindしたドキュメントに対して行えます。

const user = new User({name: 'test', email: 'test@gmail.com', password: 'pw'})
user.showName()

statics

スキーマに静的なメソッドを定義することができます。

user.js
schema.static('showName', function(doc) {
  console.log(doc.name)
})

使い方はModel.静的メソッド名で呼び出しできます。

const user = new User({name: 'test', email: 'test@gmail.com', password: 'pw'})
User.showName(user)

pre

preフックは保存前に処理を差し込みます。
差し込むメソッド単位で指定します。
ここではupdate、findOneAndUpdateの処理前にオプションを必ず追加するようにしています。
updateやfindOneAndUpdateの第3引数に毎回付けるのが大変なのでpreフックを使うことで実行直前に必ずつけることができます。
runValidatorsオプションは保存データの型がフィールドのデータ型と正しいかのチェックやvalidatorのチェックを行います。
newオプションは実行後に更新後のドキュメントを返却するか否かのフラグです。trueにすることで更新後のドキュメントを返却します。

user.js
schema.pre('update', async function(next) {
  this.setOptions({
    runValidators: true,
  })
  return next()
})
schema.pre('findOneAndUpdate', async function(next) {
  this.setOptions({
    runValidators: true,
    new: true,
  })
  return next()
})

post

postフックはドキュメント保存後に行う操作です。
差し込むメソッド単位で指定します。
主にドキュメント更新後にログや通知など行うのに適しています。

user.js
const postSave = async function(doc, next) {
  console.log(`updated ${doc._id}`)
  next()
}

schema.post('findOneAndUpdate', postSave)
schema.post('save', postSave)

エラーハンドリング用のミドルウェア
https://mongoosejs.com/docs/middleware.html#error-handling-middleware

REPL

つぎのようなREPLを作成しておくと、CLI上で挙動の確認やDBメンテナンスの作業する際に捗ります。
HISTORY_DIRECTORYには過去の実行記録を残します。(上キーで過去の実行を参照できる)

repl.js
const mongoose = require('mongoose')
mongoose.Promise = global.Promise
const dbname = 'dbname'
mongoose.connect(`mongodb://localhost/${dbname}`, {useCreateIndex: true, useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false})

const moment = require('moment')
const repl = require('repl')

const replInstance = repl.start({ prompt: '> ' })
replInstance.context.moment = moment

const HISTORY_DIRECTORY = __dirname + '/.ym_history'
// require node version above v11.10.0
replInstance.setupHistory(HISTORY_DIRECTORY, (err) => {
  if (err) console.log(err)
})

const models = require('./models')
for (const name in models) {
  replInstance.context[name] = models[name]
}

replInstance.on('exit', () => {
  mongoose.disconnect()
})

setupHistoryはv11.10以降の機能でコマンドの履歴を.ym_historyに保存します。
トップレベルawaitを使いたいため、--experimental-repl-awaitフラグをつけて実行します

$ node --experimental-repl-await repl.js

次のようなreplで実際のJSコード同様にmongooseを実行することができるようになります。

> await Simple.create({str: 'abc'})
> await Simple.find()

create

ドキュメントを作成します。requiredのフィールドが存在する場合はそのフィールドのパラメータも指定しないとエラーになります。
フォーマットはModel.create(フィールドのパラメータ)のように指定します。
ドキュメントが生成される際、ユニークなドキュメントのID(ObjectId)を自動生成します。
ドキュメントのIDは_idフィールドに格納されます。(24桁16進数の値)

> await Simple.create({str: 'abc', arr: ['a', 'b']})
{
  arr: [ 'a', 'b' ],
  refs: [],
  _id: 5eae9847da27f19dfbfa60c5,
  str: 'abc',
  __v: 0
}

後の説明用に色々データを作成しておきます。
timestampが定義されているスキーマのドキュメントはcreatedAt, updatedAtが自動的に作成されます(UTCで保存されるので注意)。
JOINをする場合はrefフィールドに対象のObjectIdが必要なので、作成後ドキュメントのIDを指定します。
refPathの場合はスキーマのタイプもObjectIdとセットで指定する必要があります。
requiredされているフィールドは必須フィールドなので、入れない場合はエラーになります。
defaultが指定されているフィールドは挿入するデータを明示しない場合、自動的にdefaultの値が入ります。
create時には保存後にpostフックが実行されます(後述)。また、戻り値としてMongooseオブジェクトが返却されるためtoObjectのtransformが実行されます。この際、virtualsなフィールドも付与され返却されます。

> await Programmer.create({skill: ['frontend', 'backend']})
{
  skill: [ 'frontend', 'backend' ],
  _id: 5eaeb551da27f19dfbfa60c7,
  createdAt: 2020-05-03T12:13:05.468Z,
  updatedAt: 2020-05-03T12:13:05.468Z
}
> await ProductManager.create({skill: ['frontend', 'backend', 'infra'], programmers: ['5eaeb551da27f19dfbfa60c7']})
{
  skill: [ 'frontend', 'backend', 'infra' ],
  programmers: [ 5eaeb551da27f19dfbfa60c7 ],
  _id: 5eaeb598da27f19dfbfa60c8,
  createdAt: 2020-05-03T12:14:16.750Z,
  updatedAt: 2020-05-03T12:14:16.750Z
}
> await User.create({name: 'myname', email: 'test@gmail.com', password: 'pw', role: {model: 'ProductManager', type: '5eaeb598da27f19dfbfa60c8'}})
updated 5eaeb78dda27f19dfbfa60c9
toObject
{
  invites: [],
  isAdmin: false,
  isDeleted: false,
  _id: 5eaeb78dda27f19dfbfa60c9,
  name: 'myname',
  email: 'test@gmail.com',
  password: true,
  role: { model: 'ProductManager', type: 5eaeb598da27f19dfbfa60c8 },
  token: 'eyJhbGciOiJIUzI1NiJ9.NWVhZWI3OGRkYTI3ZjE5ZGZiZmE2MGM5.E7huFLFvRWdUTu-StH2ayF543oBSiq-hlwzT_NSAhD0',
  reviews: [],
  createdAt: 2020-05-03T12:22:37.723Z,
  updatedAt: 2020-05-03T12:22:37.723Z,
  image: 'http://localhost/images/5eaeb78dda27f19dfbfa60c9'
}
> await User.create({name: 'yourname', email: 'example@gmail.com', password: 'pw', role: {model: 'Programmer', type: '5eaeb551da27f19dfbfa60c7'}, reviews: [{rating: 5, comment: 'abc'}], invitedFrom: '5eaeb78dda27f19dfbfa60c9'})
updated 5eaeb8feda27f19dfbfa60cc
toObject
{
  invites: [],
  isAdmin: false,
  isDeleted: false,
  _id: 5eaeb8feda27f19dfbfa60cc,
  name: 'yourname',
  email: 'example@gmail.com',
  password: true,
  role: { model: 'Programmer', type: 5eaeb551da27f19dfbfa60c7 },
  reviews: [
    {
      _id: 5eaeb8feda27f19dfbfa60cd,
      rating: 5,
      comment: 'abc',
      createdAt: 2020-05-03T12:28:46.061Z,
      updatedAt: 2020-05-03T12:28:46.061Z
    }
  ],
  invitedFrom: 5eaeb78dda27f19dfbfa60c9,
  token: 'eyJhbGciOiJIUzI1NiJ9.NWVhZWI4ZmVkYTI3ZjE5ZGZiZmE2MGNj.Ea7OndfPFhZstDVR6LSQfilQsxLNsZz5Fai3ohcqnoA',
  createdAt: 2020-05-03T12:28:46.061Z,
  updatedAt: 2020-05-03T12:28:46.061Z,
  image: 'http://localhost/images/5eaeb8feda27f19dfbfa60cc'
}

また、次のようにnew Model()してからsaveを呼んで作成する方法もあります。
newした時点で_idは発行されますが、実際のMongoDBへの保存タイミングはsaveを読んだタイミングなので注意です。
(さらにsaveはバージョニングの問題もはらんでいるので注意が必要です。)

> const simple = new Simple({str: 'abc', arr: ['a', 'b']})
> simple
{
  arr: [ 'a', 'b' ],
  refs: [],
  _id: 5eae9857da27f19dfbfa60c6,
  str: 'abc'
}
> await simple.save()

この他にもupdate系メソッドでupsertオプションを指定してドキュメントを作成する方法があります。

find、findOne、findById

findは検索条件に合致するドキュメントを全て配列形式で返します。
findOneは検索条件に合致するドキュメントを1つのみ返します。
findByIdは指定のモデルの_idに合致するドキュメントを1つのみ返します。(findOneのidのみ指定版)

検索条件

基本的にAND条件で検索となります。
findは配列形式でドキュメントが取得できます、該当ドキュメントが存在しない場合は空の配列が返ります。
findOne、findByIdは該当ドキュメントが存在しない場合はnullが返ります。

全件検索

findの検索条件なしは全件検索となり、配列形式で全てのドキュメントが取得できます。
findOne、findByIdは先頭のドキュメント1つの取得となります。

> await Simple.find()
[
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9847da27f19dfbfa60c5,
    str: 'abc',
    __v: 0
  },
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9857da27f19dfbfa60c6,
    str: 'abc',
    __v: 0
  }
]
> await Simple.findOne()
{
  arr: [ 'a', 'b' ],
  refs: [],
  _id: 5eae9847da27f19dfbfa60c5,
  str: 'abc',
  __v: 0
}

指定のフィールド別に検索条件を指定する

指定のフィールドの値に合致するドキュメントを取得します。
複数フィールド指定した場合はand条件となります。

> await Simple.find({str: 'abc'})
[
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9847da27f19dfbfa60c5,
    str: 'abc',
    __v: 0
  },
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9857da27f19dfbfa60c6,
    str: 'abc',
    __v: 0
  }
]
> await Simple.find({str: 'ab'})
[]

配列フィールドの場合、指定の値が配列に含まれているドキュメントを取得します。

> await Simple.find({arr: 'a'})
[
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9847da27f19dfbfa60c5,
    str: 'abc',
    __v: 0
  },
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9857da27f19dfbfa60c6,
    str: 'abc',
    __v: 0
  }
]
> await Simple.find({arr: 'c'})
[]

オブジェクトや子スキーマなどでネストされている場合は.でフィールドをたどります。
ネストされているものが配列の場合はさらに配列の中に含まれている条件で検索します。

> await User.findOne({'reviews.rating': 5})
toObject
{
  role: { model: 'Programmer', type: 5eaeb551da27f19dfbfa60c7 },
  invites: [],
  isAdmin: false,
  isDeleted: false,
  _id: 5eaeb8feda27f19dfbfa60cc,
  name: 'yourname',
  password: true,
  reviews: [
    {
      _id: 5eaeb8feda27f19dfbfa60cd,
      rating: 5,
      comment: 'abc',
      createdAt: 2020-05-03T12:28:46.061Z,
      updatedAt: 2020-05-03T12:28:46.061Z
    }
  ],
  invitedFrom: 5eaeb598da27f19dfbfa60c8,
  createdAt: 2020-05-03T12:28:46.061Z,
  updatedAt: 2020-05-03T12:28:46.061Z,
  image: 'http://localhost/images/5eaeb8feda27f19dfbfa60cc'
}

$ne

指定の値に一致しないものを取得します。

> await Simple.find({str: {$ne: 'abc'}})
[]
> await Simple.find({str: {$ne: 'ab'}})
[
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9847da27f19dfbfa60c5,
    str: 'abc',
    __v: 0
  },
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9857da27f19dfbfa60c6,
    str: 'abc',
    __v: 0
  }
]

配列フィールドの場合、指定の値が配列に含まれていないドキュメントを取得します。

> await Simple.find({arr: {$ne: 'a'}})
[]
> await Simple.find({arr: {$ne: 'c'}})
[
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9847da27f19dfbfa60c5,
    str: 'abc',
    __v: 0
  },
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9857da27f19dfbfa60c6,
    str: 'abc',
    __v: 0
  }
]

$exists

指定のフィールドの値が存在しているかで検索します。

> await Simple.findOne({str: {$exists: true}})
{
  arr: [ 'a', 'b' ],
  refs: [],
  _id: 5eae9847da27f19dfbfa60c5,
  str: 'abc',
  __v: 0
}
> await Simple.findOne({num: {$exists: true}})
null

配列フィールドの場合、配列の添字を指定すると配列の長さがx以上のドキュメントを返します。
(次の例は配列の長さが2以上)

> await Simple.findOne({'arr.1': {$exists: true}})
{
  arr: [ 'a', 'b' ],
  refs: [],
  _id: 5eae9847da27f19dfbfa60c5,
  str: 'abc',
  __v: 0
}

$in

配列指定の値のいずれかに一致するドキュメントを返します。

> await Simple.find({_id: {$in: ['5eae9847da27f19dfbfa60c5', '5eae9857da27f19dfbfa60c6']}})
[
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9847da27f19dfbfa60c5,
    str: 'abc',
    __v: 0
  },
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9857da27f19dfbfa60c6,
    str: 'abc',
    __v: 0
  }
]

配列フィールドの場合は配列指定の値のいずれかかが配列に含まれるドキュメントを返します。

> await Simple.find({arr: {$in: ['b', 'c']}})
[
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9847da27f19dfbfa60c5,
    str: 'abc',
    __v: 0
  },
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9857da27f19dfbfa60c6,
    str: 'abc',
    __v: 0
  }
]

$nin

配列指定の値のいずれも一致しないドキュメントを返します。

> await Simple.find({_id: {$nin: ['5eae9847da27f19dfbfa60c5', '5eae9857da27f19dfbfa60c6']}})
[]

配列フィールドの場合、配列指定の値のいずれも配列に含まないドキュメントを返します。

> await Simple.find({arr: {$nin: ['b', 'c']}})
[]
> await Simple.find({arr: {$nin: ['c', 'd']}})
[
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9847da27f19dfbfa60c5,
    str: 'abc',
    __v: 0
  },
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9857da27f19dfbfa60c6,
    str: 'abc',
    __v: 0
  }
]

$gt、$gte, $lt, $lte

指定の値に対し$gtはより大きい、$ltは未満の条件のドキュメントを検索します。
なお、$gteは以上、$lteは以下となり、等号(=)を含みます。
例えば、指定の日時範囲で検索したい場合などに使います。
時刻はUTCで保存されているため、日本時刻だと+9時間ずれています。

> await Programmer.find({createdAt: {$gt: moment('2020-05-03 21:13:04').toDate(), $lt: moment('2020-05-03 21:13:06').toDate()}})
[
  {
    skill: [ 'frontend', 'backend' ],
    _id: 5eaeb551da27f19dfbfa60c7,
    createdAt: 2020-05-03T12:13:05.468Z,
    updatedAt: 2020-05-03T12:13:05.468Z
  }
]

$or

いずれかの条件に当てはまるドキュメントを返します。
配列形式で条件を指定します。

> await User.find({$or: [{'role.model': 'ProductManager'}, {'role.model': 'Programmer'}]})
toObject
toObject
[  
  {
    invites: [],
    isAdmin: false,
    isDeleted: false,
    _id: 5eaeb78dda27f19dfbfa60c9,
    name: 'myname',
    email: 'test@gmail.com',
    password: true,
    role: { model: 'ProductManager', type: 5eaeb598da27f19dfbfa60c8 },
    token: 
  'eyJhbGciOiJIUzI1NiJ9.NWVhZWI3OGRkYTI3ZjE5ZGZiZmE2MGM5.E7huFLFvRWdUTu-StH2ayF543oBSiq-hlwzT_NSAhD0',
    reviews: [],
    createdAt: 2020-05-03T12:22:37.723Z,
    updatedAt: 2020-05-03T12:22:37.723Z,
    image: 'http://localhost/images/5eaeb78dda27f19dfbfa60c9'
  },
  {
    invites: [],
    isAdmin: false,
    isDeleted: false,
    _id: 5eaeb8feda27f19dfbfa60cc,
    name: 'yourname',
    email: 'example@gmail.com',
    password: true,
    role: { model: 'Programmer', type: 5eaeb551da27f19dfbfa60c7 },
    reviews: [
      {
        _id: 5eaeb8feda27f19dfbfa60cd,
        rating: 5,
        comment: 'abc',
        createdAt: 2020-05-03T12:28:46.061Z,
        updatedAt: 2020-05-03T12:28:46.061Z
      }
    ],
    invitedFrom: 5eaeb78dda27f19dfbfa60c9,
    token: 'eyJhbGciOiJIUzI1NiJ9.NWVhZWI4ZmVkYTI3ZjE5ZGZiZmE2MGNj.Ea7OndfPFhZstDVR6LSQfilQsxLNsZz5Fai3ohcqnoA',
    createdAt: 2020-05-03T12:28:46.061Z,
    updatedAt: 2020-05-03T12:28:46.061Z,
    image: 'http://localhost/images/5eaeb8feda27f19dfbfa60cc'
  }
]

パイプライン

findXXX系のメソッドは検索条件での取得時にさらに処理を続けることができます。
selectpopulatelimitなど複数のオペレーションをつなげることもできます。

select

取得フィールドを選択します。
空白スペース区切り、配列での指定の両方使えます。

> await Simple.findOne().select('str arr')
{ arr: [ 'a', 'b' ], _id: 5eae9847da27f19dfbfa60c5, str: 'abc' }
> await Simple.findOne().select(['str', 'arr'])
{ arr: [ 'a', 'b' ], _id: 5eae9847da27f19dfbfa60c5, str: 'abc' }

-fieldのようにマイナスをつけることでそのフィールドだけ取得しないという指定もできます。

> await Simple.findOne().select('-str')
{ arr: [ 'a', 'b' ], refs: [], _id: 5eae9847da27f19dfbfa60c5, __v: 0 }

ちなみにtoObjectのtransformで操作している場合やvirtualsフィールドは実行されるため、次の例だとselectしたフィールドにさらに付与されます。

> await User.findOne().select('name isAdmin')
toObject
{
  isAdmin: false,
  _id: 5eaeb78dda27f19dfbfa60c9,
  name: 'myname',
  image: 'http://localhost/images/5eaeb78dda27f19dfbfa60c9',
  password: false
}

populate

refフィールドのObjectIdに紐付いた別スキーマのドキュメントをJOINします。
(RDBでのJOINと同様)

文字列で指定するとJOINしたドキュメントのフィールドがすべて取得できます。
指定ドキュメントに存在しない場合、JOINされません。(undefinedならundefinedのまま、ObjectIdならObjectIdのままになる)

> await User.find().select('invitedFrom').populate('invitedFrom')
toObject
toObject
toObject
[
  {
    _id: 5eaeb78dda27f19dfbfa60c9,
    image: 'http://localhost/images/5eaeb78dda27f19dfbfa60c9',
    password: false
  },
  {
    _id: 5eaeb8feda27f19dfbfa60cc,
    invitedFrom: {
      role: [Object],
      invites: [],
      isAdmin: false,
      isDeleted: false,
      _id: 5eaeb78dda27f19dfbfa60c9,
      name: 'myname',
      password: true,
      reviews: [],
      createdAt: 2020-05-03T12:22:37.723Z,
      updatedAt: 2020-05-03T12:22:37.723Z,
      image: 'http://localhost/images/5eaeb78dda27f19dfbfa60c9'
    },
    image: 'http://localhost/images/5eaeb8feda27f19dfbfa60cc',
    password: false
  }
]

更にオブジェクト形式でオプションをつけることで細かい指定ができます。

  • path: populateするフィールド名を指定します。
  • match: populate先ドキュメントの検索条件です。条件に一致しない場合はJOINされず、該当のrefフィールドはnullになります。(配列のrefフィールドの場合は配列の中身から除外)
  • select: populate先ドキュメントの取得フィールドを選択します。
  • populate: populate先ドキュメントから指定フィールドをさらにpopulateする場合に使います。
> await User.find().select('invitedFrom').populate({path: 'invitedFrom', select: 'name', match: {isDeleted: {$ne: true}}})
toObject
toObject
toObject
[
  {
    _id: 5eaeb78dda27f19dfbfa60c9,
    image: 'http://localhost/images/5eaeb78dda27f19dfbfa60c9',
    password: false
  },
  {
    _id: 5eaeb8feda27f19dfbfa60cc,
    invitedFrom: {
      _id: 5eaeb78dda27f19dfbfa60c9,
      name: 'myname',
      image: 'http://localhost/images/5eaeb78dda27f19dfbfa60c9',
      password: false
    },
    image: 'http://localhost/images/5eaeb8feda27f19dfbfa60cc',
    password: false
  }
]
> await User.find().select('invitedFrom').populate({path: 'invitedFrom', select: 'name', match: {isDeleted: true}})
toObject
toObject
[
  {
    _id: 5eaeb78dda27f19dfbfa60c9,
    image: 'http://localhost/images/5eaeb78dda27f19dfbfa60c9',
    password: false
  },
  {
    _id: 5eaeb8feda27f19dfbfa60cc,
    invitedFrom: null,
    image: 'http://localhost/images/5eaeb8feda27f19dfbfa60cc',
    password: false
  }
]

refPathの場合、指定の先のドキュメントが異なる場合でも同時にpopulateできます。
(matchで指定のスキーマのドキュメントのみ取得も可能)

> const users = await User.find().select('role').populate({path: 'role.type', populate: { path: 'programmers', match: {skill: 'frontend'} }}).select('role')
undefined
> users
toObject
toObject
[
  {
    role: { model: 'ProductManager', type: [Object] },
    _id: 5eaeeb23b546bbae29537adc,
    image: 'http://localhost/images/5eaeeb23b546bbae29537adc',
    password: false
  },
  {
    role: { model: 'Programmer', type: [Object] },
    _id: 5eaeeb33b546bbae29537add,
    image: 'http://localhost/images/5eaeeb33b546bbae29537add',
    password: false
  }
]
> users.map(u => u.role.type.programmers)
[
  [{"skill":["frontend","backend"],"_id":"5eaeb551da27f19dfbfa60c7","createdAt":"2020-05-03T12:13:05.468Z","updatedAt":"2020-05-03T12:13:05.468Z"}],
  undefined
]
# populate in populateの該当する検索条件に当てはまらなかった場合
> const users = await User.find().select('role').populate({path: 'role.type', populate: { path: 'programmers', match: {skill: 'infra'} }}).select('role')
undefined
> users.map(u => u.role.type.programmers)
[ [], undefined ]

sort

findで取得したドキュメントを指定のフィールドでソートします。
1で昇順、-1で降順となります。

> await User.find().sort({createdAt: -1})
toObject
toObject
[
  {
    role: { type: 'Programmer', model: 5eaeb551da27f19dfbfa60c7 },
    invites: [],
    isAdmin: false,
    isDeleted: false,
    _id: 5eaeb8feda27f19dfbfa60cc,
    name: 'yourname',
    password: true,
    reviews: [ [Object] ],
    invitedFrom: 5eaeb598da27f19dfbfa60c8,
    createdAt: 2020-05-03T12:28:46.061Z,
    updatedAt: 2020-05-03T12:28:46.061Z,
    image: 'http://localhost/images/5eaeb8feda27f19dfbfa60cc'
  },
  {
    role: { type: 'ProductManager', model: 5eaeb598da27f19dfbfa60c8 },
    invites: [],
    isAdmin: false,
    isDeleted: false,
    _id: 5eaeb78dda27f19dfbfa60c9,
    name: 'myname',
    password: true,
    reviews: [],
    createdAt: 2020-05-03T12:22:37.723Z,
    updatedAt: 2020-05-03T12:22:37.723Z,
    image: 'http://localhost/images/5eaeb78dda27f19dfbfa60c9'
  }
]

findOneやfindByIdの場合は無意味です。

limit

findで取得する上限のドキュメント数を指定します。

> await Simple.find().limit(1)
[
  {
    arr: [ 'a', 'b' ],
    refs: [],
    _id: 5eae9847da27f19dfbfa60c5,
    str: 'abc',
    __v: 0
  }
]

findOneやfindByIdの場合は無意味です。

skip

検索結果の指定のドキュメントまでスキップします。ページネーションなどに使えます。
limitと組み合わせることが多いです。

> await User.find().select('name').skip(0).limit(1)
toObject
[
  {
    _id: 5eaeb78dda27f19dfbfa60c9,
    name: 'myname',
    image: 'http://localhost/images/5eaeb78dda27f19dfbfa60c9',
    password: false
  }
]
> await User.find().select('name').skip(1).limit(1)
toObject
[
  {
    _id: 5eaeb8feda27f19dfbfa60cc,
    name: 'yourname',
    image: 'http://localhost/images/5eaeb8feda27f19dfbfa60cc',
    password: false
  }
]

distinct

指定のフィールドの値の重複を除いて配列形式で指定フィールドの値のみ取得します。(指定できるフィールドは1つのみです。)

> await Simple.find().distinct('str')
[ 'abc' ]

lean

ドキュメントをMongooseドキュメントオブジェクトではなく、
プレーンなJavaScript形式で返却します。
model.idmodel.saveなどのMongooseドキュメントオブジェクトに付随しているメソッドが参照できなくなる代わりにデータの取得が大幅に高速化されます。(推奨)
virtualsやdefaultやmethodsなども省略されてしまうため、
mongoose-lean-virtualsやmongoose-lean-defaultsなどのプラグインを導入することでlean時にもオプションで付与することができます。

leanのmethodsはプラグインがなかったので作りました。(mongoose-lean-methods)

スキーマにてプラグインを指定します。

user.ts
const mongooseLeanVirtuals = require('mongoose-lean-virtuals')
const mongooseLeanDefaults = require('mongoose-lean-defaults')
const mongooseLeanMethods = require('mongoose-lean-methods')

const schema = new Schema({})

schema.plugin(mongooseLeanVirtuals)
schema.plugin(mongooseLeanDefaults)
schema.plugin(mongooseLeanMethods)

オプションを使う場合はleanの引数を指定します。

> await User.findOne().lean()
{
  _id: 5eaeb78dda27f19dfbfa60c9,
  invites: [],
  isAdmin: false,
  isDeleted: false,
  name: 'myname',
  password: 'pw',
  role: { type: 'ProductManager', model: 5eaeb598da27f19dfbfa60c8 },
  reviews: [],
  createdAt: 2020-05-03T12:22:37.723Z,
  updatedAt: 2020-05-03T12:22:37.723Z
}
> await User.findOne().lean({virtuals: true, defaults: true, methods: true})
{
  _id: 5eaeb78dda27f19dfbfa60c9,
  invites: [],
  isAdmin: false,
  isDeleted: false,
  name: 'myname',
  password: 'pw',
  role: { type: 'ProductManager', model: 5eaeb598da27f19dfbfa60c8 },
  reviews: [],
  createdAt: 2020-05-03T12:22:37.723Z,
  updatedAt: 2020-05-03T12:22:37.723Z,
  image: 'http://localhost/images/5eaeb78dda27f19dfbfa60c9',
  id: '5eaeb78dda27f19dfbfa60c9',
  showName: [Function]
}

aggregate

findのパイプラインと別にaggregateを使ったパイプラインで検索、集計することが可能です。
Mongooseドキュメントではなくlean同様、プレーンなJavaScriptとして返ります。(mongoose特有のvirtualsなどの機能は無視されるので注意)
aggregate専用の機能として主に$groupで集計処理を行うことができるため、
redashなどのBIツールで集計用などに使います。
よく使う操作として以下のものがあります。

  • $match: 検索条件で絞り込みを行います。findと等価です。
  • $project: フィールドの絞り込みやマッピングを行います。selectに似ていますが、計算結果などを新しいフィールドとして定義することができます。
  • $lookup: JOINを行うことができます。動作としてはpopulateと似ていますが記述方法が異なります。また、populateと違い参照先が存在しない時はnullのレコードは返ってきません。
  • $group: 指定のフィールドに対し、グルーピングすることで集計することができます。SQLのグループ文に似た機能です。
  • $unwind: 主に$lookupとセットで使います。$lookupすると配列でネストするため、配列を展開します。

他の操作は公式を参考にしてください。

> await User.aggregate([{$match: {name: {$exists: true}}}, {$project: {name: true, reviews: true, invitedFrom: true, createdAt: true}}, {$lookup: {from: 'users', localField: 'invitedFrom', foreignField: '_id', as: 'invitedFrom'}}, {$unwind: '$invitedFrom'}, {$project: {invitedRating: {$sum: '$invitedFrom.reviews.rating'}, rating: {$sum: '$reviews.rating'}}}])
[ { _id: 5eaeb8feda27f19dfbfa60cc, invitedRating: 0, rating: 5 } ]
> await User.aggregate([{$group: {_id: '$isAdmin', total: {$sum: 1 }}}])
[ { _id: false, total: 2 } ]

注意点としてaggregateの$matchの検索対象がidの場合はfindの時と違い、stringは不可でObjectIdである必要があります。

const { ObjectId } = require('mongodb')
new ObjectId('5eae9847da27f19dfbfa60c5')

集計の他に踏まえて一覧ページ作成の必要がある場合にaggregateを用いると良い場合が多いです。
以下のプラグインを導入するとaggregateのページングも簡単に導入できます。
mongoose-aggregate-paginate-v2

exists

ドキュメントの存在確認を行います。
指定の条件にあったドキュメントが存在すればtrue、存在しなければfalseが返ります。
フォーマットはModel.exists(検索条件)となります。

> await Simple.exists({str: 'abc'})
true
> await Simple.exists({str: 'ab'})
false

countDocuments

検索条件にあったドキュメントの数を返します。存在しなければ0が返ります。
フォーマットはModel.countDocuments(検索条件)となります。

> await Simple.countDocuments({str: 'abc'})
2
> await Simple.countDocuments({str: 'ab'})
0

estimatedDocumentCount

レコード数が多いテーブルの全件を取得したい場合はcountDocumentsであってもハングしてしまいます。
その場合、estimatedDocumentCountで大まかな全件数を知ることができます。
ただし、countDocumentsと違い、細かな検索条件指定はできません。

> await Simple.estimatedDocumentCount()
1000000

更新系の処理

findOneAndUpdate(findByIdUpdate)は戻り値としてドキュメントを返しますが、
update系(updateMany,updateOne)のメソッドは戻り値としてドキュメントを返しません。

findOneAndUpdate、findByIdAndUpdate

findOneAndUpdateは該当のドキュメントを1つのみ検索し、更新し、該当のドキュメントを返却します。
特に検索条件が_idのときはfindByIdAndUpdateが使えます。
フォーマットはModel.findOneAndUpdate('検索条件', '更新操作', 'オプション')のようになっています。

> await User.findOneAndUpdate({name: 'myname'}, {$set: {phone: '09011112222'}, $push: {reviews: [{rating: 2}]}, $unset: {isDeleted: true} }, {runValidator: true, new: true, projection: 'name phone reviews isDeleted'}).lean()
updated 5eaeb78dda27f19dfbfa60c9
{
  _id: 5eaeb78dda27f19dfbfa60c9,
  name: 'myname',
  reviews: [ { _id: 5eaee4296a87b4a9dfe3c860, rating: 2 } ],
  phone: '09011112222'
}

更新操作

よく使うオペレーターとして以下があります。
複数のオペレータを組み合わせることも可能です。
ただし、$addToSetのフィールドを$pullのフィールドに指定するというように同じフィールドを複数のオペレーターで指定するのは不可です。

  • $set: 指定フィールドに指定の値をセットする。{$set: {フィールド名: 更新する値}}のように指定する。(配列の場合は配列まるごと上書きになるので注意)
  • $unset: 指定フィールドの値を削除する。{$unset: {フィールド名: true}}のように指定する
  • $push: 指定の配列フィールドに値を追加する。{$unset: {フィールド名: 追加する値}}のように指定する。
  • $addToSet: 指定の配列フィールドに値を追加する。{$addToSet: {フィールド名: 追加する値}}}のように指定する。$pushとの違いは配列内の値の重複を許さない(重複がある場合は追加されない)
  • $pull: 指定の配列フィールドから該当する値を削除する。{$pull: {フィールド名: 削除する値}}}のように指定する。

オプション

よく使うオプションは以下のものがあります。

  • runValidator: フィールドの型チェックやvalidateチェックを行います。ほぼ必須なため、preフックなどでオプションのつけ忘れがないように指定するといいでしょう。
  • new: 更新後のドキュメントを返すかのフラグです。ほぼ必須なため、preフックなどでオプションのつけ忘れがないように指定するといいでしょう。
  • upsert: 検索条件に一致しない場合にドキュメントを作成するかのフラグです。
  • projection: 返り値のドキュメントで返すフィールドを指定します。selectと似ています。

updateMany、updateOne

updateManyは検索条件で該当する複数のドキュメントを一括更新します。
updateOneは該当する1つのドキュメントを更新します。
フォーマットはModel.updateMany('検索条件', '更新操作', 'オプション')のようになっています。

> await User.updateMany({isAdmin: false}, {$set: {isAdmin: true}}, {runValidator: true})
{ n: 2, nModified: 2, ok: 1 }

更新操作

findOneAndUpdate(findByIdAndUpdate)と同じです。
オペレーターに$set,$unset,$push,$addToSet,$pullがあります。

オプション

戻り値でドキュメントを返さないので、よく使うオプションはrunValidatorくらいです。

  • runValidator: フィールドの型チェックやvalidateチェックを行います。ほぼ必須なため、preフックなどでオプションのつけ忘れがないように指定するといいでしょう。

削除系の処理

findOneAndDelete(findByIdDelete)は戻り値としてドキュメントを返しますが、
delete系(deleteMany,deleteOne)のメソッドは戻り値としてドキュメントを返しません。

findOneAndDelete、findByIdAndDelete

findOneAndDeleteは検索条件に一致するドキュメントを1つのみ削除します。
findByIdAndDeleteは_idに一致するドキュメントを削除します。
削除後に削除したドキュメントを返却します。

> await Simple.findByIdAndDelete('5eae9847da27f19dfbfa60c5').select('_id').lean()
{ _id: 5eae9847da27f19dfbfa60c5 }

deleteMany、deleteOne

deleteManyは検索条件に一致するドキュメントをすべて削除します。
deleteOneは該当のドキュメントを1つのみ削除します。

> await Simple.deleteMany({str: 'abc'})
{ n: 1, ok: 1, deletedCount: 1 }

bulkWrite

updateOne、updateMeny、deleteOne、deleteManyなどを織り交ぜて一括で操作することができます、
配列形式で複数の操作を指定できます。
オプションのorderdフラグは配列の順番に実行するかを指定するフラグです。
orderdをfalseにすることで実行順番の保証はなくなりますが、高速に実行されます。
主にバッチ処理でデータマイグレーションする際などに使用します。

> const upsertOne = {updateOne: {filter: {name: 'othername'}, update: {$set: {name: 'othername', email: 'other@gmail.com', password: 'pw'}}, upsert: true }}
> const deleteOne = {deleteOne: {filter: {name: 'yourname'}} }
> await User.bulkWrite([upsertOne, deleteOne], {ordered: false})
BulkWriteResult {
  result: {
    ok: 1,
    writeErrors: [],
    writeConcernErrors: [],
    insertedIds: [],
    nInserted: 0,
    nUpserted: 1,
    nMatched: 0,
    nModified: 0,
    nRemoved: 1,
    upserted: [ [Object] ]
  },
  insertedCount: 0,
  matchedCount: 0,
  modifiedCount: 0,
  deletedCount: 1,
  upsertedCount: 1,
  upsertedIds: { '0': 5eaf78bc7826e38e558a596f },
  insertedIds: {},
  n: 0
}

インデックス

スキーマのフィールドをindex指定することで指定のフィールドを検索条件に指定した場合の検索が高速になります。
特にドキュメント数が多くなる想定が見込まれる場合、indexを指定することで検索速度を著しく向上させることができます。
uniqueなデータであることが保証されている場合はuniqueオプションをtrueにするとさらに速度が向上します。

user.js
schema.index({ email: 1 }, { unique: true })

インデックスが実際に設定されているかの確認はgetIndexesを使います。
フォーマットはModel.collection.getIndexes()となります。
ちなみに_idでのみの検索は自動的インデックスでの検索となります。
(_idのindexは自動的に生成される)

> await User.collection.getIndexes()
{ _id_: [ [ '_id', 1 ] ], email_1: [ [ 'email', 1 ] ] }

インデックスの指定を更新した場合などに既存のインデックスを削除するにはdropIndexesを指定します。
フォーマットはModel.collection.dropIndexes()となります。

> await User.collection.dropIndexes()

スロークエリの解析

explainを指定することで実際に検索はせず、スロークエリの実行計画を確認することができます。
検索条件時に設定したインデックスを利用しているかどうか確認するために使えます。
さらにexecutionStatsをオプションを指定することで詳細なログが確認できます。

参考:MongoDB v4 explain 結果をちゃんと理解してクエリ改善 (前半:explain結果の見方)

IXSCANが含まれていればインデックスを使っての検索がされています。
COLLSCANの場合、インデックスを使ってないフルスキャンとなっています。

> (await User.find({email: 'pdm@gmail.com'}).select('email').explain('executionStats'))[0].queryPlanner.winningPlan
{
  stage: 'PROJECTION_SIMPLE',
  transformBy: { email: 1 },
  inputStage: {
    stage: 'FETCH',
    inputStage: {
      stage: 'IXSCAN',
      keyPattern: [Object],
      indexName: 'email_1',
      isMultiKey: false,
      multiKeyPaths: [Object],
      isUnique: true,
      isSparse: false,
      isPartial: false,
      indexVersion: 2,
      direction: 'forward',
      indexBounds: [Object]
    }
  }
}

ローリングインデックス作成

レコード数が多いテーブルの検索にはインデックスを作成するのは必須ですが、
レコード数が多いテーブルへの追加インデックス作成自体も高負荷でDBのパフォーマンスに影響を与えます。
この場合はRepicaSetでのクラスタ構成前提ですがRollingインデックスという手順でインデックス作成をする必要があります。
primaryでcreateIndexすると全部作られるため、その間DBがハングしてしまうため、
まずsharedをclusterから切り離してcreateIndexして接続しなおすみたいな作業が必要になります。
(skipShardingConfigurationChecks 、disableLogicalSessionCacheRefreshを有効にしてクラスターから切り離す。createIndex後にskipShardingConfigurationChecks 、disableLogicalSessionCacheRefreshを無効にしてクラスターに再接続する)
さらにprimaryのcreateIndexする際はsecondaryに降格させてからcreateIndexする必要があります。

Rolling Index Builds on Sharded Clusters

MongoDB公式のMongo DB Atlasでホスティングしている場合はインデックス作成画面UIでBuild index via rolling processにチェックを入れてインデックス作成すると上記のような面倒な手順を裏で自動でやってくれます。

スクリーンショット 2022-02-15 21.26.25.png

ReplicaSet

MongoDBはmaster slaveの複数台構成にすることができます。
書き込み系をmaster、参照系をslaveにすることで負荷分散することができます。
また、masterに障害が発生した場合、slaveをmasterに昇格させることができます。

参考:MongoDBのReplica Setsについての概要
参考:MongoDB で 3台構成 の レプリカセット を 構築する 方法
参考:Minimal MongoDB Replica Set (OSX)

マルチドキュメントトランザクション

MongoDB 4.0からの新機能です。
これはRDBMSでお馴染みのTransactionと同等の機能です。
NoSQLであるMongoDBは単一ドキュメント操作に対してはACID特性を持っていましたが、
複数のドキュメント操作が完了するまで、実際の書き込みが発生せず、エラー時には元の状態にロールバックすることができます。
Transaction機能を使うためにはReplicaSet構成が前提です。
別記事にまとめましたのでそちらを参考にしてください。

MongoDB 4.0の待望の新機能Multiple Documents Transactionを試す

テスト

jestでのテストを行います。
mongodb-memory-serverを使うことで、メモリ上でMongoDBを起動することができます。
jest.config.jsにて全テスト前の前処理と前テスト後の後処理を設定できます。

jest.config.js
module.exports = {
  // A path to a module which exports an async function that is triggered once before all test suites
  globalSetup: "./test/global-setup.js",

  // A path to a module which exports an async function that is triggered once after all test suites
  globalTeardown: "./test/global-teardown.js",
}

テストの前処理はglobal-setup.jsに定義します。
global変数はglobalTeardownでは参照できます。
各テストでmongoose接続するため、process.envに保存します。
(globalSetup内でmongoose接続しても各テストでconnectionが持ち越しできない)

global-setup.js
const { MongoMemoryServer } = require('mongodb-memory-server')
const mongoServer = new MongoMemoryServer()

async function setup() {
  const mongoUri = await mongoServer.getUri()
  process.env.mongoUri = mongoUri
  global.mongoServer = mongoServer
}

module.exports = setup

各テストでMongoDBにmongooseから接続を行います。

mongo.js
const mongoose = require('mongoose')
mongoose.Promise = global.Promise

mongoose.connection.on('error', (e) => {
  if (e.message.code === 'ETIMEDOUT') {
    console.log(e);
    mongoose.connect(mongoUri, mongooseOpts)
  }
  console.log(e)
})

mongoose.deleteAll = async () => {
  for (const key in mongoose.models) {
    const model = mongoose.models[key]
    await model.deleteMany()
  }
}

const mongooseOpts = {
  useCreateIndex: true, 
  useNewUrlParser: true, 
  useUnifiedTopology: true, 
  useFindAndModify: false,
}

const mongoUri = process.env.mongoUri
mongoose.connect(mongoUri, mongooseOpts)
module.exports = mongoose

各テスト終了時に利用してるドキュメントをすべて削除します。
テスト終了時にMongoose接続を破棄します。

mongo.test.js
const mongo = require('./mongo')
const { User, ProductManager, Programmer } = require('../models')

afterEach(async () => {
  await mongo.deleteAll()
})

afterAll(async () => {
  await mongo.disconnect()
})

全テスト終了時にMongoDBを破棄します。

global-teardown.js
async function teardown() {
  await global.mongoServer.stop()
}

module.exports = teardown
151
121
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
151
121