[NodeJS Become A Full Stack Developer] — 透過 Socket.io 來製作即時聊天室吧

Introduction & 前言

Chat Room

這是一篇關於 Socket.io 的淺入淺出文章,如果你正在尋找相關知識或想知道什麼是 Socket.io 甚至有想要嘗試自己做一個即時聊天室,那麼這篇文章就很適合你。


Summary & 摘要

本篇文章後端將會使用 Node.js,前端將會使用 **React(Create React APP)**,不同框架語言使用方式其實應該都差不多,但如果你有打算使用其他框架,可以再多爬幾篇文章比較一下。

這邊會簡單的提一下 Socket.io 是什麼,但不會深度解析什麼是 Socket.io 及背後的原理;最後我們將會實作出有下列幾個簡單功能的聊天室。

  1. 透過 Socket.io 達到即時聊天效果。
  2. 不同聊天室分群聊天效果。
  3. 全頻道/分群廣播效果。
  4. 透過打 api 後,操作 Socket.io

淺入淺出關於 Sokect.io 的介紹

在實作 Socket.io 聊天室之前,筆者雖然預設大家都已經知道這個東西是什麼,但或許還是有一些讀者是第一次接觸或不了解 Socket.io 的。如果你已經知道,這個地方可以跳過。

不專業的解說圖

一般的伺服器與使用者溝通方式

首先我們一般前端使用者與後端溝通都是透過 API 來發送請求,然後再透過 Response 去接收回覆的訊息,但是伺服器並無法直接主動發出訊息給使用者;使用者在送出表單或者點擊某顆按鈕後透過 ajaxaxios 去打 API 亦或者連接網站後,發出任何請求至後端。

像上方左邊的圖,1 就是使用者發起請求,而伺服器收到後會傳送 Response 回給發起請求的使用者,但無法發送給其他使用者,例如 使用者A 發起請求後,伺服器只能回傳 Response 給這個使用者,無法發給 使用者B使用者C

使用 Socket.io 的伺服器與使用者溝通方式

透過 Socket.io 我們可以達到上面一般方式我們想達到的事情,可以把 Socket.io 想像成 某個訂閱服務,當使用者主動發起請求至訂閱服務,可以再回傳事件,但這時候已經不是依靠 API 方式去溝通,而是透過 Socket.io 提供的 API,待會後面會說到。

最主要的地方來了,當訂閱服務收到使用者的請求後,他除了可以主動回覆給該使用者,也可以一併的主動發出訊息給所有訂閱的使用者,聰明的你就知道這個可以達到聊天室的需求。

其實現在很多東西都會用到 Socket.io

  1. 遊戲

  2. 聊天室

  3. 客服中心

  4. 任何需要即時更新的資料,股票、博彩…等等

何謂 Socket.io Websocket Socket

這邊其實沒有要詳細說明三者差異,有興趣可以參考 【筆記】Socket,Websocket,Socket.io的差異

但還是快速地說一下:

  • Socket 就是以前的 TCP/IP,現在也變成通訊的標準之一。

  • Websocket 在七層模型中屬於「應用層」,也是一種協議。

  • Socket.io 和上面兩者不同,他是 JavaScript 的一個函式庫,使伺服器和客戶端之間即時雙向的通信成為可能


前置作業

Socket.io 官方文件

在開始前我們都需要先認識幾個簡單的 Socket.ioAPI,這邊會分成前端及後端部分去講解,就跟 Socket.io 官方文件差不多。

關於後端的 Socket.io

因為會講到連接的方式,所以這邊會從後端開始講起,這邊會先提供最終程式碼,傳送門請點我

  1. 首先我們用 Node.jsExpress 框架作為我們的後端專案,如果你想從頭寫,不用框架也可以,可以參考官方的 **教學文件**。

我們需要先安裝 express-generator,這是一個 Express 的應用程式產生器,然後透過 express-generator 去產生一個 Node.js 專案。

1
2
3
4
5
$ npm install express-generator -g // 全域安裝

$ express -h // 查看指令大全 順便檢查有無安裝成功

$ express --view=pug Socketio-Server // --view=pug 為使用 pug 模板,這邊不重要 我們不會用到

建立完成後大概會長得像下面的架構。

後端架構

  1. 接著我們要安裝我們的主角,**Socket.io**,官方文件可參考 **此處**。
1
2
3
4
5
$ npm install socket.io // 現在的版本都會預設 -S 可以不用加了

$ npm install uuidv4 // 可裝可不裝,後面判斷使用者會用到

$ npm install moment // 可裝可不裝,後面聊天室顯示送出訊息時間用

關於 -S

如果不知道 -S 是什麼意思,可以參考 [Tool Notes] — 關於Webpack #2 - Babel?開動了 一小段。

  1. 安裝完後就可以開始使用了,打開 /bin/www 這隻檔案,然後引入相關套件。
1
2
3
4
5
6
// /bin/www
const { v4: uuidv4 } = require("uuid");
const moment = require("moment");

var server = http.createServer(app); // 這行原本就有
const io = require("socket.io")(server); // 一定要在 server 後面

因為我們的聊天室會有使用者們及聊天房間,所以在加入下面程式碼,因為這次範例是全部分開聊天,如果你想要也可以在弄一個房間作為大廳。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// /bin/www

let users = [];
let chatRooms = [
{
id: uuidv4(),
name: "新手村一",
desc: "菜雞才能來喔!",
userNumber: 0,
},
{
id: uuidv4(),
name: "無差別",
desc: "不管你是什麼雞都可以來~",
userNumber: 0,
},
];
  1. 開始使用 socket.io 套件,繼續編輯我們的 /bin/www,加上下面程式碼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// /bin/www
io.on("connection", (socket) => {
// 登入
socket.on("login", ({ id, name }) => {
const sameUser = users.find((user) => {
return user.name === name;
});

if (sameUser) {
socket.emit("connectionFail", {
success: false,
message: "使用者名稱重複",
});
} else {
// 只發事件給這個使用者
socket.emit("connectionSuccess", {
success: true,
message: "歡迎加入!連線成功",
rooms: chatRooms,
});

// 給除了這個使用者外的其他人
socket.broadcast.emit("connectionSuccess", {
success: true,
message: `歡迎使用者 ${name} 連線成功`,
});

// 發事件給所有人,包括這個使用者
io.emit("connectionSuccess", {
success: true,
message: "即刻起免費使用聊天室直到~永遠",
rooms: chatRooms,
});

users.push({
id,
name,
});
}
});
})

上面這幾行其實也是使用了 Socket.io 最常使用的兩個 APIonemit,前者就是監聽事件,後者就是發送事件。

io.on("connection", (socket) => { 這行就是前端在連接(訂閱)這個 Socket.io 後會觸發的事件,這邊都是使用 connectionconnect,兩者都可以,差別就在於如果你兩者都有使用的話 connect 會先被觸發,之後再跑 connection,詳細可參考 **你知道socket.io中connect事件和connection事件的区别吗?**,如果想實驗可以改為下列程式碼。

1
2
3
4
5
6
7
8
9
10
// /bin/www
io.on("connect", (socket) => {
console.log("a user connect");
io.on("connection", (socket) => {
console.log("a user connection");
//... 下略

// 將會輸出
// a user connect
// a user connection

修改完就會發現兩個 console.log 都被觸發,但如果你反過來像下面程式碼就只會觸發一個。

1
2
3
4
5
6
7
8
io.on("connection", (socket) => {
console.log("a user connection");
io.on("connect", (socket) => {
console.log("a user connect");
//... 下略

// 將會輸出
// a user connection

等等後面會在提到 Namespaceroom 的部分。

在跑完 connection 後會回傳一個 value,這邊我們跟官方文件一樣使用 socket,這邊會發現使用了 socket.emit("connectionSuccess",但後面又有 socket.broadcast.emit("connectionSuccess"io.emit("connectionSuccess",這邊其實先搞懂 iosocket 的差異之後發事件就大同小異了。

socket.emit("connectionSuccess" 就是發送事件給發送事件給後端的這個使用者,可能有點饒舌,但簡單說就是發送事件給透過 io.on("connection", //...略 連線(訂閱)的使用者。

socket.broadcast.emit("connectionSuccess" 就是發送事件給除了發起事件給後端的前端使用者外的人。

io.emit("connectionSuccess" 就是發送事件給所有人。

後面基本上我們會大量使用到 socket 去做 socket.on 或者 socket.emit,到這邊我們大致完成了後端的部分,接下來開始著手完成前端的部分吧。

接著就可以啟用 npm start 了,筆者自己跟預設應該都是跑在 port 3000

關於前端的 Socket.io

首先我們需要在前端刻出聊天室的樣子,這邊會先提供程式碼,傳送門請點我,這邊就不詳細講解怎麼切版;

  1. 前端主要需要安裝 socket.io-client 這個套件 及 uuid 產生獨一無二的 ID。
1
2
3
$ npm i socket.io-client -S // or yarn add

$ npm i uuid

再來前端很簡單只要記得三個基本的東西:

  • 連線
  • 監聽事件(on, once)
  • 發送事件(emit)
  1. 讓我們跟後端 Socket.io 連線,根據你想連線的檔案位子可以做調整,例如我想在 component 是聊天室頁面的時候再連線。

參考 src/pages/Lobby/index.tsx 這隻檔案,可以發現在 useEffect 剛進來的時候去做連線的動作。

初始化 Socket.io

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/pages/Lobby/index.tsx 範例檔案

import { useEffect } from "react";
import { io } from "socket.io-client";

const Lobby = () => {
// 初始化
useEffect(() => {
const newSocket = io(
process.env.REACT_APP_API_DOMAIN
? process.env.REACT_APP_API_DOMAIN
: "http://localhost:3000",
{
transports: ["websocket"],
}
//... 略
// 因為前端邏輯需求,範例專案這邊註解了
return () => {
newSocket.close();
};
}, [])
);

export default Lobby;
  1. 做好連線後我們就可以在需要的地方發起事件通知後端,在需要連線得地方輸入下面程式碼,這樣一來後端就可以接到事件了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/pages/ChatRoom/index.tsx 範例檔案

import { useEffect } from "react";
import { io } from "socket.io-client";
import { v4 as uuidv4 } from "uuid";

const ChatRoom = () => {
// 初始化
useEffect(
() => {
io.emit("joinChatRoom", {
id: uuidv4(),
name: "使用者名稱",
roomId: xxxxxxxx// 房間 ID,可在連線時由後端傳過來,
});
},
[]
);
}

export default ChatRoom;
  1. 接下來為監聽事件,監聽的事件如果在後端發起了事件,而名稱剛好對到,就會收到訊息。

監聽事件

1
2
3
4
5
6
7
8
9
// src/pages/Lobby/index.tsx

data.state.ws.once("connectionFail", (data) => {
//...略
});

data.state.ws.on("updateInfo", (data) => {
//...略
});

關於 ononce 的差異可以簡單分為,前者為事件發生當下會收到, once 則為下一次才收到。

  1. 我們有接收的監聽事件,理所當然使用者端也可以主動發起事件,這邊就是透過 emit

事件發送

1
2
3
4
data.state.ws.emit("createNewChatRoom", {
userId: data.state.id,
...newChatRoom,
});

使用者端基本就是這三個 API,再詳細的話可以參考官方文件 Socket.io


初階版的小晉級

上面有提到 NamespaceRoom,雖然我們有使用 chatRooms 去分不同的聊天室,但其實後端透過 io.emit() 去發送事件,全部的人都會收到,這樣會照成一個尷尬的情況,就是 使用者A房間A 聊天,透過前端的 io.emit() 把訊息發事件到後端,然後後端使用 io.emitsocket.broadcast.emit 發事件給使用者,這時候 房間B 的全部使用者也都會接收到訊息。

為了做到真正的分流,我們可以使用官方提供的方法 NamespaceRoom

Namespace

Namespace

這邊先來説說 Namespace,一般我們前端跟 Socket.io 連線,後端的部分都是預設 io.on("connection", //...略,但如果透過 Namespace 就可以達到一個 socket.io 有很多個 channel

  1. 將原本的 /bin/www 多加下列的程式碼
1
2
3
4
5
6
7
8
9
10
11
// /bin/www
const io = require("socket.io")(server); // 原本就有的

const channel2 = io.of("/channel-2");
const channel3 = io.of("/channel-3");
channel2.on("connection", (socket) => {
//...略
});
channel3.on("connection", (socket) => {
//...略
});
  1. 再把前端加上下列程式碼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { io } from "socket.io-client"; // 原本就有的

const xxx = () => {
const newSocket = io(
process.env.REACT_APP_API_DOMAIN
? process.env.REACT_APP_API_DOMAIN
: "http://localhost:3000",
{
transports: ["websocket"],
}
);

const channel2 = io("http://localhost:3000/channel-2",
{
transports: ["websocket"],
}
);

const channel3 = io("http://localhost:3000/channel-3",
{
transports: ["websocket"],
}
);
}

export default xxx;

如此就達到分 Channel 的概念,你可以分別在不同的 Namespace 下去監聽或者發起事件。

更詳細可參考官方文件 Namespace

Room

Room

再來我們來說說 Room,和 Namespace 都一樣可以做到分流,但他的加入方式不同,Room 提供幾個基本的 API,其中加入方式為 **join()**,當然也可以離開房間 **leave()**。

  1. 我們嘗試把使用者加入到某間房間,修改一下我們原本 /bin/www 的程式碼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// /bin/www
io.on("connection", (socket) => {
// 登入
socket.on("login", ({ id, name }) => {
//... 略
});

socket.on("join", ({id, name}) => {
// 加入 Room
socket.join(id);

io.to(id).emit(`使用者 ${name} 加入房間啦!`);
});
})

上面我們模擬一個情境,在前端透過點擊參加聊天室後,透過 Socket.io 發送事件 join,並且帶著房間(Room) id使用者名稱,房間資訊在一開始登入時會傳給使用者。

接著透過 join() 這個方式把該事件的發送使用者(socket)加入名為變數 idRoom。緊接著透過 io.to(id).emit() 取代原本的 io.emit(),這樣事件只會發送給該房間(Room)的使用者(訂閱者)。

更詳細可參考官方文件 Room

結合迸出新滋味

這時候聰明的你會想到,那我不就可以先用 Namespace 做出不同的頻道(伺服器的概念),裡面再丟好幾個小房間。沒錯!你可以這麼做,而這也是 Socket.io 的最佳應用。

遊戲分流概念

如果用上圖來說明,應該會更清楚,如果你沒在玩遊戲,或者不清楚,也可以在參考下圖。

大致概念


Conclusion & 結論

沒想到最近在工作上一忙,一晃眼又是兩個月沒有更新部落格了,趁這次在工作上摸新技術時,筆記了一下所學,其中一個就是 Socket.io,其實好久之前就一直想摸,雖然這篇文章沒有講得很詳細,但其實 Socket.io 真的滿好入門的,只要掌握其中幾個 API,像是 on()emit()of()to()join()leave()…等等,其中最前面兩個也才是最常用的,大概了解了運作流程就可以自己做很多好玩的應用了。

前端專案裡面有包著 dockerfile,剛好最近工作上也有接觸到,但如果沒有研究的話,其實可以先跳過,這部分不影響程式碼進行;最後這邊會再附上 Source Code 的網址,大家都可以 colone 下來玩看看。

Chat Room

最後的最後放上一張手寫筆記結束今天這一回合,晚安各位!

筆記大全


參考網站