[Node.JS] 打造 todolist api 5 - 加入資料庫(MongoDB)
todolist api 在前一篇文章雖然已經上線運行,但是待辦事項沒有存放到資料庫,只要閒置久了資料就會消失,這篇文章會介紹 MongoDB 的操作,並應用資料庫讓每筆待辦都能被儲存下來。
如果已經熟悉 MongoDB 的操作,可以直接跳到最後一個段落。
💎 安裝 MongoDB
安裝
MacOS
- 方法1:下載壓縮檔,解壓縮後的 bin 資料夾內有4個檔案(mongo、mongod…),透過終端機指令(
sudo cp 完整路徑/mongodb-directory/bin/* /usr/local/bin/
)或是 Finder 把這4個檔案移動到/usr/local/bin/
路徑裡面。 - 方法2:使用 Homebrew 安裝,指令為
brew install mongodb-community
,安裝的內容較多、較佔空間,但是安裝好後可以直接開始使用。
- 方法1:下載壓縮檔,解壓縮後的 bin 資料夾內有4個檔案(mongo、mongod…),透過終端機指令(
Windows:下載安裝檔執行安裝即可。
環境測試與操作指令
使用終端機執行 mongo 和 mongod 指令,成功執行時會顯示很多訊息(可先略過內容),失敗時則顯示找不到該指令。
使用 MacOS 解壓縮方式安裝,在首兩次執行時會跳出安全性提示,需要開啟相關設定才能執行指令(跳出的提示會有選項可以導向設定頁面)
1
2mongo
mongod建立本地資料庫所需的資料夾和檔案,結構如下:
- MongoDB (目錄)
- data (目錄)
- logs (目錄)
- mongo.log (檔案)
開啟終端機,輸入指令啟用資料庫,成功啟用後會發現指定為資料庫路徑的目錄裡面多了好幾個檔案。
(可以先 cd 到 MongoDB 目錄,指令內的路徑就能少打一些)1
mongod --dbpath 完整路徑/MongoDB/data --logpath 完整路徑/MongoDB/logs/mongo.log
指令說明:
- mongod:啟動資料庫
- –dbpath:參數,設定資料庫的路徑
- –logpath:參數,設定 log檔 的路徑
- Ctrl+C:關閉資料庫
開啟另一個終端機,輸入 mongo 指令連線資料庫,成功連線時會出現許多訊息,其中可以看到資料庫使用本機的 27017 port(127.0.0.1:27017),進入資料庫後可以執行一些指令來測試。
MongoDB 的結構是 db(資料庫) -> Collection(集合) -> document(文件/資料),透過以下指令就會依這個順序看到每個層級的資訊
1
2
3
4
5mongo # 連線資料庫
show dbs # 顯示所有資料庫
use local # 切換到 local 資料庫
show collections # 顯示所有集合
db.startup_log.find() # 顯示 startup_log 裡面的所有資料
💎 註冊 MongoDB 雲端服務
- 前往 MongoDB 首頁 右上方區塊點擊註冊或登入,註冊帳號的過程也很簡單,選項大致選完就完成了。
- 登入後會跳轉到 Create Cluster 頁面,只是練習的話就選擇免費的就好,雲端主機的種類也使用預設的,過程請
注意頁面底下顯示的收費金額
。 - 接著會跳轉到『建立使用者名稱和密碼』、『網路存取』的設定,使用者名稱和密碼是連接資料庫時需要使用,網路存取因為是本地練習,可以選擇取得我的 ip 後加入。
如果自己連網的 ip 會浮動就需要再次來這邊更改
API 經常會設計開放給眾人戳,但是資料庫通常都是白名單開放,全開非常危險
- 完成後就可以從左側選單的 Database 查看建立好的資料庫。
💎 安裝 Compass
- Compass 是 MongoDB 的 GUI 工具,使用 Windows 系統不需額外安裝(已經包含在 MongoDB 安裝裡),MacOS 需要到 MongoDB 官方網站 下載,安裝方式很簡單,不需要另行設定。
- 連接本地資料庫:
Compass 主要操作介面有輸入資料庫連結的地方(如圖),只要空著直接點連線就會連接本地資料庫(請先確認有用 mongod 指令開啟本地資料庫) - 連接遠端資料庫:
- 回到上個步驟完成的 Mongo 雲端資料庫網頁
- 點選『Connect』
- 建立 IP,如果已經建立了就不會出現這個步驟)
- 點選『Connect using MongoDB Compass』
- 點選『I have MongoDB Compass』
- 出現一串連結複製下來,長得像這樣:
mongodb+srv://xxxxx:<password>@cluster0.uklck.mongodb.net/xxxxx
- 回到 Compass 介面輸入資料庫連結的地方貼上,把
<password>
的部份修改成先前建立的使用者密碼。 - 連線成功就完成了!
💎 資料庫操作指令
MongoDB Shell 可以用來直接操作資料庫,在學習 Node.js、PHP、C#… 等後端語言操作方式之前,可以先熟悉 MongoDB Shell,因為除了不同的後端語言會有自己的起手勢、撰寫格式外,資料庫操作的方法名稱都是大同小異的,接下來就介紹 MongoDB 的 CRUD 使用哪些指令(只有記錄部分指令,完整可以參考 官方文件)。以下都是在本地資料庫做練習
C (Create):
use <dbName>
:切換到指定資料庫。
初始環境中只有 admin、config、local 3個資料庫,可以透過 use 指令來切換到不同資料庫,CRUD 操作前也需要先用這個指令來切換到指定資料庫中。1
use 資料庫名稱
db.<collectionName>.insertOne()
:在指定集合內新增一筆資料。<collectionName>
要換成集合的名稱,()括弧
裡面帶入要新增的資料。1
db.集合名稱.insertOne({"greeting": "Hello"})
新增資料庫:
使用資料庫時不會在初始的資料庫裡操作,而是針對專案新增專用的資料庫,MongoDB 沒有新增資料庫的專用指令,需要兩個指令來完成:use 新資料庫名稱
:送出這個指令後就會切換到新的資料庫名稱中,此時只是暫時的狀態,尚未真正建立資料庫。db.<collectionName>.insertOne(...)
:利用新增資料的指令,設定了集合的名稱,並加入新的資料,資料庫也會同時建立起來。
以 todolist 為例,環境設計和對應指令如下:
- 資料庫名稱:todolist
- 集合名稱:todos
- 新增一筆資料:{“title”: “eat”}
1
2
3use todolist
db.todos.insertOne({"title": "eat"})
# 執行上面兩個指令後,資料庫、集合、資料都建立好了
db./<collectionName>.insertMany()
:在指定集合內新增多筆資料。
和單筆新增差不多,括弧內改成陣列格式來存放多筆資料。1
2
3
4
5# 新增兩筆
db.todos.insertMany([
{"title": "sleep"},
{"title": "walk"}
])官方文件:Insert
R (Read)
db.<collectionName>.find()
:取得指定集合內符合條件的所有資料。
- 取得全部資料:不加任何參數,在練習新增、刪除、修改操作時,都可以使用這個指令檢查成果。
1
2# 找出 todos 集合內的所有資料
db.todos.find() - 條件查詢:括弧() 內加入篩選條件,取得所有符合的資料。
篩選條件有非常多種,以下大致列出一些用法。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37# 單一條件
db.todos.find({
"title": "sleep"
})
# 多種條件
db.todos.find({
"title": "sleep",
"content": "..."
})
# 數值區間
db.todos.find({
"price": {$gt:500},
})
# 關鍵字
db.todos.find({
"title": /a/
})
# 保護欄位:第二個參數指定的屬性不會被取出
db.todos.find(
{
"title": /a/
},
{
"_id": 0
}
)
# 搜尋陣列裡面的值
db.todos.find(
{
"tools": {$in:["vscode"]}
}
)
查詢參數
參數 | 功用 | 參數 | 功用 |
---|---|---|---|
$eq | 等於 | $ne | 不等於 |
$gt | 大於 | $lt | 小於 |
$gte | 大於等於 | $lte | 小於等於 |
$in | 存在某個值 | $nin | 不存在某個值 |
db.<collectionName>.findOne()
:取得指定集合內符合條件的一筆資料。
篩選條件通常用 id(建立時會自動產生),不會有重複的問題。1
db.todos.findOne({"_id": PbjectId("xxxxxxxx實際的IDxxxxxxxx")})
U (Update)
db.<collectionName>.updateOne()
:修改指定集合內的一筆資料。
需要知道要改哪筆資料,所以放入兩個參數,篩選條件
和新的資料內容
。- 篩選條件通常用 id(建立時會自動產生),不會有重複的問題。
- 新的資料
需要
放到 $set 屬性裡面。1
2
3
4
5
6
7
8
9
10db.todos.updateOne(
{
"_id": PbjectId("xxxxxxxx實際的IDxxxxxxxx")
},
{
"$set": {
{"title": "run"}
}
}
)
db.<collectionName>.updateMany()
:修改指定集合內的多筆資料。- 多筆資料不會使用 id(只能找到一筆),改用其他條件來篩選。
- 新的資料
需要
放到 $set 屬性裡面,所有符合的結果都會被修改。1
2
3
4
5
6
7
8
9
10db.todos.updateMany(
{
"title": "walk"
},
{
"$set": {
{"title": "run"}
}
}
)官方文件:Update
db.<collectionName>.replaceOne()
:取代指定集合內的一筆資料。- replace 和 update 的差異在於,update 只會更新對應屬性,replace 則是覆蓋全部。
- 新的資料
不需要
放到 $set 屬性裡面。使用 updateOne()1
2
3
4
5
6# 假設原始資料長這樣
{
"title": "run",
"distance": "2000m",
"time": "1700"
}使用 replaceOne()1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17db.todos.updateOne(
{
"title": "run"
},
{
"$set": {
{"title": "walk"}
}
}
)
# 執行結果:
{
"title": "walk",
"distance": "2000m",
"time": "1700"
}1
2
3
4
5
6
7
8
9
10
11
12
13db.todos.replaceOne(
{
"title": "run"
},
{
"title": "walk"
}
)
# 執行結果:
{
"title": "walk",
}官方文件:ReplaceOne
D (Delete)
db.<collectionName>.deleteOne()
:刪除指定集合內的一筆資料。
只需要傳入篩選條件就可以,刪除的對象會是整筆資料,不是單一屬性。1
2
3
4
5db.todos.deleteOne(
{
"title": "walk"
}
)db.<collectionName>.deleteMany()
:刪除指定集合內的多筆資料。
符合條件的都會被刪除。1
2
3
4
5db.todos.deleteMany(
{
"title": "walk"
}
)db.dropDatabase()
:刪除指定資料庫。
需要兩個步驟,use 資料庫,再執行刪除指令,高風險操作請謹慎使用。1
2use DBname
db.dropDatabase()官方文件:Delete、db.dropDatabase()
💎 Todolist API 結合 MongoDB
學會 MongoDB 後就可以迎來 todolist 的第二次進化!以下就是改造的步驟。
安裝 mongodb driver 套件
有了 driver 就可讓 node.js 和 MongoDB 交流了。
1 | npm i mongodb |
認識基本程式碼
以下參考官方文件 Quick Start ,可以先試著放到專案測試,沒問題後再來改寫。
1 | const { MongoClient } = require("mongodb"); |
改寫程式碼
可以依照官方的程式碼自由改寫,以下寫法僅供參考:
串連資料庫的目的是取代原本使用陣列存放 todolist,第一步就可以把原本寫的
let todos = []
拿掉。把官方範例中的常數宣告放到 todolist 程式最上方,或是模組化寫到另一個檔案來引入。
1
2
3const { MongoClient } = require("mongodb");
const uri = "mongodb+srv://自己的 mongodb 雲端服務連結";
const client = new MongoClient(uri);剩下的部份可以把「連接資料庫 -> 關閉資料庫」抽出來寫成函式,此處我設計成可以傳入參數,讓 switch…case 判斷要執行哪個對應的 CRUD。
1
2
3
4
5
6
7
8
9
10
11
12
13
14async function connectDB(res, method, data) {
try {
await client.connect();
const database = client.db('todolist');
const todos = database.collection('todos');
// 判斷傳入的參數
switch(action) {
case :
//...先空著...
}
} finally {
await client.close();
}
}函式的架構完成後,原本 todolist 的 5 隻 api 對應的陣列操作程式碼可以修改成呼叫連接資料庫的函式,傳入參數來執行對應行為,改寫的內容依序如下:
修改回傳函式:
前面的文章原本只設計用一個函式來處理 response,為了方便,我拆成 sendRes 和 sendErr 兩個方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36const headers = {
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Content-Length, X-Requested-With',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'PATCH, POST, GET, OPTIONS, DELETE',
'Content-Type': 'application/json'
}
const statusMsg = {
'400': 'Bad Request',
'401': 'Unauthorized',
'403': 'Forbidden',
'404': 'Not Found',
'405': 'Method Not Allowed',
}
const sendRes = (res, data) => {
res.writeHead(200, headers);
if (data) {
res.write(JSON.stringify({
'status': 'true',
'data': data
}))
}
res.end();
}
const sendErr = (res, statusCode) => {
res.writeHead(statusCode, headers);
res.write(JSON.stringify({
'status': 'false',
'msg': statusMsg[statusCode]
}))
res.end();
}
module.exports = {
sendRes,
sendErr
}功能:取得全部
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29// 原本的內容
case 'GET':
sendResponse(res, 200, {
"status": "true",
// 改成回傳待辦事項的變數
"data": todos
});
break;
// 改寫成呼叫資料庫連結函式
case 'GET':
run(res, 'getAll');
break;
// 函式功能修改
async function run(res, method, data) {
try {
...
// 新增一個變數來存放資料庫回傳的訊息
let result;
switch(method) {
case 'getAll':
// find()查詢結果需要用 toArray() 方法轉換格式才能讀取
result = await todos.find().toArray();
await sendRes(res, result);
break;
}
}
}功能:新增一筆
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26// 原本的內容
case 'POST':
...
break;
// 改寫成呼叫資料庫連結函式
case 'POST':
req.on('end', () => {
let title = JSON.parse(body)?.title;
title ? run(res, 'insertOne', {title}) : sendErr(res, 400);
})
break;
// 函式功能修改
async function run(res, method, data) {
try {
...
let result;
switch(method) {
case 'insertOne':
result = await todos.insertOne(data);
await sendRes(res, result);
break;
}
}
}功能:刪除一筆
原本的 ID 檢查是透過陣列方法,連結資料庫後如果要這樣做會變成先送出查詢指令,確認有資料再送出一次刪除指令,所以這邊我改成檢查 ID 格式是否正確,確認正確後直接送出,讓資料庫回應刪除是否成功。
檢查 MongoDB 的 ID 可以安裝 mongoose 這個套件:1
npm i mongoose
除此之外,檢查通過後還需要生成帶有 ObjectId 的物件,可以從 mongodb 引用,套件都沒問題後在程式碼的最上方引入這些功能。
1
2
3
4// 多引用了 ObjectId
const { MongoClient, ObjectId } = require('mongodb');
// 引用 mongoose 來驗證ID
const mongoose = require('mongoose');程式碼修改
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49// 原本的內容
} else if (req.url.startsWith('/todo/')) {
let id = req.url.split('/').pop();
let index = todos.findIndex(el => el.id == id);
if (index > -1) {
if (req.method == 'PATCH') {
...
} else if (req.method == 'DELETE') {
todos.splice(index, 1);
sendResponse(res, 200, {
"status": "true",
"data": todos
});
} else {
...
}
}
// 改寫成呼叫資料庫連結函式
} else if (req.url.startsWith('/todo/')) {
let id = req.url.split('/').pop();
// 檢查 ID 格式是否正確
let idIsValid = mongoose.Types.ObjectId.isValid(id);
if (idIsValid) {
if (req.method == 'PATCH') {
...
} else if (req.method == 'DELETE') {
// 傳入第三個參數:ID 物件
run(res, 'deleteOne', {"_id": ObjectId(id)});
} else {
sendErr(res, 405);
}
} else {
sendErr(res, 405);
}
}
// 函式功能修改
async function run(res, method, data) {
try {
...
switch(method) {
case 'deleteOne':
result = await todos.deleteOne(data);
await sendRes(res, result);
break;
}
}
}功能:刪除全部
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28// 原本的內容
case 'DELETE':
todos.length = 0;
sendResponse(res, 200, {
"status": "true",
"data": todos
});
break;
// 改寫成呼叫資料庫連結函式
case 'GET':
run(res, 'deleteAll');
break;
// 函式功能修改
async function run(res, method, data) {
try {
...
// 新增一個變數來存放資料庫回傳的訊息
let result;
switch(method) {
case 'deleteAll':
result = await todos.deleteMany();
await sendRes(res, result);
break;
}
}
}功能:更新一筆
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60// 原本的內容
...
if (req.method == 'PATCH') {
req.on('end', () => {
try {
let title = JSON.parse(body)?.title;
if (title) {
// 修改對應 ID 的 title
todos[index].title = title;
sendResponse(res, 200, {
"status": "true",
"data": todos
})
} else {
sendResponse(res, 400, {
"status": "false"
})
}
} catch (error) {
sendResponse(res, 405, {
"status": "false"
});
}
})
}
...
// 改寫成呼叫資料庫連結函式
} else if (req.url.startsWith('/todo/')) {
let id = req.url.split('/').pop();
// 檢查 ID 格式是否正確
let idIsValid = mongoose.Types.ObjectId.isValid(id);
if (idIsValid) {
if (req.method == 'PATCH') {
req.on('end', () => {
try {
let title = JSON.parse(body)?.title;
title ? run(res, 'updateOne', [{"_id": ObjectId(id)}, {title}]) : sendErr(res, 400);
} catch (error) {
sendErr(res, 405);
}
})
}
...
}
}
// 函式功能修改
async function run(res, method, data) {
try {
...
switch(method) {
case 'updateOne':
// 因為我只提供傳入一個 data 參數,這邊用陣列來放 id 跟 新資料,也可以用物件處理
result = await todos.updateOne(data[0], {$set: data[1]});
await sendRes(res, result);
break;
}
}
}最終測試
其實改寫的過程中就會一直使用 POSTMAN 來測試,最後從頭到尾測試一遍也許會發現漏掉的 bug,像我就出現連不上雲端資料庫的問題,才發現是我的連線 IP 變更所以被阻擋掉了。
💎 總結
API 大致的流程:
收到req -> 檢查內容決定行為 -> 連結資料庫(關閉連線)-> 發送 Res(回應結束)
要做這樣基本功能的 API 要學不少新技能,但是完成後會對整個流程有進一步的認識,這系列文章的完成品 Code 我也放在 GitHub 上,希望能對同樣是新手的同學們有所幫助。