Websocket
HTTP、WebSocket 等應用層協定,都是基于 TCP 協定來傳輸資料的。
HTTP不足在于它與伺服器的全雙工通信依靠輪詢實作,對于需要從伺服器主動發送資料的情境,會給伺服器資源造成很大的浪費,WebSocket是針對HTTP在這種情況下的補充。
對于 WebSocket 來說,它必須依賴 HTTP 協定進行一次握手 ,握手成功後,資料就直接從 TCP 通道傳輸,與 HTTP 無關了。
WebSocket是一個完整的應用層協定,包含一套标準的 API 。
WebSocket 請求頭分析
Request URL: ws://localhost:8080/his-websocket/533/1giglbas/websocket
Request Method: GET
Status Code: 101
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cache-Control: no-cache
Connection: Upgrade
Cookie: Idea-e2c8f53c=ddd6f37a-65a0-4101-94f1-8864d9c71c68; sidebarStatus=0; JSESSIONID=03F59B3EE783F1CFEF2072D05835FA36; XSRF-TOKEN=50348e10-af01-441a-bb53-017ae18d0e09; SESSION=1cfa5aa3-57ec-44bb-ada7-47deb95c67b2
Host: localhost:8080
Origin: http://localhost:8080
Pragma: no-cache
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: D+ar5ktXfJ5mPzgvSIXZ/A==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Upgrade: websocket
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.132 Safari/537.36
可以發現,這段類似HTTP協定的握手請求中,多了幾個東西。
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: D+ar5ktXfJ5mPzgvSIXZ/A==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
這個就是Websocket的核心了,告訴Tomcat、Nginx等伺服器:注意啦,我發起的是Websocket協定,快點幫我找到對應的伺服器處理。
Upgrade: HTTP協定提供了一種特殊的機制,這一機制允許将一個已建立的連接配接更新成新的、不相容的協定。這裡表示要更新協定為 websocket
Sec-WebSocket-Key : 是一個Base64 encode的值,這個是浏覽器随機生成的,告訴伺服器:不要忽悠我,我要驗證你是不是真的是Websocket助理。
Sec-WebSocket-Version: 是告訴伺服器所使用的Websocket Draft(協定版本),在最初的時候,Websocket協定還在 Draft 階段,各種奇奇怪怪的協定都有,而且還有很多期奇奇怪怪不同的東西,什麼Firefox和Chrome用的不是一個版本之類的,當初Websocket協定太多可是一個大難題。不過現在還好,已經定下來啦~大家都使用的一個東西。
**Sec_WebSocket-Protocol:**是一個使用者定義的字元串,用來區分同URL下,不同的服務所需要的協定,辨別了用戶端支援的子協定的清單。
Sec-WebSocket-Extensions: 是用戶端用來與服務端協商擴充協定的字段,permessage-deflate表示協商是否使用傳輸資料壓縮,client_max_window_bits表示采用LZ77壓縮算法時,滑動視窗相關的SIZE大小。
然後伺服器會傳回下列東西,表示已經接受到請求
Connection: upgrade
Date: Wed, 25 Sep 2019 09:20:06 GMT
Sec-WebSocket-Accept: 1bISo8QakTaeaNEatm9g1yFMGaY=
Sec-WebSocket-Extensions: permessage-deflate;client_max_window_bits=15
Upgrade:
Sec-WebSocket-Accept: 這個則是經過伺服器确認,并且加密過後的 Sec-WebSocket-Key。伺服器:好啦好啦,知道啦,給你看我的ID CARD來證明行了吧,如果服務端沒有傳回此字段,用戶端會抛出“Error during WebSocket handshake”錯誤,并關閉連接配接。
用戶端通過驗證服務端傳回的Sec-WebSocket-Accept的值, 來确定兩件事情:
- 服務端是否了解WebSocket協定, 如果服務端不了解,那麼它就不會傳回正确的Sec-WebSocket-Accept,則建立WebSocket連接配接失敗。
- 服務端傳回的Response是對于用戶端的此次請求的,而不是之前的緩存。 主要是防止有些緩存伺服器傳回緩存的Response.
至此 用戶端與服務端的 WebSocket 連接配接就已經建立成功.此時的TCP連接配接不會釋放。用戶端和服務端可以互相通信了。
隻需建立一次Request/Response消息對,之後都是TCP連接配接,避免了需要多次建立Request/Response消息對而産生的備援頭部資訊。節省了大量流量和伺服器資源。是以被廣泛應用于線上WEB遊戲和線上聊天室的開發。
STOMP 與 WebSocket
WebSocket發送是以幀為機關的。而WebSocket協定上并沒有規定其消息發送的詳細格式。那就意味着每個使用WebSocket的開發者,都需要自己在服務端和用戶端定義一套規則,來傳輸資訊。那麼,有沒有已經造好的輪子呢?答案肯定是有的。這就是STOMP。
STOMP即Simple (or Streaming) Text Orientated Messaging Protocol,簡單(流)文本定向消息協定,它提供了一個可互操作的連接配接格式,允許STOMP用戶端與任意STOMP消息代理(Broker)進行互動。
STOMP協定可以建立在WebSocket之上,也可以建立在其他應用層協定之上。并不是為WS所設計的, 它其實是消息隊列的一種協定, 和AMQP,JMS是平級的。 隻不過由于它的簡單性恰巧可以用于定義WS的消息體格式。 目前很多服務端消息隊列都已經支援了STOMP, 比如RabbitMQ, Apache ActiveMQ等。很多語言也都有STOMP協定的用戶端解析庫,像JAVA的Gozirra,C的libstomp,Python的pyactivemq,JavaScript的stomp.js等等。
浏覽器提供了不同的WebSocket的協定,一些老的浏覽器不支援WebSocket的腳本或者使用别的名字。預設下,會使用浏覽器原生的
stomp.js
去建立WebSocket。但是利用
WebSocket class
這個方法可以使用其他類型的WebSockets。
Stomp.over(ws)
STOMP幀結構
STOMP是一種基于幀的協定,一幀有一個指令
一個STOMP幀由三部分組成: 指令,Header(頭資訊),Body(消息體)
- 指令使用UTF-8編碼格式,指令有SEND、SUBSCRIBE、MESSAGE、CONNECT、CONNECTED等。
- Header也使用UTF-8編碼格式,它類似HTTP的Header,有content-length,content-type等。
- Body可以是二進制也可以是文本。注意Body與Header間通過一個空行(EOL)來分隔。
來看一個實際的幀例子:
SEND
destination:/broker/roomId/1
content-length:57
{“type":"ENTER","content":"o7jD64gNifq-wq-C13Q5CRisJx5E"}
- 第1行:表明此幀為SEND幀,是COMMAND字段。
- 第2行:Header字段,消息要發送的目的位址,是相對位址。
- 第3行:Header字段,消息體字元長度。
- 第4行:空行,間隔Header與Body。
- 第5行:消息體,為自定義的JSON結構。
STOMP服務端
STOMP服務端被設計為用戶端可以向其發送消息的一組目标位址。STOMP協定并沒有規定目标位址的格式,它由使用協定的應用自己來定義。 例如/topic/a,/queue/a,queue-a對于STOMP協定來說都是正确的。應用可以自己規定不同的格式以此來表明不同格式代表的含義。比如應用自己可以定義以/topic打頭的為釋出訂閱模式,消息會被所有消費者用戶端收到,以/user開頭的為點對點模式,隻會被一個消費者用戶端收到。
STOMP用戶端
對于STOMP協定來說, 用戶端會扮演下列兩種角色的任意一種:
- 作為生産者,通過SEND幀發送消息到指定的位址
- 作為消費者,通過發送SUBSCRIBE幀到已知位址來進行消息訂閱,而當生産者發送消息到這個訂閱位址後,訂閱該位址的其他消費者會通過MESSAGE幀收到該消息
實際上,WebSocket結合STOMP相當于建構了一個消息分發隊列,用戶端可以在上述兩個角色間轉換,訂閱機制保證了一個用戶端消息可以通過伺服器廣播到多個其他用戶端,作為生産者,又可以通過伺服器來發送點對點消息。
WebSocket 和 STOMP 了解完畢,現在,我們完全可以定義一套自己的Socket服務。但是本着不要重複造輪子的原則,google一下,就會發現 Spring 已經為我們提供好了一個輪子,如果你使用 SpringBoot ,那麼使用講更加友善,隻需引入一個依賴即可:
spring-boot-starter-websocket
,在使用之前,先來了解一下 Spring中的WebSocket架構。
Spring中的WebSocket架構

圖檔來自 spring 官網: https://docs.spring.io/spring-framework/docs/5.0.0.BUILD-SNAPSHOT/spring-framework-reference/html/websocket.html
圖中各個元件介紹:
- 生産者型用戶端(左上元件): 發送SEND指令到某個目的位址(destination)的用戶端。
- 消費者型用戶端(左下元件): 訂閱某個目的位址(destination), 并接收此目的位址所推送過來的消息的用戶端。
- request channel: 一組用來接收生産者型用戶端所推送過來的消息的線程池。
- response channel: 一組用來推送消息給消費者型用戶端的線程池。
- broker: 消息隊列管理者,也可以成為消息代理。它有自己的位址(例如“/topic”),用戶端可以向其發送訂閱指令,它會記錄哪些訂閱了這個目的位址(destination)。
- 應用目的位址(圖中的”/app”): 發送到這類目的位址的消息在到達broker之前,會先路由到由應用寫的某個方法。相當于對進入broker的消息進行一次攔截,目的是針對消息做一些業務處理。
- 非應用目的位址(圖中的”/topic”,也是消息代理位址): 發送到這類目的位址的消息會直接轉到broker。不會被應用攔截。
- SimpAnnotatonMethod: 發送到應用目的位址的消息在到達broker之前, 先路由到的方法. 這部分代碼是由應用控制的。
消息從生産者發出到消費者消費的流轉流程
首先,生産者通過發送一條SEND指令消息到某個目的位址(destination),服務端request channel接受到這條SEND指令消息,如果目的位址是應用目的位址則轉到相應的由應用自己寫的業務方法做處理(對應圖中的SimpAnnotationMethod),再轉到broker(SimpleBroker)。如果目的位址是非應用目的位址則直接轉到broker。broker通過SEND指令消息來建構MESSAGE指令消息, 再通過response channel推送MESSAGE指令消息到所有訂閱此目的位址的消費者。 廢話不多說,下面直接上代碼。
Spring 與 WebSocket
讓我們以spring官網上的一個demo來看看實際的代碼
建立Message-handling Controller
在Spring中,STOMP消息會被路由到以Controller注解辨別的類中。即我們需要定義一個控制器類,并使用Controller注解來辨別它,然後在其中實作具體的消息處理方法,我們建立一個名為GreetingController的類:
Spring Framework允許和
@Controller
類同時具有HTTP請求處理和WebSocket消息處理方法。
@RestController
@Controller
public class GreetingController {
@MessageMapping("/hello")
@SendTo("/topic/greetings")
public Greeting greeting(HelloMessage message) throws Exception {
Thread.sleep(1000); // simulated delay
return new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!");
}
}
- 使用**@MessageMapping()**注解來辨別所有發送到
這個destination的消息,都會被路由到這個方法進行處理。/hello
- 使用**@SendTo()**注解來辨別這個方法傳回的結果,都會被發送到它指定的destination,
/topic/greetings
。
greeting()方法的作用是,處理所有發到
這個destination的資訊,并将處理的結果,發送到所有訂閱了/hello
這個destination的用戶端。/topic/greetings
其中模拟的延時,其本質是為了示範在WebSocket中,我們無需考慮逾時這樣的問題。 用戶端與服務端連接配接建立後,服務端可以根據實際場景,在“任何有需要”的時候“推送”消息到用戶端,直到連接配接釋放。
為Spring配置STOMP消息
The STOMP destination is used for simple prefix-based routing. For example the “/app” prefix could route messages to annotated methods while the “/topic” and “/queue” prefixes could route messages to the broker.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
//啟用SimpleBroker,使得訂閱到此"topic"字首的用戶端可以收到消息.
config.enableSimpleBroker("/topic");
// //将"app"字首綁定到MessageMapping注解指定的方法上。如"app/hello"被指定用greeting()方法來處理
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// “/gs-guide-websocket”即為用戶端嘗試建立連接配接的位址。
registry.addEndpoint("/gs-guide-websocket").withSockJS();
}
}
首先我們定義了一個Spring的配置類: WebSocketConfig ,并使用
@EnableWebSocketMessageBroker
注解啟用WebSocket的broker.即使用broker來處理消息.
在該配置類中主要包含兩部分内容,一個是消息代理,另一個是Endpoint,消息代理指定了用戶端訂閱位址,以及發送消息的路由位址;Endpoint指定了用戶端建立連接配接時的請求位址。
SimpMessagingTemplate
借助于 SimpMessagingTemplate 我們可以在 任何時機進行消息推送,如下:
Sending a message to a destination can also be done from anywhere in the application with the help of a messaging template
For example, an HTTP POST handling method can broadcast a message to connected clients, or a service component may periodically broadcast stock quotes.
@Controller
public class GreetingController {
@Autowired
private SimpMessagingTemplate template;
@MessageMapping("/hello")
@SendTo("/topic/greetings")
public Greeting greeting(HelloMessage message) throws Exception {
Thread.sleep(1000); // simulated delay
return new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!");
}
@GetMapping("/say/{word}")
@ResponseBody
public void greet(@PathVariable String word) {
template.convertAndSend("/topic/greetings", new Greeting("Hello, " + HtmlUtils.htmlEscape(word) + "!"));
}
}
至此,服務端的配置工作就完成了,非常簡單。現在,讓我們實作一個前端頁面,來驗證服務的工作情況。
建立前端實作頁面
針對STOMP,前端我們采用JavaScript的stomp的用戶端實作stomp.js以及WebSocket的實作SockJS。此處隻展示核心代碼。
Stomp
websocket使用socket實作雙工異步通信能力。但是如果直接使用websocket協定開發程式比較繁瑣,我們可以使用它的子協定Stomp
SockJS
sockjs是websocket協定的實作,增加了對浏覽器不支援websocket的時候的相容支援
SockJS的支援的傳輸的協定有3類: WebSocket, HTTP Streaming, and HTTP Long Polling。預設使用websocket,如果浏覽器不支援websocket,則使用後兩種的方式。
SockJS使用"Get /info"從服務端擷取基本資訊。然後用戶端會決定使用哪種傳輸方式。如果浏覽器使用websocket,則使用websocket。如果不能,則使用Http Streaming,如果還不行,則最後使用 HTTP Long Polling
//使用SockJS和stomp.js來打開“gs-guide-websocket”位址的連接配接,這也是我們使用Spring建構的SockJS服務。
function connect() {
var socket = new SockJS('/gs-guide-websocket');
stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
//連接配接成功後的回調方法
setConnected(true);
console.log('Connected: ' + frame);
//訂閱/topic/greetings位址,當服務端向此位址發送消息時,用戶端即可收到。
stompClient.subscribe('/topic/greetings', function (greeting) {
//收到消息時的回調方法,展示歡迎資訊。
showGreeting(JSON.parse(greeting.body).content);
});
});
}
//斷開連接配接的方法
function disconnect() {
if (stompClient !== null) {
stompClient.disconnect();
}
setConnected(false);
console.log("Disconnected");
}
//将使用者輸入的名字資訊,使用STOMP用戶端發送到“/app/hello”位址。它正是我們在GreetingController中定義的greeting()方法所處理的位址.
function sendName() {
stompClient.send("/app/hello", {}, JSON.stringify({'name': $("#name").val()}));
}
示範
與Security內建
webSocket 與Spring Security 內建,也很友善,參見:https://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#websocket-authentication
參考資料:
https://spring.io/guides/gs/messaging-stomp-websocket/