第五部分:後端 Node.js 與 Socket.IO

第五部分:後端 Node.js 與 Socket.IO
📅 發布時間: 2024-06-23 🏷️ 標籤: Node.js, Express, MongoDB, Socket.IO, RESTful API

介紹 Node.js 後端開發基礎,包括環境設置、模組系統、Express 框架建構 RESTful API、MongoDB 資料庫整合,以及 Socket.IO 實現即時通訊。


Node.js 基本介紹

什麼是 Node.js?

JavaScript 一直以來都只能在瀏覽器上運行
在過去他並不像 C、C++、Python 能獨立在你電腦上運行

這導致他無法讀取、修改你電腦上的檔案
進行更複雜的操作

而 Node.js 就是為了打破這個窘境,他允許你在你的電腦直接執行 JavaScript
這使得 JavaScript 變得異常強大,能做出其他程式語言能做的事情

Node.js 的優勢

  • 速度快:Node.js 可以很快地處理很多事情。
  • 模組系統:Node.js 有很多模組,就像樂高積木一樣,可以讓我們組裝出各種不同的功能。

Node.js 的應用場景

  • 即時應用:例如聊天應用和即時遊戲,Node.js 可以讓我們很快地傳遞訊息。
  • API 伺服器:我們可以用 Node.js 來建立 API,讓不同的應用程式互相溝通。
安裝與配置 Node.js 環境

就像我們要用新的玩具,需要先安裝它們一樣,我們也需要安裝 Node.js。

  1. 下載 Node.js:從 Node.js 的官網(Node.js 官網)下載對應自己電腦的版本。
  2. 安裝 Node.js:打開下載的安裝包,按照提示一步步安裝。安裝完成後,在命令行中輸入 node -v,如果顯示版本號,說明安裝成功。

NPM

NodeJs 最強大的莫過於 npm 這個巨大的套件管理系統
你可以把她想像成他是一個程式庫商店
任何的人,都可以將他寫好的程式碼上架上去

而今天如果你需要的功能,剛好別人已經寫好了
擬就可以直接下載下來,直接使用

俗話說的好,輪子不要重頭造
要善用前人寫好的工具,人類的科技疊代才有意義

NPM 除了提供程式庫商店這個大平台以外,他還會協助你管理你下載下來的套件
甚至可以幫你做一些簡單的專案設定


💻 Node.js 專案初始化與模組系統

🎯 學習目標:初始化 Node.js 專案並建立基本檔案理解模組系統,使用 export/import 共享程式碼引入 Node.js 原生模組如 http 建立簡單伺服器

步驟 1: 建立新專案

npm init -y

步驟 2: 建立一個 index.js 檔案

console.log("Hello World");

步驟 3: Node.js 模組系統

function add(a, b) { // 【重點】:定義加法函數,用來示範將程式碼拆分成獨立模組,提高程式可重用性和維護性
    return a + b; // 【重點】:執行加法運算並回傳結果,模組化讓此邏輯可獨立測試與分享
} // 【重點】:完成函數定義,此設計讓程式碼模組化,避免重複撰寫

步驟 4: 共享程式碼(export)

// ... (前面的程式碼保持不變)

export function add(a, b) { // [修改] 【重點】:使用 export 關鍵字,讓函數可被其他檔案導入使用,因為 Node.js 模組系統需要明確宣告共享才能跨檔案存取
    return a + b;
}

步驟 5: 導入模組(import)

// ... (前面的程式碼保持不變)

import { add } from "./add.js"; // 【重點】:透過 import 從 add.js 導入函數,因為模組系統要求明確指定來源路徑,確保依賴正確載入

console.log(add(1, 2)); // 3 // 【重點】:呼叫導入的函數執行運算並輸出結果,驗證模組共享成功

步驟 6: 使用 Node.js 原生模組

import http from "http"; // [新增] 【重點】:引入 Node.js 內建 http 模組,因為它是核心功能,無需額外安裝即可建立伺服器

// 我們等等會來解釋這段程式碼的功能
const server = http.createServer((req, res) => {
  res.statusCode = 200; // 你
  res.setHeader("Content-Type", "text/plain");
  res.end("Hello World\n");
});

server.listen(3000, () => {
  console.log("伺服器運行在 http://127.0.0.1:3000/");
});

Node.js HTTP 伺服器的基本概念

HTTP 伺服器就像是一個專門接收客人訂單的櫃檯。

以上的程式碼為例子就像是開了一家簡單的餐廳,每當有客人來訪(發送請求),我們都會回應他們一句 "Hello, World!"。

RESTful 架構設計

什麼是 RESTful 架構?

為了管理好我們的餐廳,我們設計了ㄧ個設計準則來管理我們的餐廳

想像我們的餐廳不只賣一種食物,我們有菜單(資源),客人可以對菜單上的食物進行不同的操作(CRUD:建立、讀取、更新、刪除)。

RESTful API 的基礎操作

  • 建立(Create):客人點了一道新菜(POST)
  • 讀取(Read):客人查看菜單上的菜(GET)
  • 更新(Update):客人修改點的菜(PUT)
  • 刪除(Delete):客人取消點的菜(DELETE)

基本上,每一次客人來訪時,都會很明確的聲明目的地

舉例來講,當你用瀏覽器打開某個網站,好比說 https://www.google.com/

其實你就是使用瀏覽器對 https://www.google.com/ 這個網址發起了一次 GET 請求

而 Google 的伺服器在收到你的請求後,將整個網站 HTML 傳給你

你就能看到他的網站

Express 套件

Nodejs 有一個別人寫好的套件, Express.js

他可以更簡單地去設定當客人傳什麼請求時,就做什麼事情

我們直接來看看吧


💻 Express 框架建構 RESTful API

🎯 學習目標:安裝並引入 Express,取代原生 HTTP 伺服器定義 GET/POST 等路由,處理不同 HTTP 方法整合 CORS 和 JSON 解析,實現貓咪資料 CRUD

步驟 1: 安裝 Express

npm install express  // 【重點】:透過 npm 安裝 Express,因為它是第三方套件,用來簡化路由與請求處理,避免原生 HTTP 的繁瑣設定

步驟 2: 引入 Express 並註解原生 HTTP

import http from "http";
import express from "express"; // [新增] 【重點】:引入 Express,因為它提供更高階的抽象,讓伺服器建立與路由定義更簡潔

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

// server.listen(3000, () => {
//   console.log("伺服器運行在 http://127.0.0.1:3000/");
// });

步驟 3: 使用 Express 建立伺服器

// ... (前面的程式碼保持不變)

const app = express(); // [新增] 【重點】:建立 Express 應用實例,因為它是路由與中間件的容器,簡化伺服器邏輯

app.get('/', (req, res) => { // [新增] 【重點】:定義根路由 GET,因為 RESTful 設計中根路徑常用於首頁回應
  res.send('Hello, World!'); // 【重點】:直接回傳文字,因為 Express 的 res.send 自動處理狀態碼與內容類型
}); // 【重點】:結束路由定義,因為每個 app.method 即獨立處理特定路徑與方法

app.listen(3000, () => { // [新增] 【重點】:啟動伺服器監聽,因為 Express 的 app 本身可作為伺服器,無需額外 http.createServer
  console.log("伺服器運行在 http://127.0.0.1:3000/"); // 【重點】:輸出監聽資訊,因為確認伺服器就緒後才處理請求
}); // 【重點】:結束監聽設定,因為這是 Express 標準啟動方式

步驟 4: 比較原生 HTTP 與 Express

// ... (前面的程式碼保持不變,除了註解部分)

// const server = http.createServer((req, res) => { // [修改] 【重點】:註解原生 HTTP,因為 Express 已取代,提供更簡潔語法
//   res.statusCode = 200; // 你 // 【重點】:原生需手動設狀態碼,因為無內建簡化方法
//   res.setHeader("Content-Type", "text/plain"); // 【重點】:原生需手動設標頭,因為需明確指定回應格式
//   res.end("Hello World\n"); // 【重點】:原生結束回應,因為流程較繁瑣
// }); // 【重點】:註解結束,因為 Express 更易讀

// server.listen(3000, () => { // [修改] 【重點】:註解原生監聽,因為 Express app.listen 更整合
//   console.log("伺服器運行在 http://127.0.0.1:3000/"); // 【重點】:相同輸出,但 Express 更簡短
// }); // 【重點】:完全取代,因為功能相同但程式碼更少

步驟 5: 定義根路由 GET

// ... (前面的程式碼保持不變)

app.get('/', (req, res) => { // 【重點】:根路由 GET,因為它對應瀏覽器預設存取,示範基本回應
  res.send('Hello, World!'); // 【重點】:簡易回傳,因為 res.send 自動處理常見回應細節
}); // 【重點】:路由結束,因為 Express 支援鏈式定義多路由

步驟 6: 新增 /cat 路由 GET

// ... (前面的程式碼保持不變)

app.get('/cat', (req, res) => { // [新增] 【重點】:新增 /cat GET 路由,因為 RESTful 中 GET 用於讀取資源(如查詢貓咪)
  res.send('Cat is Cute!'); // 【重點】:回傳特定訊息,因為路徑決定回應內容
}); // 【重點】:路由獨立定義,因為每個路徑對應不同邏輯

步驟 7: 新增 /cat 路由 POST

// ... (前面的程式碼保持不變)

app.post('/cat', (req, res) => { // [新增] 【重點】:新增 /cat POST 路由,因為 RESTful 中 POST 用於建立新資源(如新增貓咪)
  res.send('Create A Cat!'); // 【重點】:模擬建立回應,因為後續會擴充實際邏輯
}); // 【重點】:區分 HTTP 方法,因為同路徑不同方法處理不同操作

步驟 8: 準備貓咪資料儲存

// ... (前面的程式碼保持不變,移除不相關路由)

let cats = []; // [新增] 【重點】:使用陣列儲存貓咪,因為示範簡單記憶體狀態管理,後續替換為資料庫

app.get('/cat', (req, res) => { // 【重點】:GET 路由準備,因為將回傳陣列內容
    /** 回傳貓貓的資料 */ // 【重點】:註解說明目的,因為讀取操作需查詢狀態
}); // 【重點】:保持結構,因為逐步實作

app.post('/cat', (req, res) => { // 【重點】:POST 路由準備,因為將接收並儲存新資料
    /** 接收貓貓的資料 */ // 【重點】:註解接收邏輯,因為建立需解析請求體
}); // 【重點】:對稱設計,因為 CRUD 操作一致

步驟 9: 安裝並使用 CORS

import cors from "cors"; // [新增] 【重點】:引入 CORS 中間件,因為瀏覽器跨域政策阻擋前端請求,CORS 暫時允許所有來源存取

const app = express();
app.use(cors()); // [新增] 【重點】:全域啟用 CORS,因為 Express 中間件順序處理請求,提升前端互動便利

步驟 10: 解析 JSON 請求體

// ... (前面的程式碼保持不變)

app.use(express.json()); // 解析 JSON 格式的請求 // [新增] 【重點】:啟用 JSON 解析中間件,因為前端傳送 JSON 物件,需轉換為 req.body 可存取,提升 API 資料處理

步驟 11: 實作 GET 和 POST 路由

import express from "express"; // [修改] 移除不必要 http
import cors from "cors";

const app = express();
app.use(cors());
app.use(express.json()); // 解析 JSON 格式的請求

// 創建一個陣列來儲存貓貓
let cats = [];

app.get("/cat", (req, res) => { // [新增/修改]
  res.send(JSON.stringify(cats)); // 【重點】:轉 JSON 回傳陣列,因為前端需結構化資料,確保相容性
});

app.post("/cat", (req, res) => { // [新增/修改]
  cats.push(req.body.cat); // 【重點】:推入請求體,因為 req.body 已解析,實現簡單新增
  res.send("successfully add cat"); // 【重點】:確認回應,因為 POST 成功需回饋狀態
});

步驟 12: 前端發送 POST 請求範例

// 這裡是前端的程式碼
const response = await fetch("http://127.0.0.1:3001/cat", {
    method: "POST",
    headers: {
        'Content-Type': 'application/json' // 告訴 express,我送過去的資料格式,是 json 格式
    },
    body: JSON.stringify({cat:'test'}), // 請注意,送過去的資料必須先透過 JSON.stringify 轉成字串才能傳送
    
}) // 以 GET 方式發送請求

const data = await response.text(); // 將取得的資料以純文字解析
console.log(data) // 印出來

步驟 13: 前端 POST 新增貓咪

// ... (前面的程式碼保持不變,此為相同範例強調 POST 使用)

MongoDB 簡介

什麼是 MongoDB?

MongoDB 是一種 NoSQL 資料庫。可以想像成一個巨大的數位檔案櫃,我們可以把資料(例如菜單上的菜)存儲在其中,並且隨時讀取、更新或刪除。

當中最大的特色便是他的資料是永久儲存

不會因為電腦關機、程市關閉資料就消失

安裝與設置

首先,我們需要安裝 MongoDB。可以從 MongoDB 官網 下載並安裝。安裝完後,可以用以下指令來啟動 MongoDB


💻 MongoDB 與 Mongoose 整合

🎯 學習目標:安裝 Mongoose 並連接到 MongoDB定義 Schema 與 Model,實現貓咪 CRUD 操作替換記憶體陣列為持久化資料庫

步驟 1: 安裝 Mongoose

npm install mongoose

步驟 2: 連接到 MongoDB

import mongoose from 'mongoose'; // [新增]

mongoose.connect('mongodb://localhost:27017/restaurant', {
  useNewUrlParser: true,
  useUnifiedTopology: true // 現代連接選項
});

const db = mongoose.connection;
db.on('error', console.error.bind(console, 'MongoDB 連接錯誤:'));
db.once('open', () => {
  console.log('已成功連接到 MongoDB');
});

步驟 3: 定義資料模型

// ... (前面的連接程式碼保持不變)

const catSchema = new mongoose.Schema({ // 【重點】:定義 Schema,因為它規定資料結構(如貓咪的 name/age),確保資料一致性與驗證
  name: String, // 【重點】:name 為字串類型,因為貓咪名稱為文字,Schema 強制類型
  age: Number // 【重點】:age 為數字類型,因為年齡需數值運算,防止無效輸入
}); // 【重點】:Schema 結束,因為它是藍圖

const Cat = mongoose.model('Cat', catSchema); // 【重點】:建立 Model,因為它基於 Schema 操作資料庫集合,提供 CRUD 方法

步驟 4: 整合 Express 與 MongoDB CRUD (GET/POST)

import express from 'express';
import cors from 'cors';
import mongoose from 'mongoose'; // [新增]

const app = express();
app.use(cors());
app.use(express.json()); // 解析 JSON 格式的請求

// 連接到 MongoDB
mongoose.connect('mongodb://localhost:27017/restaurant', {
  useNewUrlParser: true,
  useUnifiedTopology: true
});

const db = mongoose.connection;
db.on('error', console.error.bind(console, 'MongoDB 連接錯誤:'));
db.once('open', () => {
  console.log('已成功連接到 MongoDB');
});

// 定義資料模型
const catSchema = new mongoose.Schema({
  name: String,
  age: Number
});

const Cat = mongoose.model('Cat', catSchema);

// 查看所有貓咪
app.get('/cat', async (req, res) => {
  try {
    const cats = await Cat.find(); // [新增] 使用 Model.find() 查詢所有
    res.json(cats);
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
});

// 新增一隻貓咪
app.post('/cat', async (req, res) => {
  const newCat = new Cat({
    name: req.body.name,
    age: req.body.age
  });

  try {
    const savedCat = await newCat.save(); // [新增] 使用 Model.save() 永久儲存
    res.status(201).json(savedCat); // 201 表示資源已創建
  } catch (err) {
    res.status(400).json({ message: err.message });
  }
});

app.listen(3000, () => {
  console.log('伺服器運行在 /');
});

步驟 5: 新增 PUT 更新路由

// ... (前面的程式碼保持不變)

// 修改貓咪的名字
app.put('/cat', async (req, res) => { // [新增] 【重點】:PUT 路由用於更新,因為 RESTful 中 PUT 修改現有資源
  const { originalName, newName } = req.body; // 【重點】:解構請求體,因為需舊名匹配、新名替換,精準定位

  try { // 【重點】:try-catch 處理錯誤,因為 DB 操作非同步可能失敗
    const updatedCat = await Cat.findOneAndUpdate( // 【重點】:使用 findOneAndUpdate,因為原子操作確保一致性,回傳更新後資料
      { name: originalName }, // 【重點】:查詢條件,因為依名稱唯一識別
      { name: newName }, // 【重點】:更新內容,因為僅改名稱
      { new: true } // 【重點】:選項回傳新資料,因為前端需最新狀態
    ); // 【重點】:結束查詢
    if (updatedCat) { // 【重點】:檢查存在,因為無匹配需 404
      res.json(updatedCat); // 【重點】:成功回傳
    } else { // 【重點】:else 分支
      res.status(404).json({ message: '找不到該名稱的貓咪' }); // 【重點】:錯誤回應
    } // 【重點】:條件結束
  } catch (err) { // 【重點】:捕捉異常
    res.status(400).json({ message: err.message });
  } // 【重點】:try 結束
}); // 【重點】:路由結束

步驟 6: 新增 DELETE 刪除路由

// ... (前面的程式碼保持不變)

// 刪除某個名字的貓咪
app.delete('/cat', async (req, res) => { // [新增] 【重點】:DELETE 路由用於刪除,因為 RESTful 中 DELETE 移除資源
  const { name } = req.body; // 【重點】:提取名稱,因為依名稱識別要刪除項目

  try { // 【重點】:錯誤處理,因為刪除也需非同步
    const deletedCat = await Cat.findOneAndDelete({ name: name }); // 【重點】:使用 findOneAndDelete,因為安全移除並回傳被刪資料,確認操作
    if (deletedCat) { // 【重點】:檢查成功
      res.json({ message: `貓咪 ${name} 已成功刪除` }); // 【重點】:確認訊息
    } else { // 【重點】:無匹配
      res.status(404).json({ message: '找不到該名稱的貓咪' });
    } // 【重點】:條件結束
  } catch (err) { // 【重點】:異常捕捉
    res.status(500).json({ message: err.message });
  } // 【重點】:try 結束
}); // 【重點】:完整 CRUD

SocketIO

前面的 HTTP 伺服器其實有個小問題

那就是伺服器只能被動的等待客戶端請求

如果今天有什麼事情要告訴客戶端,那也只能等客戶端來問,伺服器才做回應

所以就有了 WebSocket 這個通訊方法

基本上就是可以做到雙向通訊

SocketIO 便是一個對 WebSocket 的封裝套件,他使得程式碼的撰寫變得更簡單(就如同 express 之於 http 套件一樣)


💻 Socket.IO 即時通訊伺服器

🎯 學習目標:整合 Express 與 Socket.IO 建立雙向通訊處理連接、訊息廣播與斷線事件提供客戶端 HTML 範例實現聊天室

步驟 1: 建立 Socket.IO 伺服器

import express from 'express';
import http from 'http'; // [新增] 需要 http 包裹 Express
import { Server } from 'socket.io'; // [新增]

const app = express();
const server = http.createServer(app); // [新增] http 伺服器包裹 app
const io = new Server(server); // [新增] Socket.IO 實例綁定 server

app.get('/', (req, res) => {
  res.sendFile(__dirname + '/index.html'); // [新增] 提供靜態 HTML
});

io.on('connection', (socket) => { // [新增] 監聽連接事件
  console.log('一位使用者已連接');

  socket.on('chat message', (msg) => { // [新增] 監聽客戶端訊息
    console.log('訊息: ' + msg);
    io.emit('chat message', msg); // [新增] 廣播給所有客戶端,實現即時同步
  });

  socket.on('disconnect', () => { // [新增] 斷線處理
    console.log('一位使用者已離開');
  });
});

server.listen(3000, () => { // [修改] 使用 server.listen
  console.log('伺服器運行在 ');
});

步驟 2: 客戶端 HTML 程式碼

<!DOCTYPE html>
<html>
  <head>
    <title>即時聊天</title>
    <style>
      ul {
        list-style-type: none;
        padding: 0;
      }
      li {
        padding: 8px;
        margin-bottom: 10px;
        background-color: #f3f3f3;
        border-radius: 4px;
      }
      #messages {
        max-width: 300px;
        margin: auto;
      }
      #form {
        display: flex;
        justify-content: center;
        margin-top: 10px;
      }
      #input {
        width: 200px;
        padding: 10px;
      }
      #button {
        padding: 10px;
      }
    </style>
  </head>
  <body>
    <ul id="messages"></ul>
    <form id="form" action="">
      <input id="input" autocomplete="off" /><button id="button">送出</button>
    </form>
    <script src="/socket.io/socket.io.js"></script> <!-- Socket.IO 客戶端庫 -->
    <script>
      var socket = io(); // 連接伺服器
      var form = document.getElementById('form');
      var input = document.getElementById('input');
      var messages = document.getElementById('messages');

      form.addEventListener('submit', function (e) {
        e.preventDefault();
        if (input.value) {
          socket.emit('chat message', input.value); // 發送訊息
          input.value = '';
        }
      });

      socket.on('chat message', function (msg) { // 接收廣播訊息
        var item = document.createElement('li');
        item.textContent = msg;
        messages.appendChild(item);
        window.scrollTo(0, document.body.scrollHeight);
      });
    </script>
  </body>
</html>