Java Websocket 01: 原生模式 Websocket 基礎通訊

2023-06-19 06:00:59

目錄

Websocket 原生模式

原生模式下

  • 伺服器端通過 @ServerEndpoint 實現其對應的 @OnOpen, @OnClose, @OnMessage, @OnError 方法
  • 使用者端建立 WebSocketClient 實現對應的 onOpen(), onClose(), onMessage(), onError()

演示專案

完整範例程式碼 https://github.com/MiltonLai/websocket-demos/tree/main/ws-demo01

目錄結構

│   pom.xml
└───src
    ├───main
    │   ├───java
    │   │   └───com
    │   │       └───rockbb
    │   │           └───test
    │   │               └───wsdemo
    │   │                       SocketServer.java
    │   │                       WebSocketConfig.java
    │   │                       WsDemo01App.java
    │   └───resources
    │           application.yml
    └───test
        └───java
            └───com
                └───rockbb
                    └───test
                        └───wsdemo
                                SocketClient.java

pom.xml

  • 可以用 JDK11, 也可以用 JDK17
  • 通過 Spring Boot plugin repackage, 生成 fat jar
  • 用 Java-WebSocket 作為 client 的 websocket 實現庫, 當前最新版本為 1.5.3
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.rockbb.test</groupId>
    <artifactId>ws-demo01</artifactId>
    <packaging>jar</packaging>
    <version>1.0-SNAPSHOT</version>

    <name>WS: Demo 01</name>

    <properties>
        <!-- Global encoding -->
        <project.jdk.version>17</project.jdk.version>
        <project.source.encoding>UTF-8</project.source.encoding>
        <!-- Global dependency versions -->
        <spring-boot.version>2.7.11</spring-boot.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <!-- Spring Boot Dependencies -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-websocket</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-messaging</artifactId>
        </dependency>

        <dependency>
            <groupId>org.java-websocket</groupId>
            <artifactId>Java-WebSocket</artifactId>
            <version>1.5.3</version>
        </dependency>

    </dependencies>

    <build>
        <finalName>ws-demo01</finalName>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.10.1</version>
                <configuration>
                    <source>${project.jdk.version}</source>
                    <target>${project.jdk.version}</target>
                    <encoding>${project.source.encoding}</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-resources-plugin</artifactId>
                <version>3.3.0</version>
                <configuration>
                    <encoding>${project.source.encoding}</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring-boot.version}</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

application.yml

設定伺服器埠為 8763

server:
  port: 8763
  tomcat:
    uri-encoding: UTF-8

spring:
  application:
    name: ws-demo01

WsDemo01App.java

  • 將 @RestController 也合併到應用入口了. 和單獨拆開做一個 Controller 類是一樣的
  • '/msg' 路徑用於從 server 往 client 傳送訊息
@RestController
@SpringBootApplication
public class WsDemo01App {

    public static void main(String[] args) {
        SpringApplication.run(WsDemo01App.class, args);
    }

    @RequestMapping("/msg")
    public String sendMsg(String sessionId, String msg) throws IOException {
        Session session = SocketServer.getSession(sessionId);
        SocketServer.sendMessage(session, msg);
        return "send " + sessionId + " : " + msg;
    }
}

WebSocketConfig.java

必須顯式宣告 ServerEndpointExporter 這個 Bean 才能提供 websocket 服務

@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter initServerEndpointExporter(){
        return new ServerEndpointExporter();
    }
}

SocketServer.java

提供 websocket 服務的關鍵類. @ServerEndpoint 的作用類似於 RestController, 這裡指定 client 存取的路徑格式為 ws://host:port/websocket/server/[id],
當 client 存取使用不同的 id 時, 會對應產生不同的 SocketServer 範例

@Component
@ServerEndpoint("/websocket/server/{sessionId}")
public class SocketServer {
    private static final org.slf4j.Logger log = LoggerFactory.getLogger(SocketServer.class);
    private static final Map<String, Session> sessionMap = new ConcurrentHashMap<>();

    private String sessionId = "";

    @OnOpen
    public void onOpen(Session session, @PathParam("sessionId") String sessionId) {
        this.sessionId = sessionId;
        /* Old connection will be kicked by new connection */
        sessionMap.put(sessionId, session);
        /*
         * this: instance id. New instances will be created for each sessionId
         * sessionId: assigned from path variable
         * session.getId(): the actual session id (start from 0)
         */
        log.info("On open: this{} sessionId {}, actual {}", this, sessionId, session.getId());
    }

    @OnClose
    public void onClose() {
        sessionMap.remove(sessionId);
        log.info("On close: sessionId {}", sessionId);
    }

    @OnMessage
    public void onMessage(String message, Session session) {
        log.info("On message: sessionId {}, {}", session.getId(), message);
    }

    @OnError
    public void onError(Session session, Throwable error) {
        log.error("On error: sessionId {}, {}", session.getId(), error.getMessage());
    }

    public static void sendMessage(Session session, String message) throws IOException {
        session.getBasicRemote().sendText(message);
    }

    public static Session getSession(String sessionId){
        return sessionMap.get(sessionId);
    }
}

關於對談物件 Session

OnOpen 會注入一個 Session 引數, 這個是實際的 Websocket Session, 其 ID 是全域性唯一的, 可以唯一確定一個使用者端連線. 在當前版本的實現中, 這是一個從0開始自增的整數. 如果你需要實現例如單個使用者登入多個對談, 在通訊中, 將訊息轉發給同一個使用者的多個對談, 就要小心記錄這些 Session 的 ID.

@OnOpen
public void onOpen(Session session, @PathParam("sessionId") String sessionId)

關於對談意外關閉

在使用者端意外停止後, 伺服器端會收到 OnError 訊息, 可以通過這個訊息管理已經關閉的對談

SocketClient.java

client 測試類, 連線後可以通過命令列向 server 傳送訊息

public class SocketClient {

    private static final org.slf4j.Logger log = LoggerFactory.getLogger(SocketClient.class);

    public static void main(String[] args) throws URISyntaxException {

        WebSocketClient wsClient = new WebSocketClient(
                new URI("ws://127.0.0.1:8763/websocket/server/10001")) {

            @Override
            public void onOpen(ServerHandshake serverHandshake) {
                log.info("On open: {}, {}", serverHandshake.getHttpStatus(), serverHandshake.getHttpStatusMessage());
            }

            @Override
            public void onMessage(String s) {
                log.info("On message: {}", s);
            }

            @Override
            public void onClose(int i, String s, boolean b) {
                log.info("On close: {}, {}, {}", i, s, b);
            }

            @Override
            public void onError(Exception e) {
                log.info("On error: {}", e.getMessage());
            }
        };

        wsClient.connect();
        log.info("Connecting...");
        while (!ReadyState.OPEN.equals(wsClient.getReadyState())) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                log.error(e.getMessage(), e);
            }
        }
        log.info("Connected");

        wsClient.send("hello");

        Scanner scanner = new Scanner(System.in);
        while (scanner.hasNext()) {
            String line = scanner.next();
            wsClient.send(line);
        }
        wsClient.close();
    }
}

程式碼的執行過程就是新建一個 WebSocketClient 並實現其處理訊息的介面方法, 使用 10001 作為 sessionId 進行連線, 在連線成功後, 不斷讀取鍵盤輸入 (System.in), 將輸入的字串傳送給伺服器端.

執行範例

範例是一個普通的 Spring Boot jar專案, 可以通過mvn clean package進行編譯, 再通過java -jar ws-demo01.jar執行, 啟動後工作在8763埠

然後執行 SocketClient.java, 可以觀察到伺服器端接收到的訊息.

伺服器端可以通過瀏覽器存取 http://127.0.0.1:8763/msg?sessionId=10001&msg=123 向用戶端傳送訊息.

結論

以上說明並演示了原生的 Websocket 實現方式, 可以嘗試執行多個 SocketClient, 使用相同或不同的 server sessionId 路徑, 觀察通訊的變化情況.