Back-End
Express
Middleware
本篇為 [BE201] 後端中階:Express 與 Sequelize 這門課程的學習筆記。如有錯誤歡迎指正!
在之前的課程中,我們學到如何使用 Express 這套框架,以及在 Express 扮演重要角色的 Middleware:
接下來要整合我們之前所學的東西,透過串聯資料庫系統,來實作一個簡單的會員註冊系統,並且具有留言版功能的網頁。
在使用 Express 框架實作網頁時,大致上會依照下方流程進行:
接下來我們也同樣會依照這個流程來進行實作。
首先在資料庫新增一個 users Tabel:
可以把 username 設定為 UNIQUE(唯一),只要 username 有重複就會自動跳出錯誤訊息:
接著要來建立 userModel:
const db = require('../db');
const userModel = {
// 新增 user 功能
add: (user, cb) => {
db.query(
'INSERT INTO users(username, password, nickname) VALUES(?, ?, ?)',
[user.username, user.password, user.nickname],
(err, results) => {
if (err) return cb(err);
cb(null);
}
);
},
// 登入 -> 讀取 user 功能
get: (username, cb) => {
db.query(
'SELECT * FROM users WHERE username = ?', [username], (err, results) => {
if (err) return cb(err);
cb(null, results[0]);
});
}
}
module.exports = userModel;
// 引入 userModel
const userModel = require('../models/todo')
const userController = {
get: (req, res) => {
}
}
// 輸出 userController
module.exports = userController
const userController = require('./controllers/user')
app.get('/login', userController.login)
app.post('/login', userController.handleLogin)
app.get('/logout', userController.logout)
// 引入 userModel
const userModel = require('../models/user')
const userController = {
// 渲染登入畫面
login: (req, res) => {
res.render('login')
},
// 驗證登入狀態
handleLogin: (req, res) => {
if (req.body.password) === 'abc' {
req.session.isLogin = true
res.redirect('/')
} else {
req.flash('errorMessage', 'Please input the correct password.')
}
},
// 登出: 清除 session 並導回首頁
logout:: (req, res) => {
req.session.isLogin = false;
res.redirect('/');
}
}
// 輸出 userController
module.exports = userController
// 建立首頁
app.get('/', (req, res) => {
res.render('index')
})
<h1>簡易會員系統</h1>
<a href="/register">註冊</a>
<a href="/login">登入</a>
<a href="/logout">登出</a>
到這裡可先運行程式看看有沒有問題:
<h1>簡易會員系統</h1>
<% if(isLogin) { %>
<a href="/logout">登出</a>
<% } else { %>
<a href="/register">註冊</a>
<a href="/login">登入</a>
<% } %>
這樣在未登入狀態下,就只會看到註冊和登入,這樣就完成了首頁 index 的部分:
app.get('/register', userController.register)
app.post('/register', userController.handleRegister)
// 渲染註冊頁面
register: (req, res) => {
// 把路徑設在 user 資料夾,較方便管理
res.render('user/register');
},
// 驗證註冊
handleRegister: (req, res) => {
},
<h1>註冊頁面</h1>
<h2><%= errorMessage %></h2>
<form method="POST" action="/register">
<div>username: <input type="text" name="username" /></div>
<div>nickname: <input type="text" name="nickname" /></div>
<div>password: <input type="password" name="password" /></div>
<input type="submit" />
</form>
記得每做到一個段落就執行程式看有沒有出錯,這樣我們就完成簡單的註冊頁面。這時按提交還不會有反應:
// 驗證註冊
handleRegister: (req, res) => {
// 從 resquest body 拿取 user 資料
const {username, password, nickname} = req.body;
if (!username || !password || !nickname) {
// 這裡用 return 就可避免 if-else 寫法增加層數
return req.flash('errorMessage', '缺少必要欄位');
}
// 資料都沒問題的話,就可透過 userModel 寫入資料
// 傳入一個物件,若有錯誤會回傳 cb
userModel.add({
username,
nickname,
password
}, (err) => {
if (err) {
// 印出 err
return req.flash('errorMessage', err.toString());
}
}
// 註冊成功就保持登入狀態,並導回首頁
req.session.username = username;
res.redirect('/');
});
},
logout: (req, res) => {
// 登出就把 session 重置
req.session.username = null;
res.redirect('/');
}
// 透過 locals 傳值: session 功能和 errorMessage
app.use((req, res, next) => {
// 有 username 代表有登入狀態
res.locals.username = req.session.username
res.locals.errorMessage = req.flash('errorMessage')
next()
})
<%= %>
會輸出原始碼,相當於做 escpae:
<h1>簡易會員系統</h1>
<!-- 以是否有 username 來判斷登入狀況 -->
<% if(username) { %>
<!-- 使用 <%= %> 會輸出原始碼,相當於做 escpae -->
<div>Hello, <%= username + '!' %></div>
<a href="/logout">登出</a>
<% } else { %>
<a href="/register">註冊</a>
<a href="/login">登入</a>
<% } %>
$ npm install bcrypt
// 引入 bcrypt
const bcrypt = require('bcrypt');
// 加鹽,增加密碼的複雜程度
const saltRounds = 10;
bcrypt.hash(myPlaintextPassword, saltRounds, function(err, hash) {
// Store hash in your password DB.
});
// 驗證註冊
handleRegister: (req, res, next) => {
// 從 resquest body 拿取 user 資料
const {username, password, nickname} = req.body;
if (!username || !password || !nickname) {
// 這裡用 return 就可避免 if-else 寫法增加層數
req.flash('errorMessage', '缺少必要欄位');
return next();
}
// 利用 bcrypt 套件對密碼進行雜湊處理
bcrypt.hash(password, saltRounds, function (err, hash) {
// 若有 err 就直接顯示錯誤訊息
if (err) {
req.flash('errorMessage', err.toString());
return next();
}
// 資料都沒問題的話,就可透過 userModel 寫入資料
userModel.add({
username,
nickname,
password: hash
}, (err) => {
// 若有 err 就直接顯示錯誤訊息
if (err) {
req.flash('errorMessage', '已存在相同用戶名');
return next();
}
// 註冊成功就保持登入狀態,並導回首頁
req.session.username = username;
res.redirect('/');
});
});
},
執行程式並註冊一個帳號,確認資料是否有成功寫進資料庫,密碼也有先經過 hash 才儲存:
這樣就差不多完成了註冊功能:
<h1>登入頁面</h1>
<h2><%= errorMessage %></h2>
<form method="POST" action="/login">
<div>username: <input type="text" name="username" /></div>
<div>password: <input type="password" name="password" /></div>
<input type="submit" />
</form>
// 渲染登入畫面
login: (req, res) => {
res.render('login')
},
// 驗證登入狀態
handleLogin: (req, res, next) => {
const { username, password } = req.body;
// 確認是否有填入資料
if (!username || !password) {
req.flash('errorMessage', '請輸入您的帳密!');
// 每當呼叫 next 時,就會將控制權給下一個中間介 redirectBack(導回上一頁)
return next();
}
// 輸入正確就從 userModel 找出 user 資料
userModel.get(username, (err, user) => {
if (err) {
req.flash('errorMessage', err.toString());
return next();
}
if (!user) {
req.flash('errorMessage', '使用者不存在');
return next();
}
// 驗證密碼是否正確,三個參數代表: 明碼, 雜湊密碼, 方法
bcrypt.compare(password, user.password, function (err, isSccess) {
// 若出現錯誤或比對不成功,就顯示錯誤訊息
if (err || !isSccess) {
req.flash('errorMessage', '密碼錯誤');
return next();
}
req.session.username = user.username;
res.redirect('/')
});
})
},
// 登出: 清除 session 並導回首頁
logout: (req, res) => {
req.session.username = null;
res.redirect('/');
}
// 自己寫的中間介,用來導回上一頁
function redirectBack(req, res, next) {
res.redirect('back')
}
app.get('/register', userController.register)
app.post('/register', userController.handleRegister, redirectBack)
app.get('/login', userController.login)
app.post('/login', userController.handleLogin, redirectBack)
app.get('/logout', userController.logout)
能夠根據未填欄位、或是資料輸入錯誤,顯示相對應的錯誤訊息:
執行程式確認註冊和登入功能都沒問題的話,這樣就完成了簡易的會員註冊系統!藉由 MVC 架構,就能夠很清楚的分工,簡化程式邏輯。
再來要結合會員系統,實作一個有新增功能的簡易留言版。那麼就趕緊開始吧!
在 app database 新增一個 comments table,以 username 欄位和 users table 進行關聯
在首頁 index.ejs 介面新增留言表單,如果是登入狀態,就能看到留言區塊:
<h1>簡易會員系統</h1>
<!-- 以是否有 username 來判斷是否登入 -->
<% if(username) { %>
<div>Hello, <%= username + '!' %></div>
<a href="/logout">登出</a>
<form method="POST" action="/comments">
<textarea name="content" id="" cols="30" rows="10"></textarea>
<input type="submit" />
</form>
<% } else { %>
<a href="/register">註冊</a>
<a href="/login">登入</a>
<% } %>
畫面看起來會是這個樣子:
const commentController = require('./controllers/comment');
// 提交表單來新增 comment,同樣以 redirectBack 來導回上一頁
app.post('/comments', commentController.add, redirectBack)
// 引入 commentModel
const commentModel = require('../models/comment')
const commentController = {
add: (req, res, next) => {
const {username} = req.session
const {content} = req.body
if (!username) {
req.flash('errorMessage', '請先登入');
return next();
}
if (!content) {
req.flash('errorMessage', '缺少必要欄位');
return next();
}
// 若新增失敗就導回首頁
commentModel.add(username, content, err => {
return res.redirect('/');
})
}
}
// 輸出commentController
module.exports = commentController;
const commentModel = {
// 新增 comment 功能
add: (username, content, cb) => {
db.query(
'INSERT INTO comments(username, content) VALUES(?, ?)',
[username, content],
(err, results) => {
if (err) return cb(err);
cb(null);
}
);
}
}
// 讀取 comment 功能
getAll: (cb) => {
// 資料庫關聯
db.query(
`
SELECT U.nickname, C.content FROM comments as C
LEFT JOIN users as U on U.username = C.username
`,
(err, results) => {
if (err) return cb(err);
cb(null, results);
});
}
app.get('/', commentController.index)
// 在 index 頁面讀取資料
index: (req, res) => {
commentModel.getAll((err, results) => {
if (err) {
console.log(err);
}
res.render('index', {
comments: results;
});
});
}
<% comments.forEach(function(comment) { %>
<div>
<h2><%= comment.nickname %> <%= comment.created_at %></h2>
<p><%= comment.content %></p>
</div>
<% }) %>
這樣其實就完成簡易的留言版了!但可以注意到,透過 EJS 語法輸出的時間,和我們預期的 'MMM-DD-YYYY'
格式不太相同:
透過 moment.js 這個套件,我們就能夠制定要輸出的時間日期格式,也能設定不同時區的時間。
npm install moment --save
// 引入 moment 套件
const moment = require('moment');
// 設定時間格式
const shortDateFormat = "YYYY-MM-DD HH:mm:ss";
// 將 moment 和 shortDateFormat 放到 locals 全域環境中
app.locals.moment = moment;
app.locals.shortDateFormat = shortDateFormat;
moment('datetime').format('模版')
,改寫如下:
<% comments.forEach(function(comment) { %>
<div>
<h2><%= comment.nickname %></h2>
<p><%= moment(comment.created_at).format(shortDateFormat) %></p>
<p><%= comment.content %></p>
</div>
<% }) %>
這樣就成功把時間修改成 "YYYY-MM-DD h:mm:ss" 格式了!
找了幾種引用 moment 的寫法,最後覺得這篇:How to use node modules (like MomentJS) in EJS views? 內容蠻符合需要的,也就是要如何透過 node 提供的套件來改變 EJS views,稍微嘗試過後,也成功將時間格式調整成想要的樣子!
在上述範例中,我們在完成了一個有新增功能簡易留言版,接下來我們要試著把系統變得更完整一點。
在開始實作產品內容之前,需要先思考幾個重點:
GET /delete_comments/:id => /
GET /update_comments/:id => form
POST /update_comments/:id => /
規劃好系統功能和路由之後,再來就是規劃資料庫結構,這部分在先前的實作中就完成得差不多了。
在 users 和 comments table 中有個同樣的欄位 username,透過這個外鍵(Foreign Key),我們就能夠進行資料庫關聯,拿到想要的資料形式。
<% comments.forEach(function(comment) { %>
<div>
<h2><%= comment.nickname %></h2>
<p><%= moment(comment.created_at).format(shortDateFormat) %></p>
<p><%= comment.content %></p>
<!-- 判斷使用者的 username 和 comment 的 username 是否相同-->
<% if (username === comment.username) {%>
<!-- 只有本人才會看到刪除鍵 -->
<a href="/delete_comments/<%= comment.id %>">刪除</a>
<% } %>
</div>
<% }) %>
// 讀取 comment 功能
getAll: (cb) => {
// 資料庫關聯
db.query(
`
SELECT U.nickname, U.created_at, C.content, C.id, C.username, C.created_at
FROM comments as C
LEFT JOIN users as U on U.username = C.username
ORDER BY C.created_at DESC
`,
(err, results) => {
if (err) return cb(err);
cb(null, results);
});
}
app.get('/delete_comments/:id', commentController.delete)
delete: (req, res) => {
// 除了網址列上的 id,也需傳入 session 以確認是否為該 comment 作者
commentModel.delete(req.session.username, req.params.id, err => {
res.redirect('/');
})
}
delete: (username, id, cb) => {
db.query(
`
DELETE FROM comments WHERE id=? AND username=?
`, [id, username],
(err, results) => {
if (err) return cb(err);
cb(null);
});
}
記得做好權限管裡,只有該則留言的’作者才能進行刪除:
<!-- 判斷使用者的 username 和 comment 的 username 是否相同-->
<% if (username === comment.username) {%>
<a href="/update_comments/<%= comment.id %>">編輯</a>
<a href="/delete_comments/<%= comment.id %>">刪除</a>
<% } %>
app.post('/comments', commentController.add, redirectBack)
app.get('/delete_comments/:id', commentController.delete)
// 讀取要編輯的 comment
app.get('/update_comments/:id', commentController.update)
// 執行修改 comment
app.post('/update_comments/:id', commentController.handleupdate)
// 讀取相對應 id 的 comment
get: (id, cb) {
// 資料庫關聯
db.query(
`
SELECT U.nickname, U.created_at, C.content, C.id, C.username, C.created_at
FROM comments as C
LEFT JOIN users as U on U.username = C.username
WHERE C.id = ?
`, [id],
(err, results) => {
if (err) return cb(err);
// 如果結果是 undefined 就會傳空物件,可避免程式出現錯誤
cb(null, results[0] || {});
}
);
},
update: (req, res) => {
commentModel.get(req.params.id, (err, result) => {
res.render('update', {
comment: result
});
});
},
<h1>編輯留言</h1>
<a href="/">返回</a>
<!-- 前端驗證 -->
<% if (username === comment.username) { %>
<form method="POST" action="/update_comments/<%= comment.id %>">
<textarea name="content" id="" cols="30" rows="10"></textarea>
<input type="submit" />
</form>
<% } else { %>
<div>ノンノンだよ!你沒有修改權限!</div>
<% } %>
handleUpdate: (req, res) => {
// 後端驗證: 必須是本人才有權限修改
commentModel.update(req.session.username, req.params.id, req.body.content, err => {
res.redirect('/')
});
}
update: (username, id, content, cb) => {
db.query(
`
UPDATE comments SET content=? WHERE id=? AND username=?
`, [content, id, username],
(err, results) => {
if (err) return cb(err);
cb(null);
});
}
這樣就完成具有新增、刪除和編輯功能的留言板了!再來我們要學習如何美化頁面,也就是 EJS 如何修改 template 來改變畫面。
透過 EJS 提供的語法 include()
我們可以建立模版,獨立出每個頁面都有的元素,例如 head 和 navbar 等等,如此就能簡化程式碼,範例如下:
<%- include('header'); -%>
<h1>
Title
</h1>
<p>
My page
</p>
<%- include('footer'); -%>
此外,include() 也可以傳入參數:
<ul>
<% users.forEach(function(user){ %>
<%- include('user/show', {user: user}); %>
<% }); %>
</ul>
認識新語法之後,就來繼續修改畫面吧!
<%- %>
是減號,代表會經過 html 解析:
<html>
<head>
<%- include('template/head') %>
</head>
<h1>簡易會員系統</h1>
<h2><%= errorMessage %></h2>
<% if(username) { %>
<div>Hello, <%= username + '!' %></div>
<a href="/logout">登出</a>
<form method="POST" action="/comments">
<textarea name="content" id="" cols="30" rows="10"></textarea>
<input type="submit" />
</form>
<% } else { %>
<a href="/register">註冊</a>
<a href="/login">登入</a>
<% } %>
<% comments.forEach(function(comment) { %>
<div>
<h2><%= comment.nickname %></h2>
<p class="card-subtitle text-muted"><%= moment(comment.created_at).format(shortDateFormat) %></p>
<p><%= comment.content %></p>
<% if (username === comment.username) {%>
<a href="/update_comments/<%= comment.id %>">編輯</a>
<a href="/delete_comments/<%= comment.id %>">刪除</a>
<% } %>
</div>
<% }) %>
</html>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css"
integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">
成功引入之後,就會發現畫面套用了 Bootstrap 提供的樣式:
<%- include('template/navbar') %>
<nav class="navbar navbar-dark bg-dark">
<a class="navbar-brand" href="/" >這是一個留言版</a>
<div>
<% if(username) { %>
<div class="">
<span class="text-light">Hello, <%= username + '!' %></span>
<a class="btn btn-outline-light my-2 my-sm-0" href="/logout">登出</a>
</div>
<% } else { %>
<a class="btn btn-outline-light my-2 my-sm-0" href="/login">登入</a>
<a class="btn btn-outline-warning my-2 my-sm-0" href="/register">註冊</a>
<% } %>
</div>
</nav>
效果會長這樣:
此外,像 head.ejs、navbar 這些通用模版,可放在 views/template 資料夾統一管理:
<!DOCTYPE html>
<html>
<head>
<%- include('../template/head') %>
</head>
<%- include('../template/navbar') %>
<div class="container">
<!-- 若沒有錯誤會回傳一個空陣列 [] -->
<% if (errorMessage.length > 0) { %>
<div class="alert alert-danger" role="alert">
<%= errorMessage %>
</div>
<% } %>
<form method="POST" action="/register">
<div class="form-group row">
<label class="col-sm-2 col-form-label">Username: </label>
<div class="col-sm-10">
<input class="form-control" name="username">
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label">Nickname: </label>
<div class="col-sm-10">
<input class="form-control" name="nickname">
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label">Password: </label>
<div class="col-sm-10">
<input class="form-control" name="password" type="password">
</div>
</div>
<button type="submit" class="btn btn-dark mb-2">Submit</button>
</form>
</div>
</html>
<!DOCTYPE html>
<html>
<head>
<%- include('../template/head') %>
</head>
<%- include('../template/navbar') %>
<div class="container">
<!-- 若沒有錯誤會回傳一個空陣列 [] -->
<% if (errorMessage.length > 0) { %>
<div class="alert alert-danger" role="alert">
<%= errorMessage %>
</div>
<% } %>
<form method="POST" action="/login">
<div class="form-group row">
<label class="col-sm-2 col-form-label">Username: </label>
<div class="col-sm-10">
<input class="form-control" name="username">
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label">Password: </label>
<div class="col-sm-10">
<input class="form-control" name="password" type="password">
</div>
</div>
<button type="submit" class="btn btn-dark mb-2">Submit</button>
</form>
</div>
</html>
兩者寫法其實差不多,只有欄位和路徑需要修改,畫面效果如下:
<!DOCTYPE html>
<html>
<head>
<%- include('template/head') %>
</head>
<%- include('template/navbar') %>
<div class="container mx-auto mt-3" style="width: 100%;">
<% if (errorMessage.length > 0) { %>
<div class="alert alert-danger" role="alert">
<%= errorMessage %>
</div>
<% } %>
<% if(username) { %>
<form method="POST" action="/comments">
<div class="form-group">
<div class="text-dark">留言內容</div>
<textarea name="content" class="form-control"></textarea>
</div>
<button type="submit" class="btn btn-dark">提交</button>
</form>
<% } else { %>
<div class="alert alert-warning" role="alert">留言請先登入</div>
<% } %>
<div class="card-container d-flex flex-wrap">
<% comments.forEach(function(comment) { %>
<div class="card m-2" style="width: 18rem; height: 16rem;">
<div class="card-header">
<h5 class="card-title"><%= comment.nickname %></h5>
<h6 class="card-subtitle text-muted"><%= moment(comment.created_at).format(shortDateFormat) %></h6>
<% if (username === comment.username) {%>
<a href="/update_comments/<%= comment.id %>" class="card-link">編輯</a>
<a href="/delete_comments/<%= comment.id %>" class="card-link">刪除</a>
<% } %>
</div>
<div class="card-body" style="overflow:scroll; overflow-x:hidden;">
<p class="card-text" ><%= comment.content %></p>
</div>
</div>
<% }) %>
</div>
</div>
</html>
效果如下:
<!DOCTYPE html>
<html>
<head>
<%- include('template/head') %>
</head>
<%- include('template/navbar') %>
<div class="container mx-auto mt-4" style="width: 100%;">
<!-- 若沒有錯誤會回傳一個空陣列 [] -->
<% if (errorMessage.length > 0) { %>
<div class="alert alert-danger" role="alert">
<%= errorMessage %>
</div>
<% } %>
<% if (username === comment.username) { %>
<form method="POST" action="/update_comments/<%= comment.id %>">
<div class="form-group">
<div class="text-dark">編輯留言</div>
<textarea name="content" class="form-control"></textarea>
</div>
<button type="submit" class="btn btn-dark">提交</button>
</form>
<% } else { %>
<div>ノンノンだよ!你沒有修改權限!</div>
<% } %>
</div>
</html>
透過這個範例,我們學到該如何使用 include() 語法來引入模板,如此就能簡化程式碼,將重複使用的區塊給模組化;此外,也透過 BootStrap 提供的現成樣式,讓我們能快速修改頁面,達到想要的畫面效果。
實際跟著範例操作之後,會發現透過 MVC 模式撰寫程式,思考邏輯會很類似。使用 Express 框架實作網頁時,會依照下方流程進行:
也藉這個機會來複習 BootStrap 使用方式,這和之前「先刻版面再加入功能」的模式正好相反,反而是先把功能做好,再套用現有樣式,這也是藉由 MVC 將畫面和功能分割開來才能夠辦到。
在學會如何寫出一個簡單的會員註冊系統和留言版之後,其實就能夠應用到各種網頁,期待之後能夠熟悉 MVC 架構的寫法!