WebSocket

語言: CN / TW / HK

一 : 什麼是WebSocket

  • WebSocket 是一種在單個 TCP 連線上進行全雙工通訊的協議, 為瀏覽器和服務端提供了雙工非同步通訊的功能, 即瀏覽器可以向服務端傳送訊息, 服務端也可以向瀏覽器傳送訊息。WebSocket 需瀏覽器的支援, 如IE 10+、Chrome 13+、Firefox 6+, 這對我們現在的瀏覽器來說都不是問題。
  • WebSocket 是通過一個 socket 來實現雙工非同步通訊能力的。 但是直接使用 WebSocket (或者SockJS:WebSocket 協議的模擬, 增加了當瀏覽器不支援WebSocket的時候的相容支援)協議開發程式顯得特別煩瑣, 我們會使用它的子協議 STOMP, 它是一個更高級別的協議, STOMP 協議使用一個基於幀 (frame)的格式來定義訊息, 與HTTP的 request 和 response 類似(具有類似於 @RequestMapping 的 @MessageMapping), 我們會在後面實戰內容中觀察 STOMP 的幀。

二 : Spring Boot提供的自動配置

  • springboot 對內嵌的 Tomcat(7或者8)、Jetty9 和 Undertow 使用 WebSocket 提供了支援。配置原始碼存於 org.springframework.boot.autoconfigure.websocket 下
  • springboot 為 WebSocket 提供的 stater pom 是 spring-boot-starter-websocket

三 : 實戰

(一) 準備

  • 新建 Spring Boot 專案, 選擇 web 和 Websocket 依賴

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
    

(二) 廣播式

廣播式即服務端有訊息時, 會將訊息傳送給所有連線了當前endpoint的瀏覽器。

  1. 配置 WebSocket, 需要在配置類上使用 @EnableWebSocketMessageBroker 開啟 WebSocket 支援, 並通過繼承 AbstractWebSocketMessageBrokerConfigurer 類, 重寫其方法來配置 WebSocket。 程式碼如下:

    @Configuration
    /**
     * @EnableWebSocketMessageBroker 註解開啟使用STOMP協議來傳輸基於代理(message broker)的訊息
     * 這時控制器支援使用 @MessageMapping, 就像使用@RequestMapping一樣。
     */
    @EnableWebSocketMessageBroker
    public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    
        // 註冊STOMP協議的節點(endpoint), 並對映的指定的 URL
        @Override
        public void registerStompEndpoints(StompEndpointRegistry registry) {
            // 註冊一個STOMP的endpoint, 並指定使用SockJS協議。
            registry.addEndpoint("/endpointTopic").withSockJS();
        }
    
        // 配置訊息代理(Message Broker)
        @Override
        public void configureMessageBroker(MessageBrokerRegistry registry) {
            registry.enableSimpleBroker("/topic");
        }
    }
    
  2. 演示控制器, 程式碼如下:

    @RestController
    public class NoticeController {
    
        // 當瀏覽器向服務端傳送請求時, 通過@MessageMapping 對映地址, 類似於@RequestMapping
        @MessageMapping("notice")
        // 當服務端接到訊息後, 會對訂閱了@SendTo中的路徑的瀏覽器傳送訊息
        @SendTo("/topic/sendNotice")
        public String sendNotice(String message) {
            return message;
        }
    }
    
  3. 新增指令碼。

    • 將 stomp.min.js (STOMP協議的客戶端指令碼)、sockjs.min.js (SockJS的客戶端指令碼)以及 jQuery 放置在 src/main/resources/static 下。
  4. 演示頁面。

    • 在 src/main/resources/static 下新建 topic.html, 程式碼如下:
    <!DOCTYPE html>
    <html lang="en">
        <head>
            <meta charset="UTF-8">
            <title>Title</title>
        </head>
        <body>
            <textarea name="notice" id="notice" cols="30" rows="10"></textarea><br>
            <input type="text" name="content"><br>
            <button onclick="sendMsg()">傳送公告</button>
        </body>
        <script src="js/jquery.min.js"></script>
        <script src="js/sockjs.min.js"></script>
        <script src="js/stomp.min.js"></script>
        <script>
            // 連線SockJS的endpoint名稱為"/endpointTopic"
            var socket = new SockJS("/endpointTopic")
            // 使用STOMP子協議的WebSocket客戶端
            var stompClient = Stomp.over(socket)
            // 連線WebSocket服務端
            // stompClient.connect()方法簽名:client.connect(headers, connectCallback);
            stompClient.connect({},function(){
                // 訂閱 /topic/sendNotice 目標(destination)傳送的訊息, 
                // 這個是在控制器的@SendTo中定義的
                stompClient.subscribe("/topic/sendNotice",function (ret) {
                    console.log(ret)
                    $("#notice").text(ret.body)
                })
            })
    
            function sendMsg(){
                var content = $("input[name='content']").val()
                // 向/welcome目標(destination)傳送訊息, 這個是在控制器的@MessageMapping中定義的
                stompClient.send("/notice",{},content)
            }
        </script>
    </html>
    
  5. 執行。

    • 我們預期的效果是:當一個瀏覽器傳送一個訊息到服務端時, 其他瀏覽器也能接收到從服務端傳送來的這個訊息

    • 開啟三個瀏覽器視窗, 並訪問 http://localhost:8080/topic.html , 分別連線伺服器。然後在一個瀏覽器中傳送一條訊息, 其他瀏覽器接收訊息。

    • 瀏覽器抓包可以看到以下效果

      # 連線服務端的格式為
      >>> CONNECT
      accept-version:1.1,1.0
      heart-beat:10000,10000
      
      # 連線成功的返回為:
      <<< CONNECTED
      version:1.1
      heart-beat:0,0
      
      # 訂閱目標(destination)/topic/getResponse:
      >>> SUBSCRIBE
      id:sub-0
      destination:/topic/getResponse
      
      # 向目標(destination)/welcome傳送訊息的格式為:
      >>> SEND
      destination:/notice
      content-length:3
      
      111
      
      # 從目標(destination)/topic/getResponse接收的格式為:
      <<< MESSAGE
      destination:/topic/sendNotice
      content-type:text/plain;charset=UTF-8
      subscription:sub-0
      message-id:qs0sic54-14
      content-length:3
      
      111
      

(三) 點對點式

廣播式有自己的應用場景, 但是廣播式不能解決我們一個常見的場景, 即訊息由誰傳送、由誰接收的問題。 本例中演示了一個簡單的聊天室程式。例子中只有兩個使用者, 互相傳送訊息給彼此, 因需要使用者相關的內容, 所以先在這裡引入最簡單的 Spring Security 相關內容。

  1. 新增 Spring Security 的 starter pom:

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    
  2. Spring Security 的簡單配置。這裡不對 Spring Security 做過多解釋, 只解釋對本專案有幫助的部分:

    @Configuration
    @EnableWebSecurity
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
    
            http.authorizeRequests()//開啟登入配置
                .antMatchers("/","/login.html").permitAll()// "/"和"/login.html"路徑不攔截
                .anyRequest().authenticated()//剩餘的其他介面,登入之後就能訪問
                .and()
                .formLogin()
                .loginPage("/login.html")// 登入頁面為/login.html
                .loginProcessingUrl("/login")
                .defaultSuccessUrl("/chat.html")// 登入成功後轉向/chat.html路徑
                .permitAll()
                .and()
                .logout().permitAll()// 所有人都可以推出
                .and()
                .csrf().disable();// 關閉跨域
    
        }
    
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            // 在記憶體中分別配置兩個使用者info和wisely, 密碼和使用者名稱一致, 角色是USER
            auth.inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder())
                .withUser("zhangsan")
                .password(new BCryptPasswordEncoder().encode("111")).roles("USER")
                .and()
                .withUser("lisi")
                .password(new BCryptPasswordEncoder().encode("111")).roles("USER");
    
        }
    
    }
    
  3. 配置WebSocket:

    @Configuration
    @EnableWebSocketMessageBroker
    public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer{
    
        // 註冊一個名為/endpointChat的endpoint
        @Override
        public void registerStompEndpoints(StompEndpointRegistry registry) {
            registry.addEndpoint("/endpointTopic").withSockJS();
            registry.addEndpoint("/endpointQueue").withSockJS();
        }
    
        // 點對點式應增加一個/queue訊息代理
        @Override
        public void configureMessageBroker(MessageBrokerRegistry registry) {
            registry.enableSimpleBroker("/queue","/topic");
        }
    }
    
  4. 控制器。

    @RestController
    public class ChatController {
    
        // 通過SimpMessagingTemplate向瀏覽器傳送訊息
        @Autowired
        private SimpMessagingTemplate simpMessagingTemplate;
    
        // 在Spring MVC中, 可以直接在引數中獲得principal,  pinciple中包含當前使用者的資訊
        @MessageMapping("chat")
        public void chat(Principal principal, String message){
            /*
            這裡是一段硬編碼, 如果傳送人是zhangsan, 則傳送給 lisi;
            如果傳送人是lisi, 則傳送給zhangsan, 讀者可以根據專案實際需要改寫此處程式碼
             */
            if (principal.getName().equals("zhangsan")){
                /*
                通過messagingTemplate.convertAndSendToUser向用戶傳送訊息
                第一個引數是接收訊息的使用者, 第二個是瀏覽器訂閱的地址, 第三個是訊息本身
                 */
                simpMessagingTemplate
                    .convertAndSendToUser("lisi", "/queue/msg",
                                          principal.getName()+"-send: "+message);
            }else{
    
                simpMessagingTemplate
                    .convertAndSendToUser("zhangsan", "/queue/msg",
                                          principal.getName()+"-send: "+message);
            }
        }
    }
    
  5. 登入頁面。

    • 在 src/main/resources/templates 下新建 login.html, 程式碼如下:
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
    <form action="/login" method="post">
        使用者名稱: <input type="text" name="username"><br>
        密  碼: <input type="text" name="password"><br>
        <input type="submit" value="提交">
    </form>
    </body>
    </html>
    
  6. 聊天頁面。

    • 在 src/main/resources/templates 下新建 chat.html, 程式碼如下:
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
        <p> 聊天室 </p>
        <textarea rows="4" cols="60" name="text"></textarea><br>
        <button type="button" onclick="sendMsg()">傳送</button>
    
        <div id="output"></div>
    </body>
    <script src="js/jquery.min.js"></script>
    <script src="js/sockjs.min.js"></script>
    <script src="js/stomp.min.js"></script>
    <script>
    
        // 連線endpoint名稱為"/endpointQueue"的endpoint
        var sock = new SockJS("/endpointQueue");
        var stomp = Stomp.over(sock);
    
        // stompClient.connect()方法簽名:client.connect(login, passcode, connectCallback);
        stomp.connect('', '', function() {
            /*
            訂閱/user/queue/notifications傳送的訊息
            這裡與在控制器的messagingTemplate.convertAndSendToUser中定義的訂閱地址保持一致
            這裡多了一個/user, 並且這個/user是必須的, 使用了/user才會傳送訊息到指定的使用者
             */
            stomp.subscribe("/user/queue/msg", function (message) {
                $('#output').append("<b>Received: " + message.body + "</b><br/>")
            });
        });
    
        function sendMsg(){
            var text = $('textarea[name="text"]').val();
            // 第一個引數是請求路徑,第二個是請求頭資訊(JSON格式), 第三個是請求體資訊
            stomp.send("/chat", {}, text);
        }
    
    </script>
    </html>
    
  7. 執行。

    • 兩個使用者登入系統, 可以互發訊息。