Day6 - 建立 HTTP API

HTTP API

前言

接下來這一篇我們將會來嘗試用純 Node.js 建立 HTTP API,基本上會使用到前面的知識點,所以前面的許多知識點都非常的重要唷~

建立 HTTP API

開始之前你可以會想說什麼是 API(Application Programming Interface,應用程式介面),API 就是…

被打

由於這個解釋下去可能會花太多時間,所以我想提供我之前寫的文章讓你自行去閱讀:

因為這一篇我比較想要偏實作方面,因此就不花太多時間解釋了,接下來就讓我們準備一步步來建立 HTTP API 吧!

建立專案

首先請你打開終端機並輸入以下指令建立一個新專案

1
mkdir http-api-example
1
cd http-api-example

接下來請記住一個流程,只要你開始一個新的專案的時候,起手式必定會有以下流程:

  • 初始化 git
  • 初始化 npm

那麼接下來就讓我們來執行這兩個流程吧!

1
git init
1
npm init -y

接下來我們就可以準備開始撰寫我們的程式碼囉。

Note
git init 的資料夾 .git 預設是隱藏的,如果你想要看到的話可以在終端機輸入 ls -a,這個指令可以顯示所有的檔案,包含隱藏的檔案。

那麼由於我這是一個示範範例,所以不會針對 http-api-example 資料夾做 git init 初始化,因此你在練習的時候要多加注意一下,如果你有使用 git init 初始化的話,那麼請記得建立 .gitignore 檔案,並且將 node_modules 加入忽略清單,這樣才不會將 node_modules 加入到 git 版本控制中。

Note
別忘了在 package.json 中的 scripts 屬性增加 "start": "node src/main.js" 指令,這樣才能夠透過 npm start 來執行程式碼;運作 npm start 請記得務必執行 npm install 安裝相關套件。

撰寫程式碼

接下來請你輸入以下指令建立相關資料夾

1
mkdir src
1
touch src/main.js

接下來呢?很簡單,我們將前面我們所撰寫的範例程式碼貼到 src/main.js 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const http = require('node:http');

const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello, World!\n');
});

server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});

接下來我們要利用這一份範例程式碼來建立我們的 HTTP API,我們主要會需要 Get、Post、Put、Delete 四種方法,因此我們就來一個一個實作吧!

Get

首先我們先來實作 Get 方法,我們先來看一下 Get 方法的範例程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
const http = require('node:http');

// ...省略其他程式碼

const server = http.createServer((req, res) => {
if (req.method === 'GET') {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ message: 'Hello, World!' }));
}
});

// ...省略其他程式碼

我想你應該已經很熟悉了,所以我就不多說明了,接下來你就可以使用 Postman 來測試一下了,你可以使用以下網址來測試

Note
如果你不熟悉 Postman 的話,可以參考我先前寫的「跟著我一起快速入門 Postman 吧!」,這篇文章會教你如何使用 Postman 來測試 API,實戰上也會很常使用到 Postman。

1
http://localhost:3000

不出意外的話,你會得到以下結果

1
2
3
{
"message": "Hello, World!"
}

這樣子你就成功了。

那麼我們做了什麼事情呢?我們前面有解釋到 reqres 個別代表著「請求(Request)」與「回應(Response)」,而 req.method 就是代表著使用者請求的 HTTP 方法,因此這一段的程式碼白話文就是…

「使用者發出請求,如果請求的方法是 Get 的話,就回應一個狀態碼 200,並且回應一個 JSON 物件,裡面有一個 message 屬性,值為 Hello, World!

接著這裡有幾個核心重點,你會看到原本的 res.setHeader 被改成了 res.setHeader('Content-Type', 'application/json'),這是因為我們要回應的是 JSON 物件,因此我們要告訴瀏覽器這是一個 JSON 物件,這樣瀏覽器才會正確的解析。

接著你會看到 res.end(JSON.stringify({ message: 'Hello, World!' })),雖然我們前面有告知瀏覽器我們即將回傳一個 application/json 的格式,但也不代表我們可以直接將 JavaScript 物件直接傳遞給 res.end,JavaScript 的物件並不是一個字串,因此我們必須透過 JSON.stringify 來將 JavaScript 物件轉換成字串,這樣才能夠正確的回傳給瀏覽器。

Post

基本上 Post 就是 Get 的延伸,因此我們只需要將 Get 的程式碼稍微修改一下就可以了,但通常 Post 我們會需要接收資料,通常可能會這樣傳送…

1
2
3
{
"data": "Ray"
}

因此範例程式碼會稍微有一點不一樣

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
const http = require('node:http');

// ...省略其他程式碼

const server = http.createServer((req, res) => {
if (req.method === 'GET') {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ message: 'Hello, World!' }));
}

if (req.method === 'POST') {
let body = '';

req.on('data', (chunk) => {
body += chunk.toString();
});

req.on('end', () => {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ message: `新增成功, ${JSON.parse(body).data}!` }));
});
}
});

// ...省略其他程式碼

接下來,你一樣可以透過 Postman 嘗試戳看看,我這邊就不特別示範並截圖了,基本上如果你傳送的資料是以下的話

1
2
3
{
"data": "Ray"
}

那麼你就會得到以下結果:

1
2
3
{
"message": "Hello, Ray!"
}

req.method 這邊基本上就不用花太多時間解釋了,我相信聰明伶俐的你一定已經懂了!

聰明的你

所以我只會針對 req.on('data', (chunk) => {...})req.on('end', () => {...}) 這兩個部分來做解釋。

首先 req.on 是什麼呢?簡單來講就是監聽事件,這邊我們監聽了 dataend 兩個事件,當 data 事件被觸發時,就會執行 req.on('data', (chunk) => {...}) 中的程式碼,而 end 事件被觸發時,就會執行 req.on('end', () => {...}) 中的程式碼。

你可以把它看成前端開發上的 DOM 事件監聽

1
2
3
4
5
const button = document.querySelector('button');

button.addEventListener('click', () => {
// ...
});

當使用者點擊按鈕時,就會執行 button.addEventListener('click', () => {...}) 中的程式碼,這樣的概念。

那麼 req.on('data', (chunk) => {...}) 是在幹嘛呢?由於我們的使用者會傳送資料過來,因此我們要去監聽「請求數據流」的事件。當有資料傳送過來時,就會執行 req.on('data', (chunk) => {...}) 中的程式碼,而 chunk 就是傳送過來的資料片段,但是這邊有一個問題,因為資料可能會很大,因此可能會分成好幾個 chunk 傳送過來,因此我們要將這些 chunk 串接起來,這樣才能夠正確的解析資料,因此我們使用了 body += chunk.toString() 來串接資料。

Note
如果你嘗試直接將 chunk 輸出的話,你會發現它是一個 Buffer,可能會長這樣子 <Buffer 7b 0a 20 20 20 20 22 64 61 74 61 22 3a 20 22 48 65 6c 6c 6f 22 0a 7d>,因此我們才會需要透過 chunk.toString() 將它轉換成字串。

最後當所有數據處理完畢時,就會觸發結束事件(end),這時候我們就可以將結果回傳給使用者了,就是這麼的簡單(?)

Note
Buffer 是 Node.js 用來處理二進位(01)的資料類別,在瀏覽器則是 ArrayBuffer

Put & Delete

基本上你掌握了 Post 的話,Put 與 Delete 就不會覺得很難了,因為是大同小異的,所以這邊我就直接提供 Put 與 Delete 的範例程式碼讓你參考

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
const http = require('node:http');

// ...省略其他程式碼

const server = http.createServer((req, res) => {
if (req.method === 'GET') {
// ...省略其他程式碼
}

if (req.method === 'POST') {
// ...省略其他程式碼
}

if (req.method === 'PUT') {
let body = '';

req.on('data', (chunk) => {
body += chunk.toString();
});

req.on('end', () => {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ message: `更新成功, ${JSON.parse(body).data}!` }));
});
}

if (req.method === 'DELETE') {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ message: '刪除成功' }));
}
});

// ...省略其他程式碼

恭喜,基本上你已經完成了一個簡單的 HTTP API 了,雖然只是一個簡單的示範範例,但也足夠讓你了解到 HTTP API 的運作方式了。

Note
請記得,修改程式碼後都要中斷程式碼,並重新執行 npm start 才能夠看到修改後的結果。

那麼你可能會想說…

「實戰呢?難道後端開發 API 都是這樣子嗎?」

當然不是,這邊我只是想要讓你了解到 HTTP API 的運作方式,因此才會使用純 Node.js 來實作,但實際上我們在開發後端 API 時,通常都會使用框架來開發,例如:Express、Koa、Fastify 等等,這些框架都會幫我們處理很多事情,例如:路由、錯誤處理、資料驗證等等,因此我們可以專注在開發業務邏輯上,而不用花太多時間在處理這些事情上。

路由

前面我們可以發現,我們在打 Postman 的時候都是打 http://localhost:3000,但我們實際在開發時,往往其實不會只有這樣子,而是會有很多不同的路由,例如:http://localhost:3000/usershttp://localhost:3000/users/1http://localhost:3000/users/1/posts 等等,這些都是不同的路由,因此接下來我們就要來實作路由囉~

首先這邊的範例我們將會以 TodoList 作為示範,因此我們會有以下幾個路由

  • GET /todos:取得所有的 Todo
  • POST /todos:新增一個 Todo
  • PUT /todos/:id:更新特定的 Todo
  • DELETE /todos/:id:刪除特定的 Todo

那麼我並不會全部都介紹完畢,因為部分的觀念其實都是一樣的,因此我只會針對 GET /todosPOST /todos 這兩個路由做介紹,其他的路由你可以嘗試自己實作看看,也算是我留給你的小功課。

而這邊的範例將會把資料暫存在記憶體中,因此當你關閉伺服器時,資料就會消失,這邊我們只是想要讓你了解到路由的運作方式,因此就不會使用資料庫了。

Note
什麼是記憶體呢?你可以把記憶體想像成你把東西寫在紙上,當你把電腦關閉時,你就等於把紙丟進垃圾桶內,因此你的資料就會消失,如果你把紙收藏到抽屜內,那麼你的資料就會永久保存,這就是記憶體與資料庫的簡單比喻。

在前面的時候我們都是使用 req.method 來判斷使用者的 HTTP 方法,但如果要做到路由判斷的話,那麼我們就必須要搭配 req.url 來使用。

那麼我們就先來看一下如果我們今天打 Get http://localhost:3000/todos 的話,會發生什麼事情

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const http = require('node:http');

const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
console.log(req.method, req.url);
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello, World!\n');
});

server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});

不出意外的話,你應該是可以得到 POST /todo 這樣子的訊息

API

因此我們可以知道 req.url 會回傳 /todos,因此我們就可以透過 req.url 來判斷路由了。

首先我們要先宣告一個 data 變數作為一個暫存區,當使用者新增一個 Todo 時,我們就會將資料存入 data 變數中,當使用者取得所有 Todo 時,我們就會將 data 變數中的資料回傳給使用者,這樣就可以達到暫存資料的目的了。

1
2
3
4
5
6
7
8
const http = require('node:http');

const hostname = '127.0.0.1';
const port = 3000;

let data = [];

// ...省略其他程式碼

接下來就稍微特別一點了,我們會宣告一個 routers 物件,這個物件會與我們定義的路由相對應,當使用者發出請求時,我們就會去 routers 物件中尋找對應的路由,如果有找到的話,就會執行對應的程式碼,如果沒有找到的話,就會回傳 404 狀態碼給使用者。

1
2
3
4
5
6
7
8
const http = require('node:http');

const hostname = '127.0.0.1';
const port = 3000;

let data = [];

const routers = {};

接下來該怎麼寫呢?其實很簡單,就只需要 routers + [HTTP 方法] + [路由] 就可以了,例如:routers['GET/todos'],這樣就可以了,這邊我先示範寫一個簡單的 GET /todos 路由

1
2
3
4
5
6
7
8
9
const http = require('node:http');

// ...省略其他程式碼

routers['GET/todos'] = (req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(data));
};

接下來 http.createServer 的部分就會變成這樣子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const http = require('node:http');

// ...省略其他程式碼

routers['GET/todos'] = (req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(data));
};

const server = http.createServer((req, res) => {
const handler = routers[`${req.method}${req.url}`] || ((req, res) => {
res.statusCode = 404;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ message: 'Not Found' }));
});

handler(req, res);
});

// ...省略其他程式碼

我們可以看到我們宣告了一個 handler 函式,這個函式會去 routers 物件中尋找對應的路由,如果有找到的話,就會執行對應的程式碼,如果沒有找到的話,就會回傳 404 狀態碼給使用者。

如果你嘗試戳 http://localhost:3000/todos 的話,你應該會得到以下結果

1
[]

如果戳 http://localhost:3000/todo 的話,你應該會得到以下結果

1
2
3
{
"message": "Not Found"
}

超簡單的對吧?那麼 Post 也是依樣畫葫蘆,我們可以拿前面所寫的範本直接改一下就可以了

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
const http = require('node:http');

// ...省略其他程式碼

routers['GET/todos'] = (req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(data));
};

routers['POST/todos'] = (req, res) => {
let body = '';

req.on('data', (chunk) => {
body += chunk.toString();
});

req.on('end', () => {
const cacheBody = JSON.parse(body);
data.push({
id: new Date().getTime(),
title: cacheBody.title,
completed: cacheBody.completed,
});
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ message: '新增成功' }));
});
};

// ...省略其他程式碼

這樣子就完成了,你可以嘗試戳 Post http://localhost:3000/todos 新增之後,接著你就可以戳 Get http://localhost:3000/todos 來取得所有的 Todo 了。

而後面的 Put 與 Delete 也是類似的,我這邊就不額外示範了,就當作留給你的一個小功課吧!

這一篇也差不多了,我們下一篇見囉~

Note
如果你修改過程都要一直重新啟動伺服器太麻煩的話,可以考慮使用 nodemon 來幫你自動重新啟動伺服器,使用後請記得將 package.json 中的 scripts 屬性改成 "start": "nodemon src/main.js"

碎碎念

本來碎碎念想寫點什麼,但突然想想還是不要寫好了,可是當我要寫的時候,又覺得還是算了,所以今天就先這樣吧。