本篇為 [BE201] 後端中階:Express 與 Sequelize 這門課程的學習筆記。如有錯誤歡迎指正!
瞭解如何使用 Express 這套 Web 應用框架之後,再來要介紹新的工具:Sequelize,這是一款基於 Node.js 的非同步 ORM 框架,讓我們能透過 ORM(物件關聯對映)來開發網頁,以物件導向的概念來操作資料庫。
學習目標:
P1 了解什麼是 ORM
P1 了解 ORM 的優缺點
P1 了解什麼是 N+1 problem
P1 我知道如何部署 Node.js 應用程式到 heroku
什麼是 ORM?
前面我們提到 Sequelize 是一款 ORM 框架,那什麼是 ORM?
ORM(Object Relational Mapping),中文為物件關聯對映。是一種將關聯式資料庫(MySQL)映射(mapping)至物件導向(OOP)的資料抽象化技術。
簡單來說,在網站開發 MVC 結構中,ORM 扮演資料庫系統和 Model 資料容器的中間橋梁,讓我們能透過程式語言(JavaScript)去操作資料庫語言(SQL),是實作物件導向概念的一種工具模式。
JavaScript(物件) -> ORM -> SQL(資料)
初探 Sequelize
初步瞭解什麼是 ORM 框架以後,接著我們要來實際操作 Sequelize 這套 Library,詳細可參考 Sequelize 官方文件。
透過 npm 來安裝套件,如果專案設在新開的目錄底下,則需先初始化 npm:
$ npm init
$ npm install --save sequelize
並根據使用的 database 來安裝相關套件,本篇使用 MySQL 作為範例:
# One of the following:
$ npm install --save pg pg-hstore # Postgres
$ npm install --save mysql2
$ npm install --save mariadb
$ npm install --save sqlite3
$ npm install --save tedious # Microsoft SQL Server
連線資料庫
建立 index.js 來引入 sequelize 套件,並透過物件導向概念來連線資料庫:
// 引入 sequelize 套件
const { Sequelize } = require('sequelize');
// 透過 new 建立 Sequelize 這個 class,而 sequelize 就是物件 instance
const sequelize = new Sequelize('database', 'username', 'password', {
host: 'localhost',
dialect: 'mysql'
});
定義物件:Model 模型
由於 ORM 是透過物件與資料庫做連線,物件中的屬性會對映到資料庫欄位,例如資料型態、是否可為空值等等。
透過 sequelize.define
指令可定義 Model 模型:
// 定義一個叫做 User 的資料結構
const User = sequelize.define('User', {
// 定義 Model 屬性
firstName: { // 欄位名稱
type: Sequelize.STRING, // 資料型態
allowNull: false // 能不能為空,預設是 true
},
lastName: {
type: Sequelize.STRING
// allowNull defaults to true
}
}, {
// Other model options go here
});
再來必須呼叫 sequelize.sync()
來執行程式,Sequelize 就會在資料庫建立欄位。而這個指令會回傳一個 promise 物件,需要用 .then()
來接續後面的動作。
這部分可參考之前的學習筆記:[week 13] Fetch & Promise 補充。
.create()
:新增一筆資料
如果要新增資料,就在 .then()
裡面使用 <Table Name>.create()
,傳入欄位名稱和資料內容:
sequelize.sync().then(() => {
// 寫入對映欄位名稱的資料內容
User.create({
// 記得 value 字串要加上引號
firstName: 'Heidi',
lastName:'Liu'
}).then(() => {
// 執行成功後會印出文字
console.log('successfully created!!')
});
});
在 CLI 介面執行後會發現 SQL log:
Sequelize 就會根據 Model 定義,自動在資料庫建立 users table 和一筆資料,並且會自動生成 createdAt 和 updateAt 兩個欄位:
.findAll()
:選取所有資料
如果要選取所有資料,就在 .then()
裡面用 <Table Name>.findAll()
,而 findAll()
會回傳一個 promise,因此也要用 .then()
來接收:
sequelize.sync().then(() => {
User.findAll().then(users => {
// 用 JSON.stringify() 來格式化輸出
console.log("All users:", JSON.stringify(users, null, 4));
});
});
就能拿到 All users 的資料:
若試著印出 user[0]
的資料:
sequelize.sync().then(() => {
User.findAll().then(users => {
// 用 JSON.stringify() 來格式化輸出
console.log(users[0]);
});
});
會得到一個 User 物件,除了資料訊息,也會有一些底線開頭的屬性,像是 Sequelize 設定的資訊:
如果要存取 user 的資料訊息,可直接用 user[0].id
來拿到 id:
sequelize.sync().then(() => {
User.findAll().then(users => {
console.log(users[0].id, users[0].firstName);
});
});
.findAll(where{ }):設定資料條件
如果要指定讀取哪些資料,可在 .findAll()
裡面填入 where{}
這個物件來指定條件。假如在資料庫中找不到相對應的資料,就會回傳錯誤訊息:
sequelize.sync().then(() => {
User.findAll({
where: {
firstName: 'Apple'
}
}).then(users => {
console.log(users[0].id, users[0].firstName);
});
});
印出符合條件的資料:
這種寫法就相當於 SQL 語法的 SELECT * FROM users WHERE firstName = Apple
,更多有關條設定的語法可參考:官方文件 - querying。
.findOne():選取單一資料
如果只想選取單一資料,或是想要透過 id 來讀取資料,可以使用 .findOne()
:
sequelize.sync().then(() => {
User.findOne({
where: {
id: '1'
}
}).then(user => {
console.log(user.firstName);
});
});
// Heidi
成功撈取指定資料之後,就可以對該筆資料進行 CRUD。
.update():更新資料
如果要更新資料,可使用 .then()
接住資料,並在裡面傳入 function,用來執行 user.update()
,把要更新的內容用 {}
大括號包住,再填入 update()
中:
sequelize.sync().then(() => {
User.findOne({
where: {
id: '3'
}
}).then(user => {
// 在 () 裡面用 {} 大括號包住要更新的內容
user.update({
lastName: 'Banana'
});
}).then(() => {
console.log('update done!');
});
});
這樣就成功更新 id 為 3 這筆資料的 lastName:
.destroy():刪除資料
如果要刪除資料,可使用 .then()
接住資料,並在裡面傳入 function,用來執行 user.destroy()
:
sequelize.sync().then(() => {
User.findOne({
where: {
id: '2'
}
}).then(user => {
user.destroy().then(() => {
console.log('destroy done!');
});
});
});
Associations 資料庫關聯
再來我們要學習如何做資料庫關聯,也就是將兩個不同的 table 關聯,例如將 users.id
對應到 comments.userId
。我們在之前課程使用的 SQL 指令,就是透過 user.id
或是 JOIN 等方式來進行資料庫關聯。
而在 ORM 當中,要將兩個 tabel 進行關聯,則需要透過 .hasMany()
、.hasOne
等指令,告訴 Sequelize 執行資料庫關聯,詳細內容可參考官方文件。
如何關聯兩個 table
以 User.hasMany(Comment)
指令來說,意思就是告訴 Sequelize 一個 user 可以有很多 comment,接著就會在 comment table 中加上 userId 來建立關聯:
const User = sequelize.define('user', {
firstName: {
type: Sequelize.STRING,
allowNull: false
},
lastName: {
type: Sequelize.STRING
}
}, {
});
const Comment = sequelize.define('comment', {
content: {
type: Sequelize.STRING,
}
});
User.hasMany(Comment); // 將兩個 table 進行關聯
執行後就會發現建立了 comments table,還多了 userId 這個欄位:
範例:新增留言
接著我們就能對資料庫進行操作,以新建一個 comment 為例,在 Comment 要填入 content 和 userId:
sequelize.sync().then(() => {
Comment.create({
userId: '3',
content: 'Hello!'
}).then(() =>{
console.log('done!')
});
User.findOne({
where: {
firstName: 'Apple'
}
}).then(user => {
});
});
執行成功會印出設定的 done!
:
這樣就成功在 comments table 建立了一則留言:
範例:撈取資料
接著我們就可以利用關聯的 user id 來撈取資料,透過 include:<Table>
這個參數來指定 Model,如果要 include 多個 Model 可透過 Array 傳入:
sequelize.sync().then(() => {
User.findOne({
where: {
firstName: 'Apple'
},
// 新增 include 這個參數來指定 Model
include: Comment
}).then(user => {
console.log(user)
});
});
若把 user 資料印出來,會發現包含很多資訊:
利用 JSON.stringify()
來格式化輸出,即可忽略不需要的資訊:
sequelize.sync().then(() => {
User.findOne({
where: {
firstName: 'Apple'
},
include: Comment
}).then(user => {
console.log(JSON.stringify(user.comments, null, 4));
});
});
就會拿到 userId 為 3 的使用者的所有留言:
而 User.hasMany(Comment);
其實是單向關係,也就是將 User 對 Comment 進行關聯,但沒有說明 Comment 和 User 的關係是什麼。這時如果要從 comment 來查詢 user 的資料,就會出現錯誤訊息。
需要加上 Comment.belongsTo(User)
,透過這段指令將 Comment 對 User 做關聯,建立起雙向關係:
User.hasMany(Comment);
Comment.belongsTo(User);
sequelize.sync().then(() => {
Comment.findOne({
where: {
content: 'Hello!'
},
include: User
}).then(comment => {
console.log(JSON.stringify(comment, null, 4));
});
});
就會拿到有關 comment 的 user 資訊:
Sequelize CLI
在實際開發時,為了讓程式碼更有結構性以及方便管理,可安裝 Sequelize CLI 這套工具,詳細內容可參考官方文件。
安裝並初始化
$ npm install --save sequelize-cli
$ npx sequelize-cli init
完成初始化會建立 config.json 檔案,裡面會有連線資料庫的設定,包含 database 帳密等資訊,因此通常不會加到 commit:
設定連線資料庫
接著可以在 config.json 更改成我們連線資料庫的設定,三種設定分別是在開發環境、測試環境、正式環境。因為我們現在是在 localhost 開發所以要修改 development 的部分:
建立 Model
接著我們可以直接透過 CLI 指令來建立 User 和 Comment 這兩個 Model:
$ npx sequelize-cli model:generate --name User --attributes firstName:string,lastName:string,email:string
$ npx sequelize-cli model:generate --name Comment --attributes content:string
建立好 Model 之後,Sequelize 會自動在 models 和 migrations 資料夾底下建立檔案:
我們可以透過 user.js 和 comment.js 這些檔案設定,來對 model 進行微調:
'use strict';
module.exports = (sequelize, DataTypes) => {
const Comment = sequelize.define('Comment', {
content: DataTypes.STRING
}, {});
Comment.associate = function(models) {
// 在這裡設定資料庫關聯
Comment.belongsTo(Models.User);
};
return Comment;
}
執行 Migrations
但設定完這些檔案之後,資料庫裡面還不會有任何東西。這是因為必須透過執行 Migrations,才會依照我們在 Model 做的設定去操作資料庫:
$ npx sequelize-cli db:migrate
接著就會在資料庫建立 SequelizeMeta table 來記錄執行過的 migrations;並且依照 migrations 中的檔案去操作資料庫,也就是建立 users 和 comments table:
有了 migrations,就不需再透過 sync()
指令來操作資料庫,並且能夠以更結構性的方式進行管理。
透過 Migrations 關聯資料庫
接著在建立好的檔案 user.js 和 comment.js 加上關聯設定,其中 model 通常會以英文大寫開頭:
- user.js
'use strict';
const {
Model
} = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class User extends Model {
static associate(models) {
// 加上關聯資料庫的設定
User.hasMany(models.Comment)
}
};
User.init({
firstName: DataTypes.STRING,
lastName: DataTypes.STRING,
email: DataTypes.STRING
}, {
sequelize,
modelName: 'User',
});
return User;
};
- comment.js
'use strict';
const {
Model
} = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Comment extends Model {
static associate(models) {
// 加上關聯資料庫的設定,存取其他 Model
Comment.belongsTo(models.User);
}
};
Comment.init({
content: DataTypes.STRING
}, {
sequelize,
modelName: 'Comment',
});
return Comment;
};
新增資料
再來回到根目錄的 index.js,即可從 models 引入 db 來使用:
const db = require('./models');
以下列程式碼為例,如此就可以透過 index.js 來操作資料庫:
const User = db.User;
const Comment = db.Comment;
User.create({
firstName: 'Hello',
lastName: 'World'
}).then(() => {
console.log('done!')
});
執行後成功在 User 新增一筆資料:
改造留言板系統
在瞭解到什麼是 Sequelize 和 ORM 之後,接著我們要來改造之前時做的留言版系統,也就是把 Model 部分改用 Sequelize 實作。
前置作業
在 express 專案中引入 Sequelize 套件,並進行初始化:
$ npm install sequelize mysql2
$ npm install sequelize-cli
$ npx sequelize-cli init
接著在建立 Model 之前,可以先清除不必要的檔案和程式碼,例如 db.js、todocontroller、以及原本 models 的部分。
設定連線資料庫
接著修改 config.json 資料庫連線的設定:
"development": {
"username": "root",
"password": null,
"database": "mydb",
"host": "localhost",
"dialect": "mysql"
},
建立 Model
再來要建立 User 和 Comment 兩個 Model:
- 因為已經要把兩個 table 進行關聯,Comment table 就不須再加上 username 欄位
- string 型態預設會建立 VARCHAR,最大長度為 255,因此可以把 content 改用 text
- 在新增之前記得先把舊的同名 table 移除,避免互相干擾
$ npx sequelize-cli model:generate --name User --attributes username:string,password:string,nickname:string
$ npx sequelize-cli model:generate --name Comment --attributes content:text
執行後會在 models 建立檔案:
執行 Migrations
再來執行 Migrations,才會真正把資料寫入資料庫來建立 table:
$ npx sequelize-cli db:migrate
執行後會在資料庫建立 table:
但我們還需要在 comment table 加上 UserId 欄位,這部分要自己手動修改 migrations 中的 create-comment 檔案:
// 新增 UserId 這個欄位,型態是數字
UserId: {
type: Sequelize.INTEGER
}
接著要重跑一次 Migrations,可透過下方
指令來撤銷上一個動作:
// 單次撤銷(最近的一次)
$ npx sequelize-cli db:migrate:undo
// 撤銷所有
$ npx sequelize-cli db:migrate:all
到資料庫會發現 comments 不見了,但 users 不會受到影響:
再跑一次 Migrations 建立 Comment table,會多一個 UserId 欄位:
補充:設定 username 為 UNIQUE
此外,也可透過修改 create-user 檔案,把 username 欄位設定為 UNIQUE,然後再重新 Migrations。如此可避免註冊時會有重複的 username:
username: {
type: Sequelize.STRING,
unique: true
},
關聯資料庫
再來要進行資料庫關聯,分別在 models 資料夾中的 user.js 和 comment.js 加上關聯指令:
// user.js
static associate(models) {
User.hasMany(models.Comment);
}
// comment.js
static associate(models) {
Comment.belongsTo(models.User);
}
修改 Controllers
接著要來修改 user 和 comment 的 Controllers,也就是原本處理 userModel 的部分:
- controllers\user.js
- 拿掉原本引入的 userModel,改成引入 db 中的 models:
const db = require('../models');
const User = db.User;
- 修改 handleLogin:
handleLogin: (req, res, next) => {
const { username, password } = req.body;
if (!username || !password) {
req.flash('errorMessage', '請輸入您的帳密');
return next();
}
// 從 User table 撈取對應 username 的資料
User.findOne({
where: {
username
}
}).then(user => {
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;
// 在 session 中加入 user.id
req.session.userId = user.id;
res.redirect('/')
});
// 有錯誤的話就印出錯誤訊息
}).catch(err => {
req.flash('errorMessage', err.toString());
return next();
});
},
- 修改 handleRegister:
handleRegister: (req, res, next) => {
const {username, password, nickname} = req.body;
if (!username || !password || !nickname) {
req.flash('errorMessage', '缺少必要欄位');
return next();
}
bcrypt.hash(password, saltRounds, function (err, hash) {
if (err) {
req.flash('errorMessage', err.toString());
return next();
}
// 在 User table 建立資料
User.create({
username,
nickname,
password: hash
}).then(user => { // create 完會回傳一個 instance
req.session.username = username;
// 在 session 中加入 user.id
req.session.userId = user.id;
res.redirect('/');
// 有錯誤的話就印出錯誤訊息
}).catch(err => {
req.flash('errorMessage', '已存在相同用戶名');
return next();
});
});
},
- controllers\comment.js
- 拿掉原本引入的 commentModel,改成引入 db 中的 models:
// 從 models 引入 db
const db = require('../models');
const Comment = db.Comment;
const User = db.User;
- 修改 add:
add: (req, res, next) => {
const {userId} = req.session
const {content} = req.body
if (!userId) {
req.flash('errorMessage', '請先登入');
return next();
}
if (!content) {
req.flash('errorMessage', '請填入留言內容');
return next();
}
Comment.create({
content,
UserId: userId
}).then(() => {
res.redirect('/');
})
},
- 修改 index:
index: (req, res) => {
Comment.findAll({
// 撈取資料需要關聯 Comment 和 User table
include: User
}).then(comments => {
res.render('index', {
comments
});
});
},
因為資料形式改變,必須修改 View 的部分,例如拿取 nickname 的部分,要改成 comment.User.nickname 來透過關聯拿取:
<!-- index.ejs -->
<div class="card-container d-flex justify-content-around flex-wrap" style="width: 100%;">
<% comments.forEach(function(comment) { %>
<div class="card mb-2" style="width: 22rem; height: 18rem;">
<div class="card-header">
<div class="card-top d-flex justify-content-between">
<h5 class="card-title"><%= comment.User.nickname %></h5>
<div class="card-btn">
<% if (username === comment.User.username) {%>
<a href="/update_comments/<%= comment.id %>" class="card-link">編輯</a>
<a href="/delete_comments/<%= comment.id %>" class="card-link">刪除</a>
<% } %>
</div>
</div>
<p class="card-subtitle text-muted"><%= moment(comment.createdAt).format(shortDateFormat) %></p>
</div>
<div class="card-body" style="overflow:scroll; overflow-x:hidden; ">
<p class="card-text" ><%= comment.content %></p>
</div>
</div>
<% }) %>
</div>
- 修改 delete:
delete: (req, res) => {
Comment.findOne({
where: {
id: req.params.id,
UserId: req.session.userId
}
}).then(comment => {
return comment.destroy();
}).then(() => {
res.redirect('/');
}).catch(() => {
res.redirect('/');
});
},
- 修改 update 和 handleupdate:
update: (req, res) => {
Comment.findOne({
where: {
id: req.params.id
}
}).then(comment => {
res.render('update', {
comment
});
});
},
handleUpdate: (req, res) => {
Comment.findOne({
where: {
id: req.params.id,
UserId: req.session.userId
}
}).then(comment => {
return comment.update({
content: req.body.content
});
}).then(() => {
res.redirect('/');
}).catch(() => {
res.redirect('/');
});
}
修改結果:
結語
這樣就成功透過 Express 搭配 Sequelize 修改之前的留言板結構,即使不使用 SQL 指令,也能以 ORM 提供的物件導向形式來操作資料庫。
這種寫法和之前使用 PHP & MySQL 實作留言板的方式很不相同會比較偏向先完成切版,然後一步一步增加功能;但以 MVC 架構去撰寫程式碼,會先規劃不同功能對應的不同路由,接著再規劃 Model 資料結構,以及如何呈現在畫面上,這使得整體結構分工更明確,也有助於後續的維護。
參考資料:
- [ 筆記 ] Express 03 - ORM & Sequelize
- [Day20] 資料庫設計概念 - ORM