Java SSE 服務器推送WEB頁面接收數據

前言
通常情況下,無論是web瀏覽器還是移動app,我們與服務器之間的交互都是主動的,客戶端向服務器端發出請求,然後服務器端返回數據給客戶端,客戶端瀏覽器再將信息呈現,客戶端與服務端對應的模式是: 客戶端請求--服務端響應,這種機制對於信息變化不是特別頻繁的應用尚可,但對於實時要求高、海量併發的應用來說顯得捉襟見肘,尤其在當前業界移動互聯網蓬勃發展的趨勢下,高併發與用戶實時響應是 Web 應用經常面臨的問題,比如金融證券的實時信息,Web 導航應用中的地理位置獲取,社交網絡的實時消息推送,新聞的訂閱,天氣的提醒等。這些情況下,需要服務器主動推送消息給客戶端。
那麼在這樣的模式下,會有幾個問題需要我們思考下:
1.應用服務器如何確定每一個應用所在的設備
2.服務器端是如何將消息推送到客戶端的,客戶端又不像服務器有一個固定的地址

帶着這些疑問我們來研究一下目前有哪些技術可以解決該問題:

一、Ajax輪詢

所謂的Ajax輪詢,其實就是定時的通過Ajax查詢服務端,客戶端按規定時間定時像服務端發送ajax請求,服務器接到請求後馬上返回響應信息並關閉連接。
這種技術方式實現起來非常簡單,但是這種方式會有非常嚴重的問題,就是需要不斷的向服務器發送消息詢問,這種方式會對服務器造成極大的性能浪費。
還有一個類似的輪詢是使用JSONP跨域請求的方式輪詢,在實現起來有差別,但基本原理都是相同的,都是客戶端不斷的向服務器發起請求。
優點
實現簡單。
缺點
這是通過模擬服務器發起的通信,不是實時通信,不顧及應用的狀態改變而盲目檢查更新,導致服務器資源的浪費,且會加重網絡負載,拖累服務器。

二、Comet

Comet,基於 HTTP 長連接的 "服務器推" 技術,能使服務器端主動以異步的方式向客戶端程序推送數據,而不需要客戶端顯式的發出請求,目前有兩種實現方式:
1. 基於 AJAX 的長輪詢(long-polling)方式
Ajax 的出現使得 JavaScript 可以調用 XMLHttpRequest 對象發出 HTTP 請求,JavaScript 響應處理函數根據服務器返回的信息對 HTML 頁面的顯示進行更新。使用 AJAX 實現 "服務器推" 與傳統的 AJAX 應用不同之處在於:
服務器端會阻塞請求直到有數據傳遞或超時才返回。
客戶端 JavaScript 響應處理函數會在處理完服務器返回的信息後,再次發出請求,重新建立連接。
當客戶端處理接收的數據、重新建立連接時,服務器端可能有新的數據到達;這些信息會被服務器端保存直到客戶端重新建立連接,客戶端會一次把當前服務器端所有的信息取回。
基於長輪詢的服務器推模型


相對於"輪詢"(poll),這種長輪詢方式也可以稱爲"拉"(pull)。因爲這種方案基於 AJAX,具有以下一些優點:請求異步發出;無須安裝插件;IE、Mozilla FireFox 都支持 AJAX。
長輪詢 (long polling) 是在打開一條連接以後保持並等待服務器推送來數據再關閉,可以採用HTTP長輪詢和XHR長輪詢兩種方式:
(1). HTTP 和JSONP方式的長輪詢
把 script 標籤附加到頁面上以讓腳本執行。服務器會掛起連接直到有事件發生,接着把腳本內容發送回瀏覽器,然後重新打開另一個 script 標籤來獲取下一個事件,從而實現長輪詢的模型。
(2).XHR長輪詢
這種方式是使用比較多的長輪詢模式。
客戶端打開一個到服務器端的 AJAX 請求然後等待響應;服務器端需要一些特定的功能來允許請求被掛起,只要一有事件發生,服務器端就會在掛起的請求中送回響應並關閉該請求。客戶端 JavaScript 響應處理函數會在處理完服務器返回的信息後,再次發出請求,重新建立連接;如此循環。
現在瀏覽器已經支持CROS的跨域方式請求,因此HTTP和JSONP的長輪詢方式是慢慢被淘汰的一種技術,建議採用XHR長輪詢。
優點
客戶端很容易實現良好的錯誤處理系統和超時管理,實現成本與Ajax輪詢的方式類似。
缺點
需要服務器端有特殊的功能來臨時掛起連接。當客戶端發起的連接較多時,服務器端會長期保持多個連接,具有一定的風險。
>>在這裏簡單的說明下長輪詢,長連接的概念輪詢:客戶端定時向服務器發送Ajax請求,服務器接到請求後馬上返回響應信息並關閉連接。優點:後端程序編寫比較容易。缺點:請求中有大半是無用,浪費帶寬和服務器資源。實例:適於小型應用。 長輪詢:客戶端向服務器發送Ajax請求,服務器接到請求後hold住連接,直到有新消息才返回響應信息並關閉連接,客戶端處理完響應信息後再向服務器發送新的請求。優點:在無消息的情況下不會頻繁的請求。缺點:服務器hold連接會消耗資源。實例:WebQQ、Hi網頁版、Facebook IM。 另外,對於長連接和socket連接也有區分: 長連接:在頁面裏嵌入一個隱蔵iframe,將這個隱蔵iframe的src屬性設爲對一個長連接的請求,服務器端就能源源不斷地往客戶端輸入數據。優點:消息即時到達,不發無用請求。缺點:服務器維護一個長連接會增加開銷。實例:Gmail聊天 Flash Socket:在頁面中內嵌入一個使用了Socket類的 Flash 程序JavaScript通過調用此Flash程序提供的Socket接口與服務器端的Socket接口進行通信,JavaScript在收到服務器端傳送的信息後控制頁面的顯示。優點:實現真正的即時通信,而不是僞即時。缺點:客戶端必須安裝Flash插件;非HTTP協議,無法自動穿越防火牆。實例:網絡互動遊戲。
2. 基於 Iframe 及 htmlfile 的流(streaming)方式
iframe 是很早就存在的一種 HTML 標記, 通過在 HTML 頁面裏嵌入一個隱蔵幀,然後將這個隱蔵幀的 SRC 屬性設爲對一個長連接的請求,服務器端就能源源不斷地往客戶端輸入數據。
基於流方式的服務器推模型


Comet的優缺點
優點: 實時性好(消息延時小);性能好(能支持大量用戶)
缺點: 長期佔用連接,喪失了無狀態高併發的特點。
Comet實現框架
1. Dojo CometD —— http://cometdproject.dojotoolkit.org/
2. DWR —— http://directwebremoting.org/dwr/index.html
3. ICEfaces —— http://www.icefaces.org/main/home/
4. GlassFish Grizzly —— https://grizzly.dev.java.net/
CometD 目前實現 Comet 比較成熟, DWR 弱一些。 ICEfaces 更商業化,實現得很成熟。 Grizzly 是基於GlassFish ,也很成熟。CometD, DWR 開源性好。
Comet實現要點
不要在同一客戶端同時使用超過兩個的 HTTP 長連接
我們使用 IE 下載文件時會有這樣的體驗,從同一個 Web 服務器下載文件,最多只能有兩個文件同時被下載。第三個文件的下載會被阻塞,直到前面下載的文件下載完畢。這是因爲 HTTP 1.1 規範中規定,客戶端不應該與服務器端建立超過兩個的 HTTP 連接, 新的連接會被阻塞。而 IE 在實現中嚴格遵守了這種規定。
HTTP 1.1 對兩個長連接的限制,會對使用了長連接的 Web 應用帶來如下現象:在客戶端如果打開超過兩個的 IE 窗口去訪問同一個使用了長連接的 Web 服務器,第三個 IE 窗口的 HTTP 請求被前兩個窗口的長連接阻塞。
所以在開發長連接的應用時, 必須注意在使用了多個 frame 的頁面中,不要爲每個 frame 的頁面都建立一個 HTTP 長連接,這樣會阻塞其它的 HTTP 請求,在設計上考慮讓多個 frame 的更新共用一個長連接。
服務器端的性能和可擴展性
一般 Web 服務器會爲每個連接創建一個線程,如果在大型的商業應用中使用 Comet,服務器端需要維護大量併發的長連接。在這種應用背景下,服務器端需要考慮負載均衡和集羣技術;或是在服務器端爲長連接作一些改進。
應用和技術的發展總是帶來新的需求,從而推動新技術的發展。HTTP 1.1 與 1.0 規範有一個很大的不同:1.0 規範下服務器在處理完每個 Get/Post 請求後會關閉套接口連接; 而 1.1 規範下服務器會保持這個連接,在處理兩個請求的間隔時間裏,這個連接處於空閒狀態。 Java 1.4 引入了支持異步 IO 的 java.nio 包。當連接處於空閒時,爲這個連接分配的線程資源會返還到線程池,可以供新的連接使用;當原來處於空閒的連接的客戶發出新的請求,會從線程池裏分配一個線程資源處理這個請求。 這種技術在連接處於空閒的機率較高、併發連接數目很多的場景下對於降低服務器的資源負載非常有效。
但是 AJAX 的應用使請求的出現變得頻繁,而 Comet 則會長時間佔用一個連接,上述的服務器模型在新的應用背景下會變得非常低效,線程池裏有限的線程數甚至可能會阻塞新的連接。Jetty 6 Web 服務器針對 AJAX、Comet 應用的特點進行了很多創新的改進。
控制信息與數據信息使用不同的 HTTP 連接
使用長連接時,存在一個很常見的場景:客戶端網頁需要關閉,而服務器端還處在讀取數據的堵塞狀態,客戶端需要及時通知服務器端關閉數據連接。服務器在收到關閉請求後首先要從讀取數據的阻塞狀態喚醒,然後釋放爲這個客戶端分配的資源,再關閉連接。
所以在設計上,我們需要使客戶端的控制請求和數據請求使用不同的 HTTP 連接,才能使控制請求不會被阻塞。
在實現上,如果是基於 iframe 流方式的長連接,客戶端頁面需要使用兩個 iframe,一個是控制幀,用於往服務器端發送控制請求,控制請求能很快收到響應,不會被堵塞;一個是顯示幀,用於往服務器端發送長連接請求。如果是基於 AJAX 的長輪詢方式,客戶端可以異步地發出一個 XMLHttpRequest 請求,通知服務器端關閉數據連接。
在客戶和服務器之間保持“心跳”信息
在瀏覽器與服務器之間維持一個長連接會爲通信帶來一些不確定性:因爲數據傳輸是隨機的,客戶端不知道何時服務器纔有數據傳送。服務器端需要確保當客戶端不再工作時,釋放爲這個客戶端分配的資源,防止內存泄漏。因此需要一種機制使雙方知道大家都在正常運行。在實現上:
服務器端在阻塞讀時會設置一個時限,超時後阻塞讀調用會返回,同時發給客戶端沒有新數據到達的心跳信息。此時如果客戶端已經關閉,服務器往通道寫數據會出現異常,服務器端就會及時釋放爲這個客戶端分配的資源。
如果客戶端使用的是基於 AJAX 的長輪詢方式;服務器端返回數據、關閉連接後,經過某個時限沒有收到客戶端的再次請求,會認爲客戶端不能正常工作,會釋放爲這個客戶端分配、維護的資源。
當服務器處理信息出現異常情況,需要發送錯誤信息通知客戶端,同時釋放資源、關閉連接。

三,websocket方式
WebSocket是HTML5開始提供的一種在單個 TCP 連接上進行全雙工通訊的協議。WebSocket通訊協議於2011年被IETF定爲標準RFC 6455,WebSocketAPI被W3C定爲標準。在WebSocket API中,瀏覽器和服務器只需要做一個握手的動作,然後,瀏覽器和服務器之間就形成了一條快速通道。兩者之間就直接可以數據互相傳送。


由於websocket技術要說明白的話所需要的篇幅不小,所以會在之後的單獨文章中介紹下websocket的使用方式,這裏就不做詳細的說明了。
總結
根據以上技術的優缺點和具體業務需要,可以選擇合適的技術進行應用。


sse java實現

//服務器代碼
package com.xy.inuyasha.web.controller;

import javax.servlet.AsyncContext;
import javax.servlet.AsyncEvent;
import javax.servlet.AsyncListener;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Date;

/**
* Created by Administrator on 2017/3/15.
*/
@WebServlet(urlPatterns = { "/role_echo" }, asyncSupported = true)
public class SseTest extends HttpServlet {

   private static final long serialVersionUID = 1L;
   private final static int DEFAULT_TIME_OUT = 10 * 60 * 1000;

   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
       // TODO Auto-generated method stub
       resp.setContentType("text/event-stream");
       resp.setCharacterEncoding("UTF-8");
       req.setAttribute("org.apache.catalina.ASYNC_SUPPORTED", true);//注意這裏

       AsyncContext actx = req.startAsync(req, resp);
       actx.setTimeout(DEFAULT_TIME_OUT);
       actx.addListener(new AsyncListener() {
           @Override
           public void onComplete(AsyncEvent arg0) throws IOException {
               // TODO Auto-generated method stub
               System.out.println("[echo]event complete:" + arg0.getSuppliedRequest().getRemoteAddr());
           }

           @Override
           public void onError(AsyncEvent arg0) throws IOException {
               // TODO Auto-generated method stub
               System.out.println("[echo]event has error");
           }

           @Override
           public void onStartAsync(AsyncEvent arg0) throws IOException {
               // TODO Auto-generated method stub
               System.out.println("[echo]event start:" + arg0.getSuppliedRequest().getRemoteAddr());
           }

           @Override
           public void onTimeout(AsyncEvent arg0) throws IOException {
               // TODO Auto-generated method stub
               System.out.println("[echo]event time lost");
           }
       });
       new Thread(new AsyncWebService(actx)).start();
   }
}

class AsyncWebService implements Runnable {
   AsyncContext ctx;

   public AsyncWebService(AsyncContext ctx) {
       this.ctx = ctx;
   }

   public void run() {
       try {
           //等待十秒鐘,以模擬業務方法的執行
           Thread.sleep(10000);
           PrintWriter out = ctx.getResponse().getWriter();
           out.println("data:中文" + new Date() + "\r\n");  //js頁面EventSource接收數據格式:data:數據 + "\r\n"

           out.flush();
            ctx.complete();
       } catch (Exception e) {
           e.printStackTrace();
       }

   }

}
//jsp頁面js腳本
if (!!window.EventSource) { //EventSource是SSE的客戶端.此時說明瀏覽器支持EventSource對象
   var source = new EventSource('/role_echo');//發送消息
    s = '';

   source.addEventListener('message', function(e) {
      
         s += e.data + "<br/>";
       $("#msgFromPush").html(s);
   },false);//添加客戶端的監聽

   source.addEventListener('open', function(e) {
       console.log("連接打開");
   }, false);

   source.addEventListener('error',function(e){
       if(e.currentTarget.readyState==EventSource.CLOSED){
           console.log("連接關閉");
       }else{
           console.log(e.currentTarget.readyState);
       }
   });
}else{
   console.log("您的瀏覽器不支持SSE");
}

測試能跑通!!


一.WebSocket簡單介紹

  隨着互聯網的發展,傳統的HTTP協議已經很難滿足Web應用日益複雜的需求了。近年來,隨着HTML5的誕生,WebSocket協議被提出,它實現了瀏覽器與服務器的全雙工通信,擴展了瀏覽器與服務端的通信功能,使服務端也能主動向客戶端發送數據。

  我們知道,傳統的HTTP協議是無狀態的,每次請求(request)都要由客戶端(如 瀏覽器)主動發起,服務端進行處理後返回response結果,而服務端很難主動向客戶端發送數據;這種客戶端是主動方,服務端是被動方的傳統Web模式 對於信息變化不頻繁的Web應用來說造成的麻煩較小,而對於涉及實時信息的Web應用卻帶來了很大的不便,如帶有即時通信、實時數據、訂閱推送等功能的應 用。在WebSocket規範提出之前,開發人員若要實現這些實時性較強的功能,經常會使用折衷的解決方法:輪詢(polling)Comet技術。其實後者本質上也是一種輪詢,只不過有所改進。

  輪詢是最原始的實現實時Web應用的解決方案。輪詢技術要求客戶端以設定的時間間隔週期性地向服務端發送請求,頻繁地查詢是否有新的數據改動。明顯地,這種方法會導致過多不必要的請求,浪費流量和服務器資源。

  Comet技術又可以分爲長輪詢流技術長輪詢改進了上述的輪詢技術,減小了無用的請求。它會爲某些數據設定過期時間,當數據過期後纔會向服務端發送請求;這種機制適合數據的改動不是特別頻繁的情況。流技術通常是指客戶端使用一個隱藏的窗口與服務端建立一個HTTP長連接,服務端會不斷更新連接狀態以保持HTTP長連接存活;這樣的話,服務端就可以通過這條長連接主動將數據發送給客戶端;流技術在大併發環境下,可能會考驗到服務端的性能。

  這兩種技術都是基於請求-應答模式,都不算是真正意義上的實時技術;它們的每一次請求、應答,都浪費了一定流量在相同的頭部信息上,並且開發複雜度也較大。

  伴隨着HTML5推出的WebSocket,真正實現了Web的實時通信,使B/S模式具備了C/S模式的實時通信能力。WebSocket的工作流程是這 樣的:瀏覽器通過JavaScript向服務端發出建立WebSocket連接的請求,在WebSocket連接建立成功後,客戶端和服務端就可以通過 TCP連接傳輸數據。因爲WebSocket連接本質上是TCP連接,不需要每次傳輸都帶上重複的頭部數據,所以它的數據傳輸量比輪詢和Comet技術小 了很多。本文不詳細地介紹WebSocket規範,主要介紹下WebSocket在Java Web中的實現。

  JavaEE 7中出了JSR-356:Java API for WebSocket規範。不少Web容器,如Tomcat,Nginx,Jetty等都支持WebSocket。Tomcat從7.0.27開始支持 WebSocket,從7.0.47開始支持JSR-356,下面的Demo代碼也是需要部署在Tomcat7.0.47以上的版本才能運行。

二.WebSocket示例

2.1.新建JavaWeb測試項目

  在pom.xml中添加Jar包依賴

<dependency>
<groupId>javax</groupId>
<artifactId>javaee-api</artifactId>
<version>7.0</version>
<scope>provided</scope>
</dependency>

客戶端(Web主頁)代碼:

<%@ page language="java" pageEncoding="UTF-8" %>
<!DOCTYPE html>
<html>
<head>
    <title>Java後端WebSocket的Tomcat實現</title>
</head>
<body>
    Welcome<br/><input id="text" type="text"/>
    <button onclick="send()">發送消息</button>
    <hr/>
    <button onclick="closeWebSocket()">關閉WebSocket連接</button>
    <hr/>
    <div id="message"></div>
</body>
<script type="text/javascript">
    var websocket = null;
    //判斷當前瀏覽器是否支持WebSocket
    if ('WebSocket' in window) {
        websocket = new WebSocket("ws://localhost:8080/websocket");
    }
    else {
        alert('當前瀏覽器 Not support websocket')
    }
    //連接發生錯誤的回調方法
    websocket.onerror = function () {
        setMessageInnerHTML("WebSocket連接發生錯誤");
    };
    //連接成功建立的回調方法
    websocket.onopen = function () {
        setMessageInnerHTML("WebSocket連接成功");
    }
    //接收到消息的回調方法
    websocket.onmessage = function (event) {
        setMessageInnerHTML(event.data);
    }
    //連接關閉的回調方法
    websocket.onclose = function () {
        setMessageInnerHTML("WebSocket連接關閉");
    }
    //監聽窗口關閉事件,當窗口關閉時,主動去關閉websocket連接,防止連接還沒斷開就關閉窗口,server端會拋異常。
    window.onbeforeunload = function () {
        closeWebSocket();
    }
    //將消息顯示在網頁上
    function setMessageInnerHTML(innerHTML) {
        document.getElementById('message').innerHTML += innerHTML + '<br/>';
    }
    //關閉WebSocket連接
    function closeWebSocket() {
        websocket.close();
    }
    //發送消息
    function send() {
        var message = document.getElementById('text').value;
        websocket.send(message);
    }
</script>
</html>

Java Web後端代碼

package me.gacl.websocket;
import java.io.IOException;
import java.util.concurrent.CopyOnWriteArraySet;
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
/**
 * @ServerEndpoint 註解是一個類層次的註解,它的功能主要是將目前的類定義成一個websocket服務器端,
 * 註解的值將被用於監聽用戶連接的終端訪問URL地址,客戶端可以通過這個URL來連接到WebSocket服務器端
 */
@ServerEndpoint("/websocket")
public class WebSocketTest {
    //靜態變量,用來記錄當前在線連接數。應該把它設計成線程安全的。
    private static int onlineCount = 0;
    //concurrent包的線程安全Set,用來存放每個客戶端對應的MyWebSocket對象。若要實現服務端與單一客戶端通信的話,可以使用Map來存放,其中Key可以爲用戶標識
    private static CopyOnWriteArraySet<WebSocketTest> webSocketSet = new CopyOnWriteArraySet<WebSocketTest>();
    //與某個客戶端的連接會話,需要通過它來給客戶端發送數據
    private Session session;
    /**
     * 連接建立成功調用的方法
     * @param session  可選的參數。session爲與某個客戶端的連接會話,需要通過它來給客戶端發送數據
     */
    @OnOpen
    public void onOpen(Session session){
        this.session = session;
        webSocketSet.add(this);     //加入set中
        addOnlineCount();           //在線數加1
        System.out.println("有新連接加入!當前在線人數爲" + getOnlineCount());
    }
    /**
     * 連接關閉調用的方法
     */
    @OnClose
    public void onClose(){
        webSocketSet.remove(this);  //從set中刪除
        subOnlineCount();           //在線數減1
        System.out.println("有一連接關閉!當前在線人數爲" + getOnlineCount());
    }
    /**
     * 收到客戶端消息後調用的方法
     * @param message 客戶端發送過來的消息
     * @param session 可選的參數
     */
    @OnMessage
    public void onMessage(String message, Session session) {
        System.out.println("來自客戶端的消息:" + message);
        //羣發消息
        for(WebSocketTest item: webSocketSet){
            try {
                item.sendMessage(message);
            } catch (IOException e) {
                e.printStackTrace();
                continue;
            }
        }
    }
    /**
     * 發生錯誤時調用
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, Throwable error){
        System.out.println("發生錯誤");
        error.printStackTrace();
    }
    /**
     * 這個方法與上面幾個方法不一樣。沒有用註解,是根據自己需要添加的方法。
     * @param message
     * @throws IOException
     */
    public void sendMessage(String message) throws IOException{
        this.session.getBasicRemote().sendText(message);
        //this.session.getAsyncRemote().sendText(message);
    }
    public static synchronized int getOnlineCount() {
        return onlineCount;
    }
    public static synchronized void addOnlineCount() {
        WebSocketTest.onlineCount++;
    }
    public static synchronized void subOnlineCount() {
        WebSocketTest.onlineCount--;
    }
}


1.2.運行效果

  同時打開Google瀏覽器和火狐瀏覽器進行多客戶端模擬測試,運行效果如下:

  

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章