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

Joe Chang
Coding Hot Pot
Published in
Jun 8, 2022
photo by lunarts

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

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

User Story

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

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

聊天紀錄

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

mongoDB的collection

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

為甚麼需要創建房間?

假設沒有針對每一組聊天對象創建房間的話,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服務,這部分就看自己的專案結構做決定

在/bin/www將server作為參數傳入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:

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

line在斷線的時候送出訊息還是可以看到自己發送的內容

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

發送歷史訊息給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連接

Network的Fetch/XHR可以看到long polling
long polling確認相容性沒問題之後就會改走Network的WS

接收來自server端的訊息

  • 歷史訊息

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

  • 新訊息

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

斷開連接

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

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

後記

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

--

--

Joe Chang
Coding Hot Pot

前端工程師,唯有非常努力,才能看起來毫不費力