# 用Socket.io實現即時通訊 ## 背景 在早些HTTP協議開發時,並不是為了雙向溝通而準備的,起初只要網站請求-回應這樣就好了,所以為擁有雙向溝通的網站,只能透過HTTP輪詢的方式達成,因此有了`長輪詢` 與`短輪詢` **短輪詢:** 透過Client端定期詢問Server是否有新的資料,輪詢間隔大了資料不夠即時正確,但間隔小的話,則會消耗過多的流量增加伺服器的負擔。 **長輪詢:** 是對短輪詢的優化,需要Server做對應的修改來支持此事,Client端向Server端發送請求時,如果沒有新的資料產生,並不立刻回傳,而是Hold住一段時間等有新的資料或者超時再回傳。 但每一次請求包含較長的header,其中真正有效的資料可能只是很小的一部分,顯然會消耗過多的資源,因此,有了Websocket的出現, **WebSocket**,是一種網絡傳輸協議,位於 OSI 模型的應用層,可在單個 TCP 連接上進行全雙工(兩個方向上同時傳輸)通訊,能更好的節省服務器資源和頻寬並達到即時通訊,**客戶端和服務器只需要完成一次握手,兩者之間就可以創建持久性的連接**,並進行雙向數據傳輸,與HTTP使用一樣port。WebSocket預設使用80 port,協議為`ws://`,TLS加密請求使用443 port,協議為`wss://`。 ### **握手** 使用 HTTP 進行實現。由客戶端使用 http 的方式發起握手請求,服務端接請求後,將當前正在使用的連接(TCP)的協議,由 http 協議切換爲 websocket 協議。 Request 握手請求頭會帶有 `Upgrade` 參數用於升級協議類型 ```shell GET / HTTP/1.1 Upgrade: websocket Connection: Upgrade Host: example.com Origin: http://example.com Sec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ== Sec-WebSocket-Version: 13 ``` Response `HTTP/1.1101 Switching Protocols`表示服務端接受 WebSocket 協議的客戶端連接 ```shell HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: fFBooB7FAkLlXgRSz0BT3v4hq5s= Sec-WebSocket-Location: ws://example.com/ ``` ### 數據傳輸 服務端接收握手請求後,回覆 response 消息,一旦這個握手回覆發送出去,服務端就認爲此 WebSocket 連接已經建立成功,處於 OPEN 狀態。它就可以開始發送數據了。WebSocket 中所有發送的數據使用`frame`的形式發送 ![](https://hackmd.io/_uploads/r14lulAI3.png) - 建立在 TCP 協議之上,服務器端的實現比較容易。 - 可以發送文字,也可以發送二進位資料。 - 數據格式比較輕量,性能開銷小,通訊高效。 - 沒有同源限制,客戶端可以與任意服務器通訊。 ## Socketio 簡介 [Socket.IO](http://socket.io/) 創建於 2010 年。它的開發是為了使用開放連接來促進即時通訊,允許客戶端與伺服器進行雙向通訊,為了連接雙方並交換資料,有一個瀏覽器端的套件與伺服器端的套件,都是屬於事件驅動,其底層使用`Engine.io` 。Socketio 基於使用Websocket協議,如果因為環境無法建立Websocket連接則會退回HTTP長輪詢。 ### 應用場景 - 聊天室 - 共同編輯 - 即時更新系統 - 社交訂閱通知 - 多人玩家遊戲 ### 特點 - SocketIO 通過命名空間支持多路復用。使用命名空間使能夠最大限度地減少使用的 TCP 連接數 - 服務器端靈活地向所有連接的客戶端廣播事件。還可以通過房間功能向部分客戶廣播事件。 - 提供HTTP 長輪詢作為後備選項,這在不支持 WebSockets 的環境中很有用 - 提供了一種可配置的 Ping/Pong 心跳機制,檢測連接是否存在。此外,如果客戶端斷開連接,它會自動重新連接 - SocketIO 具有有限的本機安全功能。例如,它不提供端到端加密,也不提供生成和更新令牌以進行身份驗證的機制。 - SocketIO 與任何其他 WebSocket 實現都不兼容。 - SocketIO 不保證精確一次(exactly-once )消息語義。默認情況下,提供至多一次(At-most-once)保證。SocketIO 也可以配置為提供至少一次(At-least-once)保證,這會帶來額外的工程複雜性——必須使用確認、超時、為每個事件分配唯一 ID 並將事件保存在資料庫中。 心跳機制設定時間 ```javascript= const httpServer = require("http").createServer(); const io = require("socket.io")(httpServer, { pingInterval: 10000, // how often to ping/pong. pingTimeout: 30000 // time after which the connection is considered timed-out. }) ``` ### Demo code 以 Node.js 為例 安裝相關套件 ```shell npm intall express socket.io ``` 建立一個HTTP伺服器並整合socket. io ```javascript= const express = require("express"); const path = require("path"); const http = require("http"); const socket = require("socket.io"); const port = process.env.PORT || 3000; const app = express(); const server = http.createServer(app); app.use(express.static(path.join(__dirname, "public"))); // 整合socket.io const io = new Server(server); const onConnection = (socket) => { console.log("Socket.io connect success", socket.id); }; io.on("connection", onConnection); server.listen(port, () => { console.log("Server listening at port %d", port); }); ``` 準備一個網頁並透過socket.io連接到Server `main.js` ```javascript= const socket = io.connect(); socket.on("connect", () => { console.log("connect server success"); }); ``` ### Emitting events 網站傳送資料 ```javascript! socket.emit("send-message", message); ``` Server 接收訊息 ```javascript= io.on("connection", (socket) => { socket.on("send-message", (message) => { console.log(`receive: ${message}`) }); }); ``` ### Broadcasting 廣播給所有人 ```javascript= io.on('connection', (socket) => { socket.broadcast.emit('hi'); }); ``` ### NameSpaces V.S. Rooms 它們兩個存在的原因都是為了`分組`,把要傳送的訊息,送到你想要的群組中。 namespace在服務器上創建,並從客戶端加入(`io.connect('/namespace')`),在沒有指定namespace時,預設為`/` ,以非常具體的工作命名,像是`news`, `games` 等。 room是namespace的子通道。room純粹是服務器端的創建,像是在一個大主題下建立了很多不同小主題,每個 room 只屬於某個 namespace, 只能收聽同一個 namespace的消息。 ![](https://hackmd.io/_uploads/HyhrKeCI2.png) **Namespaces** client連接至users的空間 ```javascript= const userSocket = io.connect("/users"}); ``` Server 創立一個users 的io ```javascript= const userIo = io.of("/users"); userIo.on("connection", (socket) => { console.log("connected to user namespace"); }); ``` **Rooms** 加入房間(Server-side) ```javascript= socket.on("join-room", (room, cb = () => {}) => { // 加入同一空間的房間 console.log("room", room); socket.join(room); // call back messages cb(`Joined ${room}`); }); ``` 如果從Client端帶房號進來,就發送訊息至此房間 ```javascript= const onConnection = (socket) => { socket.on("send-message", (message, room) => { // 廣播給在同一空間的所有人 if (room === "") { socket.broadcast.emit("receive-message", message); } else { // 發送訊息給對應的房間 socket.to(room).emit("receive-message", message); } }); ``` ### Middleware - logging - authentication / authorization - rate limiting **Sending credentials** ```javascript= const userSocket = io.connect("/users", { auth: { token: "xxxxx", }, }); ``` ```javascript= userIo.use((socket, next) => { if (isValidToken(socket.handshake.auth.token)) { console.log("token is valid"); next(); } else { next(new Error("Please send correct token.")); } }); ``` **Client side Receive errors** ```javascript= socket.on("connect_error", (err) => { console.log(err.message); }); ``` ## 參考資料 [官網](https://socket.io) [Engine.io](https://github.com/socketio/engine.io) [Socket.io 的說話島](https://mark-lin.com/posts/20170914/) [Websocket v.s. Socket.io](https://ably.com/topic/socketio-vs-websocket) [WebSocket 是什麼?爲什麼能持久連接?](https://www.readfog.com/a/1649297763805007872) [Scaling-Socketio - practical considerations](https://ably.com/topic/scaling-socketio)