# Mongoose ###### tags: `MongoDB` Mongoose對MongoDB的關係就相當於Express對nodejs 前者可以讓我們更方便使用後者的功能 ![](https://i.imgur.com/X8eIrWU.png) [Mongoose doc](https://mongoosejs.com/docs/guide.html) ## Schema 資料庫的model會以schema的格式為原型來建立 大致上的用法是這樣,更多設定能參考Mongoose的文件 ```javascript= const tourSchema = new mongoose.Schema({ // 建立schema name: { type: String, required: true, unique: true }, price: { type: Number, required: true }, rating: { type: Number, default: 4.5 }, pictures: [String] //String陣列 }) const Tour = mongoose.model('Tour', tourSchema) // 以tourSchema為原型建立Tour model ``` ### virtual property 有時候同樣的資料要使用不同單位時,我們不希望這項資料再占一格schema的欄位,這時候可以用vitual property來計算這些需要轉換的資料,定義方法如下 ```javascript= // tourModel.js tourSchema.virtual('durationWeeks').get(function() { return this.duration / 7 // 這樣就能把duration/7 存到 durationWeeks這個virtual property裡面了 }) ``` 之後再倒schema的部分傳入option ```javascript= const tourSchema = new mongoose.Schema({ // definition }, { // option toJSON: { virtuals: true }, // 在JSON格式輸出時顯示virtual property toObject: { virtuals: true } // 在Object格式輸出時顯示virtual property }) ``` ## 新增資料 <font color='red'>不在schema規則下的資料都會被忽略</font> ```javascript= const createTour = new Tour({ name: 'The Forest Hiker', price: 123, rating: 4.8 }) createTour.save().then(res =>{ // .save()會回傳一個promise console.log(res) // 顯示新增的資料 }).catch(err => console.log(err)) // 錯誤處理 ``` ### mongoose CRUD [Query指令](https://mongoosejs.com/docs/queries.html) ## 搭配express操作資料庫 ### filter 操作語法 這邊會介紹find()搭配express的req.query用法 req.query會記錄透過網址傳進來的參數 postman上也會顯示這些參數 ![](https://i.imgur.com/zH4kV5K.png) 在server端console.log(req.query)的結果會像這樣 ``` { a: '123', b: '546', c: '789' } ``` 這個能夠搭配find()來查詢資料庫中特定條件的資料 例如我這樣post ![](https://i.imgur.com/I7ixUdJ.png) ```javascript= const allTours = await Tour.find(req.query) // 相當於 // const allTours = await Tour.find({ // ratingsAverage: '4.5', // difficulty: 'easy' // }) ``` <font color='red'>題外話</font> find()查詢除了搭配mongodb原生的query語法外,在mongoose上還能有比較好理解的寫法 能將條件直接串在find()後面 ```javascript= const allTours = await Tour.find().where('difficulty').equals('easy').where('price').gte(500) ``` ### 進階filter操作 如果要使用req.query還要能搭配{$gte}、{$lte}等語法進行查詢的話要怎麼做呢?一開始可能會想到直接把$塞到url裡,但這是行不通的,因為url不收 這時候要把gte、lte等字放在[]裡 ``` http://127.0.0.1:8000/api/v1/tours/?duration[gte]=55&difficulty=easy&page=2 ``` 這樣出來的req.query就會是這樣 ``` { duration: { gte: '5' }, difficulty: 'easy', page: '2' } ``` 之後再透過JSON.stringify和replace()等操作把$放入 最後在將加工完的query物件放到find()裡面進行查詢 ```javascript= exports.getAllTours = async(req, res) => { try { console.log(req.query) const queryObj = {...req.query} // 用解構方法產生一個新物件放入新的變數做操作,才不會改到原來的req.query const excludeField = ['page', 'limit', 'sort', 'field'] excludeField.forEach(item => delete queryObj[item]) // 過濾無效的關鍵字 let queryStr = JSON.stringify(queryObj) // queryStr = queryStr.replace(/\b(gte|gt|lte|lt)\b/, match => `$${match}`) // replace放入$符號 console.log(queryStr) // 查看replace後的字串 console.log(JSON.parse(queryStr)) // 查看query物件轉換結果 const query = Tour.find(JSON.parse(queryStr)) // 將換換後的query物件放回find()查詢 const allTours = await query // 返回查詢結果 // const allTours = await Tour.find().where('difficulty').equals('easy').where('price').gte(500) res.status(200).json({ status: 'success', results: allTours.length, data: allTours }) } catch (error) { res.status(400).json({ status: 'failed', data: error }) } } ``` ### Sort query本身就有一個sort方法可以使用,只要在後面接字串就會自動以該字串升冪排列資料,在字串前加-則會變降冪,動以該字串升冪排列資料,在字串前加-則會變降冪,若有多組串在一起則會對前一種結果相同的資料作排列 例如req.query.sort('price -days -rating') 會先按照price升冪排列再將price相同的資料以days做比較降冪排列,若days還有相同的資料再以rating做比較作降冪排列 一般sort在url會這樣子傳 ``` http://127.0.0.1:8000/api/v1/tours/?sort=-price,-duration ``` 所以要透過以下方法將req.query中的sort進行重組變成純字串,中間用空格隔開的形式 ```javascript= if(req.query.sort) { query.sort(req.query.sort.split(',').join('')) } else { query.sort('-createdAt') } ``` ### field limiting (只拿特定資訊) 通常一個資料都會包含很多資訊,而大部份的資訊可能使用者都不會用到,這時候送到client端的資料就能做field limit的處理 例如資料內只需要包含名字、價錢、分數就可以透過這個方法來找 field limit是透過select方法來達成,而select接的參數形式跟sort一樣 例如使用者只需要name,duration,difficulty,price的資料,url可以這樣傳 ``` http://127.0.0.1:8000/api/v1/tours/?fields=name,duration,difficulty,price ``` ```javascript= if(req.query.fields) { query.select(req.query.fields.split(',').join(' ')) } else { query.select('-__v') // 前面加一個-代表不要這個資料 } ``` 回來的結果就會像這樣,只有name,duration,difficulty,price (id是為了確保資料獨特性 無法消掉) ``` "data": [ { "_id": "6045c3aa8c25544b48eb80df", "name": "The Forest Hiker", "duration": 5, "difficulty": "easy", "price": 397 }, ] ``` 除了使用這招,還能夠直接在schema設定特定資料能不能被抓出來 只要加上select: false,該項就不會在查詢的時候被顯示出來 ```javascript= const tourSchema = new mongoose.Schema({ name: { type: String, required: [true, 'A tour needs a name'], unique: true select: false } }) ``` ### pagination(分頁) 分頁是很重要的功能,可以降低client端和server端的負擔,因為一次給大量資料client端會吃不消,而server端也會需要處理很多資料,分頁最好都是由後端處理 雖然前端也能處理,不過前端處理的前提是建立在已經收了大量資料的情況下,這是個不太好的選擇 pagination會用到skip()及limit()功能 skip()接收的參數代表跳過多少筆資料 limit()接收的參數代表一頁顯示多少資料 例如我要分10筆資料為一頁 第一頁會是skip(0).limit(10) 第二頁是skip(10).limit(10) 第三頁是skip(20).limit(10) ```javascript= const page = req.query.page * 1 || 1; const limit = req.query.limit * 1 || 100; const skip = (page - 1) * limit; query = query.skip(skip).limit(limit); if(req.query.page) { const numTours = await Tour.countDocuments() // 取得document數量 if(skip >= numTours) { throw new Error(`page doesn't exist`) } } ``` 呼叫時的query寫法是api/?page=x&limit=y x是頁數 y是一頁要幾筆資料 ### Aliasing (別名) 是一個middleware的用法,有些網站可能會有針對前幾名評價的商品做的功能,這些功能會用到的資料就適合使用Aliasing來抓 ```javascript= // controller.js exports.aliasTopFive = (req, res, next) => { req.query.limit = 5 req.query.sort = '-ratingsAverage,price' req.query.fields = 'name,price,ratingsAverage,summary,difficulty' next() } ``` ```javascript= // router.js router .route('/top5-tour') .get(tourController.aliasTopFive, tourController.getAllTours) ``` 這會在使用者訪問/top5-tour的時候先經過aliasTopFive這個middleware幫他的req.query加上找前五評價會使用的參數再訪問getAllTours進行過濾直接抓前五名出來 ### Model中的資料驗證 #### 內建 string可以在schema中設定最大和最小字數限制 ```javascript= // myModel.js const mySchema = mongoose.new Schema({ name: { type: String, unique: true, required: [true, 'name is required'] trim: true, maxlength: [40, `can't set a name with 40+ characters`], minlength: [10, `can't set a name with 10- characters`] } }) module.exports = mySchema ``` 這些只會在創立資料時生效,若要在update時也生效 需要到update的程式碼設定option ```javascript= // myController.js const myDoc = require('../model/myModel') exports.myUpdate = async(req, res) => { try{ const newDoc = await myDoc(req.params.id, req.params.body, { new: true, runValidators: true // update時也執行Validators }) } } ``` string能設定接收指定字串 ```javascript= // myModel.js const mySchema = mongoose.new Schema({ name: { type: String, unique: true, required: [true, 'name is required'] trim: true, maxlength: [40, `can't set a name with 40+ characters`], minlength: [10, `can't set a name with 10- characters`] }, difficulty: { type: String, enum: { // 加上enum屬性可以設定只接收特定字串的validator values: ['easy', 'medium', 'difficult'], message: `only accept 'easy', 'medium', 'difficult' as difficulty` } } }) module.exports = mySchema ``` #### 自訂 有些值在驗證的時候可能會和其他的值有關聯,例如discount的金額不能超過原價,這種需要包含其他值一起做的驗證就要自己寫 ```javascript= // myModel.js const mySchema = mongoose.new Schema({ price: { type: Number, required: true }, discountPrice: { type: Number, validate: { validator: function(val) { // this points to currnet doc when creating return val < this.price }, message: `discount({VALUE}) shouldn't be greater than the original price` } // {VALUE}是mongoose的模板語法,可以自動帶入validator中val的值 } }) module.exports = mySchema ``` #### 第三方驗證 這邊以[validator.js](https://www.npmjs.com/package/validator)做示範 ```javascript= // myModel.js const validator = require('validator') const tourSchema = new mongoose.Schema( { name: { type: String, required: [true, 'A tour needs a name'], unique: true, trim: true, maxlength: [40, 'Name should contain less or equal to 40 characters'], minlength: [10, 'Name should contain more or equal to 10 characters'], validate: [validator.isAlpha, 'can only contain characters'] } } ) ``` ## Geospatial data mongoose提供的儲存地理資訊的方法,在schema中設定 ```javascript= // tourModel.js // //***other settings*** startLocation: { // GeoJSON type: { type: String, default: 'Point', enum: ['Point'] }, coordinates: [Number], address: String, description: String }, locations: [ { type: { type: String, default: 'Point', enum: ['Point'] }, coordinates: [Number], address: String, description: String } ] //***other settings*** ``` ### Geospatial queries [官網文件](https://docs.mongodb.com/manual/reference/operator/query-geospatial/index.html) ## Referencing in mongoose 例如今天想在tour的資料中加入嚮導資料,最好的方法就是透過userID來連接該嚮導這時候可以在schema中這樣定義嚮導資料 ```javascript= // tourModel.js // // //***other settings*** guides: [ { type: mongoose.Schema.ObjectId, // 格式設定為schema物件的ID ref: 'User' // ref設定為User,會在後續操作時透過ID連接並加入該使用者在User collection中的資料 // (這邊的字串是看你該model是用什麼名字export出來的) // 例如我在userModel寫const User = mongoose.model('User', userSchema) // 這邊就是用'User' } ] //***other settings*** ``` 透過API將guides資料帶入使用者ID測試新增一個tour ![](https://i.imgur.com/MaHqQu4.png) 確認ID有被正確存入guides 之後在get tour的地方測試ref:'User'的威力 ```javascript= // tourController.js exports.getTour = catchAsync(async (req, res, next) => { const aTour = await Tour.findById(req.params.id , (err) => { if(err) return next(new ApiError('ID not found', 404)) }).populate('guides') // populated('guides') 可以依照guides中的ref將使用者資料帶入guides中 res.status(200).json({ status: 'success', data: aTour }) }) ``` 觸發get tour測試 ![](https://i.imgur.com/ay7UHz4.png) 看到確實有將該id的user資料帶入了 這邊同樣能夠帶入select來過濾要顯示的屬性 ```javascript= // tourController.js exports.getTour = catchAsync(async (req, res, next) => { const aTour = await Tour.findById(req.params.id , (err) => { if(err) return next(new ApiError('ID not found', 404)) }).populate({ path: 'guides', select: '-__v -passwordChangedAt' }) // populated('guides') 可以依照guides中的ref將使用者資料帶入guides中 res.status(200).json({ status: 'success', data: aTour }) }) ``` 如果要在所有帶有userID的資料帶進使用者資料,可以直接在query middleware做populate的處理 ```javascript= // tourModel.js tourSchema.pre(/^find/, function (next) { this.populate({ path: 'guides', select: '-__v -passwordChangedAt' }) next() }) ``` 這樣就不用在controller一個一個加了 ## Virtual populate 一些網站中會看到在一個商品底下有很多相關的評價訊息,在資料庫中是如何處理這種訊息呢? 可能會想到用populate將評價透過ID帶入資料庫的作法,但這種做法會將評價的ID通通存在商品的document中,網站規模較小的話似乎是可行的辦法,但在規模較大的網站中,評價一多的情況下可能會讓這個document容量爆掉,這時候就需要用到virtual populate ### 做法 例如想在每個tour中帶入相關review ```javascript= // tourModel.js tourSchema.virtual('reviews', { ref: 'Review', // 來源的Model foreignField: 'tour', // tour id 連接到review中的field localField: '_id' // tour id在tour model中的field }) ``` 可以對照一下tour在reviewModel中的ref ```javascript= // reviewModel.js tour: { type: mongoose.Schema.ObjectId, ref: 'Tour', required: [true, 'review must belong to a tour'] } ``` <font color='red'>一個reivew只對到一個tour,一個tour能對到多個review,為了預防tour爆炸可以選擇virtual populate來實現這種功能</font> ## Improve reading performance ### Index 現在tour中總共有9筆資料 現在下一個filter來抓price低於1000的tour ![](https://i.imgur.com/ntUU1GR.png) 會看到result有三筆資料 在來將filter的query後面加一個explain()方法可以看到詳細資訊 ```javascript= exports.getAll = model => catchAsync(async (req, res, next) => { // to allow nested get reviews on tour(small hack) let filter = {} if (req.params.tourId) filter = { tour: req.params.tourId } // result const features = new APIfeatures(model.find(), req.query) .filter() .sort() .limit() .paginate() const doc = await features.query.explain() // const allTours = await Tour.find().where('difficulty').equals('easy').where('price').gte(500) res.status(200).json({ status: 'success', results: doc.length, data: doc }) }) ``` 再抓一次資料看一下executionStats發現nReturned是3,totalDocsExamined是9 意思是執行返回了3筆資料,總共跑了9筆 代表filter的動作會把所有資料都跑一遍 這時候可以設定index來改善filter的效能 例如我想在tour中加入price的index就加上這個 ```javascript= // tourModel.js tourSchema.index({price: 1}) ``` 這麼一來在對price進行filter的時候就會先將index進行排序再找,就不會將每一筆都跑完了 對price加上index後再發送一次測試 ![](https://i.imgur.com/Ze3Y5Q6.png) 回傳三筆資料只跑了三次 index可以在compass的介面中看到 ![](https://i.imgur.com/WceBFDB.png) 要刪除也是在這邊刪 <font color='red'>在schema中被設定為unique的資料會被自動加進index,如果有加錯的記得一定要來這裡刪掉,不然那個unique就會一直卡在這裡,可能會造成一些問題(例如註冊的時候一直說有key duplicate,但你的schema根本就沒那個key,這時候就要來這裡檢查一下是不是以前有動到unique的東西還卡在這)</font> ### Compound index ```javascript= tourSchema.index({price: 1, reatingsAverage: 1}) ``` ### Index 使用與否 由於index會隨著資料庫的資料數量變多,比較好的用法是只設定在比較常被讀取的屬性,常被寫入的屬性其實不需要加入index ## Calculating average number ### 新增review時計算 在評論功能中會打分數,這裡要示範如何算出所有review的平均分數並存入該tour的document中 1. 在reviewModel中定義一個靜態方法使用aggregatie來算出平均值 ```javascript= // reviewModel.js const Tour = require('./tourModel') // 引入Tour才能進行儲存 reviewSchema.statics.calcAverageRatings = async function (tourId) { const stats = await this.aggregate([ { $match: { tour: tourId // select the tour to update } }, { $group: { _id: '$tour', nRating: { $sum: 1 }, avgRating: { $avg: '$rating' } } } ]) console.log(stats) // 查看計算結果 await Tour.findByIdAndUpdate(tourId, { // 將結果存入Tour ratingsQuantity: stats[0].nRating, ratingsAverage: stats[0].avgRating }) } ``` 2. 定義一個middleware在review被存入後呼叫計算平均值的方法 ```javascript= reviewSchema.post('save', function () { // this point to current review this.constructor.calcAverageRatings(this.tour) // 使用this.constructor存取Review,因為在呼叫這個函數時 Review實體還沒建立,所以要用這個方法使用Review中的函數 }) ``` ### 在review被刪掉或被修改時計算 正常情況下findAndUpdate和findByAndDelete這類方法是不會觸發document中的middleware的,這時候需要做一下手腳 ```javascript= reviewSchema.pre(/^findOneAnd/, async function (next) { this.r = await this.findOne() // 先將query存到this.r以便在下一個middleware中 next() }) reviewSchema.post(/^findOneAnd/, async function () { //this.r = await this.findOne() doesn't work here, cuz query has been executed await this.r.constructor.calcAverageRatings(this.r.tour._id) // 將tourId帶入修改平均值和評論數 }) ``` 最後再回到calcAverageRatings對空stats的情況做處理(以防刪光出錯) ```javascript= reviewSchema.statics.calcAverageRatings = async function (tourId) { const stats = await this.aggregate([ { $match: { tour: tourId // select the tour to update } }, { $group: { _id: '$tour', nRating: { $sum: 1 }, avgRating: { $avg: '$rating' } } } ]) console.log(stats) // stats若為空陣列就使用預設值 if (stats.length > 0) { await Tour.findByIdAndUpdate(tourId, { ratingsQuantity: stats[0].nRating, ratingsAverage: stats[0].avgRating }) } else { await Tour.findByIdAndUpdate(tourId, { ratingsQuantity: 0, ratingsAverage: 4.5 }) } } ``` ## Prevent duplicate reviews 防止同一個user對同一個tour發送複數review 使用compound index設定 ```javascript= reviewSchema.index({ tour: 1, user: 1 }, { unique: true }) //設定unique代表同個組合只能出現一次 ```