WebSocket+SockJs+STMOP

應(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é)議

  1. 假設(shè)HTTP協(xié)議并不存在,只能使用TCP套接字來(lái)編寫(xiě)web應(yīng)用,你可能認(rèn)為這是一件瘋狂的事情。
  2. 不過(guò)幸好,我們有HTTP協(xié)議,它解決了 web 瀏覽器發(fā)起請(qǐng)求以及 web 服務(wù)器響應(yīng)請(qǐng)求的細(xì)節(jié)。
  3. 直接使用 WebSocket(SockJS) 就很類(lèi)似于 使用 TCP 套接字來(lái)編寫(xiě) web 應(yīng)用;因?yàn)闆](méi)有高層協(xié)議,因此就需要我們定義應(yīng)用間所發(fā)送消息的語(yǔ)義,還需要確保 連接的兩端都能遵循這些語(yǔ)義。
  4. 同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ì)以上代碼分析:

  1. SEND:STOMP命令,表明會(huì)發(fā)送一些內(nèi)容;
  2. destination:頭信息,用來(lái)表示消息發(fā)送到哪里;
  3. content-length:頭信息,用來(lái)表示 負(fù)載內(nèi)容的 大小;
  4. 空行;
  5. 幀內(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ì)以上代碼分析:

  1. EnableWebSocketMessageBroker 注解表明: 這個(gè)配置類(lèi)不僅配置了 WebSocket,還配置了基于代理的 STOMP 消息;
  2. 它復(fù)寫(xiě)了 registerStompEndpoints() 方法:添加一個(gè)服務(wù)端點(diǎn),來(lái)接收客戶(hù)端的連接。將 “/gs-guide-websocket
  3. ” 路徑注冊(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
  4. ’ 與 STOMP server 進(jìn)行連接,之后再轉(zhuǎn)發(fā)到訂閱url;
  5. 它復(fù)寫(xiě)了 configureMessageBroker() 方法:配置了一個(gè) 簡(jiǎn)單的消息代理,通俗一點(diǎn)講就是設(shè)置消息連接請(qǐng)求的各種規(guī)范信息。
  6. 發(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ì)以上代碼分析

  1. @MessageMapping 標(biāo)識(shí)客戶(hù)端發(fā)來(lái)消息的請(qǐng)求地址,前面我們?nèi)峙渲弥兄贫朔?wù)端接收的地址以“/app”開(kāi)頭,所以客戶(hù)端發(fā)送消息的請(qǐng)求連接是:/app/hello;
  2. @SendToUser可以將消息只返回給發(fā)送者
  3. @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";
    }
}

以上代碼分析

  1. 通過(guò)依賴(lài)注入SimpMessagingTemplate我們可以在服務(wù)端的任何地方發(fā)送消息
  2. 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);
    }
  1. @DestinationVariable 用以取出請(qǐng)求地址中的房間 id 參數(shù) roomId;

參考文獻(xiàn):

  1. http://blog.csdn.net/pacosonswjtu/article/details/51914567
  2. http://tech.lede.com/2017/03/08/qa/websocket+spring/
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,030評(píng)論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,310評(píng)論 3 415
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人,你說(shuō)我怎么就攤上這事。” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 175,951評(píng)論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 62,796評(píng)論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,566評(píng)論 6 407
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 55,055評(píng)論 1 322
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,142評(píng)論 3 440
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 42,303評(píng)論 0 288
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,799評(píng)論 1 333
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,683評(píng)論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,899評(píng)論 1 369
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,409評(píng)論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,135評(píng)論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 34,520評(píng)論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 35,757評(píng)論 1 282
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,528評(píng)論 3 390
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,844評(píng)論 2 372

推薦閱讀更多精彩內(nèi)容