應(yīng)用場(chǎng)景
websocket 是 Html5 新增加特性之一,目的是瀏覽器與服務(wù)端建立全雙工的通信方式,解決 http 請(qǐng)求-響應(yīng)帶來(lái)過(guò)多的資源消耗,同時(shí)對(duì)特殊場(chǎng)景應(yīng)用提供了全新的實(shí)現(xiàn)方式,比如聊天、股票交易、游戲等對(duì)對(duì)實(shí)時(shí)性要求較高的行業(yè)領(lǐng)域。
1.WebSocket
WebSocket 是發(fā)送和接收消息的底層API,WebSocket 協(xié)議提供了通過(guò)一個(gè)套接字實(shí)現(xiàn)全雙工通信的功能。也能夠?qū)崿F(xiàn) web 瀏覽器和 server 間的異步通信,全雙工意味著 server 與瀏覽器間可以發(fā)送和接收消息。需要注意的是必須考慮瀏覽器是否支持,瀏覽器的支持情況如下:

1.1 編寫(xiě)Handler類(lèi)
方法一:實(shí)現(xiàn) WebSocketHandler 接口,WebSocketHandler 接口如下
public interface WebSocketHandler {
void afterConnectionEstablished(WebSocketSession session) throws Exception;
void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception;
void handleTransportError(WebSocketSession session, Throwable exception) throws Exception;
void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception;
boolean supportsPartialMessages();
}
方法二:擴(kuò)展 AbstractWebSocketHandler
@Service
public class ChatHandler extends AbstractWebSocketHandler {
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
session.sendMessage(new TextMessage("hello world."));
}
}
該類(lèi)中的方法我們都可以按需求重載
1.2 攔截器的實(shí)現(xiàn)
@Component
public class WebSocketHandshakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
if (request instanceof ServletServerHttpRequest) {
attributes.put("username",userName);
}
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
}
}
- beforeHandshake()方法,在調(diào)用 handler 前調(diào)用。常用來(lái)注冊(cè)用戶(hù)信息,綁定 WebSocketSession,在 handler 里根據(jù)用戶(hù)信息獲取WebSocketSession發(fā)送消息
1.3 WebSocketConfig配置
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer{
@Autowired
private ChatHandler chatHandler;
@Autowired
private WebSocketHandshakeInterceptor webSocketHandshakeInterceptor;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(chatHandler,"/chat")
.addInterceptors(webSocketHandshakeInterceptor);
}
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxTextMessageBufferSize(8192*4);
container.setMaxBinaryMessageBufferSize(8192*4);
return container;
}
}
- 實(shí)現(xiàn) WebSocketConfigurer 接口,重寫(xiě) registerWebSocketHandlers 方法,這是一個(gè)核心實(shí)現(xiàn)方法,配置 websocket 入口,允許訪(fǎng)問(wèn)的域、注冊(cè) Handler、SockJs 支持和攔截器。
- registry.addHandler()注冊(cè)和路由的功能,當(dāng)客戶(hù)端發(fā)起 websocket 連接,把 /path 交給對(duì)應(yīng)的 handler 處理,而不實(shí)現(xiàn)具體的業(yè)務(wù)邏輯,可以理解為收集和任務(wù)分發(fā)中心。
- addInterceptors,顧名思義就是為 handler 添加攔截器,可以在調(diào)用 handler 前后加入我們自己的邏輯代碼。
- ServletServerContainerFactoryBean可以添加對(duì)WebSocket的一些配置
1.4 客戶(hù)端配置
var wsServer = 'ws://127.0.0.1:8080/chat';
var websocket = new WebSocket(wsServer);
websocket.onopen = function (evt) { onOpen(evt) };
websocket.onclose = function (evt) { onClose(evt) };
websocket.onmessage = function (evt) { onMessage(evt) };
websocket.onerror = function (evt) { onError(evt) };
function onOpen(evt) {
console.log("Connected to WebSocket server.");
}
function onClose(evt) {
console.log("Disconnected");
}
function onMessage(evt) {
console.log('Retrieved data from server: ' + evt.data);
}
function onError(evt) {
console.log('Error occured: ' + evt.data);
}
websocket.send(“test”);//客戶(hù)端向服務(wù)器發(fā)送消息
注意:
其中 wsServer = ‘ws://127.0.0.1:8080/chat’中的地址要根據(jù)自己的實(shí)際情況來(lái)定,一般形式為:ws://域名:端口/應(yīng)用路徑/WebSocket 配置的 path。“應(yīng)用路徑”是應(yīng)用部署在 tomcat 中的文件夾路徑,“WebSocket 配置的 path”是配置文件中這條配置項(xiàng)配置的路徑。
后臺(tái)輸出:
- Connection established
- getId:1
- getLocalAddress:/127.0.0.1:8080
- getUri:/chat
1.5 Bad Code
- 1006
nginx配置添加
proxy_connect_timeout 500s;
- 1009
內(nèi)容長(zhǎng)度超限,將MaxTextMessageBufferSize增大
2. SockJs
為了應(yīng)對(duì)許多瀏覽器不支持WebSocket協(xié)議的問(wèn)題,設(shè)計(jì)了備選SockJs
。
SockJS 是 WebSocket 技術(shù)的一種模擬。SockJS 會(huì) 盡可能對(duì)應(yīng) WebSocket API,但如果 WebSocket 技術(shù)不可用的話(huà),就會(huì)選擇另外的通信方式協(xié)議。
2.1 WebSocketConfig配置
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer{
@Autowired
private ChatHandler chatHandler;
@Autowired
private WebSocketHandshakeInterceptor webSocketHandshakeInterceptor;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// withSockJS() 方法聲明我們想要使用 SockJS 功能,如果WebSocket不可用的話(huà),會(huì)使用 SockJS;
registry.addHandler(chatHandler,"/chat").addInterceptors(webSocketHandshakeInterceptor).withSockJS();
}
}
客戶(hù)端配置
具體做法是 依賴(lài)于 JavaScript 模塊加載器(如 require.js or curl.js) 還是簡(jiǎn)單使用 script 標(biāo)簽加載 JavaScript 庫(kù)。最簡(jiǎn)單的方法是 使用 script 標(biāo)簽從 SockJS CDN 中進(jìn)行加載,如下所示:
//加載sockjs
<script src="http://cdn.sockjs.org/sockjs-0.3.min.js"></script>
var url = '/chat';
var sock = new SockJS(url);
//.....
對(duì)以上代碼分析
- SockJS 所處理的 URL 是 “http://“ 或 “https://“ 模式,而不是 “ws://“ or “wss://“;
- 其他的函數(shù)如 onopen, onmessage, and onclose ,SockJS 客戶(hù)端與 WebSocket 一樣,在此代碼省略
后臺(tái)輸出:
- Connection established
- getId: qtfwdtti**(注意:此處和直接利用websocket API有區(qū)別)**
- getLocalAddress:/127.0.0.1:8080
- getUri: /chat/668/qtfwdtti/websocket**(注意:此處和直接利用websocket API有區(qū)別)**
3.STOMP
SockJS 為 WebSocket 提供了 備選方案。但無(wú)論哪種場(chǎng)景,對(duì)于實(shí)際應(yīng)用來(lái)說(shuō),這種通信形式層級(jí)過(guò)低。下面看一下如何 在 WebSocket 之上使用 STOMP協(xié)議,來(lái)為瀏覽器 和 server 間的 通信增加適當(dāng)?shù)南⒄Z(yǔ)義。(STOMP—— Simple Text Oriented Message Protocol——面向消息的簡(jiǎn)單文本協(xié)議)
3.1 WebSocket、SockJs、STOMP三者關(guān)系
簡(jiǎn)而言之,WebSocket 是底層協(xié)議,SockJS 是WebSocket 的備選方案,也是 底層協(xié)議,而 STOMP 是基于 WebSocket(SockJS) 的上層協(xié)議
- 假設(shè)HTTP協(xié)議并不存在,只能使用TCP套接字來(lái)編寫(xiě)web應(yīng)用,你可能認(rèn)為這是一件瘋狂的事情。
- 不過(guò)幸好,我們有HTTP協(xié)議,它解決了 web 瀏覽器發(fā)起請(qǐng)求以及 web 服務(wù)器響應(yīng)請(qǐng)求的細(xì)節(jié)。
- 直接使用 WebSocket(SockJS) 就很類(lèi)似于 使用 TCP 套接字來(lái)編寫(xiě) web 應(yīng)用;因?yàn)闆](méi)有高層協(xié)議,因此就需要我們定義應(yīng)用間所發(fā)送消息的語(yǔ)義,還需要確保 連接的兩端都能遵循這些語(yǔ)義。
- 同HTTP在TCP套接字上添加請(qǐng)求-響應(yīng)模型層一樣,STOMP在 WebSocket之上提供了一個(gè)基于幀的線(xiàn)路格式層,用來(lái)定義消息語(yǔ)義。
3.2 STOMP
STOMP幀由命令,一個(gè)或多個(gè)頭信息以及負(fù)載所組成。如下就是發(fā)送數(shù)據(jù)的一個(gè)STOMP幀:
SEND
destination:/app/room-message
content-length:20
{\"message\":\"Hello!\"}
對(duì)以上代碼分析:
- SEND:STOMP命令,表明會(huì)發(fā)送一些內(nèi)容;
- destination:頭信息,用來(lái)表示消息發(fā)送到哪里;
- content-length:頭信息,用來(lái)表示 負(fù)載內(nèi)容的 大小;
- 空行;
- 幀內(nèi)容(負(fù)載)內(nèi)容
3.3 WebSockConfig配置
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
//定義了一個(gè)客戶(hù)端訂閱地址的前綴信息,也就是客戶(hù)端接收服務(wù)端發(fā)送消息的前綴信息
config.enableSimpleBroker("/topic");
//定義了服務(wù)端接收地址的前綴,也即客戶(hù)端給服務(wù)端發(fā)消息的地址前綴
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
//添加一個(gè)/chat端點(diǎn),客戶(hù)端就可以通過(guò)這個(gè)端點(diǎn)來(lái)進(jìn)行連接;withSockJS作用是添加SockJS支持
registry.addEndpoint("/gs-guide-websocket")).withSockJS();
}
}
對(duì)以上代碼分析:
- EnableWebSocketMessageBroker 注解表明: 這個(gè)配置類(lèi)不僅配置了 WebSocket,還配置了基于代理的 STOMP 消息;
- 它復(fù)寫(xiě)了 registerStompEndpoints() 方法:添加一個(gè)服務(wù)端點(diǎn),來(lái)接收客戶(hù)端的連接。將 “/gs-guide-websocket
- ” 路徑注冊(cè)為 STOMP 端點(diǎn)。這個(gè)路徑與之前發(fā)送和接收消息的目的路徑有所不同, 這是一個(gè)端點(diǎn),客戶(hù)端在訂閱或發(fā)布消息到目的地址前,要連接該端點(diǎn),即用戶(hù)發(fā)送請(qǐng)求 :url=’/127.0.0.1:8080/gs-guide-websocket
- ’ 與 STOMP server 進(jìn)行連接,之后再轉(zhuǎn)發(fā)到訂閱url;
- 它復(fù)寫(xiě)了 configureMessageBroker() 方法:配置了一個(gè) 簡(jiǎn)單的消息代理,通俗一點(diǎn)講就是設(shè)置消息連接請(qǐng)求的各種規(guī)范信息。
- 發(fā)送應(yīng)用程序的消息將會(huì)帶有 “/app” 前綴。
3.4 Controller
@Controller
public class GreetingController {
@Autowired
private SimpMessagingTemplate template;
@MessageMapping("/hello")
@SendToUser("/topic/greetings")
//@SendTo("/topic/greetings")
public Greeting greeting(HelloMessage message) throws Exception {
Thread.sleep(1000); // simulated delay
return new Greeting("Hello, " + message.getName() + "!");
}
}
對(duì)以上代碼分析
- @MessageMapping 標(biāo)識(shí)客戶(hù)端發(fā)來(lái)消息的請(qǐng)求地址,前面我們?nèi)峙渲弥兄贫朔?wù)端接收的地址以“/app”開(kāi)頭,所以客戶(hù)端發(fā)送消息的請(qǐng)求連接是:/app/hello;
- @SendToUser可以將消息只返回給發(fā)送者
- @SendTo會(huì)將消息廣播給所有訂閱
/hello
這個(gè)路徑的用戶(hù)。
3.5 客戶(hù)端代碼
function connect() {
var socket = new SockJS('/gs-guide-websocket');
stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
setConnected(true);
console.log('Connected: ' + frame);
stompClient.subscribe('/user/topic/greetings', function (greeting) {
showGreeting(JSON.parse(greeting.body).content);
});
});
}
3.6 獲取用戶(hù)信息,定向推送
雖然STMOP的協(xié)議很好的實(shí)現(xiàn)了訂閱,返回的模式,但是沒(méi)法定向的從服務(wù)端推送消息個(gè)某個(gè)用戶(hù),要解決這個(gè)問(wèn)題就需要獲取用戶(hù)的信息,使得我們可以對(duì)其推送。
3.6.1 MyHandsHandler
public class MyHandsHandler extends DefaultHandshakeHandler {
public MyHandsHandler() {
}
public MyHandsHandler(RequestUpgradeStrategy requestUpgradeStrategy) {
super(requestUpgradeStrategy);
}
@Override
protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
URI uri = request.getURI();
String id = null;
if (uri != null) {
String query = uri.getPath();
String[] pairs = query.split("/");
id = pairs[3];
}
MyPrincipal principal = new MyPrincipal();
principal.setName(id);
System.out.println(id);
return principal;
}
class MyPrincipal implements Principal {
private String name;
@Override
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MyPrincipal that = (MyPrincipal) o;
return name != null ? name.equals(that.name) : that.name == null;
}
@Override
public int hashCode() {
return name != null ? name.hashCode() : 0;
}
}
}
繼承DefaultHandshakeHandler,重寫(xiě)其determineUser方法,根據(jù)需要自定義Principal的返回值,其name就是用來(lái)標(biāo)記返回對(duì)象的id。更進(jìn)一步可以用一個(gè)List來(lái)維護(hù)用戶(hù)的登陸狀態(tài)等。
3.6.2 注冊(cè)MyHandsHandler
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
//定義了一個(gè)客戶(hù)端訂閱地址的前綴信息,也就是客戶(hù)端接收服務(wù)端發(fā)送消息的前綴信息
config.enableSimpleBroker("/topic");
//定義了服務(wù)端接收地址的前綴,也即客戶(hù)端給服務(wù)端發(fā)消息的地址前綴
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
//添加一個(gè)/chat端點(diǎn),客戶(hù)端就可以通過(guò)這個(gè)端點(diǎn)來(lái)進(jìn)行連接;withSockJS作用是添加SockJS支持
registry.addEndpoint("/gs-guide-websocket").setHandshakeHandler(new MyHandsHandler()).withSockJS();
}
}
3.6.3 Controller
@Controller
public class GreetingController {
@Autowired
private SimpMessagingTemplate template;
@RequestMapping("/xxx")
public String greetingx(String id) throws Exception {
template.convertAndSendToUser(id, "/topic/greetings", new Greeting("Hello, " + id + "!"));
return "success";
}
}
以上代碼分析
- 通過(guò)依賴(lài)注入
SimpMessagingTemplate
我們可以在服務(wù)端的任何地方發(fā)送消息 - template.convertAndSendToUser(id, "/topic/greetings", new Greeting("Hello, " + id + "!"))可以將消息發(fā)送給我們指定id的用戶(hù),此處的id就是Principal中的name值
3.7 其他Annotation說(shuō)明
3.7.1@DestinationVariable
@MessageMapping("/hello/{roomId}")
public void roomMessage(HelloMessage message, @DestinationVariable String roomId){
String dest = "/topic/" + roomId + "/" + "greetings";
this.template.convertAndSend(dest, message);
}
- @DestinationVariable 用以取出請(qǐng)求地址中的房間 id 參數(shù) roomId;
參考文獻(xiàn):