# Real-Time Application(Polling, WebSocket, SSE)
###### tags: `Real-Time`,`polling`,`WebSocket`,`SSE`
在 web application 中,如何讓 client 端及時地接收到 server 端傳送的更新訊息,大概有三種中做法:
- Long/short polling
- WebSockets
- Server-Sent Events
其中 polling 是由 client 端發起(client pull),WebSockets 和 SSE 是由 server 端發起(server push)
## Http connection 小知識
- 使用 TCP 協定建立 client & server 端的連結,TCP 建立連線跟關閉連線都有交握過程,頻繁的建立/關閉 TCP 連線會造成 connect/close delay。
- HTTP/1.1 預設是 persistent connections,允許一個 persistent 的 TCP 連線有多次的 HTTP request/response,直到 broswer 或 server 端中斷這個 TCP 連線,減少 connect/close delay。
- TCP 中斷連線的情況有:
-- 達到 broswer 或 server 端 connection timeout 設定時間
-- server 端 header 回傳 `Connection: close`
- 每種 broswer 支援最大 persistent connections 數目不一樣,可參考 [Diffusion Browser connection limitations 文章](https://docs.pushtechnology.com/cloud/latest/manual/html/designguide/solution/support/connection_limitations.html),直得注意的是最大連線數是 `per browser`, `per browser tab`,所以同一個網站開啟多個 browser tab,連線數上限是一起計算的。
## Polling
client 端**定時**發 request 至 server 端,分成 Short Polling 和 Long Polling。
### Short Polling
AJAX-based timer,設定固定的時間,透過 AJAX 發送請求至 server 端。最常見的處理方式就是透過 js 的 `setInterval` function 發送 AJAX 請求,不管 Server 端有沒有需要更新的資料,client 端都會發送 request。
#### 缺點
- Short Polling 不管 server 端有沒有更新資料,都會頻繁地發送 request,可能會出現頻繁又無效的 request。
- 每一次 request-response 都是完整的 HTTP 連線,HTTP/1.1 的 Headers 是沒有壓縮的,而每個 Requests 絕大部份 Headers 都是重複的。如果每次更新的資料(response body)不多,會發現 Polling 大部份都是在傳輸重複的 Header。
- Polling 佔用 HTTP 連線,會頻繁地開啟關閉 TCP/IP 連線。
### Long Polling
Long Polling 其實就是設定 AJAX timeout 時間,也就是發一個長時間等待的 request,而當 Server 端有 Response 更新資料時或是到達 timeout 時間,連線就會斷掉,接著再重新發一個新的 request,減少無效的 request。一個簡單的例子如下:
```javascript=
const pollUserEvents = () => {
$.ajax({
method: 'GET',
url: 'http://localhost:8080/getUserEvents',
dataType: "json",
timeout: 30000,
success: (data) => {
/* Do something */
},
complete: () => {
/* Do something */
pollUserEvents();
},
})
}
pollUserEvents()
```
#### 缺點
- Long Polling 不適合頻繁更新訊息的情境。
- 如果某次連線出了問題,Long Polling 必須等到 timeout 後才會再發起新的 request,訊息上會出現延遲。
- 每一次 request-response 都是完整的 HTTP 連線,HTTP/1.1 的 Headers 是沒有壓縮的,而每個 Requests 絕大部份 Headers 都是重複的。如果每次更新的資料(response body)不多,會發現 Polling 大部份都是在傳輸重複的 Header。
- Polling 佔用 HTTP 連線,會頻繁地開啟關閉 TCP/IP 連線。
- 更多的 real-world challenges 可參考 [HTTP Long Polling Issues](https://tools.ietf.org/html/rfc6202#section-2.2)
### Polling 應用服務
- [Amazon SQS](https://docs.aws.amazon.com/zh_tw/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-short-and-long-polling.html)
- [Twilio Message-Statuses](https://www.twilio.com/docs/sms/outbound-message-logging#message-statuses)
## WebSocket
WebSocket 是一種通訊協議(Communication Protocol),透過 TCP 連線提供全雙工(full duplex)的通訊 channel,默認 port 為 80 & 443,兼容 HTTP 協議。
client 端與 server 端在 handshake 後,會建立一條全雙工、雙向的資料傳輸連線,雙方可透過 WebSocket API 進行發送或接收。HTTP protocol 與 WebSocket protocol 都依賴於傳輸層(layer 4)的 TCP 協定。使用 WebSocket 要注意 browser 的 TCP 連線上限。
協議標示符號是 `ws` ,若加密則為 `wss`,連線到一個 WebSocket Server 的位址如下:
```
ws://example.com:80/path
```
```graphviz
digraph webscket {
nodesep=1.0 // increases the separation between nodes
node [color=Red,fontname=Courier,shape=box] //All nodes will this shape and colour
edge [color=Blue, style=dashed] //All the lines look like this
WebSocketServer->{Client1 Client2 Client3}
Client1->{WebSocketServer}
Client2->{WebSocketServer}
Client3->{WebSocketServer}
}
```
### handshake 細節
#### HTTP Upgrade header
client 端與 server 端在 handshake 階段使用 HTTP Upgrade header,header 會帶入 `Connection:Upgrade` 和 `Upgrade:websocket`,告訴 server 端把 HTTP protocol 轉成 WebSocket protocol,一但 server 端返回 101 Switching Protocols,代表切換成功,即完成建立一條 WebSocket 連線。
#### Sec-WebSocket-Key & Sec-WebSocket-Accept
交握過程中 client 端 header 會帶入 [`Sec-WebSocket-Key`](https://developer.mozilla.org/zh-TW/docs/Web/HTTP/Protocol_upgrade_mechanism#Sec-WebSocket-Key),基本上是由 browser 幫忙生成,而 server 端 header 會回傳 [`Sec-WebSocket-Accept`](https://developer.mozilla.org/zh-TW/docs/Web/HTTP/Protocol_upgrade_mechanism#Sec-WebSocket-Accept)。
server 端生成的 `Sec-WebSocket-Accept` 值為 client 端傳來的 `Sec-WebSocket-Key` 和字串 `258EAFA5-E914-47DA-95CA-C5AB0DC85B11` 組合起來做 SHA-1 hash,再進行 Base64 編碼。因此 client 端可以驗證 server 端回傳的 `Sec-WebSocket-Accept`,來防止 client 端無意間或重複請求 WebSocket 連接。
P.S. `Sec-` 開頭的 header 可以避免被瀏覽器讀取,XMLHttpRequest 也不允許設置 `Sec-` 開頭的 header,可避免偽造。
#### Origin
HTTP 協議對於跨域請求(CORS)是由 server 端決定的, server 端可以透過 header 中 [`Access-Control-Allow-Origin`](https://developer.mozilla.org/zh-TW/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) 設定值,來決定 client 端 domain 是否有存取權限,但 WebSocket 沒有明確規範跨域處理做法。
WebSocket 連線在 handshake 時,client 端的 header 會帶著 `Origin`,這值基本上是由 browser 幫忙生成,告訴 server 端這個 `Origin` 要進行 WebSocket 連線,WebSocket 規範並沒有規範 client 端與 server 端必須同源,所以如果 server 端沒有進行 `Origin` 驗證,將會出現漏洞,即使 server 端有針對使用者進行身份驗證仍可能會造成危害。
例如有位在 A 網站已登入的使用者,瀏覽其他的惡意網站時,該網站對 A 網站的 WebSocket Server 發起連線,瀏覽器會把 A 網站的 Cookie 等身份驗證參數及惡意網站的 `Origin` 帶給 A 網站的 WebSocket Server,如果 WebSocket Server 沒有查驗 `Origin`,惡意網站就假冒該使用者與 A 網站的 WebSocket Server 建立了連線,可以收到使用者訊息,甚至可以發送訊息到 server 端竄改資料。
p.s. 非瀏覽器的 client 端可能不會在 header 中帶有 `Origin`,驗證 `Origin` 方法無法適用所有情境。
### 跨域安全性
WebSocket 沒有明確規範跨域處理做法,有鑒於 WebSocket 連線都是在 handshake 階段進行驗證,可以在 Server 端針對 `Origin` header 進行檢查,減少跨域漏洞的風險。但非瀏覽器的 client 端 header 中可能不會帶有 `Origin`,且 header `Origin` 內容是可以偽造的,因此可以參考下面解法:
server 端為每一組 client 端生成一組唯一且一次性 token,client 端發起 WebSocket 連線請求前,透過一般 HTTP API 先來跟 server 端要一組 token,發起 WebSocket 連線請求前帶這組 token,server 端再驗證 token 正確性,完成 WebSocket 連線並棄用該 token。因 HTTP 同源政策使得惡意網站無法透過 HTTP API 得到 token,另外即使惡意網站事後得到使用者先前拿到的 token,因該 token 早在先前使用後即被棄用,也無法進行 WebSocket 連線。
```sequence socket_token
"Client"->"Server":取 token
"Server"->"Server":驗證身份,生成 token
"Server"->"Storage":儲存 token
"Server"->"Client":返回 token
"Client"->"Server":要求 WebSocket 連線
"Server"->"Server":驗證身份
"Server"->"Storage":驗證 token
"Server"->"Storage":註銷 token
"Server"->"Client":101 Switching Protocols,建立 WebSocket 連線
```
### WebSocket 應用服務
- [AWS AppSync](https://aws.amazon.com/tw/blogs/mobile/appsync-realtime/)
- [Amazon API Gateway](https://aws.amazon.com/tw/blogs/compute/announcing-websocket-apis-in-amazon-api-gateway)
- [Twilio](https://www.twilio.com/docs/glossary/what-are-websockets#how-twilio-uses-websockets)
## SSE (Server Send Event)
SSE 是一種當 client-server connection 建立後,server 端會非同步地 push data 至 client 端的機制。不同於 WebSocket,SSE 顧名思義是單向的,就是 server to client,是建立一條持久且單向的 HTTP connection。
如果不需要雙向的情境,很適合用 SSE,例如狀態更新、新的提醒之類的功能。
SSE 另外的好處是可以設定 event ID,可以針對 event ID 進行追蹤。
### 資料格式
SSE 定義了一些特別的資料傳輸格式,SSE 傳輸的資料都要符合它所定義的格式。最基本的資料格式就是以 `data:` 開頭,加上資料的內容,最後要以兩個換行字元 `\n\n` 作結尾,也可以將資料分成多行來傳輸,每一行資料都是以 `data:` 開頭,然後以一個換行字元 `\n` 作結尾:
```
data: first message\n
data: second message\n\n
```
連續的 `data:` 會被視為同一筆資料,這些資料傳送至瀏覽器時,只會觸發一個 event,而這些資料會以換行字元為分隔,合併為一個字串,上面的例子瀏覽器收到的會是 `first line\nsecond line`。
另外也可以設定 event ID,以 `id:` 開頭,設定 event ID 可以針對 event 進行追蹤。使用 `retry:` 來指定等待的時間(預設是 3 秒)。為事件命名可以以 `event:` 來指定。
### client 端
js 中有 [EventSource interface](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) 去接收 server 端傳來的訊息,它會對 HTTP Server 建立持久的連線,這也是 HTML5 標準的 API,但要注意瀏覽器支援(IE 跟 Edge 不支援)。
### server 端
#### header 設定
- `Content-Type` 必須設置為 `text/event-stream`
- 要禁止瀏覽器快取,`Cache-Control` 設為 `no-cache`。
- 如果使用 Nginx Server 必須設定立即輸出緩衝區資料,`X-Accel-Buffering` 設為 `no`。
#### PHP
簡單的發送例子:
```php=
<?php
ini_set('max_execution_time', 0);
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
$event_id = 'ooxxoox1233';
$data = [
'name' => 'Burgess',
'message' => 'New Event!!!'
]
echo 'id: ' . $event_id . '\n';
echo 'data: New message\n';
echo 'data: ' . json_encode($data) . '\n\n';
ob_flush();
flush();
```
SSE 為持久的連線,因此不能限制 PHP 執行時間,必須將 `max_execution_time` 設定為 0。
PHP Script 全部執行完畢後,產生的資料才會從 output_buffer 一次輸出到 Browser,但使用 SSE 應立即輸出每次的資料,因此必須設定讓buffer 資料立即輸出。
`ob_flush()` : 將 buffer 中的資料輸出給 Server。
`flush()` : 將 Server 中的資料輸出。
## WebRTC(Web Real-Time Communication)
有別於 WebSocket 建立 client 端與 server 端的一條全雙工、雙向的資料傳輸連線,WebRTC 是建立 client 端與 client 端的傳輸連線(Peer-to-Peer),最常應用場景就是兩個 clinet 端間的直播
```graphviz
digraph webrtc {
nodesep=1.0 // increases the separation between nodes
node [color=Red,fontname=Courier,shape=box] //All nodes will this shape and colour
edge [color=Blue, style=dashed] //All the lines look like this
WebRTCServer->{Client1 Client2}
Client1->{WebRTCServer Client2}
Client2->{WebRTCServer Client1}
{rank=same;Client1 Client2}
}
```
兩個 client 間建立連線是有很多問題的,首先大部分 client 是沒有專屬 IP 的,如何從茫茫網路中找到對方就是個問題。另外各自的防火牆設定也是問題
### WebRTC protocols 組成
- NAT: NAT 是一種 IP mapping 的技術,將外部連進來的 client 配發一組 private IP,每組 private IP 會對應到 NAT public IP 的唯一 port,所以只要 NAT public IP + port 就能找到 client。NAT 有分兩類: Core NAT 跟 Symmetric NAT,Core NAT 可以保持同一個 client 的在 public IP 的 port 是固定的,Symmetric NAT 對一個 client 可能會發多組 port
- ICE(Interactive Connectivity Establishment): 允許兩個裝置直接連結的一項技,兩個不同 NAT 的 public IP + port 互相進行 UDP/TCP 連結
- STUN: 發送 public IP + 唯一 port 給 client
- TURN: 如果是 Symmetric NAT,一個 client 可能有多組 port,需要 TURN server 做中繼,搭起兩個 client 連結
- SDP: 定義雙方傳送內容的 formats, codecs, encryption 等等標準
### WebRTC 連線流程
- 呼叫方初始化本地的 Media,並建立 RTCPeerConnection
- 創建符合 SDP 的 offer,這 offer 帶有本地端的連結描述
- 呼叫端請求 STUN server,並取得 ICE candidates,ICE candidates 是標明的可用的通信方法(UDP or TCP)
- 透過 signaling server 把 offer 傳給接收方,在兩 client 端建立連線之前,需透過 signaling server 轉送 Offer 和 Answer 訊息,signaling server 也可稱作 signaling service
- 接收方收到 offer 後,也會初始化本地的 Media,並創建 answer
- 接收方發送 answer 給呼叫方
- 呼叫方接收到 answer,雙方都知道彼此連結描述,可直接通信
以下是 Symmetric NAT 流程圖

<待續...>
# 參考資料
- [WebRTC API](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API)