一杯茶的時間,上手 Express 框架開發

tuture發表於2020-04-13

Node.js 已經成為 Web 後臺開發圈一股不容忽視的力量,憑藉其良好的非同步效能、豐富的 npm 庫以及 JavaScript 語言方面的優勢,已經成為了很多大公司開發其後臺架構的重要技術之一,而 Express 框架則是其中知名度最高、也是最受歡迎的後端開發框架。在這篇教程中,你將瞭解 Express 在 Node 內建 http 模組的基礎上做了怎樣的封裝,並掌握路由和中介軟體這兩個關鍵概念,學習和使用模板引擎、靜態檔案服務、錯誤處理和 JSON API,最終開發出一個簡單的個人簡歷網站。

此教程屬於 Node.js 後端工程師學習路線的一部分,歡迎來 Star 一波,鼓勵我們繼續創作出更好的教程,持續更新中~。

舊時代:用內建 http 模組實現一個伺服器

自從 Ryan Dahl 在 2009 年的 JSConf 正式推出 Node.js 平臺後,這門技術的使用率就如同坐了火箭一般迅速上升,成為了最受喜愛的後端開發平臺之一,而 Express 則是其中最為耀眼的 Web 框架。在正式開始這篇教程之前,我們將列舉一下這篇教程所需要的預備知識、所用技術和學習目標。

預備知識

本教程假定你已經知道了:

  • JavaScript 語言基礎知識(包括一些常用的 ES6+ 語法)
  • Node.js 基礎知識,特別是非同步程式設計(這篇教程主要用到的是回撥函式)和 Node 模組機制,還有 npm 的基本使用,可以參考這篇教程進行學習
  • HTTP 協議基礎知識,瀏覽器和伺服器之間是如何互動的

所用技術

  • Node.js:8.x 及以上
  • npm:6.x 及以上
  • Express.js:4.x

學習目標

讀完這篇教程後,你將學會

  • Express 框架的兩大核心概念:路由和中介軟體
  • 用 Nodemon 加速開發迭代
  • 使用模板引擎渲染頁面,並接入 Express 框架中
  • 使用 Express 的靜態檔案服務
  • 編寫自定義的錯誤處理函式
  • 實現一個簡單的 JSON API 埠
  • 通過子路由拆分邏輯,實現模組化

注意

雖然資料庫是後端開發中非常重要的環節,但 Express 並不內建處理資料庫的模組,需要額外的第三方庫提供支援。這篇教程將重點放在了 Express 相關的概念講解上,因此不會涉及資料庫的開發。在學完這篇教程後,你可以瀏覽 Express 相關的進階教程

用內建 http 模組建立伺服器

在講解 Express 之前,我們先了解一下怎麼用 Node.js 內建的 http 模組來實現一個伺服器,從而能夠更好地瞭解 Express 對底層的 Node 程式碼做了哪些抽象和封裝。如果你還沒有安裝 Node.js,可以去官方網站下載並安裝。

我們將實現一個個人簡歷網站。建立一個資料夾 express_resume,並進入其中:

mkdir express_resume && cd express_resume

建立 server.js 檔案,程式碼如下:

const http = require('http');

const hostname = 'localhost';
const port = 3000;

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

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

如果你熟悉 Node.js,上面的程式碼含義很清晰:

  1. 匯入 http 模組
  2. 指定伺服器的主機名 hostname 和埠號 port
  3. http.createServer 建立 HTTP 伺服器,引數為一個回撥函式,接受一個請求物件 req 和響應物件 res,並在回撥函式中寫入響應內容(狀態碼 200,型別為 HTML 文件,內容為 Hello World
  4. 在指定的埠開啟伺服器

最後執行 server.js:

node server.js

用瀏覽器開啟 localhost:3000,可以看到 Hello World 的提示:

可以發現,直接用內建的 http 模組去開發伺服器有以下明顯的弊端:

  • 需要寫很多底層程式碼——例如手動指定 HTTP 狀態碼和頭部欄位,最終返回內容。如果我們需要開發更復雜的功能,涉及到多種狀態碼和頭部資訊(例如使用者鑑權),這樣的手動管理模式非常不方便
  • 沒有專門的路由機制——路由是伺服器最重要的功能之一,通過路由才能根據客戶端的不同請求 URL 及 HTTP 方法來返回相應內容。但是上面這段程式碼只能在 http.createServer 的回撥函式中通過判斷請求 req 的內容才能實現路由功能,搭建大型應用時力不從心

由此就引出了 Express 對內建 http 的兩大封裝和改進:

  • 更強大的請求(Request)和響應(Response)物件,新增了很多實用方法
  • 靈活方便的路由的定義與解析,能夠很方便地進行程式碼拆分

接下來,我們將開始用 Express 來開發 Web 伺服器!

新時代:用 Express 搭建伺服器

在第一步中,我們把伺服器放在了一個 JS 檔案中,也就是一個 Node 模組。從現在開始,我們將把這個專案變成一個 npm 專案。輸入以下命令建立 npm 專案:

npm init

接著你可以一路回車下去(當然也可以仔細填),就會發現 package.json 檔案已經建立好了。然後新增 Express 專案依賴:

npm install express

在開始用 Express 改寫上面的伺服器之前,我們先介紹一下上面提到的兩大封裝與改進

更強大的 Request 和 Response 物件

首先是 Request 請求物件,通常我們習慣用 req 變數來表示。下面列舉一些 req 上比較重要的成員(如果不知道是什麼也沒關係哦):

  • req.body:客戶端請求體的資料,可能是表單或 JSON 資料
  • req.params:請求 URI 中的路徑引數
  • req.query:請求 URI 中的查詢引數
  • req.cookies:客戶端的 cookies

然後是 Response 響應物件,通常用 res 變數來表示,可以執行一系列響應操作,例如:

// 傳送一串 HTML 程式碼
res.send('HTML String');

// 傳送一個檔案
res.sendFile('file.zip');

// 渲染一個模板引擎併傳送
res.render('index');

Response 物件上的操作非常豐富,並且還可以鏈式呼叫:

// 設定狀態碼為 404,並返回 Page Not Found 字串
res.status(404).send('Page Not Found');

提示

在這裡我們並沒有簡單地列舉 Request 和 Response 的全部 API ,因為圖雀社群的理念是——從實戰中學習和深化理解,拒絕枯燥的 API 記憶!

路由機制

客戶端(包括 Web 前端、移動端等等)向伺服器發起請求時包括兩個元素:路徑(URI)以及 HTTP 請求方法(包括 GET、POST 等等)。路徑和請求方法合起來一般被稱為 API 端點(Endpoint)。而伺服器根據客戶端訪問的端點選擇相應處理邏輯的機制就叫做路由。

在 Express 中,定義路由只需按下面這樣的形式:

app.METHOD(PATH, HANDLER)

其中:

  • app 就是一個 express 伺服器物件
  • METHOD 可以是任何小寫的 HTTP 請求方法,包括 getpostputdelete 等等
  • PATH 是客戶端訪問的 URI,例如 //about
  • HANDLER 是路由被觸發時的回撥函式,在函式中可以執行相應的業務邏輯

nodemon 加速開發

Nodemon 是一款頗受歡迎的開發伺服器,能夠檢測工作區程式碼的變化,並自動重啟。通過以下命令安裝 nodemon:

npm install nodemon --save-dev

這裡我們將 nodemon 安裝為開發依賴 devDependencies,因為僅僅只有在開發時才需要用到。同時我們在 package.json 中加入 start 命令,程式碼如下:

{
  "name": "express_resume",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "nodemon server.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.1"
  },
  "devDependencies": {
    "nodemon": "^2.0.2"
  }
}

正式實現

到了動手的時候了,我們用 Express 改寫上面的伺服器,程式碼如下:

const express = require('express');

const hostname = 'localhost';
const port = 3000;

const app = express();
app.get('/', (req, res) => {
  res.send('Hello World');
});

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

在上面的程式碼中,我們首先用 express() 函式建立一個 Express 伺服器物件,然後用上面提到的路由定義方法 app.get 定義了主頁 / 的路由,最後同樣呼叫 listen 方法開啟伺服器。

從這一步開始,我們執行 npm start 命令即可開啟伺服器,並且同樣可以看到 Hello World 的內容,但是程式碼卻簡單明瞭了不少。

提示

在執行 npm start 之後,可以讓伺服器一直開啟著,編輯程式碼並儲存後,Nodemon 就會自動重啟伺服器,執行最新的程式碼。

編寫第一個中介軟體

接下來我們開始講解 Express 第二個重要的概念:中介軟體(Middleware)。

理解中介軟體

中介軟體並不是 Express 獨有的概念。相反,它是一種廣為使用的軟體工程概念(甚至已經延伸到了其他行業),是指將具體的業務邏輯和底層邏輯解耦的元件(可檢視這個討論)。換句話說,中介軟體就是能夠適用多個應用場景、可複用性良好的程式碼。

Express 的簡化版中介軟體流程如下圖所示:

首先客戶端向伺服器發起請求,然後伺服器依次執行每個中介軟體,最後到達路由,選擇相應的邏輯來執行。

提示

這個是一個簡化版的流程描述,目的是便於你對中介軟體有個初步的認識,在後面的章節中我們將進一步完善這一流程。

有兩點需要特別注意:

  • 中介軟體是按順序執行的,因此在配置中介軟體時順序非常重要,不能弄錯
  • 中介軟體在執行內部邏輯的時候可以選擇將請求傳遞給下一個中介軟體,也可以直接返回使用者響應

Express 中介軟體的定義

在 Express 中,中介軟體就是一個函式:

function someMiddleware(req, res, next) {
  // 自定義邏輯
  next();
}

三個引數中,reqres 就是前面提到的 Request 請求物件和 Response 響應物件;而 next 函式則用來觸發下一個中介軟體的執行。

注意

如果忘記在中介軟體中呼叫 next 函式,並且又不直接返回響應時,伺服器會直接卡在這個中介軟體不會繼續執行下去哦!

在 Express 使用中介軟體有兩種方式:全域性中介軟體路由中介軟體

全域性中介軟體

通過 app.use 函式就可以註冊中介軟體,並且此中介軟體會在使用者發起任何請求都可能會執行,例如:

app.use(someMiddleware);

路由中介軟體

通過在路由定義時註冊中介軟體,此中介軟體只會在使用者訪問該路由對應的 URI 時執行,例如:

app.get('/middleware', someMiddleware, (req, res) => {
  res.send('Hello World');
});

那麼使用者只有在訪問 /middleware 時,定義的 someMiddleware 中介軟體才會被觸發,訪問其他路徑時不會觸發。

編寫中介軟體

接下來我們就開始實現第一個 Express 中介軟體。功能很簡單,就是在終端列印客戶端的訪問時間、 HTTP 請求方法和 URI,名為 loggingMiddleware。程式碼如下:

// ...

const app = express();

function loggingMiddleware(req, res, next) {
  const time = new Date();
  console.log(`[${time.toLocaleString()}] ${req.method} ${req.url}`);
  next();
}

app.use(loggingMiddleware);

app.get('/', (req, res) => {
  res.send('Hello World');
});

// ...

注意

在中介軟體中寫 console.log 語句是比較糟糕的做法,因為 console.log(包括其他同步的程式碼)都會阻塞 Node.js 的非同步事件迴圈,降低伺服器的吞吐率。在實際生產中,推薦使用第三方優秀的日誌中介軟體,例如 morganwinston 等等。

執行伺服器,然後用瀏覽器嘗試訪問各個路徑。這裡我訪問了首頁(localhost:3000)和 /hellolocalhost:3000/hello,瀏覽器應該看到的是 404),可以看到控制檯相應的輸出:

[11/28/2019, 3:54:05 PM] GET /
[11/28/2019, 3:54:11 PM] GET /hello

這裡為了讓你初步理解中介軟體的概念,我們只實現了一個功能很簡單的中介軟體。實際上,中介軟體不僅可以讀取 req 物件上的各個屬性,還可以新增新的屬性或修改已有的屬性(後面的中介軟體和路由函式都可以獲取),能夠很方便地實現一些複雜的業務邏輯(例如使用者鑑權)。

用模板引擎渲染頁面

最後,我們的網站要開始展示一些實際內容了。Express 對當今主流的模板引擎(例如 Pug、Handlebars、EJS 等等)提供了很好的支援,可以做到兩行程式碼接入。

提示

如果你不瞭解模板引擎,不用擔心,這篇教程幾乎不需要用到它的高階功能,你只需理解成一個“升級版的 HTML 文件”即可。

這篇教程將使用 Handlebars 作為模板引擎。首先新增 npm 包:

npm install hbs

建立 views 資料夾,用於放置所有的模板。然後在其中建立首頁模板 index.hbs,程式碼如下:

<h1>個人簡歷</h1>
<p>我是一隻小小的圖雀,渴望學習技術,磨鍊實戰本領。</p>
<a href="/contact">聯絡方式</a>

建立聯絡頁面模板 contact.hbs,程式碼如下:

<h1>聯絡方式</h1>
<p>QQ1234567</p>
<p>微信:一隻圖雀</p>
<p>郵箱:mrc@tuture.co</p>

最後便是在 server.js 中配置和使用模板。配置模板的程式碼非常簡單:

// 指定模板存放目錄
app.set('views', '/path/to/templates');

// 指定模板引擎為 Handlebars
app.set('view engine', 'hbs');

在使用模板時,只需在路由函式中呼叫 res.render 方法即可:

// 渲染名稱為 hello.hbs 的模板
res.render('hello');

修改後的 server.js 程式碼如下:

// ...

const app = express();

app.set('views', 'views');
app.set('view engine', 'hbs');

// 定義和使用 loggingMiddleware 中介軟體 ...

app.get('/', (req, res) => {
  res.render('index');
});

app.get('/contact', (req, res) => {
  res.render('contact');
})

// ...

注意在上面的程式碼中,我們新增了 GET /contact 的路由定義。

最後,我們再次執行伺服器,訪問我們的主頁,可以看到:

點選”聯絡方式“,跳轉到相應頁面:

新增靜態檔案服務

通常網站需要提供靜態檔案服務,例如圖片、CSS 檔案、JS 檔案等等,而 Express 已經自帶了靜態檔案服務中介軟體 express.static,使用起來非常方便。

例如,我們新增靜態檔案中介軟體如下,並指定靜態資源根目錄為 public

// ...

app.use(express.static('public'));

app.get('/', (req, res) => {
  res.render('index');
});

// ...

假設專案的 public 目錄裡面有這些靜態檔案:

public
├── css
│   └── style.css
└── img
    └── tuture-logo.png

就可以分別通過以下路徑訪問:

http://localhost:3000/css/style.css
http://localhost:3000/img/tuture-logo.png

樣式檔案 public/css/style.css 的程式碼如下(直接複製貼上即可):

body {
  text-align: center;
}

h1 {
  color: blue;
}

img {
  border: 1px dashed grey;
}

a {
  color: blueviolet;
}

圖片檔案可通過這個 GitHub 上的連結下載,然後下載到 public/img 目錄中。當然,你也可以使用自己的圖片,記得在模板中替換相應的連結就可以了。

在首頁模板 views/index.hbs 中加入 CSS 樣式表和圖片:

<link rel="stylesheet" href="/css/style.css" />

<h1>個人簡歷</h1>
<img src="/img/tuture-logo.png" alt="Logo" />
<p>我是一隻小小的圖雀,渴望學習技術,磨鍊實戰本領。</p>
<a href="/contact">聯絡方式</a>

在聯絡模板 views/contact.hbs 中加入樣式表:

<link rel="stylesheet" href="/css/style.css" />

<h1>聯絡方式</h1>
<p>QQ1234567</p>
<p>微信:一隻圖雀</p>
<p>郵箱:mrc@tuture.co</p>

再次執行伺服器,並訪問我們的網站。首頁如下:

聯絡我們頁面如下:

可以看到樣式表和圖片都成功載入出來了!

處理 404 和伺服器錯誤

人有悲歡離合,月有陰晴圓缺,伺服器也有出錯的時候。HTTP 錯誤一般分為兩大類:

  • 客戶端方面的錯誤(狀態碼 4xx),例如訪問了不存在的頁面(404)、許可權不夠(403)等等
  • 伺服器方面的錯誤(狀態碼 5xx),例如伺服器內部出現錯誤(500)或閘道器錯誤(503)等等

如果你開啟伺服器,訪問一個不存在的路徑,例如 localhost:3000/what,就會出現這樣的頁面:

很顯然,這樣的使用者體驗是很糟糕的。

在這一節中,我們將講解如何在 Express 框架中處理 404(頁面不存在)及 500(伺服器內部錯誤)。在此之前,我們要完善一下 Express 中介軟體的運作流程,如下圖所示:

這張示意圖和之前的圖有兩點重大區別:

  • 每個路由定義本質上是一個中介軟體(更準確地說是一個中介軟體容器,可包含多箇中介軟體),當 URI 匹配成功時直接返回響應,匹配失敗時繼續執行下一個路由
  • 每個中介軟體(包括路由)不僅可以呼叫 next 函式向下傳遞、直接返回響應,還可以丟擲異常

從這張圖就可以很清晰地看出怎麼實現 404 和伺服器錯誤的處理了:

  • 對於 404,只需在所有路由之後再加一箇中介軟體,用來接收所有路由均匹配失敗的請求
  • 對於錯誤處理,前面所有中介軟體丟擲異常時都會進入錯誤處理函式,可以使用 Express 自帶的,也可以自定義。

處理 404

在 Express 中,可以通過中介軟體的方式處理訪問不存在的路徑:

app.use('*', (req, res) => {
  // ...
});

* 表示匹配任何路徑。將此中介軟體放在所有路由後面,即可捕獲所有訪問路徑均匹配失敗的請求。

處理內部錯誤

Express 已經自帶了錯誤處理機制,我們先來體驗一下。在 server.js 中新增下面這條”壞掉“的路由(模擬現實中出錯的情形):

app.get('/broken', (req, res) => {
  throw new Error('Broken!');
});

然後開啟伺服器,訪問 localhost:3000/broken

危險!

伺服器直接返回了出錯的呼叫棧!很明顯,向使用者返回這樣的呼叫棧不僅體驗糟糕,而且大大增加了被攻擊的風險。

實際上,Express 的預設錯誤處理機制可以通過設定 NODE_ENV 來進行切換。我們將其設定為生產環境 production,再開啟伺服器。如果你在 Linux、macOS 或 Windows 下的 Git Bash 環境中,可以執行以下命令:

NODE_ENV=production node server.js

如果你在 Windows 下的命令列,執行以下命令:

set NODE_ENV=production
node server.js

這時候訪問 localhost:3000/broken 就會直接返回 Internal Server Error(伺服器內部錯誤),不會顯示任何錯誤資訊:

體驗還是很不好,更理想的情況是能夠返回一個友好的自定義頁面。這可以通過 Express 的自定義錯誤處理函式來解決,錯誤處理函式的形式如下:

function (err, req, res, next) {
  // 處理錯誤邏輯
}

和普通的中介軟體函式相比,多了第一個引數,也就是 err 異常物件。

實現自定義處理邏輯

通過上面的講解,實現自定義的 404 和錯誤處理邏輯也就非常簡單了。在 server.js 所有路由的後面新增如下程式碼:

// 中介軟體和其他路由 ...

app.use('*', (req, res) => {
  res.status(404).render('404', { url: req.originalUrl });
});

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).render('500');
});

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

提示

在編寫處理 404 的邏輯時,我們用到了模板引擎中的變數插值功能。具體而言,在 res.render 方法中將需要傳給模板的資料作為第二個引數(例如這裡的 { url: req.originalUrl } 傳入了使用者訪問的路徑),在模板中就可以通過 {{ url }} 獲取資料了。

404 和 500 的模板程式碼分別如下:

<link rel="stylesheet" href="/css/style.css" />

<h1>找不到你要的頁面了!</h1>
<p>你所訪問的路徑 {{ url }} 不存在</p>
<link rel="stylesheet" href="/css/style.css" />

<h1>伺服器好像開小差了</h1>
<p>過一會兒再試試看吧!See your later~</p>

再次執行伺服器,訪問一個不存在的路徑:

訪問 localhost:3000/broken

體驗很不錯!

三行程式碼實現 JSON API

在這篇教程的最後,我們將實現一個非常簡單的 JSON API。如果你有過其他後端 API 開發(特別是 Java)的經驗,那麼你一定會覺得用 Express 實現一個 JSON API 埠簡單得不可思議。在之前提到的 Response 物件中,Express 為我們封裝了一個 json 方法,直接就可以將一個 JavaScript 物件作為 JSON 資料返回,例如:

res.json({ name: '百萬年薪', price: 996 });

會返回 JSON 資料 { "name": "百萬年薪", "price": 996 },狀態碼預設為 200。我們還可以指定狀態碼,例如:

res.status(502).json({ error: '公司關門了' });

會返回 JSON 資料 { "error": "公司關門了"},狀態碼為 502。

到了動手環節,讓我們在 server.js 中新增一個簡單的 JSON API 埠 /api,返回關於圖雀社群的一些資料:

// ...

app.get('/api', (req, res) => {
  res.json({ name: '圖雀社群', website: 'https://tuture.co' });
});

app.get('/broken', (req, res) => {
  throw new Error('Broken!');
});

// ...

我們可以用瀏覽器訪問 localhost:3000/api 埠,看到返回了想要的資料:

或者你可以用 PostmanCurl 訪問,也能看到想要的資料哦。

使用子路由拆分邏輯

當我們的網站規模越來越大時,把所有程式碼都放在 server.js 中可不是一個好主意。“拆分邏輯”(或者說“模組化”)是最常見的做法,而在 Express 中,我們可以通過子路由 Router 來實現。

const express = require('express');
const router = express.Router();

express.Router 可以理解為一個迷你版的 app 物件,但是它功能完備,同樣支援註冊中介軟體和路由:

// 註冊一箇中介軟體
router.use(someMiddleware);

// 新增路由
router.get('/hello', helloHandler);
router.post('/world', worldHandler);

最後,由於 Express 中“萬物皆中介軟體”的思想,一個 Router 也作為中介軟體加入到 app 中:

app.use('/say', router);

這樣 router 下的全部路由都會加到 /say 之下,即相當於:

app.get('/say/hello', helloHandler);
app.post('/say/world', worldHandler);

正式實現

到了動手環節,首先建立 routes 目錄,用於存放所有的子路由。建立 routes/index.js 檔案,程式碼如下:

const express = require('express');
const router = express.Router();

router.get('/', (req, res) => {
  res.render('index');
});

router.get('/contact', (req, res) => {
  res.render('contact');
});

module.exports = router;

建立 routes/api.js,程式碼如下:

const express = require('express');
const router = express.Router();

router.get('/', (req, res) => {
  res.json({ name: '圖雀社群', website: 'https://tuture.co' });
});

router.post('/new', (req, res) => {
  res.status(201).json({ msg: '新的篇章,即將開始' });
});

module.exports = router;

最後我們把 server.js 中老的路由定義全部刪掉,替換成剛剛實現的兩個 Router,程式碼如下:

const express = require('express');
const path = require('path');

const indexRouter = require('./routes/index');
const apiRouter = require('./routes/api');

const hostname = 'localhost';
const port = 3000;

const app = express();

// ...
app.use(express.static('public'));

app.use('/', indexRouter);
app.use('/api', apiRouter);

app.use('*', (req, res) => {
  res.status(404).render('404', { url: req.originalUrl });
});

// ...

是不是瞬間清爽了很多呢!如果你伺服器還開著,可以測試一下之前的路由是否還能成功執行哦。這裡我貼一下用 Curl 測試 /api 路由的結果:

$ curl localhost:3000/api
{"name":"圖雀社群","website":"https://tuture.co"}
$ curl -X POST localhost:3000/api/new
{"msg":"新的篇章,即將開始"}

至此,這篇教程也就結束了。所完成的網站的確很簡單,但是希望你能從中學到 Express 的兩大精髓:路由和中介軟體。掌握了這兩大概念之後,後續進階教程的學習也會輕鬆很多哦!

想要學習更多精彩的實戰技術教程?來圖雀社群逛逛吧。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

圖雀社群

相關文章