一文搞懂四種 WebSocket 使用方式,建議收藏!!!

和耳朵發表於2022-06-06

在上家公司做IM訊息系統的時候,一直是使用 WebSocket 作為收發訊息的基礎元件,今天就和大家聊聊在 Java 中,使用 WebSocket 所常見的四種姿勢,如果大家以後或者現在碰到有要使用 WebSoocket 的情況可以做個參考。

image.png

上面的思維導圖已經給大家列出了三種使用 WebSocket 的方式,下文會對它們的特點進行一一解讀,不同的方式具有不同的特點,我們先按下不表。

在這裡,我想讓大家思考一下我在思維導圖中列舉的第四種做 WebScoket 支援的方案可能是什麼?不知道大家能不能猜對,後文將會給出答案。


本文程式碼:以下倉庫中 spring-websocket 模組,拉整個倉庫下來後可在 IDEA Maven 工具欄中單獨編譯此模組。

  • Github
  • Gitee

    WS簡介

    在正式開始之前,我覺得有必要簡單介紹一下 WebSocket 協議,引入任何一個東西之前都有必要知道我們為什麼需要它?

    在 Web 開發領域,我們最常用的協議是 HTTP,HTTP 協議和 WS 協議都是基於 TCP 所做的封裝,但是 HTTP 協議從一開始便被設計成請求 -> 響應的模式,所以在很長一段時間內 HTTP 都是隻能從客戶端發向服務端,並不具備從服務端主動推送訊息的功能,這也導致在瀏覽器端想要做到伺服器主動推送的效果只能用一些輪詢和長輪詢的方案來做,但因為它們並不是真正的全雙工,所以在消耗資源多的同時,實時性也沒理想中那麼好。

    既然市場有需求,那肯定也會有對應的新技術出現,WebSocket 就是這樣的背景下被開發與制定出來的,並且它作為 HTML5 規範的一部分,得到了所有主流瀏覽器的支援,同時它還相容了 HTTP 協議,預設使用 HTTP 的80埠和443埠,同時使用 HTTP header 進行協議升級。

    和 HTTP 相比,WS 至少有以下幾個優點:

  1. 使用的資源更少:因為它的頭更小。
  2. 實時性更強:服務端可以通過連線主動向客戶端推送訊息。
  3. 有狀態:開啟連結之後可以不用每次都攜帶狀態資訊。

除了這幾個優點以外,我覺得對於 WS 我們開發人員起碼還要了解它的握手過程和協議幀的意義,這就像學習 TCP 的時候需要了解 TCP 頭每個位元組幀對應的意義一樣。

像握手過程我就不說了,因為它複用了 HTTP 頭只需要在維基百科(阮一峰的文章講的也很明白)上面看一下就明白了,像協議幀的話無非就是:識別符號、操作符、資料、資料長度這些協議通用幀,基本都沒有深入瞭解的必要,我認為一般只需要關心 WS 的操作符就可以了。

WS 的操作符代表了 WS 的訊息型別,它的訊息型別主要有如下六種:

  1. 文字訊息
  2. 二進位制訊息
  3. 分片訊息(分片訊息代表此訊息是一個某個訊息中的一部分,想想大檔案分片)
  4. 連線關閉訊息
  5. PING 訊息
  6. PONG 訊息(PING的回覆就是PONG)

那我們既然知道了 WS 主要有以上六種操作,那麼一個正常的 WS 框架應當可以很輕鬆的處理以上這幾種訊息,所以接下來就是本文的中心內容,看看以下這幾種 WS 框架能不能很方便的處理這幾種 WS 訊息。

J2EE 方式

先來 J2EE,一般我把 javax 包裡面對 JavaWeb 的擴充套件都叫做 J2EE,這個定義是否完全正確我覺得沒必要深究,只是一種個人習慣,而本章節所介紹的 J2EE 方式則是指 Tomcat 為 WS 所做的支援,這套程式碼的包名字首叫做:javax.websocket

這套程式碼中定義了一套適用於 WS 開發的註解和相關支援,我們可以利用它和 Tomcat 進行WS 開發,由於現在更多的都是使用 SpringBoot 的內嵌容器了,所以這次我們就來按照 SpringBoot 內嵌容器的方式來演示。

首先是引入 SpringBoot - Web 的依賴,因為這個依賴中引入了內嵌式容器 Tomcat:

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

接著就是將一個類定義為 WS 伺服器,這一步也很簡單,只需要為這個類加上@ServerEndpoint註解就可以了,在這個註解中比較常用的有三個引數:WS路徑、序列化處理類、反序列化處理類。

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface ServerEndpoint {
    String value();

    String[] subprotocols() default {};

    Class<? extends Decoder>[] decoders() default {};

    Class<? extends Encoder>[] encoders() default {};

    Class<? extends Configurator> configurator() default Configurator.class;
}

接下來我們來看具體的一個 WS 伺服器類示例:

@Component
@ServerEndpoint("/j2ee-ws/{msg}")
public class WebSocketServer {

    //建立連線成功呼叫
    @OnOpen
    public void onOpen(Session session, @PathParam(value = "msg") String msg){
        System.out.println("WebSocketServer 收到連線: " + session.getId() + ", 當前訊息:" + msg);
    }

    //收到客戶端資訊
    @OnMessage
    public void onMessage(Session session, String message) throws IOException {
        message = "WebSocketServer 收到連線:" + session.getId() +  ",已收到訊息:" + message;
        System.out.println(message);
        session.getBasicRemote().sendText(message);
    }

    //連線關閉
    @OnClose
    public void onclose(Session session){
        System.out.println("連線關閉");
    }

}

在以上程式碼中,我們著重關心 WS 相關的註解,主要有以下四個:

  1. @ServerEndpoint : 這裡就像 RequestMapping 一樣,放入一個 WS 伺服器監聽的 URL。
  2. @OnOpen :這個註解修飾的方法會在 WS 連線開始時執行。
  3. @OnClose :這個註解修飾的方法則會在 WS 關閉時執行。
  4. @OnMessage :這個註解則是修飾訊息接受的方法,並且由於訊息有文字和二進位制兩種方式,所以此方法引數上可以使用 String 或者二進位制陣列的方式,就像下面這樣:

     @OnMessage
     public void onMessage(Session session, String message) throws IOException {
    
     }
    
     @OnMessage
     public void onMessage(Session session, byte[] message) throws IOException {
    
     }

    除了以上這幾個以外,常用的功能方面還差一個分片訊息、Ping 訊息 和 Pong 訊息,對於這三個功能我並沒有查到相關用法,只在原始碼的介面列表中看到了一個 PongMessage 介面,有知道的讀者朋友們有知道的可以在評論區指出。
    細心的小夥伴們可能發現了,示例中的 WebSocketServer 類還有一個 @Component 註解,這是由於我們使用的是內嵌容器,而內嵌容器需要被 Spring 管理並初始化,所以需要給 WebSocketServer 類加上這麼一個註解,所以程式碼中還需要有這麼一個配置:

    @Configuration
    public class WebSocketConfig {
    
     @Bean
     public ServerEndpointExporter serverEndpointExporter() {
         return new ServerEndpointExporter();
     }
    }

    Tips:在不使用內嵌容器的時候可以不做以上步驟。
    最後上個簡陋的 WS 效果示例圖,前端方面直接使用 HTML5 的 WebScoket 標準庫,具體可以檢視我的倉庫程式碼:
    image.png

    Spring 方式

    第二部分來說 Spring 方式,Spring 作為 Java 開發界的老大哥,幾乎封裝了一切可以封裝的,對於 WS 開發呢 Spring 也提供了一套相關支援,而且從使用方面我覺得要比 J2EE 的更易用。

    使用它的第一步我們先引入 SpringBoot - WS 依賴,這個依賴包也會隱式依賴 SpringBoot - Web 包:

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

    第二步就是準備一個用來處理 WS 請求的 Handle了,Spring 為此提供了一個介面—— WebSocketHandler,我們可以通過實現此介面重寫其介面方法的方式自定義邏輯,我們來看一個例子:

    @Component
    public class SpringSocketHandle implements WebSocketHandler {
    
     @Override
     public void afterConnectionEstablished(WebSocketSession session) throws Exception {
         System.out.println("SpringSocketHandle, 收到新的連線: " + session.getId());
     }
    
     @Override
     public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
         String msg = "SpringSocketHandle, 連線:" + session.getId() +  ",已收到訊息。";
         System.out.println(msg);
         session.sendMessage(new TextMessage(msg));
     }
    
     @Override
     public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
         System.out.println("WS 連線發生錯誤");
     }
    
     @Override
     public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
         System.out.println("WS 關閉連線");
     }
    
     // 支援分片訊息
     @Override
     public boolean supportsPartialMessages() {
         return false;
     }
    }

    上面這個例子很好的展示了 WebSocketHandler 介面中的五個函式,通過名字我們就應該知道它具有什麼功能了:

  5. afterConnectionEstablished:連線成功後呼叫。
  6. handleMessage:處理髮送來的訊息。
  7. handleTransportError:WS 連線出錯時呼叫。
  8. afterConnectionClosed:連線關閉後呼叫。
  9. supportsPartialMessages:是否支援分片訊息。

以上這幾個方法重點可以來看一下 handleMessage 方法,handleMessage 方法中有一個 WebSocketMessage 引數,這也是一個介面,我們一般不直接使用這個介面而是使用它的實現類,它有以下幾個實現類:

  1. BinaryMessage:二進位制訊息體
  2. TextMessage:文字訊息體
  3. PingMessage:Ping 訊息體
  4. PongMessage:Pong 訊息體

但是由於 handleMessage 這個方法引數是WebSocketMessage,所以我們實際使用中可能需要判斷一下當前來的訊息具體是它的哪個子類,比如這樣:

    public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
        if (message instanceof TextMessage) {
            this.handleTextMessage(session, (TextMessage)message);
        } else if (message instanceof BinaryMessage) {
            this.handleBinaryMessage(session, (BinaryMessage)message);
        }
    }

但是總這樣寫也不是個事,為了避免這些重複性程式碼,Spring 給我們定義了一個 AbstractWebSocketHandler,它已經封裝了這些重複勞動,我們可以直接繼承這個類然後重寫我們想要處理的訊息型別:

    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    }

    protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception {
    }

    protected void handlePongMessage(WebSocketSession session, PongMessage message) throws Exception {
    }

上面這部分都是對於 Handle 的操作,有了 Handle 之後我們還需要將它繫結在某個 URL 上,或者說監聽某個 URL,那麼必不可少的需要以下配置:

@Configuration
@EnableWebSocket
public class SpringSocketConfig implements WebSocketConfigurer {

    @Autowired
    private SpringSocketHandle springSocketHandle;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(springSocketHandle, "/spring-ws").setAllowedOrigins("*");
    }
}

這裡我把我的自定義 Handle 註冊到 "/spring-ws" 上面並設定了一下跨域,在整個配置類上還要打上@EnableWebSocket 註解,用於開啟 WS 監聽。

Spring 的方式也就以上這些內容了,不知道大家是否感覺 Spring 所提供的 WS 封裝要比 J2EE 的更方便也更全面一些,起碼我只要看 WebSocketHandler 介面就能知道所有常用功能的用法,所以對於 WS 開發來說我是比較推薦 Spring 方式的。

最後上個簡陋的 WS 效果示例圖,前端方面直接使用 HTML5 的 WebScoket 標準庫,具體可以檢視我的倉庫程式碼:
image.png

SocketIO 方式

SocketIO 方式和上面兩種有點不太一樣,因為 SocketIO 誕生初就是為了相容性作為考量的,前端的讀者們應該對它更熟悉,因為它是一個 JS 庫,我們先來看一下維基百科對它的定義:

Socket.IO 是一個面向實時 web 應用的 JavaScript 庫。它使得伺服器和客戶端之間實時雙向的通訊成為可能。他有兩個部分:在瀏覽器中執行的客戶端庫,和一個面向Node.js的服務端庫,兩者有著幾乎一樣的API。
Socket.IO 主要使用WebSocket協議。但是如果需要的話,Socket.io可以回退到幾種其它方法,例如Adobe Flash Sockets,JSONP拉取,或是傳統的AJAX拉取,並且在同時提供完全相同的介面。

所以我覺得使用它更多是因為相容性,因為 HTML5 之後原生的 WS 應該也夠用了,然而它是一個前端庫,所以 Java 語言這塊並沒有官方支援,好在民間大神已經以 Netty 為基礎開發了能與它對接的 Java 庫: netty-socketio

不過我要先給大家提個醒,不再建議使用它了,不是因為它很久沒更新了,而是因為它支援的 Socket-Client 版本太老了,截止到 2022-04-29 日,SocketIO 已經更新到 4.X 了,但是 NettySocketIO 還只支援 2.X 的 Socket-Client 版本。

說了這麼多,該教大家如何使用它了,第一步還是引入最新的依賴:

        <dependency>
            <groupId>com.corundumstudio.socketio</groupId>
            <artifactId>netty-socketio</artifactId>
            <version>1.7.19</version>
        </dependency>

第二步就是配置一個 WS 服務:

@Configuration
public class SocketIoConfig {

    @Bean
    public SocketIOServer socketIOServer() {
        com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration();

        config.setHostname("127.0.0.1");
        config.setPort(8001);
        config.setContext("/socketio-ws");
        SocketIOServer server = new SocketIOServer(config);
        server.start();
        return server;
    }

    @Bean
    public SpringAnnotationScanner springAnnotationScanner() {
        return new SpringAnnotationScanner(socketIOServer());
    }
}

大家在上文的配置中,可以看到設定了一些 Web 伺服器引數,比如:埠號和監聽的 path,並將這個服務啟動起來,服務啟動之後日誌上會列印這樣一句日誌:

[ntLoopGroup-2-1] c.c.socketio.SocketIOServer : SocketIO server started at port: 8001

這就代表啟動成功了,接下來就是要對 WS 訊息做一些處理了:

@Component
public class SocketIoHandle {

    /**
     * 客戶端連上socket伺服器時執行此事件
     * @param client
     */
    @OnConnect
    public void onConnect(SocketIOClient client) {
        System.out.println("SocketIoHandle 收到連線:" + client.getSessionId());
    }

    /**
     * 客戶端斷開socket伺服器時執行此事件
     * @param client
     */
    @OnDisconnect
    public void onDisconnect(SocketIOClient client) {
        System.out.println("當前連結關閉:" + client.getSessionId());
    }

    @OnEvent( value = "onMsg")
    public void onMessage(SocketIOClient client, AckRequest request, Object data) {
        System.out.println("SocketIoHandle 收到訊息:" + data);
        request.isAckRequested();
        client.sendEvent("chatMsg", "我是 NettySocketIO 後端服務,已收到連線:" + client.getSessionId());
    }
}

我相信對於以上程式碼,前兩個方法是很好懂的,但是對於第三個方法如果大家沒有接觸過 SocketIO 就比較難理解了,為什麼@OnEvent( value = "onMsg")裡面這個值是自定義的,這就涉及到 SocketIO 裡面發訊息的機制了,通過 SocketIO 發訊息是要發給某個事件的,所以這裡的第三個方法就是監聽 發給onMsg事件的所有訊息,監聽到之後我又給客戶端發了一條訊息,這次發給的事件是:chatMsg,客戶端也需要監聽此事件才能接收到這條訊息。

最後再上一個簡陋的效果圖:
image.png
由於前端程式碼不再是標準的 HTML5 的連線方式,所以我這裡簡要貼一下相關程式碼,具體更多內容可以看我的程式碼倉庫:

    function changeSocketStatus() {
        let element = document.getElementById("socketStatus");
        if (socketStatus) {
            element.textContent = "關閉WebSocket";
            const socketUrl="ws://127.0.0.1:8001";
            socket = io.connect(socketUrl, {
                transports: ['websocket'],
                path: "/socketio-ws"
            });
            //開啟事件
            socket.on('connect', () => {
                console.log("websocket已開啟");
            });
            //獲得訊息事件
            socket.on('chatMsg', (msg) => {
                const serverMsg = "收到服務端資訊:" + msg;
                pushContent(serverMsg, 2);
            });
            //關閉事件
            socket.on('disconnect', () => {
                console.log("websocket已關閉");
            });
            //發生了錯誤事件
            socket.on('connect_error', () => {
                console.log("websocket發生了錯誤");
            })
        }
    }

第四種方式?

第四種方式其實就是 Netty 了,Netty 作為 Java 界大名鼎鼎的開發元件,對於常見協議也全部進行了封裝,所以我們可以直接在 Netty 中去很方便的使用 WebSocket,接下來我們可以看看 Netty 怎麼作為 WS 的伺服器進行開發。

注意:以下內容如果沒有 Netty 基礎可能一臉蒙的進,一臉蒙的出,不過還是建議大家看看,Netty 其實很簡單。

第一步需要先引入一個 Netty 開發包,我這裡為了方便一般都是 All In:

        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-all</artifactId>
            <version>4.1.75.Final</version>
        </dependency>

第二步的話就需要啟動一個 Netty 容器了,配置很多,但是比較關鍵的也就那幾個:

public class WebSocketNettServer {
    public static void main(String[] args) {

        NioEventLoopGroup boss = new NioEventLoopGroup(1);
        NioEventLoopGroup work = new NioEventLoopGroup();

        try {
            ServerBootstrap bootstrap = new ServerBootstrap();
            bootstrap
                    .group(boss, work)
                    .channel(NioServerSocketChannel.class)
                    //設定保持活動連線狀態
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
                    .localAddress(8080)
                    .handler(new LoggingHandler(LogLevel.DEBUG))
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                            ch.pipeline()
                                    // HTTP 請求解碼和響應編碼
                                    .addLast(new HttpServerCodec())
                                    // HTTP 壓縮支援
                                    .addLast(new HttpContentCompressor())
                                    // HTTP 物件聚合完整物件
                                    .addLast(new HttpObjectAggregator(65536))
                                    // WebSocket支援
                                    .addLast(new WebSocketServerProtocolHandler("/ws"))
                                    .addLast(WsTextInBoundHandle.INSTANCE);
                        }
                    });

            //繫結埠號,啟動服務端
            ChannelFuture channelFuture = bootstrap.bind().sync();
            System.out.println("WebSocketNettServer啟動成功");

            //對關閉通道進行監聽
            channelFuture.channel().closeFuture().sync();

        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            boss.shutdownGracefully().syncUninterruptibly();
            work.shutdownGracefully().syncUninterruptibly();
        }

    }
}

以上程式碼我們主要關心埠號和重寫的 ChannelInitializer 就行了,裡面我們定義了五個過濾器(Netty 使用責任鏈模式),前面三個都是 HTTP 請求的常用過濾器(畢竟 WS 握手是使用 HTTP 頭的所以也要配置 HTTP 支援),第四個則是 WS 的支援,它會攔截 /ws 路徑,最關鍵的就是第五個了過濾器它是我們具體的業務邏輯處理類,效果基本和 Spring 那部門中的 Handle 差不多,我們來看看程式碼:

@ChannelHandler.Sharable
public class WsTextInBoundHandle extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    private WsTextInBoundHandle() {
        super();
        System.out.println("初始化 WsTextInBoundHandle");
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("WsTextInBoundHandle 收到了連線");
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {

        String str = "WsTextInBoundHandle 收到了一條訊息, 內容為:" + msg.text();

        System.out.println(str);

        System.out.println("-----------WsTextInBoundHandle 處理業務邏輯-----------");

        String responseStr = "{\"status\":200, \"content\":\"收到\"}";

        ctx.channel().writeAndFlush(new TextWebSocketFrame(responseStr));
        System.out.println("-----------WsTextInBoundHandle 資料回覆完畢-----------");
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {

        System.out.println("WsTextInBoundHandle 訊息收到完畢");
        ctx.flush();
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        System.out.println("WsTextInBoundHandle 連線邏輯中發生了異常");
        cause.printStackTrace();
        ctx.close();
    }
}

這裡面的方法我都不說了,看名字就差不多知道了,主要是看一下這個類的泛型:TextWebSocketFrame,很明顯這是一個 WS 文字訊息的類,我們順著它的定義去看發現它繼承了 WebSocketFrame,接著我們去看它的子類:

image.png

一圖勝千言,我想不用多說大家也都知道具體的類是處理什麼訊息了把,在上文的示例中我們是一定了一個文字 WS 訊息的處理類,如果你想處理其他資料型別的訊息,可以將泛型中的 TextWebSocketFrame 換成其他 WebSocketFrame 類就可以了
至於為什麼沒有連線成功後的處理,這個是和 Netty 的相關機制有關,可以在 channelActive 方法中處理,大家有興趣的可以瞭解一下 Netty。

最後上個簡陋的 WS 效果示例圖,前端方面直接使用 HTML5 的 WebScoket 標準庫,具體可以檢視我的倉庫程式碼:
image.png

總結

洋洋灑灑五千字,有了收穫別忘贊。

在上文中,我總共介紹了四種在 Java 中使用 WS 的方式,從我個人使用意向來說我感覺應該是這樣的:Spring 方式 > Netty 方式 > J2EE 方式 > SocketIO 方式,當然了,如果你的業務存在瀏覽器相容性問題,其實只有一種選擇:SocketIO。

最後,我估計某些讀者會去具體拉程式碼看程式碼,所以我簡單說一下程式碼結構:

├─java
│  └─com
│      └─example
│          └─springwebsocket
│              │  SpringWebsocketApplication.java
│              │  TestController.java
│              │
│              ├─j2ee
│              │      WebSocketConfig.java
│              │      WebSocketServer.java
│              │
│              ├─socketio
│              │      SocketIoConfig.java
│              │      SocketIoHandle.java
│              │
│              └─spring
│                      SpringSocketConfig.java
│                      SpringSocketHandle.java
│
└─resources
    └─templates
            J2eeIndex.html
            SocketIoIndex.html
            SpringIndex.html

程式碼結構如上所示,應用程式碼分成了三個資料夾,分別放著三種方式的具體示例程式碼,在資原始檔夾下的 templates 資料夾也有三個 HTML 檔案,就是對應三種示例的 HTML 頁面,裡面的連結地址和埠我都預設好了,拉下來直接單獨編譯此模組執行即可。

我沒有往裡面放 Netty 的程式碼,是因為感覺 Netty 部分內容很少,文章示例中的程式碼直接複製就能用,後面如果寫 Netty 的話會再開一個 Netty 模組用來放 Netty 相關的程式碼。

好了,今天的內容就到這了,希望對大家有幫助的話可以幫我文章點點贊,GitHub 也點點贊,大家的點贊與評論都是我更新的不懈動力,下期見。

相關文章