[MERN] 使用 socket.io 實現即時通信功能
效果實現如下:
MERN-socket.io 實現即時聊天
Github 項目地址:https://github.com/GoldenaArcher/messenger-mern
項目使用了 MERN(MongoDB, Express, React, Node.js) + socket.io 實現即時通信功能,并且使用了Turborepo進行 monorepo 的后端、前端和 socket 的模塊管理
用到的具體技術棧有:
-
MongoDB
實現了數據的持久化
-
Express
實現的后端,負責登陸/注冊/獲取聊天記錄的功能
-
React
實現 UI 功能
-
Redux
實現了 UI 部分的狀態管理
這次也是第一次嘗試使用 RTQK 進行 CRUD 的操作,大體使用感覺是比 RTK 還要簡單很多,loading/error 的狀態全都由 RTKQ 進行管理,確實少寫了很多的代碼
如果要單獨進行狀態的更新,實現起來也不太難,在
onQueryStarted
里面就可以通過queryFulfilled
去進行操作。因為 api 是 cache 的,所以可以通過調動invalidateTags
去自動重新調用 fetch api,使用起來確實方便很多唯一稍微有點麻煩的可能是 debug,我在實現信息的 delivered/read 這部分狀態更新的時候卡 bug 卡了一段時間,主要是 RTKQ 在沒有遇到硬核報錯,并且有些情況下出現 cache 里找不到對應的數據這種情況下,會自動放棄(abort)剩下的操作。因為沒有在 devtool 里看到任何的報錯,函數里的 log 也沒有被觸發,所以在 debug 的時候也確實是折騰了挺久的……
-
turborepo
實現了 monorepo 的管理
其實這個配置起來還挺簡單的,因為項目比較簡單,所以也沒有辦法進行一個和 yarn workspace 的對比,主要還是為了使用 一個指令 就能啟動所有的項目,減少反復更新 concurrently 去運行對應的模塊
底層上來說 turborepo 還是使用了 yarn workspace,不過官網上說 turborepo 可以只重新編譯修改過的模塊,而 yarn workspace 是會更新所有的模塊
從 bash 上更新的情況上來說是這樣的,不過主要問題就是項目體量太小了,確實沒有辦法感受出差別來
-
docker
這個為了方便一鍵運行整個項目,只要跑一下
docker compose up -d
就行了
MongoDB
上次用 mongo 還是上次的事情了,這次的設計相對而言比較簡單,就用了 2 個 model,一個是 user,一個是 message。這方面主要還是一個復習+學習,常見的 schema 比較簡單,但是比較有趣的東西吧……一個是 aggregate,另一個是用 find
+updateMany
比起 updateMany
+find
的搭配可以提升一部分的性能——其主要原因是數據庫在 mutation 后會出現一些延遲
但是從另一個方面來說,實用 find
+updateMany
的操作也不是絕對的。在實際應用期間,我們為了保證數據的一致性,后端都是用 updateMany
+find
;后端返回給前端時,如果是 update 的操作,其實并不會返回具體的數據,只會返回一個 null
,所以我們其實都是依賴前端的數據進行的修改……
所以,具體怎么操作還是一個 case by case 的操作,對于目前這個 app,二者的差異其實不太大
其實我還是不太會寫 query,后端做的少這個是真沒什么辦法,只能說有空的時候多看看、多寫寫,多少補一點吧
具體到實踐就是,寫的還是簡單了一些,對于 user 的設計其實沒有做好友的部分,所以 demo 里的好友實現還是拉了一下數據庫里所有存在的 users
message 的設計目前來說是可以滿足需求的,雖然之前有在考慮設計一下 conversation 這個 schema 提升獲取最后消息的功能,但是鑒于還沒有做群組聊天這一功能,想想也就算了……
Express
express 這部分沒什么特別大的難點,總體來說使用還是比較簡單的,畢竟 nodejs 作為服務端配置是真的簡單……
目前來說主要做了 3 個路由:
- auth,這個就是比較簡單的登錄和注冊的驗證
- friend,這個是獲取好友列表
- message,這個就是獲取信息相關的路由
middleware 做了兩個:
-
auth,負責 verify/encode/decode jwt,以及將 decode 的 jwt 加到 request 中
-
upload,負責文件上傳的部分
文件會上傳到 server 的
/upload
文件下,同時用戶要獲取文件的話,就可以獲取這個文件夾下 host 的靜態文件上傳的主要實現時通過 Formidable 做的,在 v3 之后 Formidable 會返回一個數組而不是單獨的屬性,所以我新寫了一個 util 文件去負責這部分
demo 的時候沒做注冊,不過功能差不多都在這里了,唯一不太確定的是上傳文件這塊……我試過了上傳圖片并且渲染成功,但是上傳其他類型的文件還真沒試過
總體來說,express 部分做的和我記憶里面沒什么特別大的差別,流程大概就是:
- 設計并實現對應的 controller,負責相關的 CRUD 操作
- 根據情況選擇是否需要添加/實現 middleware
- 設計一個 route,并且決定對應的 CRUD 操作
- 在 server.js 里面使用對應的 route,使得 express 能夠找到相關的路徑
或者反過來也行,先搞定 route 再實現 controller
React/Redux
React 部分沒有用什么特別先進的東西,基本上就是 16.8 以后用到現在的 hooks,這次主要升級的部分還是 redux——第一次嘗試全局用 RTKQ 操作
早起的項目(2020 年前)用的都是 redux/react-redux+redux thunk 的實現,當時 redux 本身對于異步 api 的支持是比較差的,所以 redux-thunk 和 redux-saga 的應用還是比較廣泛。我們當時的考量是為了減少依賴的實現,所以只考慮了 thunk,沒有使用 saga
后來也簡單地了解了一下 saga 的用法,不過因為馬上就用到了 redux toolkit,后面就直接換到了 RTK 的 createSlice
,對于比較簡單的實現不需要重復寫 action,也就一直用了下去。期間的確是發現,使用 asyncThunk
的時候,還是需要寫不少的 reducers,并且同樣需要自己管理 loading 相關的狀態。當然,這方面和之前使用 redux-thunk 時沒什么太大的差別,所以一直以來感覺有點麻煩,但是可以接受
但是使用 RTKQ 是另外一種感覺了,RTKQ 本身就管理了 loading 和 error 的狀態,讓使用和調用都變得簡單很多。并且因為 RTKQ 本身是 cache 了 API 調用的,所以它的調用不需要額外找 state,直接從導出的 useFunction 去找 data 就行了,二者在 React 中的 component 調用對比如下:
// slice
const { userInfo } = useSelector((state) => state.auth);// RTKQ
const { data, isFetching, error } = useFetchUsersQuery();
二者差別好像不是特別大,但是使用 (state) => state.auth
這種操作最大的問題還是在于 typo——雖然 slice 也可以導出 reducerPath
,不過 slice 層面的導入/導出還是需要額外的操作,我們一般都是直接寫對應的狀態
實現方面 RTKQ 就干凈得多,以一個相對比較簡單的 fetch query 為例:
fetchMessages: builder.query({query: ({ sender, receiver }) => ({url: `/messages`,method: "GET",params: {sender,receiver,},}),transformResponse: (res) => res.data,
});
主要是不用管理 error 和 loading 的狀態,所以就不用像傳統寫法那樣寫 pending
, fulfilled
, rejected
三個 actions
之余在 mutation 中更新 query 的數據,這個實現方法是這樣的:
postMessage: builder.mutation({query: (data) => ({url: "/messages",method: "POST",data,}),transformResponse: (res) => res.data,async onQueryStarted(arg, { dispatch, queryFulfilled, getState }) {await updateMessageCache({queryFulfilled,dispatch,getState,messageApi,friendApi,});},
});
updateMessageCache
的實現:
export const updateMessageCache = async ({queryFulfilled,messageApi,friendApi,getState,dispatch,
}) => {try {const { data } = await queryFulfilled;dispatch(messageApi.util.updateQueryData("fetchMessages",{ sender: data.sender, receiver: data.receiver },(messageList) => {messageList.push(data);}));const friendsState = getState()[friendApi.reducerPath];const friendList =friendsState?.queries?.["fetchFriends(undefined)"]?.data || [];if (friendList.length > 0) {dispatch(messageApi.util.updateQueryData("fetchLastFriendMessages",{ friendList },(lastMessages) => {const existingIndex = lastMessages.findIndex((msg) =>(msg.sender === data.sender &&msg.receiver === data.receiver) ||(msg.sender === data.receiver && msg.receiver === data.sender));if (existingIndex !== -1) {lastMessages[existingIndex] = data;} else {lastMessages.push(data);}}));}} catch (error) {console.error("Error authenticating user:", error);}
};
這里需要注意的是,在 updateQueryData
更新數據時,參數必須要和在 builder 的定義是一樣的。如果是無參函數,那么寫法如下: fetchDemo(undefined)
,需要傳一個 undefined
,也不能留空
還需要注意的一點就是,如果在 updateQueryData
調用的方法里面找不到對應的數據,那么整個 callback 里面方法都不會被調用,而且也不會有任何的報錯
以 fetchMessages
為例,它的代碼是這樣的:
messageApi.util.updateQueryData("fetchMessages",{ sender: data.sender, receiver: data.receiver },(messageList) => {messageList.push(data);}
);
我當時的情況是,我在不同的好友列表上,也就是說 sender
和 receiver
和當前的數據不同,因此這段代碼就沒有觸發,callback 也就沒有執行。這個邏輯藏的還是挺深的,后來在這個 stack overflow 上找到的答案:Unable to trigger updateQueryData in redux-toolkit。這里面提到,如果整個 cache entry 不存在,即 fetchMessages
之前沒有被調用過,那么這個 callback 也不會被執行
另外之前本來想著說多研究一下 invalidateTag
怎么用的,結果最后還是沒怎么用上……
socket.io
socket.io 的使用還是比較簡單的,這里沒有用到特別難的地方,主要就是用到了 socket.on(event, callback)
和 socket.emit(event, callback)
,并且所有的操作都是從 socket 這部分執行的
基本邏輯就是:UI 觸發一個 event,socket 接收到這個 event,在模塊內處理完必須的邏輯后,再 emit 一個新的 event。UI 端監聽到對應的 event 后,也會負責實現對應的邏輯,包括更新 redux 中的數據,或者是觸發另一個 rest api 調用
簡單的說就是,這個項目只用到了比較初級的 socket.io 進行實現——畢竟除了在當前的 socket 上發消息之外,就是通過 to
給指定的用戶發消息。但是 socket.io 本身的能力還是挺強的,更廣的功能目前還沒辦法用到,比如說:
-
廣播消息
類似于游戲里的世界廣播那種功能
-
創建 room 和 namespace
這個我其實有點想做的,比如說上面提到的創建一個 conversation schema,和做 group chat 就可以做這個功能
不過想想先維持基本功能的實現吧
-
acknowledge
這個看了下,大體是如果 socket 的信息完成傳輸后,可以直接調用 callback 去確認信息已經傳輸完成
在當前項目里的例子大體是:當用戶發送信息后,就可以直接在后端更新消息已經 deliver 的操作,再通過 socket 將消息傳到前端
對于現在的實現——即用戶 A 發送信息后通過 socket 發送給用戶 B 新消息已經發送,用戶 B 在 UI 接收到這個消息后,發送一個 rest api 調用到后端,再將消息通過 rest api 結果返回到前端,前端再對 UI 進行更新
比起來,這里少了一個 socket 到 UI,UI 調用 rest api 的交流,因此對于性能的提升也是顯著的
沒這么做的原因是想做 MFE 的……后面會提到
-
middleware
和 acknowledge 類似,可以直接在 socket 中驗證用戶,減少客戶端對 API 調用
-
自動重連
我知道有這個功能,沒具體研究過
-
MFE
因為現在是把 socket 單獨拉出來做了一個模塊,之前有在考慮升級到 MFE 的實現,不過這樣就會涉及到多個 socket instance,然后解決方案就需要升級到 redis。目前我對于 redis 的使用是項目里有用,一直想看,一直還沒抽出時間來具體研究是怎么做的
所以升級到 MFE 會上比較大的強度,因此暫時就擱置了。可能等到之后有空會回來看看吧……