2024/08/05

WebSocket in Tomcat 10

JSR 356, Java API for WebSocket 是 java 為 WebSocket 制定的 API 規格,Tomcat 10 內建 JSR 356 的實作,以下記錄如何使用 Tomcat 支援的 WebSocket

Server Side

Server 部分,不需要調整 web.xml,只需要有一個 Java Class,加上適當的 annotation

  • @ServerEndpoint("/websocketendpoint")

    這是將這個 Java Class 註冊為處理 ws://localhost:8080/testweb/websocketendpoint 這個 WebSocket 網址的對應處理的程式

  • @OnOpen, @OnClose

    分別寫在兩個 method 上面,對應處理 WebSocket 連線及斷線的 method

  • @OnMessage

    收到 WebSocket 的一個 message 時,對應的處理的 method

  • @OnError

    錯誤處理

WsServer.java

package com.maxkit.testweb.ws;

import jakarta.websocket.*;
import jakarta.websocket.server.ServerEndpoint;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

@ServerEndpoint("/websocketendpoint")
public class WsServer {
    private Logger logger = LoggerFactory.getLogger(this.getClass().getName());
    private Session wsSession = null;
    private String username;

    @OnOpen
    public void onOpen(Session session) throws IOException {

        this.wsSession = session;
        session.getBasicRemote().sendText("The session is opened.");
        WsSeverSessions.getInstance().putSession(session.getId(), session);

        // parsing url in this format
        // ws://localhost:8080/testweb/websocketendpoint?username=name
        // queryString: username=name
        String queryString = session.getQueryString();
        Map<String, String> params = parseQuerystring(queryString);
//        this.username = QueryString.substring(QueryString.indexOf("=")+1);
        this.username = params.get("username");
        logger.info("Open Connection session id={}, username={}", session.getId(), username);

        String message = (username+" is in chatroom");
        broadcastMessage(message);
    }

    @OnClose
    public void onClose(Session session) {
        logger.info("Close Connection username={}, session id={}", username, session.getId());
        this.wsSession = null;
        WsSeverSessions.getInstance().removeSession(session.getId());
    }

    @OnMessage
    public String onMessage(String message) throws IOException {
        logger.info("Message from the client username={}, session id ={}, message={}", username, this.wsSession.getId(), message);
        broadcastMessage(username + " broadcast: " + message);
        return username+ " echo: " + message;
    }

    @OnError
    public void onError(Throwable e){
        e.printStackTrace();
    }

    public void broadcastMessage(String message) throws IOException {
        for (Session session : WsSeverSessions.getInstance().getSessions()) {
            if (session.isOpen()) {
                session.getBasicRemote().sendText(message);
            }
        }
    }

    public static Map<String, String> parseQuerystring(String queryString) {
        Map<String, String> map = new HashMap<String, String>();
        if ((queryString == null) || (queryString.equals(""))) {
            return map;
        }
        String[] params = queryString.split("&");
        for (String param : params) {
            try {
                String[] keyValuePair = param.split("=", 2);
                String name = URLDecoder.decode(keyValuePair[0], StandardCharsets.UTF_8);
                if (name.equals("")) {
                    continue;
                }
                String value = keyValuePair.length > 1 ? URLDecoder.decode( keyValuePair[1], StandardCharsets.UTF_8) : "";
                map.put(name, value);
            } catch (IllegalArgumentException  e) {
                // ignore this parameter if it can't be decoded
            }
        }
        return map;
    }
}

因為 Server 是透過 session.getBasicRemote() ,以 sendText 發送訊息給 client,如果要能夠在 server side 將訊息廣播給所有連線的 clients,必須在 server 的記憶體中,紀錄所有的 jakarta.websocket.Session

以下是用 Singleton 的方式,將 jakarta.websocket.Session 記錄在 ConcurrentHashMap 裡面。上面的 @OnOpen, @OnClose 會有將 Session 儲存在 WsServerSessions 的程式,另外 broadcastMessage 會取得所有的 Session,用以發送廣播訊息。

WsServerSessions.java

package com.maxkit.testweb.ws;

import jakarta.websocket.Session;

import java.util.Collection;
import java.util.concurrent.ConcurrentHashMap;

public class WsSeverSessions {
    private final ConcurrentHashMap<String, Session> sessions = new ConcurrentHashMap<>();

    private volatile static WsSeverSessions _instance = null;

    private WsSeverSessions() {
    }

    public static WsSeverSessions getInstance() {
        if (_instance == null) {
            synchronized (WsSeverSessions.class) {
                if (_instance == null) {
                    _instance = new WsSeverSessions();
                }
            }
        }
        return _instance;
    }

    public synchronized void putSession(String id, Session session) {
        sessions.put(id, session);
    }

    public synchronized void removeSession(String id) {
        sessions.remove(id);
    }

    public synchronized Collection<Session> getSessions() {
        return sessions.values();
    }
}

Client Side

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Tomcat WebSocket</title>
</head>
<body>
    <form>
        username: <input id="username" type="text">
        <input onclick="wsLogin();" value="Login" type="button">
        <br/>
        message: <input id="message" type="text">
        <input onclick="wsSendMessage();" value="Echo" type="button">
        <input onclick="wsCloseConnection();" value="Disconnect" type="button">
    </form>
    <br>
    <textarea id="echoText" rows="20" cols="100"></textarea>
    <script type="text/javascript">
        var webSocket = null;
        var username = document.getElementById("username");
        var echoText = document.getElementById("echoText");
        var message = document.getElementById("message");

        function wsLogin() {
            if( username.value === "" ) {
                username.value = getRandomInt(10000).toString();
            }
            webSocket = new WebSocket("ws://localhost:8080/testweb/websocketendpoint?username="+username.value );
            echoText.value = "";
            webSocket.onopen = function (message) {
                wsOpen(message);
            };
            webSocket.onmessage = function (message) {
                wsGetMessage(message);
            };
            webSocket.onclose = function (message) {
                wsClose(message);
            };
            webSocket.onerror = function (message) {
                wsError(message);
            };

            function wsOpen(message) {
                echoText.value += "Connected ... \n";
            }

            function wsGetMessage(message) {
                echoText.value += "Message received from to the server : " + message.data + "\n";
            }

            function wsClose(message) {
                echoText.value += "Disconnect ... \n";
            }

            function wsError(message) {
                echoText.value += "Error ... \n";
            }
        }
        function wsSendMessage() {
            if( webSocket == null ) {
                return;
            }
            webSocket.send(message.value);
            echoText.value += "Message sended to the server : " + message.value + "\n";
            message.value = "";
        }

        function wsCloseConnection() {
            if( webSocket == null ) {
                return;
            }
            webSocket.close();
        }
        function getRandomInt(max) {
            return Math.floor(Math.random() * max);
        }

    </script>
</body>
</html>

Tomcat Example

網頁部分

https://github.com/apache/tomcat/tree/main/webapps/examples/websocket

Server Side

https://github.com/apache/tomcat/tree/main/webapps/examples/WEB-INF/classes/websocket

可以直接把 server side 的 java package: websocket 複製到自己的測試 web project,程式裡面有用到 util package 的 classes,把 util 也複製過去。server side 的寫法比上面測試的還複雜一點點

網頁部分只需要直接複製 websocket 這個目錄的網頁,但因為範例程式的 webapp 是固定寫成 examples,稍微修改 xhtml 網頁,把 examples 替代為自己的 webapp,例如 testweb

只需要這些 code 就可以執行 tomcat 的 websocket 範例


範例有四個

  1. chat:chat room

  2. echo:echo message

  3. drawboard:多人在一個繪圖板上,任意繪圖

  4. snake:可多人在分享的 snake board 上面,用 keyboard 將 snake 轉向


要注意 project 加上官方 websocket examples後,上面自己寫的程式變成無法運作,原因是 java.websocket.ExamplesConfig 這個 class 實作 ServerApplicationConfig 介面,多了一些限制

  • getAnnotatedEndpointClasses

    限制只會掃描 websocket 這個 package 裡面的 Endpoint annotation,所以將上面的程式移到這個 package 裡面

  • getEndpointConfigs

    加上以下這段程式

            if (scanned.contains(WsServer.class)) {
                result.add(ServerEndpointConfig.Builder.create(
                        WsServer.class,
                        "/websocketendpoint").build());
            }

References

WebSocket – Tomcat 簡單的聊天室 – Max的程式語言筆記

Apache Tomcat Websocket Tutorial - Java Code Geeks

沒有留言:

張貼留言