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に接続します。
constmongoose=require('mongoose')mongoose.Promise=global.Promiseconstdbname='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
constmongoose=require('mongoose')constSchema=mongoose.SchemaconstsubSchema=newSchema({str:String,})constschema=newSchema({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=>{constname=/^([a-z]+)\.js$/i.test(e)&&RegExp.$1if(name&&name!=='index'){constmodel=require('./'+name)module.exports[model.modelName]=model}})
フィールドのオプション
mongooseの各フィールドにはオプションをつけることができます。
フィールドオブジェクトにオプションを追加します。
よく使うものに関して説明してます。
他のオプションは公式を参照してください。
required
必須フィールドです。ドキュメント(レコード)作成時に該当フィールドが必須かのフラグです。
必須パラメータがない場合はドキュメントを作成できず、エラーになります。
デフォルト(無指定)はfalseです。
unique
全ドキュメントの中でユニーク(重複なし)かどうかのフラグです。
ドキュメント作成時、もしくは更新時に重複してる場合はエラーとなります。
使う際はrequiredオプションと組み合わせがほぼ必須です。(undefinedも重複対象となるため)
デフォルトはfalseです。
select
find時に指定のフィールドを返却するか否かのフラグです。
falseにすることで明示的にselectメソッドで該当のフィールドを指定しない限り、取得できません。
ユーザの個人情報などを隠蔽する際に使えます。
デフォルトはtrueです。
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時にフィールドの値が指定されていない場合でも入る値)です。
デフォルトでは設定されていません。
また、関数を指定することもできます。この場合、thisは操作対象のドキュメントそのものを指します。
ちなみにアロー関数にするとthisは取得できないので注意です。(スコープの巻き上げ)
user.js
default:function(){returnjwt.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です。
どのみち処理順番が大事な場合はupdatedAtなどで更新順番を管理する実装をする必要があったり、
(管理画面とユーザで複数人が同じデータを更新し合う場合に発生する)
MongoDB v4から実装されたトランザクションを使ったほうが無難です。
ちなみにsaveメソッド以外のfindByOneAndUpdate(findByIdAndUpdate)やupdateManyやupdateOneの場合はバージョニングを無視して上書きします。
以上のような問題をはらんでいるため、上記のversionKeyを無効にするかいっそsaveメソッドを使わないほうが無難です。
timestamps
createdAt、updatedAtフィールドを自動的に作成します。
createdAtはドキュメントが作られた日時が一度のみ入ります。
updatedAtはドキュメントが更新する度に同時に日時が更新されます。
特に理由がなければ、どのスキーマも基本的にtrueにして運用することが多いです。
デフォルトはfalseです。
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
functiontransform(doc,user){deleteuser.iduser.password=!!user.passwordif(user.isDeleted){deleteuser.tokenuser.email=!!user.emailuser.phone=!!user.phone}returnuser}
他のオプションに関して公式を参照してください
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)=>{constuser=awaitUser.findOne()res.json(user)})
virtuals
仮想的なフィールドを作成することができます。
MongoDBには直接保存されませんが、ドキュメント取得時に付与されます。
主にファイルのパスなどを保存する際に役立ちます。(ファイル保存場所を変更するとパスも変える必要があるため)
user.js
// 仮想的なフィールドschema.virtual('image').get(function(){constwebServer='http://localhost'// 保存の場所を変えた場合に差し替えできるようにするreturn`${webServer}/images/${this._id}`})
methods
ドキュメントにメソッドを定義することができます。
取得したドキュメント毎に付与されます。
呼び出し元のオブジェクトのthisなため、アロー関数は使えません。
user.js
schema.method('showName',function(){console.log(this.name)})
使い方はnewしたドキュメントもしくはfindしたドキュメントに対して行えます。
constuser=newUser({name:'test',email:'test@gmail.com',password:'pw'})user.showName()
statics
スキーマに静的なメソッドを定義することができます。
user.js
schema.static('showName',function(doc){console.log(doc.name)})
使い方はModel.静的メソッド名
で呼び出しできます。
constuser=newUser({name:'test',email:'test@gmail.com',password:'pw'})user.showName()
pre
preフックは保存前に処理を差し込みます。
差し込むメソッド単位で指定します。
ここではupdate、findOneAndUpdateの処理前にオプションを必ず追加するようにしています。
updateやfindOneAndUpdateの第3引数に毎回付けるのが大変なのでpreフックを使うことで実行直前に必ずつけることができます。
runValidatorsオプションは保存データの型がフィールドのデータ型と正しいかのチェックやvalidatorのチェックを行います。
newオプションは実行後に更新後のドキュメントを返却するか否かのフラグです。trueにすることで更新後のドキュメントを返却します。
user.js
schema.pre('update',asyncfunction(next){this.setOptions({runValidators:true,})returnnext()})schema.pre('findOneAndUpdate',asyncfunction(next){this.setOptions({runValidators:true,new:true,})returnnext()})
post
postフックはドキュメント保存後に行う操作です。
差し込むメソッド単位で指定します。
主にドキュメント更新後にログや通知など行うのに適しています。
user.js
constpostSave=asyncfunction(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
constmongoose=require('mongoose')mongoose.Promise=global.Promiseconstdbname='dbname'mongoose.connect(`mongodb://localhost/${dbname}`,{useCreateIndex:true,useNewUrlParser:true,useUnifiedTopology:true,useFindAndModify:false})constmoment=require('moment')constrepl=require('repl')constreplInstance=repl.start({prompt:'> '})replInstance.context.moment=momentconstHISTORY_DIRECTORY=__dirname+'/.ym_history'// require node version above v11.10.0replInstance.setupHistory(HISTORY_DIRECTORY,(err)=>{if(err)console.log(err)})constmodels=require('./models')for(constnameinmodels){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を実行することができるようになります。
>awaitSimple.create({str:'abc'})>awaitSimple.find()
create
ドキュメントを作成します。requiredのフィールドが存在する場合はそのフィールドのパラメータも指定しないとエラーになります。
フォーマットはModel.create(フィールドのパラメータ)
のように指定します。
ドキュメントが生成される際、ユニークなドキュメントのID(ObjectId)を自動生成します。
ドキュメントのIDは_idフィールドに格納されます。(24桁16進数の値)
>awaitSimple.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なフィールドも付与され返却されます。
>awaitProgrammer.create({skill:['frontend','backend']}){skill:['frontend','backend'],_id:5eaeb551da27f19dfbfa60c7,createdAt:2020-05-03T12:13:05.468Z,updatedAt:2020-05-03T12:13:05.468Z}>awaitProductManager.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}>awaitUser.create({name:'myname',email:'test@gmail.com',password:'pw',role:{model:'ProductManager',type:'5eaeb598da27f19dfbfa60c8'}})updated5eaeb78dda27f19dfbfa60c9toObject{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'}>awaitUser.create({name:'yourname',email:'example@gmail.com',password:'pw',role:{model:'Programmer',type:'5eaeb551da27f19dfbfa60c7'},reviews:[{rating:5,comment:'abc'}],invitedFrom:'5eaeb78dda27f19dfbfa60c9'})updated5eaeb8feda27f19dfbfa60cctoObject{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はバージョニングの問題もはらんでいるので注意が必要です。)
>constsimple=newSimple({str:'abc',arr:['a','b']})>simple{arr:['a','b'],refs:[],_id:5eae9857da27f19dfbfa60c6,str:'abc'}>awaitsimple.save()
この他にもupdate系メソッドでupsertオプションを指定してドキュメントを作成する方法があります。
find、findOne、findById
findは検索条件に合致するドキュメントを全て配列形式で返します。
findOneは検索条件に合致するドキュメントを1つのみ返します。
findByIdは指定のモデルの_idに合致するドキュメントを1つのみ返します。(findOneのidのみ指定版)
検索条件
基本的にAND条件で検索となります。
findは配列形式でドキュメントが取得できます、該当ドキュメントが存在しない場合は空の配列が返ります。
findOne、findByIdは該当ドキュメントが存在しない場合はnullが返ります。
全件検索
findの検索条件なしは全件検索となり、配列形式で全てのドキュメントが取得できます。
findOne、findByIdは先頭のドキュメント1つの取得となります。
>awaitSimple.find()[{arr:['a','b'],refs:[],_id:5eae9847da27f19dfbfa60c5,str:'abc',__v:0},{arr:['a','b'],refs:[],_id:5eae9857da27f19dfbfa60c6,str:'abc',__v:0}]>awaitSimple.findOne(){arr:['a','b'],refs:[],_id:5eae9847da27f19dfbfa60c5,str:'abc',__v:0}
指定のフィールド別に検索条件を指定する
指定のフィールドの値に合致するドキュメントを取得します。
複数フィールド指定した場合はand条件となります。
>awaitSimple.find({str:'abc'})[{arr:['a','b'],refs:[],_id:5eae9847da27f19dfbfa60c5,str:'abc',__v:0},{arr:['a','b'],refs:[],_id:5eae9857da27f19dfbfa60c6,str:'abc',__v:0}]>awaitSimple.find({str:'ab'})[]
配列フィールドの場合、指定の値が配列に含まれているドキュメントを取得します。
>awaitSimple.find({arr:'a'})[{arr:['a','b'],refs:[],_id:5eae9847da27f19dfbfa60c5,str:'abc',__v:0},{arr:['a','b'],refs:[],_id:5eae9857da27f19dfbfa60c6,str:'abc',__v:0}]>awaitSimple.find({arr:'c'})[]
オブジェクトや子スキーマなどでネストされている場合は.
でフィールドをたどります。
ネストされているものが配列の場合はさらに配列の中に含まれている条件で検索します。
>awaitUser.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
指定の値に一致しないものを取得します。
>awaitSimple.find({str:{$ne:'abc'}})[]>awaitSimple.find({str:{$ne:'ab'}})[{arr:['a','b'],refs:[],_id:5eae9847da27f19dfbfa60c5,str:'abc',__v:0},{arr:['a','b'],refs:[],_id:5eae9857da27f19dfbfa60c6,str:'abc',__v:0}]
配列フィールドの場合、指定の値が配列に含まれていないドキュメントを取得します。
>awaitSimple.find({arr:{$ne:'a'}})[]>awaitSimple.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
指定のフィールドの値が存在しているかで検索します。
>awaitSimple.findOne({str:{$exists:true}}){arr:['a','b'],refs:[],_id:5eae9847da27f19dfbfa60c5,str:'abc',__v:0}>awaitSimple.findOne({num:{$exists:true}})null
配列フィールドの場合、配列の添字を指定すると配列の長さがx以上のドキュメントを返します。
(次の例は配列の長さが2以上)
>awaitSimple.findOne({'arr.1':{$exists:true}}){arr:['a','b'],refs:[],_id:5eae9847da27f19dfbfa60c5,str:'abc',__v:0}
$in
配列指定の値のいずれかに一致するドキュメントを返します。
>awaitSimple.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}]
配列フィールドの場合は配列指定の値のいずれかかが配列に含まれるドキュメントを返します。
>awaitSimple.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
配列指定の値のいずれも一致しないドキュメントを返します。
>awaitSimple.find({_id:{$nin:['5eae9847da27f19dfbfa60c5','5eae9857da27f19dfbfa60c6']}})[]
配列フィールドの場合、配列指定の値のいずれも配列に含まないドキュメントを返します。
>awaitSimple.find({arr:{$nin:['b','c']}})[]>awaitSimple.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時間ずれています。
>awaitProgrammer.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
いずれかの条件に当てはまるドキュメントを返します。
配列形式で条件を指定します。
>awaitUser.find({$or:[{'role.model':'ProductManager'},{'role.model':'Programmer'}]})toObjecttoObject[{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系のメソッドは検索条件での取得時にさらに処理を続けることができます。
select
、populate
、limit
など複数のオペレーションをつなげることもできます。
select
取得フィールドを選択します。
空白スペース区切り、配列での指定の両方使えます。
>awaitSimple.findOne().select('str arr'){arr:['a','b'],_id:5eae9847da27f19dfbfa60c5,str:'abc'}>awaitSimple.findOne().select(['str','arr']){arr:['a','b'],_id:5eae9847da27f19dfbfa60c5,str:'abc'}
-field
のようにマイナスをつけることでそのフィールドだけ取得しないという指定もできます。
>awaitSimple.findOne().select('-str'){arr:['a','b'],refs:[],_id:5eae9847da27f19dfbfa60c5,__v:0}
ちなみにtoObjectのtransformで操作している場合やvirtualsフィールドは実行されるため、次の例だとselectしたフィールドにさらに付与されます。
>awaitUser.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のままになる)
>awaitUser.find().select('invitedFrom').populate('invitedFrom')toObjecttoObjecttoObject[{_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する場合に使います。
>awaitUser.find().select('invitedFrom').populate({path:'invitedFrom',select:'name',match:{isDeleted:{$ne:true}}})toObjecttoObjecttoObject[{_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}]>awaitUser.find().select('invitedFrom').populate({path:'invitedFrom',select:'name',match:{isDeleted:true}})toObjecttoObject[{_id:5eaeb78dda27f19dfbfa60c9,image:'http://localhost/images/5eaeb78dda27f19dfbfa60c9',password:false},{_id:5eaeb8feda27f19dfbfa60cc,invitedFrom:null,image:'http://localhost/images/5eaeb8feda27f19dfbfa60cc',password:false}]
refPathの場合、指定の先のドキュメントが異なる場合でも同時にpopulateできます。
(matchで指定のスキーマのドキュメントのみ取得も可能)
>constusers=awaitUser.find().select('role').populate({path:'role.type',populate:{path:'programmers',match:{skill:'frontend'}}}).select('role')undefined>userstoObjecttoObject[{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]#populateinpopulateの該当する検索条件に当てはまらなかった場合>constusers=awaitUser.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で降順となります。
>awaitUser.find().sort({createdAt:-1})toObjecttoObject[{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で取得する上限のドキュメント数を指定します。
>awaitSimple.find().limit(1)[{arr:['a','b'],refs:[],_id:5eae9847da27f19dfbfa60c5,str:'abc',__v:0}]
findOneやfindByIdの場合は無意味です。
skip
検索結果の指定のドキュメントまでスキップします。ページネーションなどに使えます。
limitと組み合わせることが多いです。
>awaitUser.find().select('name').skip(0).limit(1)toObject[{_id:5eaeb78dda27f19dfbfa60c9,name:'myname',image:'http://localhost/images/5eaeb78dda27f19dfbfa60c9',password:false}]>awaitUser.find().select('name').skip(1).limit(1)toObject[{_id:5eaeb8feda27f19dfbfa60cc,name:'yourname',image:'http://localhost/images/5eaeb8feda27f19dfbfa60cc',password:false}]
distinct
指定のフィールドの値の重複を除いて配列形式で指定フィールドの値のみ取得します。(指定できるフィールドは1つのみです。)
>awaitSimple.find().distinct('str')['abc']
lean
ドキュメントをMongooseドキュメントオブジェクトではなく、
プレーンなJavaScript形式で返却します。
model.id
やmodel.save
などのMongooseドキュメントオブジェクトに付随しているメソッドが参照できなくなる代わりにデータの取得が大幅に高速化されます。(推奨)
vitualsやdefaultやmethodsなども省略されてしまうため、
mongoose-lean-virtualsやmongoose-lean-defaultsなどのプラグインを導入することでlean時にもオプションで付与することができます。
leanのmethodsはプラグインがないので、
次のようなプラグインを作成することでmethods: true
をleanの引数にわたすことで付与できるようになります。
mongoose-lean-methods.js
constmpath=require('mpath')module.exports=functionmongooseLeanMethods(schema){constfn=attachMethodsMiddleware(schema)schema.pre('find',function(){if(typeofthis.map==='function'){this.map((res)=>{fn.call(this,res)returnres})}else{this.options.transform=(res)=>{fn.call(this,res)returnres}}})schema.post('find',fn)schema.post('findOne',fn)schema.post('findOneAndUpdate',fn)schema.post('findOneAndRemove',fn)schema.post('findOneAndDelete',fn)}functionattachMethodsMiddleware(schema){returnfunction(res){attachMethods.call(this,schema,res)}}functionattachMethods(schema,res){if(res==null){return}if(this._mongooseOptions.lean&&this._mongooseOptions.lean.methods){constmethods={}for(constkeyinschema.methods){if(key==='initializeTimestamps')continuemethods[key]=schema.methods[key]}if(Array.isArray(res)){constlen=res.lengthfor(leti=0;i<len;++i){res[i]=attachMethodsToDoc(res[i],methods)}}else{res=attachMethodsToDoc(res,methods)}for(leti=0;i<schema.childSchemas.length;++i){const_path=schema.childSchemas[i].model.pathconst_schema=schema.childSchemas[i].schemaconst_doc=mpath.get(_path,res)if(_doc==null){continue}attachMethods.call(this,_schema,_doc)}returnres}else{returnres}}functionattachMethodsToDoc(doc,methods){if(Object.keys(methods).length===0){returndoc}for(constkeyinmethods){doc[key]=methods[key]}returndoc}
スキーマにてプラグインを指定します。
user.ts
constmongooseLeanVirtuals=require('mongoose-lean-virtuals')constmongooseLeanDefaults=require('mongoose-lean-defaults')constmongooseLeanMethods=require('../mongoose-lean-methods')constschema=newSchema({})schema.plugin(mongooseLeanVirtuals)schema.plugin(mongooseLeanDefaults)schema.plugin(mongooseLeanMethods)
オプションを使う場合はleanの引数を指定します。
>awaitUser.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}>awaitUser.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と似ていますが記述方法が異なります。$group
: 指定のフィールドに対し、グルーピングすることで集計することができます。SQLのグループ文に似た機能です。$unwind
: 主に$lookup
とセットで使います。$lookup
すると配列でネストするため、配列を展開します。
他の操作は公式を参考にしてください。
>awaitUser.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}]>awaitUser.aggregate([{$group:{_id:'$isAdmin',total:{$sum:1}}}])[{_id:false,total:2}]
exists
ドキュメントの存在確認を行います。
指定の条件にあったドキュメントが存在すればtrue、存在しなければfalseが返ります。
フォーマットはModel.exists(検索条件)
となります。
>awaitSimple.exists({str:'abc'})true>awaitSimple.exists({str:'ab'})false
countDocuments
検索条件にあったドキュメントの数を返します。存在しなければ0が返ります。
フォーマットはModel.countDocuments(検索条件)
となります。
>awaitSimple.countDocuments({str:'abc'})2>awaitSimple.countDocuments({str:'ab'})0
更新系の処理
findOneAndUpdate(findByIdUpdate)は戻り値としてドキュメントを返しますが、
update系(updateMany,updateOne)のメソッドは戻り値としてドキュメントを返しません。
findOneAndUpdate、findByIdAndUpdate
findOneAndUpdateは該当のドキュメントを1つのみ検索し、更新し、該当のドキュメントを返却します。
特に検索条件が_idのときはfindByIdAndUpdateが使えます。
フォーマットはModel.findOneAndUpdate('検索条件', '更新操作', 'オプション')
のようになっています。
>awaitUser.findOneAndUpdate({name:'myname'},{$set:{phone:'09011112222'},$push:{reviews:[{rating:2}]},$unset:{isDeleted:true}},{runValidator:true,new:true,projection:'name phone reviews isDeleted'}).lean()updated5eaeb78dda27f19dfbfa60c9{_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('検索条件', '更新操作', 'オプション')
のようになっています。
>awaitUser.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に一致するドキュメントを削除します。
削除後に削除したドキュメントを返却します。
>awaitSimple.findByIdAndDelete('5eae9847da27f19dfbfa60c5').select('_id').lean(){_id:5eae9847da27f19dfbfa60c5}
deleteMany、deleteOne
deleteManyは検索条件に一致するドキュメントをすべて削除します。
deleteOneは該当のドキュメントを1つのみ削除します。
>awaitSimple.deleteMany({str:'abc'}){n:1,ok:1,deletedCount:1}
bulkWrite
updateOne、updateMeny、deleteOne、deleteManyなどを織り交ぜて一括で操作することができます、
配列形式で複数の操作を指定できます。
オプションのorderdフラグは配列の順番に実行するかを指定するフラグです。
orderdをfalseにすることで実行順番の保証はなくなりますが、高速に実行されます。
主にバッチ処理でデータマイグレーションする際などに使用します。
>constupsertOne={updateOne:{filter:{name:'othername'},update:{$set:{name:'othername',email:'other@gmail.com',password:'pw'}},upsert:true}}>constdeleteOne={deleteOne:{filter:{name:'yourname'}}}>awaitUser.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は自動的に生成される)
>awaitUser.collection.getIndexes(){_id_:[['_id',1]],email_1:[['email',1]]}
インデックスの指定を更新した場合などに既存のインデックスを削除するにはdropIndexesを指定します。
フォーマットはModel.collection.dropIndexes()
となります。
>awaitUser.collection.dropIndexes()
スロークエリの解析
explainを指定することで実際に検索はせず、スロークエリの実行計画を確認することができます。
検索条件時に設定したインデックスを利用しているかどうか確認するために使えます。
さらにexecutionStats
をオプションを指定することで詳細なログが確認できます。
参考:MongoDB v4 explain 結果をちゃんと理解してクエリ改善 (前半:explain結果の見方)
IXSCANが含まれていればインデックスを使っての検索がされています。
COLLSCANの場合、インデックスを使ってないフルスキャンとなっています。
>(awaitUser.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]}}}
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 suitesglobalSetup:"./test/global-setup.js",// A path to a module which exports an async function that is triggered once after all test suitesglobalTeardown:"./test/global-teardown.js",}
テストの前処理はglobal-setup.jsに定義します。
global変数はglobalTeardownでは参照できます。
各テストでmongoose接続するため、process.envに保存します。
(globalSetup内でmongoose接続しても各テストでconnectionが持ち越しできない)
global-setup.js
const{MongoMemoryServer}=require('mongodb-memory-server')constmongoServer=newMongoMemoryServer()asyncfunctionsetup(){constmongoUri=awaitmongoServer.getUri()process.env.mongoUri=mongoUriglobal.mongoServer=mongoServer}module.exports=setup
各テストでMongoDBにmongooseから接続を行います。
mongo.js
constmongoose=require('mongoose')mongoose.Promise=global.Promisemongoose.connection.on('error',(e)=>{if(e.message.code==='ETIMEDOUT'){console.log(e);mongoose.connect(mongoUri,mongooseOpts)}console.log(e)})mongoose.deleteAll=async()=>{for(constkeyinmongoose.models){constmodel=mongoose.models[key]awaitmodel.deleteMany()}}constmongooseOpts={useCreateIndex:true,useNewUrlParser:true,useUnifiedTopology:true,useFindAndModify:false,}constmongoUri=process.env.mongoUrimongoose.connect(mongoUri,mongooseOpts)module.exports=mongoose
各テスト終了時に利用してるドキュメントをすべて削除します。
テスト終了時にMongoose接続を破棄します。
mongo.test.js
constmongo=require('./mongo')const{User,ProductManager,Programmer}=require('../models')afterEach(async()=>{awaitmongo.deleteAll()})afterAll(async()=>{awaitmongo.disconnect()})
全テスト終了時にMongoDBを破棄します。
global-teardown.js
asyncfunctionteardown(){awaitglobal.mongoServer.stop()}module.exports=teardown