0%

[week 17] 後端中階 - Express 中不可或缺的拼圖:淺談 Middleware

本篇為 [BE201] 後端中階:Express 與 Sequelize 這門課程的學習筆記。如有錯誤歡迎指正!

學習目標:

 P1 學習如何使用 Express 及其相關套件
 P1 我理解為什麼會需要框架

Middleware 中間介

在上一篇筆記 後端中階 - Node.js + Express 框架:建立一個靜態網頁,我們學到如何透過 Node.js 搭配 Express 框架,來快速建立一個靜態網頁。並瞭解到什麼是 MVC 架構,以及如何串接 MySQL 資料庫。

而 Express 的核心,其實就是由 Routing(路由系統)和 Middleware(中間介)兩個部分所組成。

也就是說,Express 會根據定義不同路由來執行接收到的 request,過程中會透過一連串的 middleware 處理,執行到最後產生 response。

接下來我們會針對 Middleware 的部分做介紹。

什麼是 Middleware?

在 Express 開發框架中,middleware 扮演資料庫與應用程式之間的溝通橋樑,透過不同類別的 middleware,依照需求對資料進行不同處裡,讓資料傳遞更加便利。

例如先前範例中的 app.get('/todos', todoController.getAll),其實就可以看做是一個 middleware。

我們可透過 middleware function 傳入三個參數,然後輸出想要的資料:

  • 第一個參數是 request
  • 第二個參數是 response
  • 再透過第三個參數 next 把控制權轉移到下一個 middleware

舉個簡單的例子,在之前實作 todolist 的 index.js 中加入 app.use(),代表整個程式都能使用這個 middleware:

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}`)
}) 

重整瀏覽器頁面時,會發現畫面什麼東西都沒有:

但是在 CLI 介面會印出執行結果,每重整一次畫面就會執行 log 一次:

之所以沒有得到 response,是因為沒有加入第三個參數,也就是呼叫 next 把控制權轉移到下一個 middleware。可以把程式碼修改如下:

app.use((req, res, next) => {
  console.log('Time: ', new Date())
  next()  // 呼叫 next 把控制權轉移到下一個 middleware
})

重整頁面後,就能看到經渲染過的畫面,同時 CLI 介面上也會印出接收 request 的時間:

這其實就是一個簡單的 middleware 應用。那這個機制實際上在 Express 有哪些用處呢?比如說,在 Express 程式中,並沒有內建解析透過 post method 的 request body、管理 session 機制等功能,就必須透過 middleware 來實現。

此外,middleware 處理是有順序性的。以一個簡單的權限管理機制為範例,例如網址列上必須有 admin 才能顯示頁面:

方法一:最直覺的作法

這個方法是透過 Express 內建的 .query 語法,來拿到網址列上的參數。

直接在兩個 Controllers 都加上 checkPermission() 進行權限驗證:

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 參數才能讀取畫面:

這樣就完成簡單的權限驗證機制,但這其實不是一個好做法,一旦 function 變多就會不易管理。這種情況就是 middleware 登場的時候了!

方法二:透過 middleware 機制

在 index.js 加上 app.use(),並傳入 next 參數,若網址列通過驗證就會把控制權傳下去:

app.use((req, res, next) => {
  // 如果網址列通過驗證,就把控制權傳下去
  if (req.query.admin === '1') {
    next();
  } else {
    // 不通過的話就顯示 Error
    res.end('Error')
  }
})

執行結果如下:

這其實就是 middleware 的作用,相較於方法一,我們能透過 middleware 來簡化程式碼。

此外,我們也能改寫上述程式碼,獨立出 checkPermission() 這個 function 來進行驗證:

function checkPermission(req, res, next) {
  if (req.query.admin === '1') {
    next();
  } else {
    res.end('Error')
  }
}

app.use(checkPermission)

這種寫法的好處,在於我們可以針對不同路由進行處理。例如加在 /todos 時,就只有這個路由會被影響:

app.get('/todos', checkPermission, todoController.getAll)

/todos 這個路由,必須加上 ?admin=1 才能顯示畫面:

但是 /todos/:id 這個路由不會受到影響,因為沒有加上 checkPermission() 這個 middleware:

在上一篇筆記的 todolist 範例中,之所以沒有寫到 next 來轉移控制權,是因為處理完就回傳 response 資料,既然不會用到 next 這個參數,就可省略宣告。

body-parser:用來解析 HTTP Request

接著要來介紹 body-parser 這個很常使用到的 middleware,使用方法可參考 GitHub 上 expressjs/body-parser 的範例。

body-parser 是一個用來解析解析 HTTP Request 的中間介。前面有提到說,我透過 Express 內建的語法,我們只能拿到 query string,因此只適用於 GET method,但若是 POST method 就必須透過 middleware 才能拿到 request body。

安裝 body-parser

$ npm install body-parser

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

程式碼範例如下:

app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json())

實作新增 todo 功能

同樣以之前的 todolist 範例,繼續實作新增 todo 功能:

  1. 在 index.js 引入 body-parser 套件,就可以使用 bodyParser() 處理 Request。接著在根目錄新增一個處理 addTodo 的路由:
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}`)
})
  1. 在 Controllers 新增一個處理 addTodo 的 Controller,須注意這裡只負責 render 渲染頁面,而不是真的處理新增 todo 動作:
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
  1. 接著要實作 addTodo 的 view 部分,也就是新增一個 addTodo.ejs 檔:
<h1>Add Todo</h1>

<form method="POST"" action="/todos">
  Content: <input type="text" name="content" />
  <input type="submit" />
</form>

執行後可在瀏覽器確認是否有畫面:

這時如果點選提交,會跳轉到錯誤頁面,這是因為還沒有處理路由:

  1. 回到 index.js 新增一個處理 newTodo 的路由:
// 新增一個處理 newTodo 的路由
app.post('/todos', todoController.newTodo)
app.get('/todos', todoController.getAll)
app.get('/todos/:id', todoController.get)
// 新增一個處裡 addTodo 的路由
app.get('/', todoController.addTodo)
  1. 接著同樣新增一個處理 newTodo 的 Controller,確認是否有成功拿到表單資料:
newTodo: (req, res) => {
  // 透過 body-parser 解析 resquest body 來拿取 content
  const content = req.body.content
  // 先輸出確認是否有拿到資料
  res.end(content)
},

在瀏覽器提交表單,確認有拿到資料:

之所以能夠拿到表單提交的資料,是透過 body-parser 這個中間介解析 resquest body,才能拿取 content,否則程式會因為無法解析而出現錯誤。

  1. 接著繼續修改 newTodo Controller,把資料交給 Model 處理:
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');
  })
},
  1. 再來是處理 todoModel.add() 的部分,也就是在 Model 新增一個 add 功能:
// 新增 todoModel.add()
add: (content, cb) => {
  db.query(
    'INSERT INTO todos(content) VALUES(?)', [content], (err, results) => {
      if (err) return cb(err);
      cb(null)
    }
  );
}

回到瀏覽器確認是否能夠新增 todo:

這樣就完成一個簡單的 Back-end 專案了!並且有 MVC 架構,也就是 View 顯示畫面,Model 處理資料,Controller 藉由不同路由接收 requset,會執行相對應的 method;還有透過 body-parser 這個 middleware 處理 POST 表單提交的資料。

express-session:負責管理 session

再來介紹 Express 框架中,用來管理 session 的中間介:express-session,使用方法可參考 GitHub 的 expressjs/session 頁面。

安裝 express-session

$ npm install express-session

實作簡易登入功能

接著延續前面的 todolist 範例,實作一個簡單的登入功能。

  1. 在 index.js引入 express-session 套件:
// 引入 express-session
const session = require('express-session')
  1. 接著設定 app 載入模組 express-session:
// 在 app.js 中設定載入模組 express-session
app.use(session({
  secret: 'keyboard cat',
  resave: false,
  saveUninitialized: true
}))
  1. 實作 login、提交表單、logout 的路由,須注意是 req.session,request 才有 session:
// 實作 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('/')
})
  1. 設定 Controller 部分,在 addTo 首頁加上 isLogin 參數,用來判別是否登入:
addTodo: (req, res) => {
  res.render('addTodo', {
    isLogin: req.session.isLogin
  })
}
  1. 設定 view 部分,在 addTodo.ejs 頁面顯示是否登入,增加連結導向 login 頁面或 logout:
<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>
  1. 新增 login.ejs 頁面,能夠輸入密碼提交表單:
<h1>Login</h1>

<form method="POST" action="/login">
  Password: <input type="password" name="password" />
  <input type="submit" />
</form>

執行結果:

這樣就透過 express-session 中間介提供的功能,完成簡單的登入登出功能。

但這種寫法其實會遇到一個問題,也就是每個 render 的頁面都要加上 isLogin 判斷登入狀態,我們再來要介紹的中間介就可以解決這個問題。

connect-flash:顯示錯誤訊息

藉由 connect-flash 提供的 flash message 功能,我們就能在頁面顯示錯誤訊息等等,其實這背後的機制就是透過 session,能夠和 express-session 搭配使用。

使用方法可參考 GitHub 的 jaredhanson/connect-flash 頁面。

安裝 connect-flash

$ npm install connect-flash

實作錯誤顯示功能

  1. 在 index.js引入 connect-flash 套件:
// 引入 connect-flash
const flash = require('connect-flash');
  1. 設定 app 載入 flash 模組:
app.use(flash())
  1. 在 login 路由使用 flash(),傳入的兩個參數分別代表 key: value:
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')
  }
})
  1. 設定 login.ejs,當登入失敗就會在畫面顯示 errorMessage:
<h1>Login</h1>

<h2><%= errorMessage %></h2>

<form method="POST" action="/login">
  Password: <input type="password" name="password" />
  <input type="submit" />
</form>

執行結果如下,當提交錯誤時會顯示 errorMessage,重整頁面後就會消失,這就是 flash 的功用:

但這種寫法其實還是不夠簡潔,如果要判斷輸出錯誤都還是要向 isLogin 那樣加上 errorMessage。

重構程式碼:透過 res.locals 傳值給 view

其實在 express 中有個捷徑,我們可以自己新增 middleware。也就是把東西存放在 res.locals ,view 就可以直接從 locals 存取使用,可想像成全域變數的感覺:

// 透過 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 也可以改回原本的:

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 的應用程式
  • bodyParser中间件的研究