集點送紅利 已發佈 2019-11-12

Node.js 實作 The F2E_ChatRoom (2) 仿登入系統與排程

本系列介紹使用 Node.js 實作聊天室時會遇到的大小坑。

上一篇 我們介紹了建立路由監聽、串接 SQL 與 Socket.IO 基本使用,本篇則分享在聊天室建立當中,筆者較困惑的地方,以及在最後會附上片段程式碼。

本篇目錄

  1. 仿登入系統 - 與 Session 和 CORS 打交道。

  2. 使用 Node Schedule 做排程。

仿登入系統

這邊的仿登入系統,指的是輸入的名字不能和線上使用者撞名,因此我們需要搭配 Socket.IO 去做上線寫入資料庫,下線即刪除的動作。

但這邊值得注意的是,Socket.IO 隔一段時間會自動重新登入。另外當使用者在聊天室頁面重新刷新或是發生連線逾時的話,會失去一開始登入的名字。

這在使用者體驗上是非常糟糕的,因此我們應該搭配 Session 去判斷使用者是真的離開,又或是單純的重新整理頁面而已。下圖是筆者想出來的登入邏輯。

image

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 就給我一組新的 ?

image

是因為我們還沒設置好 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;

這時候再試試看!

image

這樣就成功讓 Session 與 CORS 打交道啦!
接下來,我們只需要做出下面兩隻 API,並和 Socket.IO 做連線配合就 OK 了。

  • 登入後寫入資料庫 + Session。

  • 確認登入狀態。
    Server 端先辨認是否已存有 Session,再來以 Vue Router 判斷使用者是否為刷新,兩者為是則重新更換 socket ID 並上線。


最後,這邊貼上聊天室片段程式碼:

  1. 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();
          });
        };
      });
    });
  });
});    
  1. 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();
          }
        })
      }
    });
  })
});
  1. 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 * * * * *)。

image

所以我們應該要改成,下圖為後台管理狀態:

const j = schedule.scheduleJob('0 0 * * *', function(){
  // 更換主題
});

image

後記

在做登入狀態判斷時,應該可以用更簡單的方法去做,只是我想得比較複雜…最糾結的點還是在於如何監測使用者離開網頁並做下線刪除動作。所以 Socket.IO 連線我才放在全域並搭配 Session 。

另外,在整理資料時發現了另一個比較多人用的排程工具 Cron,寫法都很像(只好改天再研究看看了。

參考資料

Day25 - session 在 express 上的應用 - 登入實作為例 - iT 邦幫忙::一起幫忙解決難題,拯救 IT 人的一天

和 CORS 跟 cookie 打交道

node.js 中间件express-session使用详解

關於筆者

暱稱:集點送紅利

介紹:我喜歡日文,也喜歡 coding

文章列表 文章列表