雙向互動的即時訊息:Websocket 入門

雙向互動的即時訊息:Websocket 入門

在 HTTP 設計之初,網路應用主要是交換文件,因此當提交訊息或更新訊息時,需要刷新整個頁面,這也導致大量 HTML 被重複傳輸,浪費使用頻寬。後來 AJAX 被提出,讓 HTTP 可以只取得想要的伺服端訊息,同時在沒有重新導向的情況下更新頁面,讓 HTTP 更符合現代網路應用情境。

但是對需要互動的應用,像是聊天室、遊戲、即時狀態監控等等來說,如果使用 HTTP 傳遞訊息,則需要客戶端頻繁向伺服端輪詢(Polling),有點像客戶端三不五時跟伺服端問說:「你有沒有新資料需要更新的啊?」可想而知會造成客戶端跟伺服端很大的負擔。比較理想的情況是,應該存在一個事件驅動模型,當伺服端有事件發生時,它會主動通知訂閱的客戶端,客戶端再進行更新,而這就是 WebSocket 這套通訊協定誕生的原因。

Websocket 沒有限定語言,但為了簡化操作,後端可以用 node.js,好跟前端的 JavaScript 共用一套函式庫。本文中會使用 node.js 常見的後端框架 Express,並搭配 socket.io,來建立前後端之間的 WebSocket 連線。

想 Clone 程式碼的,可以到這裡

Create Server

既然是網路應用,首先來建立 Server,node.js 的專案結構是

project
├── public
├── index.js
└── README.md

初始化專案

npm init

npm 會問你一堆問題,通常按照預設來回答就好

ken@DESKTOP-2R08VK6:~/git/medium-example-nodejs/socket-io$ npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.

See `npm help json` for definitive documentation on these fields
and exactly what they do.

Use `npm install <pkg>` afterwards to install a package and
save it as a dependency in the package.json file.

Press ^C at any time to quit.
package name: (socket-io) 
version: (1.0.0) 
description: socket.io demo
entry point: (index.js) 
test command: 
git repository: 
keywords: 
author: kenwschen
license: (ISC) MIT
About to write to /home/ken/git/medium-example-nodejs/socket-io/package.json:

{
    "name": "socket-io",
    "version": "1.0.0",
    "description": "socket.io demo",
    "main": "index.js",
    "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
    },
    "author": "kenwschen",
    "license": "MIT"
}

Is this OK? (yes)

回答完後,專案初始化完成。

接著來安裝 express,這是一套 node.js 的 Web 框架,可以用來將不同的 URL 導向不同的資源(用專有名詞來說,可以用來做多路複用),它的官網是
Express — Node.js web application framework

安裝方式是

npm install express --save

npm 會將 express 加入 node_modules 中,讓 node 可以調用依賴,專案變成

project
├── node_modules
├── public
├── index.js
├── package.json
├── package-lock.json
└── README.md

在入口 index.js 中加入內容

const http = require("http");
const express = require('express');
const app = express();

app.get('/', function (req, res) {
    res.type('text/plain');
    res.status(200).send('Hello, World.');
})

server = http.createServer(app)
server.listen(8001, () => {
    console.log('Express started')
})

前面是引用函式庫,並建立 express instance

const http = require("http");
const express = require('express');
const app = express();

再建立 root 的回覆訊息

app.get('/', function (req, res) {
    res.type('text/plain');
    res.status(200).send('Hello, World.');
})

當收到 Get / 的 HTTP request 時,response 的形式是純文本;狀態是 200 ok;內容是 “Hello, World.”

最後,監聽 port 8001,如果建立成功,印出訊息

server = http.createServer(app)
server.listen(8001, () => {
    console.log('Express started')
})

執行程式

node index.js

在瀏覽器的導航列輸入 http://127.0.0.1:8001 可以看到 “Hello, World.”

Create WebSocket Server

有了基本 Server 後,再來加入 WebSocket,這邊使用 Socket.IO 這套函式庫

安裝方式是

npm install socket.io --save

改寫 index.js 內容

const http = require("http");
const io = require('socket.io');
const express = require('express');
const app = express();

// ...

var servIo = io.listen(server);
servIo.on('connection', function (socket) {
    setInterval(function () {
        socket.emit('second', { 'second': new Date().getSeconds() });
    }, 1000);
});

WebSocket 跟 HTTP Listen 同一個 Port,訂閱 connection 事件,當連線建立時會觸發

servIo.on('connection', function (socket)

若是連線成功,則每秒發送當前的秒數到 second 事件中

setInterval(function () {
        socket.emit('second', { 'second': new Date().getSeconds() });
    }, 1000);

Create WebSocket Client

建立完伺服端,接著建立客戶端。客戶端用到的的靜態資源會放在 public 下,因此新增兩個檔案

project
├── node_modules
├── public
│   ├── client.js
│   └── socket.html
├── index.js
├── package.json
├── package-lock.json
└── README.md

也要讓 express 知道這件事,改寫 index.js

const http = require("http");
const io = require('socket.io');
const express = require('express');
const app = express();

app.use(express.static(__dirname + '/public'));

新增的兩個資源,socket.html 用以描述前端頁面;client.js 用來建立 WebSocket 並改寫 HTML 顯示的資訊。

先來看 HTML

<html>

<head>
    <script src="/socket.io/socket.io.js"></script>
    <script src="//ajax.googleapis.com/ajax/libs/jquery/1.11.0/jquery.min.js"></script>
</head>

<body>
    <script src="client.js"></script>
    <div id="second"></div>
</body>

</html>

引用了 socket.io 的函式庫,還有 jQuery 用來操作 HTML 元素。body 內執行 client.js 的內容,並有一個 div 顯示秒數資訊。

接著看 client.js

var socket = io.connect();

socket.on('second', function (second) {
    $('#second').text(second.second);
});

訂閱 second 事件;當收到 second 時,改寫 second 元素中的內容

用瀏覽器打開 http://127.0.0.1:8001/socket.html,叫出剛剛建立的頁面,用 F12 打開瀏覽器的開發者視窗,可以看到資源都被 Get 回來

其中有個 WebSocket 連線,點選後可以看到伺服器不斷傳送訊息

前端頁面上的秒數值也會不斷被刷新。

Monitor WebSocket Packet

我們可以用 WireShark 來觀察 WebSocket 的封包。WebSocket 的交握過程如下圖

Client 會發起 HTTP Upgrade,要求將 Session 升級為 WebSocket 協定,Server 收到後,會回覆 Client 已經 Upgrade,雙方後續就可以使用 WebSocket 通訊。如果用 WireShark 抓取封包,結果會是

可以看到一開始是 HTTP,等到雙方交握完成後,就改為 WebSocket。

進一步查看交握的封包內容

HTTP 中會帶許多 Header,Upgrade 表示要升級的協定;Sec-WebSocket-Key 則是交握用的資訊。Server 收到後會回

狀態碼是 101,表示協議切換;其中 Sec-WebSocket-Accept 是用 Sec-WebSocket-Key 算出來的值,用來避免跨協議攻擊。

後面 WebSocket 協定包括幀頭跟載荷

Fin 的 bit 表示該幀為完結幀,通常 WebSocket 傳送只用到一幀,但也能支援多幀傳送;Opcode 是操作碼,表示內容的類型,通常用於 WebSocket 的傳送都是文本;Payload 是資料內容,可以看到事件名稱跟秒數都在 Payload 中。

Send Message From Client

由於 WebSocket 是雙向通訊,也可以改寫程式,由前端發訊息給後端,先在 HTML 中建立文本輸入欄位

<body>
    <script src="client.js"></script>
    <div id="second"></div>
    <textarea id="text"></textarea>
</body>

改寫 client.js

$(document).ready(function () {
    $('#text').keypress(function (e) {
        socket.emit('client_data', String.fromCharCode(e.charCode));
    });
});

在前端載入完成時註冊一個 function,當文本輸入欄位被輸入新的值,這個值就會立刻傳送回後端。

接著改寫 index.js

servIo.on('connection', function (socket) {
    setInterval(function () {
        socket.emit('second', { 'second': new Date().getSeconds() });
    }, 1000);

    socket.on('client_data', function (data) {
        console.log(data);
    });
});

訂閱 client_data,當收到前端回傳的資料,用 console.log() 印出。

這樣就能完成雙向通訊了。

小結

隨著 Web 領域的發展,WebSocket 已經是非常常見的應用了,畢竟現在的網路應用越來越接近桌面程式,前後端的互動更加頻繁,有些時候必須仰賴 WebSocket 才能滿足即時性的需求。像是開發設備前端,如果只使用單純的 AJAX,由於瀏覽器不可能每秒都跟後端取資料,拿到的數據不免有可能會失真,無法正確反映瞬間峰值,這時就是改用 WebSocket 的時機點。

Reference

Read more

CDN 的快取失效設計:內容平台場景

CDN 的快取失效設計:內容平台場景

Phil Karlton 有句名言:「計算機科學中只有兩件難事:快取失效和命名。」 想像你在管理網站,因為傳輸速度與伺服器效能問題,網站讀取速度很差,特別是當你的使用者來自地球另外一端,常常需要等待幾秒才能看到畫面,這讓他們的使用體驗大打折扣。身為一名重視使用體驗的開發者,你肯定知道該如何解決這問題,沒錯,答案就是 CDN(內容傳遞網路)。 CDN 可以看成是服務商在全球各地建置伺服器,當你的網站內容(例如圖片、CSS、JavaScript、影片等)流經這些伺服器時,它會保留一份複本(稱為快取),等到下次有人讀取同樣的內容,CDN 會拿出複本給使用者。因為全球各地都有 CDN 節點,美國的使用者可以由美國節點提供,日本的使用者可以由日本節點提供。這樣既加速網路傳遞效率,也降低來源伺服器的效能壓力,可謂一舉兩得。 當然這是有條件的。CDN 會使用網址來判斷快取是否是相同檔案,假設你的內容以圖片為主,通常來說,當你更換圖片,新舊兩張圖片會有不同網址,被當成兩個不同的檔案,新圖使用新快取,舊快取留著也沒差;但如果你的內容是文字,新舊版文字很可能有相同網址,

By Ken Chen
收拾行李搬家去:從 Medium 到 Ghost

收拾行李搬家去:從 Medium 到 Ghost

想搬家想很久,連身邊的朋友都搬完了,我還沒動工。 原因是我懶,我討厭麻煩,每次有人問我吃什麼,我都回答麥當勞。搬家是一件麻煩事,我已經有一份很讚的工作了,全副精神都放在工作上,偶爾才會想起來,反正家什麼時候都能搬,一點也不急,有什麼好急的呢對吧。這樣一拖,就拖到現在。 繼續用 Medium 不好嗎? 跟男女朋友分手一樣,通常被問到:「對方不好嗎?」得到回答是:「也沒有不好啦,只是……(以下開放填空)。」 從優點開始講吧!Medium 的編輯器很棒,它是 WYSIWYG(所見即所得)類型的編輯器,能讓創作者快速發佈內容,也因為它讓內容發佈更容易了,它開始吸引一批優秀的創作者,這批創作者持續創作內容,又吸引來更多讀者,更多讀者激勵創作者產出內容,內容又再吸引讀者……這形成一個增強迴圈。Medium 還能支援多人協作,拜它時尚簡約的風格所賜,科技公司會使用 Medium 來打造品牌形象,例如我前公司的 Tech Blog

By Ken Chen
OpenTelemetry 的可觀察性工程:以 Sentry 為例

OpenTelemetry 的可觀察性工程:以 Sentry 為例

點進 OpenTelemetry 的官方文件,它最先映入眼中的句子是「什麼是 OpenTelemetry」。例如,它是套可觀察性框架,用於檢測、蒐集與導出遙測數據;它是開源且供應商中立,能搭配其他的開源工具,像 Jaeger 或 Prometheus;它能將應用程式與系統儀表化,無關是用 Go 還是 .NET 開發,也無關部署在 AWS 還是 GCP 上。 但是身為一名開發者,當下我們想的是:「公司常開發一些沒人要用的功能,聽說 OpenTelemetry 可以提高可觀察性,也許我們應該放棄開發功能,轉頭建立更好的開發環境。」「AWS 常常要不到需要的數據,也許我們應該改用另一套工具,像是 OpenTelemetry,來解決這件事。」我們想像 OpenTelemetry 「應該」要能解決目前面臨到的一些問題,就像在技術的鏡像中尋找願望一樣。 如果已經有在用 Sentry,還需要導入 OpenTelemetry

By Ken Chen
標準化之路:Go 1.23 中的迭代器

標準化之路:Go 1.23 中的迭代器

Ian Lance Taylor 在 "Range Over Function Types" 這篇文章聊到 iterator 誕生的原因。如果我們有兩個容器,稱為集合(Set),想要取得這兩個集合中的不重複元素,加到新的集合中形成聯集,我們可以寫個 Union 函式來執行 // Set holds a set of elements. type Set[E comparable] struct { m map[E]struct{} } // Union returns the union of two sets. func Union[E comparable](s1, s2 *Set[

By Ken Chen