###### tags: `Back-End` `Express` `Middleware` # [week 17] 後端中階 - Express 中不可或缺的拼圖:淺談 Middleware > 本篇為 [[BE201] 後端中階:Express 與 Sequelize](https://lidemy.com/p/be201-express-sequelize) 這門課程的學習筆記。如有錯誤歡迎指正! ``` 學習目標: P1 學習如何使用 Express 及其相關套件 P1 我理解為什麼會需要框架 ``` --- ## Middleware 中間介 在上一篇筆記 [後端中階 - Node.js + Express 框架:建立一個靜態網頁](https://hackmd.io/@Heidi-Liu/note-be201-express-node),我們學到如何透過 Node.js 搭配 Express 框架,來快速建立一個靜態網頁。並瞭解到什麼是 MVC 架構,以及如何串接 MySQL 資料庫。 而 Express 的核心,其實就是由 Routing(路由系統)和 Middleware(中間介)兩個部分所組成。 也就是說,Express 會根據定義不同路由來執行接收到的 request,過程中會透過一連串的 middleware 處理,執行到最後產生 response。 ![](https://i.imgur.com/uvrlRi6.png) 接下來我們會針對 Middleware 的部分做介紹。 ### 什麼是 Middleware? 在 Express 開發框架中,middleware 扮演資料庫與應用程式之間的溝通橋樑,透過不同類別的 middleware,依照需求對資料進行不同處裡,讓資料傳遞更加便利。 例如先前範例中的 `app.get('/todos', todoController.getAll)`,其實就可以看做是一個 middleware。 我們可透過 middleware function 傳入三個參數,然後輸出想要的資料: - 第一個參數是 request - 第二個參數是 response - 再透過第三個參數 next 把控制權轉移到下一個 middleware 舉個簡單的例子,在之前實作 todolist 的 index.js 中加入 `app.use()`,代表整個程式都能使用這個 middleware: ```javascript= const express = require('express'); const db = require('./db') const app = express(); const port = 5002; const todoController = require('./controllers/todo') app.set('view engine', 'ejs') // app.use(): 代表整個程式都能使用這個 middleware app.use((req, res) => { console.log('Time: ', new Date()) res.end() }) app.get('/todos', todoController.getAll) app.get('/todos/:id', todoController.get) app.listen(port, () => { db.connect() console.log(`Example app listening at http://localhost:${port}`) }) ``` 重整瀏覽器頁面時,會發現畫面什麼東西都沒有: ![](https://i.imgur.com/N6etIbh.png) 但是在 CLI 介面會印出執行結果,每重整一次畫面就會執行 log 一次: ![](https://i.imgur.com/J3r3CtM.png) 之所以沒有得到 response,是因為沒有加入第三個參數,也就是呼叫 next 把控制權轉移到下一個 middleware。可以把程式碼修改如下: ```javascript= app.use((req, res, next) => { console.log('Time: ', new Date()) next() // 呼叫 next 把控制權轉移到下一個 middleware }) ``` 重整頁面後,就能看到經渲染過的畫面,同時 CLI 介面上也會印出接收 request 的時間: ![](https://i.imgur.com/9vEZvbU.png) 這其實就是一個簡單的 middleware 應用。那這個機制實際上在 Express 有哪些用處呢?比如說,在 Express 程式中,並沒有內建解析透過 post method 的 request body、管理 session 機制等功能,就必須透過 middleware 來實現。 此外,middleware 處理是有順序性的。以一個簡單的權限管理機制為範例,例如網址列上必須有 `admin` 才能顯示頁面: #### 方法一:最直覺的作法 這個方法是透過 Express 內建的 `.query` 語法,來拿到網址列上的參數。 直接在兩個 Controllers 都加上 checkPermission() 進行權限驗證: ```javascript= const todoModel = require('../models/todo') function checkPermission(req) { // 利用 .query 能夠拿到網址列上的參數 return req.query.admin === '1'; } const todoController = { getAll: (req, res) => { // 如果驗證失敗就結束 request if (!checkPermission(req)) return res.end(); todoModel.getAll((err, results) => { if (err) return console.log(err); res.render('todos', { todos: results }) }) }, get: (req, res) => { // 如果驗證失敗就結束 request if (!checkPermission(req)) return res.end(); const id = req.params.id todoModel.get(id, (err, results) => { if (err) return console.log(err); res.render('todo', { todo: results[0] }) }) } } module.exports = todoController ``` 回到瀏覽器,會發現必須網址列加上 `?admin=1` 參數才能讀取畫面: ![](https://i.imgur.com/rJV2764.png) 這樣就完成簡單的權限驗證機制,但這其實不是一個好做法,一旦 function 變多就會不易管理。這種情況就是 middleware 登場的時候了! #### 方法二:透過 middleware 機制 在 index.js 加上 app.use(),並傳入 next 參數,若網址列通過驗證就會把控制權傳下去: ```javascript= app.use((req, res, next) => { // 如果網址列通過驗證,就把控制權傳下去 if (req.query.admin === '1') { next(); } else { // 不通過的話就顯示 Error res.end('Error') } }) ``` 執行結果如下: ![](https://i.imgur.com/WkQUoAq.png) 這其實就是 middleware 的作用,相較於方法一,我們能透過 middleware 來簡化程式碼。 此外,我們也能改寫上述程式碼,獨立出 checkPermission() 這個 function 來進行驗證: ```javascript= function checkPermission(req, res, next) { if (req.query.admin === '1') { next(); } else { res.end('Error') } } app.use(checkPermission) ``` 這種寫法的好處,在於我們可以針對不同路由進行處理。例如加在 `/todos` 時,就只有這個路由會被影響: ```javascript= app.get('/todos', checkPermission, todoController.getAll) ``` 在 `/todos` 這個路由,必須加上 `?admin=1` 才能顯示畫面: ![](https://i.imgur.com/38VqxUf.png) 但是 `/todos/:id` 這個路由不會受到影響,因為沒有加上 checkPermission() 這個 middleware: ![](https://i.imgur.com/wqOkqgg.png) 在上一篇筆記的 todolist 範例中,之所以沒有寫到 next 來轉移控制權,是因為處理完就回傳 response 資料,既然不會用到 next 這個參數,就可省略宣告。 ## body-parser:用來解析 HTTP Request 接著要來介紹 body-parser 這個很常使用到的 middleware,使用方法可參考 GitHub 上 [expressjs/body-parser](https://github.com/expressjs/body-parser#readme) 的範例。 body-parser 是一個用來解析解析 HTTP Request 的中間介。前面有提到說,我透過 Express 內建的語法,我們只能拿到 query string,因此只適用於 GET method,但若是 POST method 就必須透過 middleware 才能拿到 request body。 ### 安裝 body-parser ``` $ npm install body-parser ``` ![](https://i.imgur.com/4q8sKI0.png) ### body-parser 語法 body-parser 根據不同語法,能夠處理下列幾種格式資料: - bodyParser.urlencoded() - 處理 UTF-8 編碼的資料,常見的表單(form)提交 - 例如:application/x-www-form-urlencoded - bodyParser.json() - 處理 JSON 格式的資料 - 例如:application/json - bodyParser.text() - 處理 type 為 text 的資料 - 例如:text/html, text/css - bodyParser.raw() - 處理 type 為 application 的資料 - 例如:application/pdf, application/zip 程式碼範例如下: ```javascript= app.use(bodyParser.urlencoded({ extended: false })) app.use(bodyParser.json()) ``` ### 實作新增 todo 功能 同樣以之前的 todolist 範例,繼續實作新增 todo 功能: 1. 在 index.js 引入 `body-parser` 套件,就可以使用 bodyParser() 處理 Request。接著在根目錄新增一個處理 addTodo 的路由: ```javascript= const express = require('express'); // 記得要引入 body-parser 才能使用 const bodyParser = require('body-parser') const db = require('./db') const app = express(); const port = 5002; const todoController = require('./controllers/todo') app.set('view engine', 'ejs') // 處理 UTF-8 編碼的資料 app.use(bodyParser.urlencoded({ extended: false })) // 處理 json 資料 app.use(bodyParser.json()) app.get('/todos', todoController.getAll) app.get('/todos/:id', todoController.get) // 在根目錄新增一個 addTodo 路由 app.get('/', todoController.addTodo) app.listen(port, () => { db.connect() console.log(`Example app listening at http://localhost:${port}`) }) ``` 2. 在 Controllers 新增一個處理 addTodo 的 Controller,須注意這裡只負責 render 渲染頁面,而不是真的處理新增 todo 動作: ```javascript= const todoModel = require('../models/todo') const todoController = { getAll: (req, res) => { todoModel.getAll((err, results) => { if (err) return console.log(err); res.render('todos', { todos: results }) }) }, get: (req, res) => { const id = req.params.id todoModel.get(id, (err, results) => { if (err) return console.log(err); res.render('todo', { todo: results[0] }) }) } // 這裡只負責 render 頁面,並不是真的處理新增 todo addTodo: (req, res) => { res.render('addTodo') } } module.exports = todoController ``` 3. 接著要實作 addTodo 的 view 部分,也就是新增一個 addTodo.ejs 檔: ```javascript= <h1>Add Todo</h1> <form method="POST"" action="/todos"> Content: <input type="text" name="content" /> <input type="submit" /> </form> ``` 執行後可在瀏覽器確認是否有畫面: ![](https://i.imgur.com/scuiz9Q.png) 這時如果點選提交,會跳轉到錯誤頁面,這是因為還沒有處理路由: ![](https://i.imgur.com/9xs6k0Y.png) 4. 回到 index.js 新增一個處理 newTodo 的路由: ```javascript= // 新增一個處理 newTodo 的路由 app.post('/todos', todoController.newTodo) app.get('/todos', todoController.getAll) app.get('/todos/:id', todoController.get) // 新增一個處裡 addTodo 的路由 app.get('/', todoController.addTodo) ``` 5. 接著同樣新增一個處理 newTodo 的 Controller,確認是否有成功拿到表單資料: ```javascript= newTodo: (req, res) => { // 透過 body-parser 解析 resquest body 來拿取 content const content = req.body.content // 先輸出確認是否有拿到資料 res.end(content) }, ``` 在瀏覽器提交表單,確認有拿到資料: ![](https://i.imgur.com/EQIScXT.png) 之所以能夠拿到表單提交的資料,是透過 body-parser 這個中間介解析 resquest body,才能拿取 content,否則程式會因為無法解析而出現錯誤。 6. 接著繼續修改 newTodo Controller,把資料交給 Model 處理: ```javascript= newTodo: (req, res) => { // 透過 body-parser 解析 resquest body 來拿取 content const content = req.body.content todoModel.add(content, (err) => { if (err) return console.log(err); // 重新導回 todos 頁面 res.redirect('/todos'); }) }, ``` 7. 再來是處理 todoModel.add() 的部分,也就是在 Model 新增一個 add 功能: ```javascript= // 新增 todoModel.add() add: (content, cb) => { db.query( 'INSERT INTO todos(content) VALUES(?)', [content], (err, results) => { if (err) return cb(err); cb(null) } ); } ``` 回到瀏覽器確認是否能夠新增 todo: ![](https://i.imgur.com/Y9fPgmN.png) 這樣就完成一個簡單的 Back-end 專案了!並且有 MVC 架構,也就是 View 顯示畫面,Model 處理資料,Controller 藉由不同路由接收 requset,會執行相對應的 method;還有透過 body-parser 這個 middleware 處理 POST 表單提交的資料。 ## express-session:負責管理 session 再來介紹 Express 框架中,用來管理 session 的中間介:express-session,使用方法可參考 GitHub 的 [expressjs/session](https://github.com/expressjs/session) 頁面。 ### 安裝 express-session ``` $ npm install express-session ``` ![](https://i.imgur.com/jWw5ZYd.png) ### 實作簡易登入功能 接著延續前面的 todolist 範例,實作一個簡單的登入功能。 1. 在 index.js引入 express-session 套件: ```javascript= // 引入 express-session const session = require('express-session') ``` 2. 接著設定 app 載入模組 express-session: ```javascript= // 在 app.js 中設定載入模組 express-session app.use(session({ secret: 'keyboard cat', resave: false, saveUninitialized: true })) ``` 3. 實作 login、提交表單、logout 的路由,須注意是 req.session,request 才有 session: ```javascript= // 實作 login 路由 app.get('/login', (req, res) => { res.render('login') }) // login 提交表單的路由 app.post('/login', (req, res) => { if (req.body.password === 'abc') { // 注意是 request 才有 session req.session.isLogin = true // 成功就導回首頁;失敗則導回上一頁 res.redirect('/') } else { res.redirect('/login') } }) // 實作 logout 路由 app.get('/logout', (req, res) => { req.session.isLogin = false res.redirect('/') }) ``` 4. 設定 Controller 部分,在 addTo 首頁加上 isLogin 參數,用來判別是否登入: ```javascript= addTodo: (req, res) => { res.render('addTodo', { isLogin: req.session.isLogin }) } ``` 5. 設定 view 部分,在 addTodo.ejs 頁面顯示是否登入,增加連結導向 login 頁面或 logout: ```javascript= <h1>Add Todo</h1> <% if(isLogin) { %> 已經登入 <a href="/logout">登出</a> <% } else { %> 請先登入 <a href="/login">登入</a> <% } %> <form method="POST"" action="/todos"> Content: <input type="text" name="content" /> <input type="submit" /> </form> ``` 6. 新增 login.ejs 頁面,能夠輸入密碼提交表單: ```javascript= <h1>Login</h1> <form method="POST" action="/login"> Password: <input type="password" name="password" /> <input type="submit" /> </form> ``` 執行結果: ![](https://i.imgur.com/3RZUKgn.png) 這樣就透過 express-session 中間介提供的功能,完成簡單的登入登出功能。 但這種寫法其實會遇到一個問題,也就是每個 render 的頁面都要加上 isLogin 判斷登入狀態,我們再來要介紹的中間介就可以解決這個問題。 ## connect-flash:顯示錯誤訊息 藉由 connect-flash 提供的 flash message 功能,我們就能在頁面顯示錯誤訊息等等,其實這背後的機制就是透過 session,能夠和 express-session 搭配使用。 使用方法可參考 GitHub 的 [jaredhanson/connect-flash](https://github.com/jaredhanson/connect-flash) 頁面。 ### 安裝 connect-flash ``` $ npm install connect-flash ``` ### 實作錯誤顯示功能 1. 在 index.js引入 connect-flash 套件: ```javascript= // 引入 connect-flash const flash = require('connect-flash'); ``` 2. 設定 app 載入 flash 模組: ```javascript= app.use(flash()) ``` 3. 在 login 路由使用 flash(),傳入的兩個參數分別代表 key: value: ```javascript= app.get('/login', (req, res) => { res.render('login', { // 從 flash() 中拿取 errorMessage 這個 key 的 value errorMessage: req.flash('errorMessage') }) }) app.post('/login', (req, res) => { if (req.body.password === 'abc') { req.session.isLogin = true res.redirect('/') } else { // falsh() 要傳入兩個參數,代表 key: value req.flash('errorMessage', 'Please input the correct password.') res.redirect('/login') } }) ``` 4. 設定 login.ejs,當登入失敗就會在畫面顯示 errorMessage: ```javascript= <h1>Login</h1> <h2><%= errorMessage %></h2> <form method="POST" action="/login"> Password: <input type="password" name="password" /> <input type="submit" /> </form> ``` 執行結果如下,當提交錯誤時會顯示 errorMessage,重整頁面後就會消失,這就是 flash 的功用: ![](https://i.imgur.com/OepYfsy.png) 但這種寫法其實還是不夠簡潔,如果要判斷輸出錯誤都還是要向 isLogin 那樣加上 errorMessage。 ### 重構程式碼:透過 res.locals 傳值給 view 其實在 express 中有個捷徑,我們可以自己新增 middleware。也就是把東西存放在 `res.locals` ,view 就可以直接從 locals 存取使用,可想像成全域變數的感覺: ```javascript= // 透過 locals 傳值給 view: session 功能和 errorMessage app.use((req, res, next) => { res.locals.isLogin = req.session.isLogin res.locals.errorMessage = req.flash('errorMessage') // 記得加上 next() 把控制權轉移到下一個中間介 next() }) // 就不需在路由加上 errorMessage app.get('/login', (req, res) => { res.render('login') }) ``` addTodo 的 Controller 也可以改回原本的: ```javascript= addTodo: (req, res) => { res.render('addTodo') } ``` 修改完成之後,同樣能夠執行程式,透過範例整理兩個重點: - 透過 req.flash() 可實作出 errorMessage - 透過 res.locals 可傳值給 view 使用,通常會用在驗證功能或是顯示 errorMessage --- ## 結語 透過上述範例,我們能夠得知在使用 Express 框架實作網頁時,大致上會依照下方流程進行: 1. 思考產品全貌:網頁外觀、需要哪些功能等等 2. 規劃資料庫結構 3. 載入需要的模組,設定 app 路由部分 4. 依照 MVC 架構撰寫程式碼: - 設定 Controller:針對不同路由進行控制 - 設定 Model:如何處理資料 - 設定 View:如何呈現畫面 在接下來的課程,我們會綜合之前所學的知識,來實作簡單的會員註冊系統以及留言版功能。 參考資料: - [「筆記」- 何謂 Middleware?如何幫助我們建立 Express 的應用程式](https://medium.com/pierceshih/%E7%AD%86%E8%A8%98-%E4%BD%95%E8%AC%82-middleware-%E5%A6%82%E4%BD%95%E5%B9%AB%E5%8A%A9%E6%88%91%E5%80%91%E5%BB%BA%E7%AB%8B-express-%E7%9A%84%E6%87%89%E7%94%A8%E7%A8%8B%E5%BC%8F-19082b1d8e06) - [bodyParser中间件的研究](https://segmentfault.com/a/1190000004407008)