上一篇我們已經完成 Notes API 的 CRUD,可以新增、取得、修改與刪除 Note:
GET /notes
GET /notes/:id
POST /notes
PATCH /notes/:id
DELETE /notes/:id
功能雖然完成了,但在實作過程中,其實已經碰到不少 Request 裡的資料,例如 req.params.id、req.body,只是當時把重點放在 CRUD,沒有特別拆開說明。
這一篇就回頭整理這些資料到底從哪裡來,以及什麼情況適合放在 Params、Query、Body 或 Headers,最後再利用 Query String,替 Notes API 加上一個簡單的搜尋功能。另外,上一篇使用的 notes.length + 1 也留下了一個 ID 重複問題,會放在文章最後一起處理。
Client 向 Server 發送 Request 時,除了告訴 Server 要使用哪個 HTTP Method、存取哪個 URL,也可能需要附帶其他資料,例如要取得哪一筆 Note、要搜尋什麼關鍵字,或是要新增哪些內容。
以 Notes API 為例,下面三個 Request 帶的資料位置就不一樣:
GET /notes/3
GET /notes?keyword=node
POST /notes
GET /notes/3 把 Note ID 放在 URL 路徑裡,GET /notes?keyword=node 把搜尋條件放在 Query String,而 POST /notes 要新增的標題和內容通常會放在 Request Body。
在 Express 中,最常使用以下三個屬性取得這些資料:
req.params
req.query
req.body
除此之外,HTTP Request 還有 Headers,可以攜帶資料格式、驗證資訊等附加內容。
req.params:取得 Route Parameter上一篇取得單筆 Note 時,我們建立了這樣的 Route:
app.get("/notes/:id", function (req, res) {
// ...
});
其中的 :id 稱為 Route Parameter,代表 URL 這個位置可以放入不同的值,因此以下 Request 都會進入同一個 Route:
GET /notes/1
GET /notes/2
GET /notes/15
假設 Client 發送:
GET /notes/3
Express 會把 3 放進 req.params:
{
id: "3"
}
因此可以透過 req.params.id 取得 URL 裡的 ID:
app.get("/notes/:id", function (req, res) {
const id = Number(req.params.id);
const note = notes.find(function (note) {
return note.id === id;
});
res.json({
data: note
});
});
這裡需要使用 Number(),是因為 URL 裡取得的 Route Parameter 是字串,所以 /notes/3 得到的是 "3",而目前 Notes 裡的 ID 則是數字 3,如果直接使用嚴格比較,會變成:
3 === "3"
結果會是 false,因此要先把 req.params.id 轉成 Number。
Route Parameter 很適合用來表示「要操作哪一個資源」,像是取得、修改或刪除某一筆 Note:
GET /notes/3
PATCH /notes/3
DELETE /notes/3
req.query:取得 Query String如果不是要指定某一筆資料,而是想替一批資料加上查詢條件,就很常使用 Query String。
例如我們希望搜尋標題中包含 node 的 Note,可以設計成:
GET /notes?keyword=node
URL 中 ? 後面的 keyword=node 就是 Query String,其中 keyword 是參數名稱,node 則是它的值。
在 Express 中,可以透過 req.query 取得:
app.get("/notes", function (req, res) {
console.log(req.query);
res.json({
data: notes
});
});
當 Request 是:
GET /notes?keyword=node
req.query 會得到類似以下內容:
{
keyword: "node"
}
因此搜尋關鍵字可以透過 req.query.keyword 取得。
Query String 很適合用在搜尋、篩選、排序或分頁,例如:
GET /notes?keyword=node
GET /products?category=book
GET /products?sort=price
GET /notes?page=2
如果有多個條件,可以使用 & 串在一起:
GET /products?category=book&sort=price&page=2
這時 req.query 可能會是:
{
category: "book",
sort: "price",
page: "2"
}
要注意 Query String 取得的值通常也是字串,因此如果 page 後面要拿來做數字運算,仍然需要自行轉換:
const page = Number(req.query.page);
req.body:取得 Request BodyParams 和 Query 都會直接出現在 URL 上,但新增或修改資料時,通常需要傳送比較完整的內容,這時就會把資料放在 Request Body。
例如建立新的 Note,可以發送:
POST /notes
Request Body 則帶上:
{
"title": "學習 Node.js",
"content": "今天練習 Express"
}
Express 可以透過 req.body 取得:
app.post("/notes", function (req, res) {
const newNote = {
id: notes.length + 1,
title: req.body.title,
content: req.body.content
};
notes.push(newNote);
res.status(201).json({
data: newNote
});
});
因此 req.body.title 會取得 "學習 Node.js",req.body.content 則會取得 "今天練習 Express"。
Request Body 很常用在 POST 和 PATCH,POST 通常用來傳送建立資料所需要的內容,而 PATCH 則可以只傳送這次要修改的欄位,例如:
{
"title": "修改後的標題"
}
上一篇已經介紹過 express.json(),這裡只要記得,如果 Client 傳送的是 JSON Request Body,就需要先經過它的解析,後面的 Route 才能透過 req.body 取得資料。
除了 URL 和 Body 之外,HTTP Request 還可以透過 Headers 攜帶一些描述這次 Request 的額外資訊。
目前先認識兩個之後會經常遇到的 Header:Content-Type 和 Authorization。
當 Request Body 傳送 JSON 時,通常會看到:
Content-Type: application/json
Content-Type 用來告訴 Server Request Body 使用什麼資料格式,這裡的 application/json 就代表內容是 JSON。
另一個常見的是 Authorization,未來學到 JWT 時可能會看到:
Authorization: Bearer xxxxx.yyyyy.zzzzz
它通常用來攜帶身分驗證資訊,Server 收到 Request 後,可以再根據其中的 Token 判斷使用者身分與權限,目前先知道它是放在 Header 裡即可,後面學驗證機制時再深入處理。
整理前面的內容,可以先用這個方式區分:
Params → 指定要操作哪一個資源
Query → 描述要怎麼查詢資源
Body → 傳送要建立或修改的內容
例如取得 ID 為 3 的 Note:
GET /notes/3
3 放在 Params。
如果要從所有 Notes 中搜尋 node:
GET /notes?keyword=node
keyword=node 放在 Query。
如果要建立一筆新的 Note:
POST /notes
要建立的資料則放在 Body:
{
"title": "學習 Node.js",
"content": "今天練習 Express"
}
這個分類不是所有 API 都必須完全照著使用的硬性規則,但在設計一般 REST API 時,可以作為很實用的判斷方式。
理解 req.query 之後,就可以替目前的 GET /notes 加上一個簡單的搜尋功能。
假設目前有以下資料:
const notes = [
{
id: 1,
title: "學習 Node.js",
content: "認識 Runtime"
},
{
id: 2,
title: "學習 Express",
content: "建立 Express Server"
},
{
id: 3,
title: "學習 CSS",
content: "整理 Flexbox"
}
];
我們希望 Client 可以使用:
GET /notes?keyword=node
搜尋標題中包含 node 的 Note,因此可以把原本的 GET /notes 改成:
app.get("/notes", function (req, res) {
const keyword = req.query.keyword;
if (!keyword) {
return res.json({
data: notes
});
}
const filteredNotes = notes.filter(function (note) {
return note.title
.toLowerCase()
.includes(keyword.toLowerCase());
});
res.json({
data: filteredNotes
});
});
如果 Client 只發送 GET /notes,因為沒有 keyword,API 就直接回傳全部 Notes;如果發送 GET /notes?keyword=node,程式會取得 "node",再透過 filter() 找出符合條件的資料。
這裡同時把標題和搜尋關鍵字使用 toLowerCase() 轉成小寫,是為了避免 "Node" 和 "node" 因為大小寫不同而搜尋不到。
搜尋結果可能會是:
{
"data": [
{
"id": 1,
"title": "學習 Node.js",
"content": "認識 Runtime"
}
]
}
之後如果想繼續擴充,也可以加入分頁、排序等 Query:
GET /notes?keyword=node&page=1
GET /notes?keyword=node&sort=desc&page=1
notes.length + 1?前面的 POST API 目前還是使用:
const newNote = {
id: notes.length + 1,
title: req.body.title,
content: req.body.content
};
這個寫法在資料只新增、不刪除時不太容易出現問題,假設目前有三筆 Note,notes.length 是 3,下一筆 ID 就會是 4。
但如果刪除其中一筆,例如原本的 ID 是 1、2、3,刪除 ID 2 後只剩:
const notes = [
{
id: 1,
title: "學習 Node.js"
},
{
id: 3,
title: "學習 API"
}
];
這時 notes.length 是 2,下一次使用 notes.length + 1 又會得到 3,結果就會出現兩筆相同 ID。
原因很簡單,notes.length 只代表目前有幾筆資料,並不代表目前最大的 ID。
目前 Notes 還只是放在 JavaScript 陣列裡,可以先找出最大的 ID,再加上 1:
const maxId = Math.max(...notes.map(note => note.id), 0);
接著修改 POST API:
app.post("/notes", function (req, res) {
const maxId = Math.max(...notes.map(note => note.id), 0);
const newNote = {
id: maxId + 1,
title: req.body.title,
content: req.body.content
};
notes.push(newNote);
res.status(201).json({
data: newNote
});
});
假設目前的 ID 是 1 和 3,Math.max() 會找到最大的 3,所以下一筆資料會使用 4,即使中間刪過資料,也不會因為陣列長度縮短而重複使用 ID。
Math.max() 中加入 0,則是為了處理空陣列,如果目前沒有任何 Note,最大值會以 0 計算,因此第一筆資料的 ID 會是 1。
這種方式很適合目前用陣列練習,但正式專案通常不會自己掃過所有資料再尋找最大的 ID。
真正接上資料庫之後,常見做法之一是讓資料庫自己產生流水號 ID,例如 PostgreSQL 可以使用 Identity 或 Sequence。
概念上可以寫成:
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY
新增資料時,就不需要自己提供 ID:
INSERT INTO notes (title, content)
VALUES ('學習 Node.js', '今天練習 Express');
資料庫會負責產生下一個值,因此不需要在 Node.js 中使用 Math.max() 自己計算,而且資料庫也有相應機制處理多個 Request 同時新增資料的情況。
另一種正式專案也很常見的方式是 UUID,全名為 Universally Unique Identifier,產生的 ID 可能像:
550e8400-e29b-41d4-a716-446655440000
Node.js 可以使用內建的 crypto.randomUUID():
const crypto = require("crypto");
const id = crypto.randomUUID();
UUID 不需要知道目前最大的 ID,也不依賴上一筆資料,因此不同 Server 或服務可以各自產生識別碼,而產生相同 UUID 的機率也非常低。
目前可以先簡單區分:
最大 ID + 1
→ 目前使用陣列練習時的處理方式
資料庫 Identity / Sequence
→ 正式專案常見的數字流水號
UUID
→ 正式專案常見的另一種唯一識別方式
這一篇主要補上一篇 CRUD 中已經使用、但還沒有仔細拆開說明的 Request 資料來源,req.params 適合取得 URL 路徑中的動態資料,req.query 常用來接收搜尋、篩選、排序與分頁條件,而 req.body 則用來取得 Client 傳送的主要內容,Headers 則負責攜帶 Content-Type、Authorization 等附加資訊。
理解這些資料放在哪裡之後,也就能進一步替 GET /notes 加入搜尋功能,而上一篇留下的 ID 問題則可以先用「最大 ID + 1」處理,等之後接上資料庫,再交給 Identity、Sequence 或 UUID 等更正式的方式管理。