JAVA-WebSocket服務

本篇文章,基於個人知識的理解和適用環境,如果有異議,可以建議修改,本人不升感激,謝謝,我只是一隻小小的蝸牛。

2017.12.26 WebSocket網絡通信
WebSocket協議是基於TCP的一種新的網絡協議。它實現了瀏覽器與服務器全雙工(full-duplex)通信——允許服務器主動發送信息給客戶端。
WebSocket通信協議於2011年被IETF定爲標準RFC 6455,並被RFC7936所補充規範。(摘至百度百科)
長久以來,web通信的問題其實也是http協議訪問通信的問題:
1.服務器被迫爲每個客戶端使用許多不同的底層TCP連接:一個用於向客戶端發送信息,其它用於接收每個傳入消息。
2.有些協議有很高的開銷,每一個客戶端和服務器之間都有HTTP頭。
3.客戶端腳本被迫維護從傳出連接到傳入連接的映射來追蹤回覆。
一個更簡單的解決方案是使用單個TCP連接雙向通信。 這就是WebSocket協議所提供的功能。 結合WebSocket API ,WebSocket協議提供了一個用來替代HTTP輪詢實現網頁到遠程主機的雙向通信的方法。
WebSocket協議被設計來取代用HTTP作爲傳輸層的雙向通訊技術,這些技術只能犧牲效率和可依賴性其中一方來提高另一方,因爲HTTP最初的目的不是爲了雙向通訊。
握手協議:
瀏覽器請求
GET /webfin/websocket/ HTTP/1.1
Host: localhost
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: xqBt3ImNzJbYqRINxEFlkg==
Origin: http://服務器地址
Sec-WebSocket-Version: 13

服務器迴應
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: K7DJLdLooIwIG/MOpvWFB3y3FE8=
WebSocket借用http請求進行握手,相比正常的http請求,多了一些內容。其中,
Upgrade: websocket
Connection: Upgrade
表示希望將http協議升級到Websocket協議。
Sec-WebSocket-Key是瀏覽器隨機生成的base64 encode的值,用來詢問服務器是否是支持WebSocket。

服務器返回
Upgrade: websocket
Connection: Upgrade
告訴瀏覽器即將升級的是Websocket協議
Sec-WebSocket-Accept是將請求包“Sec-WebSocket-Key”的值,與”258EAFA5-E914-47DA-95CA-C5AB0DC85B11″這個字符串進行拼接,
然後對拼接後的字符串進行sha-1運算,再進行base64編碼得到的。用來說明自己是WebSocket助理服務器。
Sec-WebSocket-Version是WebSocket協議版本號。RFC6455要求使用的版本是13,之前草案的版本均應當被棄用。

以下摘自<飄飄雪>,比較個人對這個知識點的理解。
      你可以把 WebSocket 看成是 HTTP 協議爲了支持長連接所打的一個大補丁,它和 HTTP 有一些共性,是爲了解決 HTTP 本身無法解決的某些問題而做出的一個改良設計。
      在以前 HTTP 協議中所謂的 keep-alive connection 是指在一次 TCP 連接中完成多個 HTTP 請求,但是對每個請求仍然要單獨發 header;
     所謂的 polling 是指從客戶端(一般就是瀏覽器)不斷主動的向服務器發 HTTP 請求查詢是否有新數據。這兩種模式有一個共同的缺點,就是除了真正的數據部分外,
     服務器和客戶端還要大量交換 HTTP header,信息交換效率很低。它們建立的“長連接”都是僞.長連接,只不過好處是不需要對現有的 HTTP server 和瀏覽器架構做修改就能實現。

        WebSocket 解決的第一個問題是,通過第一個 HTTP request 建立了 TCP 連接之後,之後的交換數據都不需要再發 HTTP request了,使得這個長連接變成了一個真.長連接。
但是不需要發送 HTTP header就能交換數據顯然和原有的 HTTP 協議是有區別的,所以它需要對服務器和客戶端都進行升級才能實現。在此基礎上 WebSocket 還是一個雙通道的連接,
在同一個 TCP 連接上既可以發也可以收信息。此外還有 multiplexing 功能,幾個不同的 URI 可以複用同一個 WebSocket 連接。這些都是原來的 HTTP 不能做到的。Session通信。

另外說一點技術細節,因爲看到有人提問 WebSocket 可能進入某種半死不活的狀態。這實際上也是原有網絡世界的一些缺陷性設計。上面所說的 WebSocket 真.長連接雖然解決了服務器和客戶端兩邊的問題,但坑爹的是網絡應用除了服務器和客戶端之外,另一個巨大的存在是中間的網絡鏈路。一個 HTTP/WebSocket 連接往往要經過無數的路由,防火牆。你以爲你的數據是在一個“連接”中發送的,實際上它要跨越千山萬水,經過無數次轉發,過濾,才能最終抵達終點。在這過程中,中間節點的處理方法很可能會讓你意想不到。

比如說,這些坑爹的中間節點可能會認爲一份連接在一段時間內沒有數據發送就等於失效,它們會自作主張的切斷這些連接。
在這種情況下,不論服務器還是客戶端都不會收到任何提示,它們只會一廂情願的以爲彼此間的紅線還在,徒勞地一邊又一邊地發送抵達不了彼岸的信息。
計算機網絡協議棧的實現中又會有一層套一層的緩存,除非填滿這些緩存,你的程序根本不會發現任何錯誤。
這樣,本來一個美好的 WebSocket 長連接,就可能在毫不知情的情況下進入了半死不活狀態。

而解決方案,WebSocket 的設計者們也早已想過。就是讓服務器和客戶端能夠發送 Ping/Pong Frame(RFC 6455 - The WebSocket Protocol)。
這種 Frame 是一種特殊的數據包,它只包含一些元數據而不需要真正的 Data Payload,可以在不影響 Application 的情況下維持住中間網絡的連接狀態。

個人實戰項目案列---------
項目是一個微信商城在線客服聊天通信功能
環境是:JDK1.8和Spring boot  ,工具是IDEA
WebSocketHotol類
package cn.hotol.hsp.chatroom;

import cn.hotol.base.common.util.CommonUtil;
import cn.hotol.bp.domain.bean.TdHtBuyers;
import cn.hotol.bp.domain.bean.TdHtSupplier;
import cn.hotol.bp.redis.RedisClient;
import cn.hotol.hsp.domain.base.bean.TdHtSmsChatMessage;
import cn.hotol.hsp.service.core.ChatMessageCoreService;
import net.sf.json.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.Session;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

/**
 * ,configurator = SpringConfigurator.class
 * @author mmh
 * @ServerEndpoint 註解是一個類層次的註解,它的功能主要是將目前的類定義成一個websocket服務器端,
 * 註解的值將被用於監聽用戶連接的終端訪問URL地址,客戶端可以通過這個URL來連接到WebSocket服務器端
 * @date 2017/12/20
 */

@Component
@ServerEndpoint(value = ".../{token}")
public class WebSocketHotol extends SpringBootBeanAutowiringSupport {

    @Autowired
    private RedisClient redisClient;
    @Autowired
    private ChatMessageCoreService chatMessageCoreService;

    /**
     *  concurrent包的線程安全Set,
     *  用來存放每個客戶端對應的MyWebSocket對象。
     *  若要實現服務端與單一客戶端通信的話,
     *  可以使用Map來存放,其中Key可以爲用戶標識
     *  session即時會話通道
     *  private static CopyOnWriteArraySet<WebSocketTest> webSocketSet = new CopyOnWriteArraySet<WebSocketTest>();
     */

    private static final Map<String, Object> webSocketSet = new HashMap<String, Object>();
    //與某個客戶端的連接會話,需要通過它來給客戶端發送數據
    private Session session;
    private static final String INIT_CUSTOMER_SERVICE_KEY= "customer_service_init_key";

    public WebSocketHotol() {
    }

    /**
     * 打開鏈接初始化通話鏈接
     * @param session 鏈接通道
     * @param token token標籤
     */
    @OnOpen
    public void onOpen(Session session,@PathParam(value = "token") String token) {
        this.session = session;
        if (token.startsWith("B") && !webSocketSet.containsKey(token)){
            //保存買家的客戶通道
            webSocketSet.put(token, this);
        }else if (token.startsWith("S") && !webSocketSet.containsKey(token)){
            //保存供應商的客戶通道
            webSocketSet.put(token, this);
        }else {
            //保存在線客戶的客戶通道
            webSocketSet.put(INIT_CUSTOMER_SERVICE_KEY, this);
        }
    }
    @OnClose
    public void onClose() {
        webSocketSet.remove(this);
    }

    @OnMessage
    public void onMessage(String message, Session session) {
        /*1.解析聊天信息*/
        JSONObject jsStr = JSONObject.fromObject(message);
        String token = jsStr.getString("token");
        String fromName = jsStr.getString("fromName");;
        String toName = jsStr.getString("toName");
        String messageInfo = jsStr.getString("messageInfo");
        String imgUrl = jsStr.getString("imgUrl");
        String toToken = jsStr.getString("toToken");
        Date date = new Date();
        DateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        String dateTime = sdf.format(date);
        /*2.聊天信息發送
        * 判斷聊天發起方:B是買家,S是供應商,其他信息就是客服
        * 如果是買家,判斷客戶是否已打開服務,如果打開服務,就鏈接客戶,未打開服務保存未讀信息
        * 如果是客服,判斷買家是否已打開服務,如果打開服務,就發送給客戶或者供應商,未打開服務保存未讀信息
        */
        if (token.startsWith("B")){
            String adminKey = INIT_CUSTOMER_SERVICE_KEY + fromName;
            WebSocketHotol admin = null;
            //判斷是否客服爲初始化在線
            //判斷客服是否已與買家通話中
            if(webSocketSet.containsKey(adminKey)){
                admin = (WebSocketHotol) webSocketSet.get(adminKey);
            }else if (webSocketSet.containsKey(INIT_CUSTOMER_SERVICE_KEY)){
                admin = (WebSocketHotol) webSocketSet.get(INIT_CUSTOMER_SERVICE_KEY);
                webSocketSet.put(adminKey,admin);
                webSocketSet.remove(INIT_CUSTOMER_SERVICE_KEY);
            }
            WebSocketHotol buyerSession = (WebSocketHotol) webSocketSet.get(token);
            try {

                buyerSession.session.getBasicRemote().sendText(fromName + ":" + messageInfo + "," + dateTime + "," + imgUrl );
                TdHtSmsChatMessage chatMessage = new TdHtSmsChatMessage();
                chatMessage.setReadState(1);
                if (admin != null){
                    admin.session.getBasicRemote().sendText(fromName + ":" + messageInfo + "," + dateTime + "," + imgUrl);
                    chatMessage.setReadState(0);
                }
        /*聊天信息保存*/    
                chatMessageCoreService.saveChatMessageRecord(chatMessage,token);
            }catch (IOException e){
                webSocketSet.remove(buyerSession);
                try {
                    sendMessage("發送信息失敗");
                } catch (IOException e1) {
                    e1.printStackTrace();
                }
            }
        }else if (token.startsWith("S")){
            String adminKey = INIT_CUSTOMER_SERVICE_KEY + fromName;
            WebSocketHotol admin = null;
            //判斷是否客服爲初始化在線
            //判斷客服是否已與買家通話中
            if(webSocketSet.containsKey(adminKey)){
                admin = (WebSocketHotol) webSocketSet.get(adminKey);
            }else if (webSocketSet.containsKey(INIT_CUSTOMER_SERVICE_KEY)){
                admin = (WebSocketHotol) webSocketSet.get(INIT_CUSTOMER_SERVICE_KEY);
                webSocketSet.put(adminKey,admin);
                webSocketSet.remove(INIT_CUSTOMER_SERVICE_KEY);
            }

            WebSocketHotol supplierSession = (WebSocketHotol) webSocketSet.get(token);
            try {
                admin.session.getBasicRemote().sendText(fromName + ":" + messageInfo + "," + dateTime + "," + imgUrl);
                supplierSession.session.getBasicRemote().sendText(fromName + ":" + messageInfo + "," + dateTime + "," + imgUrl);
                TdHtSmsChatMessage chatMessage = new TdHtSmsChatMessage();
                chatMessage.setReadState(1);
                if (admin != null){
                    admin.session.getBasicRemote().sendText(fromName + ":" + messageInfo + "," + dateTime + "," + imgUrl);
                    chatMessage.setReadState(0);
                }
                /*聊天信息保存*/
                chatMessageCoreService.saveChatMessageRecord(chatMessage,token);
            }catch (IOException e){
                webSocketSet.remove(supplierSession);
                try {
                    sendMessage("發送信息失敗");
                } catch (IOException e1) {
                    e1.printStackTrace();
                }
            }
        }else {
            //客服發送服務
            String adminKey = INIT_CUSTOMER_SERVICE_KEY + toName;
            WebSocketHotol admin = null;
            if (webSocketSet.containsKey(adminKey)){
                admin = (WebSocketHotol) webSocketSet.get(adminKey);
            }else if (webSocketSet.containsKey(INIT_CUSTOMER_SERVICE_KEY)){
                admin = (WebSocketHotol) webSocketSet.get(INIT_CUSTOMER_SERVICE_KEY);
                webSocketSet.put(adminKey,admin);
                webSocketSet.remove(INIT_CUSTOMER_SERVICE_KEY);
            }
            WebSocketHotol custSession = (WebSocketHotol) webSocketSet.get(toToken);
            try {
                admin.session.getBasicRemote().sendText(fromName + ":" + messageInfo + "," + dateTime + "," + imgUrl);
                TdHtSmsChatMessage chatMessage = new TdHtSmsChatMessage();
                chatMessage.setReadState(1);
                if (custSession != null){
                    custSession.session.getBasicRemote().sendText(fromName + ":" + messageInfo + "," + dateTime + "," + imgUrl);
                }
        /*聊天信息保存*/    
                chatMessageCoreService.saveChatMessageRecord(chatMessage,token);
            }catch (IOException e){
                webSocketSet.remove(admin);
                try {
                    sendMessage("發送信息失敗");
                } catch (IOException e1) {
                    e1.printStackTrace();
                }
            }
        }
    }
        @OnError
        public void onError(Session session, Throwable error){
            System.out.println("發生錯誤");
             error.printStackTrace();
        }

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


}


主要業務邏輯功能大致就是這樣的。
因爲我們需要把聊天的信息在數據庫做保存記錄,所以困難的地方就是Spring BOOT 融合注入service
package cn.hotol.hsp.chatroom;

import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;

import javax.servlet.ServletContext;
import javax.servlet.ServletException;

/**
 * Web應用上下文自定義初始化加載器
 * Created by Administrator on 2017/12/25.
 */
@Configuration
public class WebApplicationContextLocator implements ServletContextInitializer {

    private static WebApplicationContext webApplicationContext;

    public static WebApplicationContext getCurrentWebApplicationContext() {
        return webApplicationContext;
    }

    /**
     * 啓動時讀取出來,後面讀取二次使用
     * @param servletContext
     * @throws ServletException
     */
    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        webApplicationContext = WebApplicationContextUtils.getWebApplicationContext(servletContext);
    }
}


重寫容器框架,上下文加載
package cn.hotol;

import cn.hotol.hsp.chatroom.WebApplicationContextLocator;
import cn.hotol.hsp.common.filter.MyFilter;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ImportResource;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

import javax.servlet.Filter;

@EnableTransactionManagement
@SpringBootApplication
@ComponentScan(basePackages = "...")
@ImportResource("classpath:xml/*")
@MapperScan(basePackages = {"..."})
@ServletComponentScan
@EnableAutoConfiguration
public class AppStarter extends WebApplicationContextLocator {


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

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

package cn.hotol.hsp.chatroom;

import com.alibaba.druid.support.logging.Log;
import com.alibaba.druid.support.logging.LogFactory;
import com.alibaba.dubbo.common.utils.Assert;
import org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor;
import org.springframework.util.ClassUtils;
import org.springframework.web.context.WebApplicationContext;

/**
 * spring註解初始化引導容器加載
 * Created by mmh on 2017/12/25.
 */
public abstract class SpringBootBeanAutowiringSupport {

    private static final Log logger = LogFactory.getLog(SpringBootBeanAutowiringSupport.class);

    public SpringBootBeanAutowiringSupport() {
        System.out.println("SpringBootBeanAutowiringSupport.SpringBootBeanAutowiringSupport");
        processInjectionBasedOnCurrentContext(this);
    }

    public static void processInjectionBasedOnCurrentContext(Object target) {
        Assert.notNull(target, "Target object must not be null");
        WebApplicationContext cc = WebApplicationContextLocator.getCurrentWebApplicationContext();
        if (cc != null) {
            AutowiredAnnotationBeanPostProcessor bpp = new AutowiredAnnotationBeanPostProcessor();
            bpp.setBeanFactory(cc.getAutowireCapableBeanFactory());
            bpp.processInjection(target);
        }
        else {
            if (logger.isDebugEnabled()) {
                logger.debug("Current WebApplicationContext is not available for processing of " +
                        ClassUtils.getShortName(target.getClass()) + ": " +
                        "Make sure this class gets constructed in a Spring web application. Proceeding without injection.");
            }
        }
    }

}


如果有需要,請自行補腦。
前端測試:
<!DOCTYPE HTML>
<html>
<head>
    <title>My WebSocket</title>
    <meta charset="UTF-8">
</head>

<body>
Welcome<br/>
<input id="text" type="text" /><button onclick="send()">Send</button>    <button onclick="closeWebSocket()">Close</button>
<div id="message">
</div>
</body>

<script type="text/javascript">
    var websocket = null;
    var token ="857e8ba875a44c71a8ee328c12142501";
    //判斷當前瀏覽器是否支持WebSocket
    if('WebSocket' in window){
        websocket = new WebSocket("ws://.../" + token);
    }
    else{
        alert('Not support websocket')
    }

    //連接發生錯誤的回調方法
    websocket.onerror = function(){
        setMessageInnerHTML("error");
    };

    //連接成功建立的回調方法
    websocket.onopen = function(event){
        setMessageInnerHTML("open");
    }

    //接收到消息的回調方法
    websocket.onmessage = function(event){
        setMessageInnerHTML(event.data);
    }

    //連接關閉的回調方法
    websocket.onclose = function(){
        setMessageInnerHTML("close");
    }

    //監聽窗口關閉事件,當窗口關閉時,主動去關閉websocket連接,防止連接還沒斷開就關閉窗口,server端會拋異常。
    window.onbeforeunload = function(){
        websocket.close();
    }

    //將消息顯示在網頁上
    function setMessageInnerHTML(innerHTML){
        document.getElementById('message').innerHTML += innerHTML + '<br/>';
    }

    //關閉連接
    function closeWebSocket(){
        websocket.close();
    }

    //String messageType = "token,fromName,toName,dateTime,messageInfo,imgUrl";
    //發送消息
    function send(){
        var message = document.getElementById('text').value;
       // message = "B857e8ba875a44c71a8ee328c12142501,A,admin,2017-12-21:16:29:00,"+message+",";
        var tempData = {};
        tempData.token = "B857e8ba875a44c71a8ee328c12142501";
        tempData.fromName = "admin";
        tempData.toName = "A";
        tempData.dateTime = "2017-12-21 16:29:00";
        tempData.messageInfo = message;
        tempData.imgUrl = "";
        var last = JSON.stringify(tempData);
        var tempData = '{"token":"B857e8ba875a44c71a8ee328c12142501","fromName":"A","toName":"admin","dateTime":"2017-12-21 16:29:00","message":"'+message+'","imgUrl":""}';
        websocket.send(last);
    }
</script>
</html>

上傳的圖片這麼看不到...鬱悶

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