如何使用Tomcat實現WebSocket即時通訊服務服務端

語言: CN / TW / HK
摘要:HTTP協議是“請求-響應”模式,瀏覽器必須先發請求給伺服器,伺服器才會響應該請求。即伺服器不會主動傳送資料給瀏覽器。

本文分享自華為雲社群《Tomcat支援WebSocket嗎?》,作者: JavaEdge 。

HTTP協議是“請求-響應”模式,瀏覽器必須先發請求給伺服器,伺服器才會響應該請求。即伺服器不會主動傳送資料給瀏覽器。

實時性要求高的應用,如線上遊戲、股票實時報價和線上協同編輯等,瀏覽器需實時顯示伺服器的最新資料,因此出現Ajax和Comet技術:

  • Ajax本質還是輪詢
  • Comet基於HTTP長連線做了一些hack

但它們實時性不高,頻繁請求也會給伺服器巨大壓力,也浪費網路流量和頻寬。於是HTML5推出WebSocket標準,使得瀏覽器和伺服器之間任一方都可主動發訊息給對方,這樣伺服器有新資料時可主動推給瀏覽器。

WebSocket原理

網路上的兩個程式通過一個雙向鏈路進行通訊,這個雙向鏈路的一端稱為一個Socket。一個Socket對應一個IP地址和埠號,應用程式通常通過Socket向網路發出或應答網路請求。

Socket不是協議,是對TCP/IP協議層抽象出來的API。

WebSocket跟HTTP協議一樣,也是應用層協議。為相容HTTP協議,它通過HTTP協議進行一次握手,握手後資料就直接從TCP層的Socket傳輸,與HTTP協議再無關。

這裡的握手指應用協議層,不是TCP層,握手時,TCP連線已建立。即HTTP請求裡帶有websocket的請求頭,服務端回覆也帶有websocket的響應頭。

瀏覽器發給服務端的請求會帶上跟WebSocket有關的請求頭,比如Connection: Upgrade和Upgrade: websocket

若伺服器支援WebSocket,同樣會在HTTP響應加上WebSocket相關的HTTP頭部:

這樣WebSocket連線就建立好了。

WebSocket的資料傳輸以frame形式傳輸,將一條訊息分為幾個frame,按先後順序傳輸出去。為何這樣設計?

  • 大資料的傳輸可以分片傳輸,無需考慮資料大小問題
  • 和HTTP的chunk一樣,可邊生成資料邊傳輸,提高傳輸效率

Tomcat如何支援WebSocket

WebSocket聊天室案例

瀏覽器端核心程式碼:

var Chat = {};
Chat.socket = null;
Chat.connect = (function(host) {

    //判斷當前瀏覽器是否支援WebSocket
    if ('WebSocket' in window) {
        // 若支援,則建立WebSocket JS類
        Chat.socket = new WebSocket(host);
    } else if ('MozWebSocket' in window) {
        Chat.socket = new MozWebSocket(host);
    } else {
        Console.log('WebSocket is not supported by this browser.');
        return;
    }

  	// 再實現幾個回撥方法
    // 回撥函式,當和伺服器的WebSocket連線建立起來後,瀏覽器會回撥這個方法
    Chat.socket.onopen = function () {
        Console.log('Info: WebSocket connection opened.');
        document.getElementById('chat').onkeydown = function(event) {
            if (event.keyCode == 13) {
                Chat.sendMessage();
            }
        };
    };

    // 回撥函式,當和伺服器的WebSocket連線關閉後,瀏覽器會回撥這個方法
    Chat.socket.onclose = function () {
        document.getElementById('chat').onkeydown = null;
        Console.log('Info: WebSocket closed.');
    };

    // 回撥函式,當伺服器有新訊息傳送到瀏覽器,瀏覽器會回撥這個方法
    Chat.socket.onmessage = function (message) {
        Console.log(message.data);
    };
});

伺服器端Tomcat實現程式碼:

Tomcat端的實現類加上**@ServerEndpoint**註解,value是URL路徑

@ServerEndpoint(value = "/websocket/chat")
public class ChatEndpoint {

    private static final String GUEST_PREFIX = "Guest";
 
    // 記錄當前有多少個使用者加入到了聊天室,它是static全域性變數。為了多執行緒安全使用原子變數AtomicInteger
    private static final AtomicInteger connectionIds = new AtomicInteger(0);
 
    //每個使用者用一個CharAnnotation例項來維護,請你注意它是一個全域性的static變數,所以用到了執行緒安全的CopyOnWriteArraySet
    private static final Set<ChatEndpoint> connections =
            new CopyOnWriteArraySet<>();

    private final String nickname;
    private Session session;

    public ChatEndpoint() {
        nickname = GUEST_PREFIX + connectionIds.getAndIncrement();
    }

    //新連線到達時,Tomcat會建立一個Session,並回調這個函式
    @OnOpen
    public void start(Session session) {
        this.session = session;
        connections.add(this);
        String message = String.format("* %s %s", nickname, "has joined.");
        broadcast(message);
    }

    //瀏覽器關閉連線時,Tomcat會回撥這個函式
    @OnClose
    public void end() {
        connections.remove(this);
        String message = String.format("* %s %s",
                nickname, "has disconnected.");
        broadcast(message);
    }

    //瀏覽器傳送訊息到伺服器時,Tomcat會回撥這個函式
    @OnMessage
    public void incoming(String message) {
        // Never trust the client
        String filteredMessage = String.format("%s: %s",
                nickname, HTMLFilter.filter(message.toString()));
        broadcast(filteredMessage);
    }

    // WebSocket連接出錯時,Tomcat會回撥這個函式
    @OnError
    public void onError(Throwable t) throws Throwable {
        log.error("Chat Error: " + t.toString(), t);
    }

    // 向聊天室中的每個使用者廣播訊息
    private static void broadcast(String msg) {
        for (ChatAnnotation client : connections) {
            try {
                synchronized (client) {
                    client.session.getBasicRemote().sendText(msg);
                }
            } catch (IOException e) {
              ...
            }
        }
    }
}

根據Java WebSocket規範的規定,Java WebSocket應用程式由一系列的WebSocket Endpoint組成。Endpoint是一個Java物件,代表WebSocket連線的一端,就好像處理HTTP請求的Servlet一樣,你可以把它看作是處理WebSocket訊息的介面。

跟Servlet不同的地方在於,Tomcat會給每一個WebSocket連線建立一個Endpoint例項。

可以通過兩種方式。

定義和實現Endpoint

程式設計式

編寫一個Java類繼承javax.websocket.Endpoint,並實現它的onOpen、onClose和onError方法。這些方法跟Endpoint的生命週期有關,Tomcat負責管理Endpoint的生命週期並呼叫這些方法。並且當瀏覽器連線到一個Endpoint時,Tomcat會給這個連線建立一個唯一的Session(javax.websocket.Session)。Session在WebSocket連線握手成功之後建立,並在連線關閉時銷燬。當觸發Endpoint各個生命週期事件時,Tomcat會將當前Session作為引數傳給Endpoint的回撥方法,因此一個Endpoint例項對應一個Session,我們通過在Session中新增MessageHandler訊息處理器來接收訊息,MessageHandler中定義了onMessage方法。在這裡Session的本質是對Socket的封裝,Endpoint通過它與瀏覽器通訊。

註解式

實現一個業務類並給它新增WebSocket相關的註解。

@ServerEndpoint(value = "/websocket/chat")

註解,它表明當前業務類ChatEndpoint是個實現了WebSocket規範的Endpoint,並且註解的value值表明ChatEndpoint對映的URL是/websocket/chat。ChatEndpoint類中有@OnOpen、@OnClose、@OnError和在@OnMessage註解的方法,見名知義。

我們只需關心具體的Endpoint實現,比如聊天室,為向所有人群發訊息,ChatEndpoint在內部使用了一個全域性靜態的集合CopyOnWriteArraySet維護所有ChatEndpoint例項,因為每一個ChatEndpoint例項對應一個WebSocket連線,即代表了一個加入聊天室的使用者。

當某個ChatEndpoint例項收到來自瀏覽器的訊息時,這個ChatEndpoint會向集合中其他ChatEndpoint例項背後的WebSocket連線推送訊息。

  • Tomcat主要做了哪些事情呢?
    Endpoint載入和WebSocket請求處理。

WebSocket載入

Tomcat的WebSocket載入是通過SCI,ServletContainerInitializer,是Servlet 3.0規範中定義的用來接收Web應用啟動事件的介面。

為什麼要監聽Servlet容器的啟動事件呢?這樣就有機會在Web應用啟動時做一些初始化工作,比如WebSocket需要掃描和載入Endpoint類。

將實現ServletContainerInitializer介面的類增加HandlesTypes註解,並且在註解內指定的一系列類和介面集合。比如Tomcat為了掃描和載入Endpoint而定義的SCI類如下:

定義好SCI,Tomcat在啟動階段掃描類時,會將HandlesTypes註解指定的類都掃描出來,作為SCI的onStartup引數,並呼叫SCI#onStartup。

WsSci#HandlesTypes註解定義了ServerEndpoint.class、ServerApplicationConfig.class和Endpoint.class,因此在Tomcat的啟動階段會將這些類的類例項(不是物件例項)傳遞給WsSci#onStartup。

  • WsSci的onStartup方法做了什麼呢?

構造一個WebSocketContainer例項,你可以把WebSocketContainer理解成一個專門處理WebSocket請求的Endpoint容器。即Tomcat會把掃描到的Endpoint子類和添加了註解@ServerEndpoint的類註冊到這個容器,並且該容器還維護了URL到Endpoint的對映關係,這樣通過請求URL就能找到具體的Endpoint來處理WebSocket請求。

WebSocket請求處理

  • Tomcat聯結器的元件圖

Tomcat用ProtocolHandler元件遮蔽應用層協議的差異,ProtocolHandler兩個關鍵元件:Endpoint和Processor。

這裡的Endpoint跟上文提到的WebSocket中的Endpoint完全是兩回事,聯結器中的Endpoint元件用來處理I/O通訊。WebSocket本質是個應用層協議,不能用HttpProcessor處理WebSocket請求,而要用專門Processor,在Tomcat就是UpgradeProcessor。

因為Tomcat是將HTTP協議升級成WebSocket協議的,因為WebSocket是通過HTTP協議握手的,當WebSocket握手請求到來時,HttpProtocolHandler首先接收到這個請求,在處理這個HTTP請求時,Tomcat通過一個特殊的Filter判斷該當前HTTP請求是否是一個WebSocket Upgrade請求(即包含Upgrade: websocket的HTTP頭資訊),如果是,則在HTTP響應裡新增WebSocket相關的響應頭資訊,並進行協議升級。

就是用UpgradeProtocolHandler替換當前的HttpProtocolHandler,相應的,把當前Socket的Processor替換成UpgradeProcessor,同時Tomcat會建立WebSocket Session例項和Endpoint例項,並跟當前的WebSocket連線一一對應起來。這個WebSocket連線不會立即關閉,並且在請求處理中,不再使用原有的HttpProcessor,而是用專門的UpgradeProcessor,UpgradeProcessor最終會呼叫相應的Endpoint例項來處理請求。

Tomcat對WebSocket請求的處理沒有經過Servlet容器,而是通過UpgradeProcessor元件直接把請求發到ServerEndpoint例項,並且Tomcat的WebSocket實現不需要關注具體I/O模型的細節,從而實現了與具體I/O方式的解耦。

總結

WebSocket技術實現了Tomcat與瀏覽器的雙向通訊,Tomcat可以主動向瀏覽器推送資料,可以用來實現對資料實時性要求比較高的應用。這需要瀏覽器和Web伺服器同時支援WebSocket標準,Tomcat啟動時通過SCI技術來掃描和載入WebSocket的處理類ServerEndpoint,並且建立起了URL到ServerEndpoint的對映關係。

當第一個WebSocket請求到達時,Tomcat將HTTP協議升級成WebSocket協議,並將該Socket連線的Processor替換成UpgradeProcessor。這個Socket不會立即關閉,對接下來的請求,Tomcat通過UpgradeProcessor直接呼叫相應的ServerEndpoint來處理。

還可以通過Spring來實現WebSocket應用。

 

點選關注,第一時間瞭解華為雲新鮮技術~