天天看點

WebSocket 開發指南

    websocket 由來已久, 常用于 "伺服器推" 場景。最近開始學習 websocket (從 tomcat examples 開始), 本文的主要目的是做學習筆記, 同時記錄一份開發指南。

我們先來看看 http。

一次 http 請求過程如下:

用戶端

伺服器

1.

用戶端建立到伺服器的 tcp 連接配接

2.

用戶端發送請求

3.

用戶端等待響應

4.

伺服器收到請求

5.

伺服器發送響應

6.

用戶端收到響應

7.

請求結束

tcp 連接配接是支援雙向同時讀寫的全雙工協定, 但是我們看傳統的 http 協定有幾個問題:

請求過程是串行的, 用戶端與伺服器互相等待.

請求是單向的, 總是必須用戶端先發起請求.

也就是說, 傳統的 http 1.0/1.1 協定沒有充分利用 tcp 連接配接的能力.

http 協定是無狀态的, 兩個請求是從同一個 tcp 連接配接發過來, 還是從不同的 tcp 連接配接發過來, 對伺服器來說應該是等價的.

    http 協定這樣的設計主要是簡化了程式設計模型, 想一想傳統的 cgi 腳本, 一個腳本隻要能夠接受輸入, 産生輸出, 就可以提供 web 服務。http 協定缺少 iso 7 層網絡模型中的會話層, 動态 web 應用使用 cookie 來儲存會話資訊。http/1.1 預設開啟長連接配接來優化性能, 但 http 連接配接和請求依然是無狀态的。對傳統提供靜态内容服務, 或傳回資訊相對确定的 web 應用而言, 這樣的設計并沒有問題, 或者說雖然有一些不足, 但尚能忍受。無狀态的設計也簡化了 http 測試, 日志回放也成為重要的 http 服務測試手段之一。

    直到 "伺服器推" 場景的出現。伺服器端資訊随時可能變化, 我們希望将變化後最新的資訊立即通知給用戶端。傳統的解決方案是用戶端不斷輪詢伺服器, 如每秒 1 次。這種輪詢将産生許多額外的代價, 包括移動端流量收費, 并且程式設計模型也相對複雜。是以, 是時候開放 tcp 雙向通信的能力了。我們可以重新寫一個 tcp 伺服器, 使用新的協定來通信。但也許是為了複用 http 的 80 端口, 依附現有 http 生态圈, 讓 web 應用平滑更新, websocket 基于 http 協定設計, 顧名思義就是基于 web http 協定, 釋放原生 tcp socket 的能力。是以 websocket 一開始還是按 http 協定通信, 随後才轉換成 websocket。

一個 websocket 連接配接的建立過程如下:

用戶端請求将目前 tcp 連接配接用作 websocket

伺服器收到請求, 同意并确認将此 tcp 連接配接用作 websocket

用戶端收到确認, http 協定通信結束

雙方使用 websocket 協定自由雙向通信

websocket 可基于 http 建立, 即 <code>ws</code> 協定, 也可基于 https 建立, 即 <code>wss</code> 協定, 果然是複用了 http 的基礎設施。

http 用戶端發送完請求後才會監聽響應, 收到一次響應後即結束。常見的 http 用戶端有:

<code>curl</code>, 如 <code>curl localhost:8080/http</code>.

浏覽器 js 用戶端, 如 angularjs 的 <code>$http</code> 服務.

直接在浏覽器輸入 url.

    回顧 "伺服器推" 場景, websocket 與 http 協定最大的不同在于伺服器不必等待請求, 也不再使用 "請求", "響應" 這樣的術語, 而改稱為消息, 雙方都可以随時互發消息。http 用戶端不會一直監聽消息, 是以顯然不能作為 websocket 用戶端 (且不說協定是否相容)。要使用 websocket, 用戶端和伺服器都需要改造。常見的 websocket 用戶端有:

浏覽器 js 用戶端。感謝浏覽器廠商, 現在的主流浏覽器都支援 websocket 。

    再來看服務端開發, java 定義了一套 <code>javax.servlet-api</code>, 一個 <code>httpservlet</code> 就是一個 http 服務。java websocket 并非基于 servlet-api 簡單擴充, 而是新定義了一套 <code>javax.websocket-api</code>。一個 websocket 服務對應一個 <code>endpoint</code>。與 <code>servletcontext</code> 對應, websocket-api 也定義了 <code>websocketcontainer</code>, 而程式設計方式注冊 websocket 的接口是繼承自 <code>websocketcontainer</code> 的 <code>servercontainer</code>。一個 websocket 可以接受并管理多個連接配接, 是以可被視作一個 <code>server</code>。主流 servlet 容器都支援 websocket, 如 tomcat, jetty 等。看 <code>servercontainer</code> api 文檔, 可從 <code>servletcontext</code> attribute 找到 <code>servercontainer</code>。

注冊 endpoint 關鍵需要兩個東西, endpoint 類和對應 url 路徑, 代碼如下:

一個簡單 servlet 示例如下:

一個簡單 endpoint 示例如下:

兩者貌似相似, 其實有很大不同。

<code>doget()</code> 是處理一次請求, 輸入參數 <code>req</code> 和 <code>resp</code> 一次有效, 本次請求傳回即失效。

<code>onopen()</code> 是打開 websocket 會話, 輸入參數 <code>session</code> 會話内一直有效, 可收發多次消息。

示例中會話打開後即發送第一條消息。

endpoint 是有狀态的, 容器為每個會話建立一個 endpoint 對象執行個體, 維護目前會話狀态資訊。

是以注冊 endpoint 必須使用類而不能使用對象, 且 endpoint 類必須有無參建構函數。

而 servlet 是無狀态的, 可以使用 servlet 執行個體注冊, 多連接配接多線程均隻有一個 servlet 對象執行個體。

websocket 連接配接是有狀态的, 必須使用長連接配接, 一個連接配接天然就是一個會話 session。

http 連接配接是無狀态的, servlet 借助 cookie 管理會話, 對是否長連接配接無感覺。

注意: websocket 長連接配接與 http 長連接配接有很大不同。

http 長連接配接隻是為了向同一伺服器發送請求時複用已有的 tcp 連接配接, 優化性能, 發送請求帶不同的 cookie 就可能關聯不同的 http session。

一個 websocket 長連接配接隻為一個會話服務, 隻能收發該會話的消息。

http 長連接配接轉化為 websocket 後, 就不能再用于發送 http 請求。

http 請求是串行的, 一個 http 長連接配接必須在上一個請求響應傳回後, 才能繼續發送請求。

雙方可以自由收發消息, 不必等待, 剛收到的消息未必與剛發出去的消息對應。

收發消息的含義應該在建立 websocket 連接配接時便已經确認。

由于 websocket 收發消息的含義在建立 websocket 連接配接時便已經确認,

收發消息時可以省去很多頭資訊和參數, 包括辨別會話的 cookie 資訊, 有效節約帶寬。

服務端代碼結合前面的用戶端代碼, 即可測試 websocket。

    前面提到的 websocket 用戶端隻有浏覽器 js。回顧建立 websocket 連接配接的流程, 用戶端隻需要發一段 http 請求, 那麼是否可以使用 curl 建立 websocket 連接配接呢? 經測試是可以的, 所需參數資訊如下, 并且 curl 也不會不停的接收響應和消息。

    curl 預設會緩沖區滿或接收完響應才輸出, 由于 websocket 響應 "永不結束", <code>--no-buffer</code> 禁用 curl 内部的緩沖區, 使其立即輸出接收到的資訊。其他參數為複制浏覽器建立 websocket 時發送請求資訊, 篩選出的必須資訊。

使用 curl 測試 websocket, 結果如下:

    由于 websocket 消息含有二進制頭部, 使用 <code>od -t c</code> 進行轉義, 消息頭部為 <code>201 017</code> 兩個位元組, 其中 <code>017</code> = 15, 應該表示消息長度。消息長度使用了變長整數表示, 長度超過 127 時會使用多位元組表示長度。我們看到消息體沒有全部輸出, 這是因為 od 指令頁做了緩沖, 攢滿一行才輸出, 修改為 <code>od -t c -w1 -v</code>, 即一個位元組一行, 即可避免這個問題, 但輸出消息将被拆成很多行。

<code>-v</code> 表示顯示重複行, 預設會壓縮消除重複行。

    使用 <code>sendtext()</code> 時, websocket 自動添加了消息頭資訊, 以自動實作消息封幀和拆幀。websocket 還支援發送二進制消息或發送流式資料。測試發現一個 websocket 可以同時支援二進制和文本消息收發。但當正在發送流式消息時, 不能發送其他類型消息。

    websocket 發送流式二進制資料時, 是否可以作為原始的 tcp socket 使用呢? 即照搬所有資料, 不要加消息幀頭部。測試代碼如下:

    其中外層循環表示寫幾次資料, 内層循環表示每次寫資料長度。測試發現隻寫一次并且長度小于4時, 用戶端收不到任何資料, bug or 用法不對?

測試發現如下問題:

資料長度很小并且不調用 <code>flush()</code> 時, 用戶端也始終收不到資料。

這也是與 servlet 不一樣的地方, servlet 正常情況下不應該調用 <code>flush()</code>,

servlet 方法傳回後響應就會傳回給用戶端,

顯式調用 <code>flush()</code> 會導緻以 <code>chunked</code> 方式立即傳回部分内容。

流式發送資料依然會添加消息頭部. od 輸出如下:

w

s

一次寫資料很長時, 不用調用 <code>flush()</code> 也會發送資料到用戶端 (緩沖區滿?),

并且發送資料拆為 n 段, 每段都會加消息頭。

問題: 如何結束流式消息發送?

    由此可知, websocket 始終會加消息頭進行分幀, 不能作為原始的 tcp socket 使用。想想也是, 不加消息頭 websocket 也不能區分流式資料和分幀消息, 并且普通消息間還可以夾帶 ping/pong 應用層心跳檢測。是以 websocket 應該是一個支援消息封幀和應用層心跳檢測的會話層協定。

    前面提到 java websocket-api 要求使用 endpoint class 注冊 websocket, 然後由 servlet 容器為每個連接配接建立 endpoint 對象執行個體, 這樣就很難将 endpoint 執行個體納入 spring 容器中。spring 對 websocket 的處理與使用 dispatcherservlet 處理 http 請求類似。spring 定義了 <code>websockethandler</code> 接口處理 websocket 請求, 類似 http 的 <code>httprequesthandler</code>。然後 spring 攔截所有托管的 websocket 請求, 分發到 <code>websockethandler</code> 上。唯一的缺點是 <code>websockethandler</code> 與 <code>httprequesthandler</code> 一樣是無狀态的單例, 不能直接儲存單個會話狀态, 然而這并沒有關系。接下來我們就可以忘記 java websocket-api, 使用 spring websocket api 來程式設計了。

前面示例的 websocket, 使用 spring 重寫如下:

注冊 websocket 代碼如下:

看一下 <code>abstractwebsockethandler</code> 方法定義, 我們發現少了流式資料收發處理, 但封裝簡化了 web 應用常用的消息收發處理。

    websocket 提供了應用層心跳檢測, 由 ping/pong 消息組成。ping 表示 "你在嗎?", pong 表示 "我在!"。ping/pong 對應 ip 的 ping 請求和 ping 響應, websocket 不使用請求響應模式, 是以都叫做消息。websocket 區分資料消息和控制消息, 是以隻監聽資料消息是不會收到控制消息的。ping/pong 屬于控制消息。浏覽器通常不支援控制 ping/pong 消息, 對 ping 直接回複 pong, 忽略 pong。java websocket-api 和 spring 都對發送 ping/pong 提供了直接支援, 預設忽略 pong。

    websocket 一旦建立連接配接以後, 用戶端與伺服器是對等的, 都叫 endpoint, 兩端可以複用協定解析和消息監聽的代碼。是以 java websocket-api 也定義了用戶端 api, 調用 <code>websocketcontainer.connecttoserver()</code> 即可建立用戶端到伺服器的連接配接。前面提到 <code>servercontainer</code> 即是 <code>websocketcontainer</code>, 用戶端也可調用 <code>containerprovider.getwebsocketcontainer()</code> 擷取 <code>websocketcontainer</code>。

示例代碼如下:

java websocket-api 用戶端代碼是注解導向的, endpoint 類必須加 <code>@clientendpoint</code> 注解.

<code>onopen()</code> 方法也必須添加 <code>@onopen</code> 注解才會生效.

endpoint 類使用普通 pojo 類即可.

由于用戶端可以控制每一個連接配接的建立過程, 可以使用 endpoint 對象示例作為參數, 這樣應該更容易與 spring 內建.

endpoint 類的最佳實踐應該隻用于在會話期間儲存會話狀态.

websocket 用戶端不阻塞程序(沒有前台線程?), 是以示例程式添加 sleep 避免程式立即退出。

websocket 用戶端不依賴 servlet 容器, 普通應用也可以很容易的使用 websocket。

執行上述用戶端并沒有收到消息, 猜想是執行 <code>addmessagehandler()</code> 時已經收完消息了。

endpoint 類添加消息處理:

這回終于收到消息了, 同時出現如下異常:

可知:

要想不遺漏消息, 應在 endpoint 類上添加消息處理方法(使用注解), 而 <code>addmessagehandler()</code> 用于動态添加消息處理器。

去掉 endpoint 類上的消息處理, 伺服器延時發送消息, 用戶端果然也能收到消息。

或者 websocket 會話應用層維護 ready 狀态, 用戶端初始化完成再告訴服務端 ready 。

一種消息類型 (如 string 文本消息) 隻能設定一個消息處理器。

    spring 也對 websocket 用戶端進行了簡單封裝, 并且複用了 <code>websockethandler</code> 接口設計, 建立連接配接的核心類是 <code>websocketconnectionmanager</code>,

    用戶端與伺服器使用了相同的 <code>websockethandler</code>, 運作示例程式後用戶端與伺服器各自發出并收到一條相同的消息。注意這裡用戶端沒有 sleep, 用戶端優雅退出前已經收到消息。

    修改 websockethandler 發送 ping 消息, 列印 pong 消息, 測試發現伺服器和用戶端都收到一條 pong 消息。可見 spring 也對 ping 消息做了處理, 自動回複 pong 消息。

    spring websocket api 整體感覺比 <code>javax.websocket-api</code> 更加簡單一緻。除了少了流式讀寫接口, 然而這并不重要。