如何使用Socket.io實作一對一聊天功能?

這次報名了 node.js 直播班,挑戰了其中一項許願功能  —  使用 websocket 開發即時聊天室,由於專題的雛型是要打造類似 FB 的網站,可以發文、留言、按讚,因此決定參考 FB 的通訊軟體 messenger,開發讓會員可以一對一聊天的功能,爬了許多文發現大部分的人都是使用 socket.io 這個套件來實作即時聊天,語法簡易而且對於舊版本的瀏覽器較為友善,如果判斷網頁版本不支援 webSocket 還可以改用 polling 的方式來實現即時通訊,因此就決定是你了!socket.io!

關於 websocket 和 polling 的差異可以閱讀這篇

User Story

使用者可以造訪其他人的頁面,並且傳送訊息給對方

如果曾經和別人聊過天,則可以從自己的聊天記錄查看曾經和誰聊過天,聊天紀錄會顯示最後一筆聊天訊息和時間

聊天紀錄

為了實現聊天記錄的功能,每個使用者身上都會存放一個聊天記錄的陣列(chatRecord),這邊的聊天記錄指的並非對話紀錄,而是指使用者曾經和誰聊過天的紀錄,chatRecord 會存聊天對象的 userId 和聊天室的房間 Id

有了初步的聊天室規劃之後,接下來就要思考如何實現一對一的聊天功能,我們必須為每一組聊天對象創建房間

為甚麼需要創建房間?

假設沒有針對每一組聊天對象創建房間的話,socket.io 在廣播訊息的時候大家都看的到,比如說 A 和 B 開啟聊天視窗在講小祕密,但是 B 在和 C 聊天居然也能看到 A 和 B 的對話內容,聽起來不是蠻可怕的嗎 😂?, 因此就需要創建房間,讓 socket.io 針對該房間廣播訊息,確保只有在房間裡面的人才能看到訊息

由於創建房間這段的程式沒有使用到 socket.io,因此這段的流程會用流程圖簡單帶過,當使用者 A 想要傳送訊息給使用者 B 的時候,會先檢查使用者 A 的聊天紀錄是否曾經和使用者 B 聊過天,如果有找到跟 B 聊天的房間 ID,就直接回傳該房間 ID,沒有的話代表從未聊過天,會在 room 的資料表建立一間新的房間,並且將房間 ID 存放在使用者 A 和使用者 B 的聊天紀錄當中

聊天視窗

當使用者點了聊天記錄或是傳送訊息都會開啟聊天室視窗,規劃的功能有以下幾點

  • 能夠與對方即時聊天
  • 打開聊天視窗會自動載入先前的三十筆聊天紀錄
  • 如果滑動到聊天視窗的頂部,會繼續加載更久以前的聊天紀錄
  • 當對方正在輸入的時候, 聊天視窗會出現對方輸入中的字樣

有了初步的規劃,就可以開始動工了!本次開發採前後分離的方式,後端使用 node.js、mongoDB, 前端使用 vue3、vite、tailwindcss

socket.io —  後端篇

由於 socket 的程式邏輯非常多,會將它抽成一隻獨立檔案放在 service,大部分的範例練習都是直接在根目錄的 server.js 建立 socket 服務,但因為這次是用 express 開發,所以會在/bin/www 建立 socket 服務,這部分就看自己的專案結構做決定

在前後分離的情況下,通常會有跨域問題,需要將 cors 設定為 origin: ‘*’,不然就會看到熟悉的跨域錯誤

on、emit

接下來的程式碼會頻繁地出現 on 和 emit,可以說是 socket.io 的兩大核心要角,不論是 server 端或是 client 端都可以使用這兩個方法

  • server 端

on → 監聽 client 端發送的事件

emit → 發送事件給 client 端,根據不同的發送對象,emit 又會分為以下這幾種

  • socket.emit():向建立該連接的使用者發送事件(自己)
  • socket.broadcast.emit() : 向建立該連接的使用者以外的使用者發送事件 (除了自己之外)
  • io.sockets.emit() :向所有使用者發送事件 (自己和其他人)

在發送事件給 client 端時最重要的部分就是先確認要發送的對象然後決定要使用什麼樣的 emit,單純這樣敘述可能蠻抽象的,讓我們用生活的情境來舉例,假設 server 端是老師,三個 client 端是學生好了,學生 A 先跟老師打小報告(橘色),這時老師可以有三個選擇,1.臭罵學生 A(綠色) 、 2.臭罵學生 B 和學生 C(藍色)、3.臭罵全部的學生(A、B、 C)(灰色)

一開始開發的時候會不太知道甚麼情況要使用哪一種 emit,要等到實作之後遇到不同的情境時才會比較清楚,以多人聊天室來說,如果使用者A進入聊天室要讓其他人看到「userA 進入房間」這則訊息,就需要使用 socket.broadcast.emit()通知 userA 以外的人

  • client 端

以 client 端來說就比較單純,因為發送對象就只有唯一的 server 端

on → 監聽 server 端發送的事件

emit → 發送事件給 server 端

middleware

socket.io 也提供了 middleware 的功能,能夠在 socket.io 建立連線之前確認使用者的登入狀態是否有效,可以依照自身需求建立多個 middleware,有通過檢核就執行 next(),沒有就執行 next(傳入自定義錯誤)

甚麼是 middleware ?

可以想像成是一道道的檢核關卡,確認資料來源是否符合格式,確認 token 是否有效等等,假設有其中一項不符合就會中斷流程, 拋出我們自定義的錯誤

namespace

當初規劃會用到 websocket 的地方有兩個,即時更新貼文和即時聊天,因此我定義了一個 chat 的 namespace 來處理跟聊天室有關的邏輯,使用不同的 namespace 可以想像是切分成不同的頻道,可以清楚劃分各自的功能,不過要特別注意 namspace 和 path 是不一樣的,初次使用 socket.io 的人很容易把這兩個搞混(就是我 😅

io.of(“/chat”)

connection

後端會透過監聽 connection 事件來與 client 端建立連接,由於聊天室的 namespace 設定為 chat,所以監聽連接事件的時候需要特別寫 io.of(/chat) ,如果在沒有設置 namespace 的情況下,那其實只要寫 io.on(‘connection’)即可

當 client 端連上 socket 時,就會觸發 connection 的 callback function,我們可以從傳入的 socket 物件拿到很多資訊

  • socket.rooms —  目前 client 端所在的房間
  • socket.handshake — client 端在建立連接時帶的參數 (ex. token , 房間 Id)
  • socket.id —  獨一無二的 client id,在匿名聊天室可以用來識別使用者身份

更多 socket 物件的屬性介紹可以參考這裡

join Room

接下來就是要將使用者加入房間啦! 這邊採取的作法是當 client 端一連線就加入房間,另一種做法是監聽 client 端發送加入房間的事件,等到 server 端收到事件之後再加入房間

接收 client 端的訊息

接下來要監聽 client 端發送過來的訊息,收到訊息後需要將訊息廣播給這個房間裡的所有人  *.to(room)*, 發送訊息這邊可以有兩種作法

方法 1:

使用者按下傳送訊息,前端把訊息傳送給後端,再由後端廣播訊息給大家(包含自己),這個作法的缺點是如果網路異常,使用者(送出訊息的人)會無法接收後端應該要回傳的內容,就會看不到自己剛剛送出的訊息

方法 2:

當使用者按下傳送訊息時,會先把剛剛傳送的的內容添加到訊息陣列裡面,同時前端把訊息傳送給後端,後端廣播訊息給大家(不包含自己),跟第一種作法的差別在於自己送出的訊息都是前端手動加入,不是由後端傳過來的,如果想要做到在斷線的情況下使用者還是可以看到自己剛剛送出的訊息,就必須採取這種作法

因為這次的專題有時間壓力所以採取第一種作法,比較簡單,不然個人覺得第二種作法的使用者體驗會比較好

發送歷史訊息給 client 端

當我們打開通訊軟體時,只要不斷地往上滑動就能讀取更多的歷史訊息,但因為訊息量龐大,不可能一次加載全部的聊天訊息,所以都是採用分段加載的方式,在請求歷史訊息這部份因為聊天訊息的資料表變動得很頻繁,所以無法使用一般的分頁方式取請求資料,這裡我的設計是預先請求最新的 30 筆,當使用者滑到最上方要請求更多資料的時候,就用最後一筆訊息的建立時間去請求比這個時間更久之前的聊天訊息

錯誤處理

還記得之前我們有在 middleware 寫過一些驗證來檢核 client 端送來的資料是否正確嗎?如果不符合驗證就會拋出錯誤,而這些錯誤就會由 socket on error 來接收,再將錯誤發送給前端,讓前端可以用彈窗或是 toast 的方式提示使用者

socket.io —  前端篇

當後端的部分都準備的差不多了,就可以開始著手前端串接 socket.io 的部分,如果是用 vue2 開發的朋友可以考慮使用Vue-Socket.io,但因為我們小組是用 vue3 開發,就只能乖乖用 socket.io-client,因為 Vue-Socket.io 還不支援 vue3🥲

安裝好 socket.io-client 之後,在聊天室的 component 初始化 socket io,假設有設定 namespace 需要一併帶在網址後面(/chat),不然 socket 永遠不會通,在連線時需要夾帶的資訊都可以放在 query 底下,token 則是會放在 auth 物件底下,這裡會帶上 token 和房間 id 給後端做驗證

發送訊息給 server 端

當使用者按發送訊息按鈕時,會發送 chatMessage 事件給 server 端接收,並且一併帶上訊息內容和發送者的 userId(事後才想起來可以用 token 回推出 userId,所以前端其實可以不用傳 userId)

打開網頁測試,觀察 DevTools 的 network,發現 socket.io 戳了好幾次 api,說好的 webSocket 呢?先別緊張,當連接成功時 socket.io 會先使用 long polling 的方式,確認相容性沒問題之後,再使用 websocket 連接


接收來自 server 端的訊息

  • 歷史訊息

接下來監聽 history 如果拿到歷史訊息, 就將後端回傳的訊息加入到現有的訊息陣列前方

  • 新訊息

監聽 chatMessage 如果有人發送訊息,就將訊息 push 到現有的訊息陣列

斷開連接

當使用者離開聊天室時,要記得斷開 socket 連接,不然就會佔著資源,目前還沒測試過連接上限是多少,不過因為用的是 heroku 免費方案,還是要省著點用

由於文章篇幅有限,有些實作功能沒有辦法一一介紹,大家有興趣可以直接看 repo

後記

在開發聊天室這個功能時,其實也是遇到了不少問題,本來以為很快就搞定的問題殊不知卡超久,翻了不下百篇的 stackoverflow 和一大堆的技術文章,很多時候真的是不知道自己這樣寫到底正不正確,不過也正是一路跌跌撞撞,才能有所收穫,這也是我第一次體驗當一條龍的感覺(前後端都包辦),真的是蠻累的,想當全端工程師真的不容易,不過還是希望自己可以朝這個方向繼續努力。