[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/ 路徑裡面。

      MongoDB官方下載頁操作文件

    • 方法2:使用 Homebrew 安裝,指令為 brew install mongodb-community,安裝的內容較多、較佔空間,但是安裝好後可以直接開始使用。

      操作文件 (GitHub)

  • Windows:下載安裝檔執行安裝即可。

    MongoDB官方下載頁

環境測試與操作指令

  1. 使用終端機執行 mongo 和 mongod 指令,成功執行時會顯示很多訊息(可先略過內容),失敗時則顯示找不到該指令。
    使用 MacOS 解壓縮方式安裝,在首兩次執行時會跳出安全性提示,需要開啟相關設定才能執行指令(跳出的提示會有選項可以導向設定頁面)

    1
    2
    mongo
    mongod
  2. 建立本地資料庫所需的資料夾和檔案,結構如下:

  • MongoDB (目錄)
    • data (目錄)
    • logs (目錄)
      • mongo.log (檔案)
  1. 開啟終端機,輸入指令啟用資料庫,成功啟用後會發現指定為資料庫路徑的目錄裡面多了好幾個檔案。
    (可以先 cd 到 MongoDB 目錄,指令內的路徑就能少打一些)

    1
    mongod --dbpath 完整路徑/MongoDB/data --logpath 完整路徑/MongoDB/logs/mongo.log

    指令說明:

    • mongod:啟動資料庫
    • –dbpath:參數,設定資料庫的路徑
    • –logpath:參數,設定 log檔 的路徑
    • Ctrl+C:關閉資料庫
  2. 開啟另一個終端機,輸入 mongo 指令連線資料庫,成功連線時會出現許多訊息,其中可以看到資料庫使用本機的 27017 port(127.0.0.1:27017),進入資料庫後可以執行一些指令來測試。
    MongoDB 的結構是 db(資料庫) -> Collection(集合) -> document(文件/資料),透過以下指令就會依這個順序看到每個層級的資訊

    1
    2
    3
    4
    5
    mongo # 連線資料庫
    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 指令開啟本地資料庫)
  • 連接遠端資料庫:
    1. 回到上個步驟完成的 Mongo 雲端資料庫網頁
    2. 點選『Connect』
    3. 建立 IP,如果已經建立了就不會出現這個步驟)
    4. 點選『Connect using MongoDB Compass』
    5. 點選『I have MongoDB Compass』
    6. 出現一串連結複製下來,長得像這樣:
      mongodb+srv://xxxxx:<password>@cluster0.uklck.mongodb.net/xxxxx
    7. 回到 Compass 介面輸入資料庫連結的地方貼上,把<password>的部份修改成先前建立的使用者密碼。
    8. 連線成功就完成了!

💎 資料庫操作指令

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 沒有新增資料庫的專用指令,需要兩個指令來完成:

    1. use 新資料庫名稱:送出這個指令後就會切換到新的資料庫名稱中,此時只是暫時的狀態,尚未真正建立資料庫。
    2. db.<collectionName>.insertOne(...):利用新增資料的指令,設定了集合的名稱,並加入新的資料,資料庫也會同時建立起來。

    以 todolist 為例,環境設計和對應指令如下:

    • 資料庫名稱:todolist
    • 集合名稱:todos
    • 新增一筆資料:{“title”: “eat”}
      1
      2
      3
      use todolist
      db.todos.insertOne({"title": "eat"})
      # 執行上面兩個指令後,資料庫、集合、資料都建立好了

    官方文件:How to Create a Database in MongoDB

  • db./<collectionName>.insertMany():在指定集合內新增多筆資料。
    和單筆新增差不多,括弧內改成陣列格式來存放多筆資料。
    1
    2
    3
    4
    5
    # 新增兩筆
    db.todos.insertMany([
    {"title": "sleep"},
    {"title": "walk"}
    ])

    官方文件:Insert

R (Read)

  • db.<collectionName>.find():取得指定集合內符合條件的所有資料。
  1. 取得全部資料:不加任何參數,在練習新增、刪除、修改操作時,都可以使用這個指令檢查成果。
    1
    2
    # 找出 todos 集合內的所有資料
    db.todos.find()
  2. 條件查詢:括弧() 內加入篩選條件,取得所有符合的資料。
    篩選條件有非常多種,以下大致列出一些用法。
    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")})

    官方文件:FindFindeOne

U (Update)

  • db.<collectionName>.updateOne():修改指定集合內的一筆資料。
    需要知道要改哪筆資料,所以放入兩個參數,篩選條件新的資料內容

    • 篩選條件通常用 id(建立時會自動產生),不會有重複的問題。
    • 新的資料需要放到 $set 屬性裡面。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      db.todos.updateOne(
      {
      "_id": PbjectId("xxxxxxxx實際的IDxxxxxxxx")
      },
      {
      "$set": {
      {"title": "run"}
      }
      }
      )
  • db.<collectionName>.updateMany():修改指定集合內的多筆資料。

    • 多筆資料不會使用 id(只能找到一筆),改用其他條件來篩選。
    • 新的資料需要放到 $set 屬性裡面,所有符合的結果都會被修改。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      db.todos.updateMany(
      {
      "title": "walk"
      },
      {
      "$set": {
      {"title": "run"}
      }
      }
      )

      官方文件:Update

  • db.<collectionName>.replaceOne():取代指定集合內的一筆資料。

    • replace 和 update 的差異在於,update 只會更新對應屬性,replace 則是覆蓋全部。
    • 新的資料不需要放到 $set 屬性裡面。
      1
      2
      3
      4
      5
      6
      # 假設原始資料長這樣
      {
      "title": "run",
      "distance": "2000m",
      "time": "1700"
      }
      使用 updateOne()
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      db.todos.updateOne(
      {
      "title": "run"
      },
      {
      "$set": {
      {"title": "walk"}
      }
      }
      )

      # 執行結果:
      {
      "title": "walk",
      "distance": "2000m",
      "time": "1700"
      }
      使用 replaceOne()
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      db.todos.replaceOne(
      {
      "title": "run"
      },
      {
      "title": "walk"
      }
      )

      # 執行結果:
      {
      "title": "walk",
      }

      官方文件:ReplaceOne

D (Delete)

  • db.<collectionName>.deleteOne():刪除指定集合內的一筆資料。
    只需要傳入篩選條件就可以,刪除的對象會是整筆資料,不是單一屬性。

    1
    2
    3
    4
    5
    db.todos.deleteOne(
    {
    "title": "walk"
    }
    )
  • db.<collectionName>.deleteMany():刪除指定集合內的多筆資料。
    符合條件的都會被刪除。

    1
    2
    3
    4
    5
    db.todos.deleteMany(
    {
    "title": "walk"
    }
    )
  • db.dropDatabase():刪除指定資料庫。
    需要兩個步驟,use 資料庫,再執行刪除指令,高風險操作請謹慎使用。

    1
    2
    use DBname
    db.dropDatabase()

    官方文件:Deletedb.dropDatabase()


💎 Todolist API 結合 MongoDB

學會 MongoDB 後就可以迎來 todolist 的第二次進化!以下就是改造的步驟。

安裝 mongodb driver 套件

有了 driver 就可讓 node.js 和 MongoDB 交流了。

1
2
3
npm i mongodb
# 也可以全域安裝
npm i -g mongodb

認識基本程式碼

以下參考官方文件 Quick Start ,可以先試著放到專案測試,沒問題後再來改寫。

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
const { MongoClient } = require("mongodb");

// 這串改成自己的 mongodb 雲端服務連結
const uri =
"mongodb+srv://<user>:<password>@<cluster-url>?retryWrites=true&writeConcern=majority";

const client = new MongoClient(uri);

async function run() {
try {
await client.connect();
// 改成自己的資料庫名稱
const database = client.db('sample_mflix');
// 改成自己的集合名稱,變數名稱也順便調整
const movies = database.collection('movies');
// 這是查詢字串
const query = { title: 'Hello' };
// movie 和 movies 變數名稱請自行修改
// query 可以拿掉,變成查詢所有資料
const movie = await movies.findOne(query);
// 變數名稱自行修改,成功顯示資料就沒問題了
console.log(movie);
} finally {
// 每次連線完成都要記得關閉,佔用滿了資料庫就掛了
await client.close();
}
}
run().catch(console.dir);

改寫程式碼

可以依照官方的程式碼自由改寫,以下寫法僅供參考:

  1. 串連資料庫的目的是取代原本使用陣列存放 todolist,第一步就可以把原本寫的 let todos = [] 拿掉。

  2. 把官方範例中的常數宣告放到 todolist 程式最上方,或是模組化寫到另一個檔案來引入。

    1
    2
    3
    const { MongoClient } = require("mongodb");
    const uri = "mongodb+srv://自己的 mongodb 雲端服務連結";
    const client = new MongoClient(uri);
  3. 剩下的部份可以把「連接資料庫 -> 關閉資料庫」抽出來寫成函式,此處我設計成可以傳入參數,讓 switch…case 判斷要執行哪個對應的 CRUD。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    async 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();
    }
    }
  4. 函式的架構完成後,原本 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
    36
    const 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 上,希望能對同樣是新手的同學們有所幫助。