Node.js 實作 The F2E_ChatRoom (2) 仿登入系統與排程
本系列介紹使用 Node.js 實作聊天室時會遇到的大小坑。
在 上一篇 我們介紹了建立路由監聽、串接 SQL 與 Socket.IO 基本使用,本篇則分享在聊天室建立當中,筆者較困惑的地方,以及在最後會附上片段程式碼。
本篇目錄
仿登入系統 - 與 Session 和 CORS 打交道。
使用 Node Schedule 做排程。
仿登入系統
這邊的仿登入系統,指的是輸入的名字不能和線上使用者撞名,因此我們需要搭配 Socket.IO 去做上線寫入資料庫,下線即刪除的動作。
但這邊值得注意的是,Socket.IO 隔一段時間會自動重新登入。另外當使用者在聊天室頁面重新刷新或是發生連線逾時的話,會失去一開始登入的名字。
這在使用者體驗上是非常糟糕的,因此我們應該搭配 Session 去判斷使用者是真的離開,又或是單純的重新整理頁面而已。下圖是筆者想出來的登入邏輯。
Session
所以我們的第一步,要從 Session 走起!
本文主要講解實作上遇到的困難,如果不了解 Session 的話可以先參考 這篇文章。
第一步要先安裝:
$ npm install express-session
並且引入 ( 屬性配置可參考 NPM ):
const session = require('express-session');
app.use(session({
secret: 'thef2e_chatroom',
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 60 * 60 * 1000 * 3, // 存活時間為三小時
},
}));
設置好後當我們服務端接收到請求時,客戶端都會帶有一組 Session ID (SID)
。正常沒有設置下,SID 會存活到使用者關閉瀏覽器為止,而我們在上處有設置存活 3 小時。
接著,我們會需要兩端來做測試才會準確,也就是說要先開啟服務端 (3000),再用客戶端 (8080) 對服務端做 API get。(用 Postman 會失效)
在服務端上我們貼上以下程式碼:
app.get('/session', (req, res) => {
console.log('SID: ', req.sessionID);
req.session.user = req.sessionID;
res.send('Hello Session');
});
客戶端我們用 axios 做 get 測試:
this.axios.get('http://localhost:3000/session').then((response) => {
console.log(response.data);
});
但…發現問題出在哪了嗎?
為什麼我每請求一次 SID 就給我一組新的 ?
是因為我們還沒設置好 CORS!
CORS
CORS又是什麼?如果不知道的話可以先參考這篇文章,講得非常詳細!
簡單來說就是因為前後端分離,為了要讓瀏覽器辨別是同一人,前後端都需要加上 CORS 的 Credentials 認證屬性。
當初測試時,一直對 Session 刷新抱持著疑問,因為瀏覽器也沒報錯,弄了一整天才查到是 CORS 的問題。
接著我們來加上 CORS 試試看:
$ npm install cors --save
引入並設置 CORS:
const cors = require('cors');
const corsOptions = {
origin: 'http://localhost:8080', // 客戶端 port
credentials: true,
};
app.use(cors(corsOptions)); // 要在 API 的上面先使用
// 加上 credentials 後,origin 必須設置網址,不能為 * (通用)
另外在前端也需要開啟認證配置 (這邊使用 vue-axios):
axios.defaults.withCredentials = true;
這時候再試試看!
這樣就成功讓 Session 與 CORS 打交道啦!
接下來,我們只需要做出下面兩隻 API,並和 Socket.IO 做連線配合就 OK 了。
登入後寫入資料庫 + Session。
確認登入狀態。
Server 端先辨認是否已存有 Session,再來以 Vue Router 判斷使用者是否為刷新,兩者為是則重新更換 socket ID 並上線。
最後,這邊貼上聊天室片段程式碼:
- Socket.IO
在 connection 上線時,把 ID 先存進變數,變數在下面 API 會使用到。 disconnect 離線時,則是以離開聊天室的 ID,去查詢資料庫內有無此使用者,有則刪除使用者。
// variable
let socketID = '';
// Socket.IO
const io = require('socket.io')(server);
io.on('connection', socket => {
console.log('連接成功,上線ID: ', socket.id);
// 存進變數
socketID = socket.id;
// 監聽訊息
socket.on('getTopicMessage', message => {
console.log('topic 聊天室', message);
}
// 連接斷開
socket.on('disconnect', () => {
console.log('有人離開了!, 下線ID: ', socket.id);
// 查詢下線ID (socket.id)
pool.getConnection((err, connection) => {
connection.query(`SELECT * from user where user_socket_id =
"${socket.id}"`, (err, rows, fields) => {
if (err) throw err;
if (rows.length === 0) {
console.log('此人未登入過');
connection.release();
} else {
// 刪除資料讓使用者下線
connection.query(`DELETE from user where user_socket_id =
"${socket.id}"`, (err, rows, fields) => {
if (err) {
console.log(err);
console.log('下線失敗');
} else {
console.log('下線成功');
}
connection.release();
});
};
});
});
});
});
- Login API
取得 POST 資料後,對資料庫做查詢,如沒撞名則寫入並上線。
// User Login
app.post('/api/login', (req, res) => {
pool.getConnection((err, connection) => {
if (err) throw err;
// 查詢是否撞名
connection.query(`SELECT user_name from user where user_name =
"${req.body.username}"`, function(err, rows, fields) {
if (err) throw err;
if (rows.length !== 0) {
res.send({
success: false,
message: '此名字已有人使用,請使用其他名字'
});
connection.release();
} else {
// 寫入資料,使用者上線
connection.query('INSERT INTO user SET ?', {
user_name: req.body.username,
user_session: req.sessionID,
user_socket_id: socketID
}, (err, rows, fields) => {
if (err) {
console.log(err);
res.send({
success: false,
message: '登入失敗!'
})
connection.release();
} else {
// 新增 user 至 session
req.session.user = {
session: req.sessionID,
name: req.body.username,
socketID: socketID
}
res.send({
success: true,
message: '登入成功!'
})
connection.release();
}
})
}
});
})
});
Login Status
這邊應該是最複雜的地方。一,先判斷是否登入過 (有無 Session),無則回傳 false 並讓使用者繼續登入步驟。
二,客戶端會用 Vue Router 判斷是否為刷新頁面,並帶 params 進來服務端。
否 (normal) 則放行使用者使用聊天室。
是 (refresh) 則代表剛剛使用者下線 但有 Session,所以我們應該做資料庫的寫入讓他重新上線。要注意的是,使用者回來並重新帶了一個新的聊天室 ID,我們要在寫入資料庫前調換新聊天室 ID。
// User Login Status
app.get('/api/loginstatus/:method', (req, res) => {
// *第一步
if (req.session.user) {
// *第二步
if (req.params.method === 'normal') {
res.send({
success: true,
message: '使用者未下線,無需處理'
})
return;
}
// 更換 socketID,此處取上面存好的變數
req.session.user.socketID = socketID;
// *第三步
pool.getConnection((err, connection) => {
if (err) throw err;
connection.query(`SELECT user_name from user where user_name =
"${req.session.user.name}"`, function(err, rows, fields) {
if (err) throw err;
if (rows.length !== 0) {
res.send({
success: false,
message: '此名字已有人使用,請使用其他名字'
});
connection.release();
} else {
connection.query('INSERT INTO user SET ?', {
user_name: req.session.user.name,
user_session: req.sessionID,
user_socket_id: socketID
}, (err, rows, fields) => {
if (err) {
console.log(err);
res.send({
success: false,
message: '重新登入失敗!'
})
connection.release();
} else {
res.send({
success: true,
message: req.session.user.name
})
connection.release();
}
})
}
});
})
} else {
res.send({
success: false,
message: '此使用者未登入過'
});
}
});
排程
還記得嗎?在上一篇的聊天室圖中有個每日主題,需要在每日午時做主題更換。平常我們做固定時間處理某些事會用 setInterval 或是 setTimeout,但時間如果一拉長的話,就可以試著引入 Node Schedule 此套件。
使用方法非常簡單,先下載下來:
$ npm install node-schedule --save
引入專案:
const schedule = require('node-schedule');
// 42 分時執行 console 打印
const j = schedule.scheduleJob('42 * * * *', function(){
console.log('The answer to life, the universe, and everything!');
});
Node Schedule 總共有 6 個 * 號,分別為 (秒)、分、時、日期、月與星期幾,秒可以省略所以一般為 5 個 * 號。
這邊要注意的是,(5 * * * * *) 你可能會以為這是每五秒執行一次,
但這意思是時鐘指到第五秒時才執行,所以是每分執行一次。
想要每隔五秒執行一次的話,則是要輸入 (*/5 * * * * *)。
所以我們應該要改成,下圖為後台管理狀態:
const j = schedule.scheduleJob('0 0 * * *', function(){
// 更換主題
});
後記
在做登入狀態判斷時,應該可以用更簡單的方法去做,只是我想得比較複雜…最糾結的點還是在於如何監測使用者離開網頁並做下線刪除動作。所以 Socket.IO 連線我才放在全域並搭配 Session 。
另外,在整理資料時發現了另一個比較多人用的排程工具 Cron,寫法都很像(只好改天再研究看看了。
參考資料
Day25 - session 在 express 上的應用 - 登入實作為例 - iT 邦幫忙::一起幫忙解決難題,拯救 IT 人的一天