webSocket 實現聊天功能

2020-08-13 11:28:42

1、WebSocket協定概述

WebSocket protocol 是HTML5一種新的協定。它實現了瀏覽器與伺服器全雙工通訊(full-duplex)。一開始的握手需要藉助HTTP請求完成。

WebSocket是真正實現了全雙工通訊的伺服器向用戶端推的網際網路技術。

它是一種在單個TCP連線上進行全雙工通訊協定。Websocket通訊協定與2011年倍IETF定爲標準RFC 6455,Websocket API被W3C定爲標準。

2、優點:

可實現瀏覽器與伺服器全雙工通訊(full-duplex),它可以做到:瀏覽器和伺服器只需要做一個握手的動作,然後,瀏覽器和伺服器之間就形成了一條快速通道。兩者之間就直接可以數據互相傳送。這個新的協定的特點正好適合這種線上即時通訊。

傳統的Http協定實現方式:

http協定可以多次請求,因爲每次請求之後,都會關閉鏈接,下次重新請求數據,需要再次開啓鏈接。

https://pic3.zhimg.com/v2-8a490c5fa757a2291c2f30962a993cd6_b.png

說明:

  1. 基於polling(輪詢)技術:以頻繁請求方式來保持用戶端和伺服器端的同步
  2. 問題:用戶端的頻繁的請求,伺服器端的數據無變化,造成通訊低效

 

圖解:

 

傳統socket技術:

長連線

用戶端   --(先連線上去)----- 伺服器端

好處:可以實現用戶端和伺服器端雙向通訊

缺點:如果大家都不說話,是不是資源就浪費了

WebSocket協定實現方式:

它是一種長鏈接,只能通過一次請求來初始化鏈接,然後所有的請求和響應都是通過這個TCP鏈接進行通訊,這意味着它是一種基於事件驅動,非同步的訊息機制 機製

https://pic1.zhimg.com/v2-dd03b92f7ad12778776ffdd4300b941c_b.png

說明:原理和TCP一樣,只需做一個握手動作,就可以形成一條快速通道。

 

圖解:

詳細的通訊過程:

1)用戶端發起http請求,附加頭資訊爲:「Upgrade Websocket」

https://pic3.zhimg.com/v2-519f84d0109f4f0028fedb7f9eeff342_r.png

https://pic2.zhimg.com/v2-26205f87250112ecff70fc6eeac638e1_b.png

 

2)伺服器端解析,並返回握手資訊,從而建立連線

 

https://pic1.zhimg.com/v2-5edbba2d364e53f226f1fd15d0c95004_b.png

 

3)傳輸數據(雙向)

 

https://pic3.zhimg.com/v2-743a9e1ad8af5895fa5dd7ff7a97a60a_b.png

 

4)用戶端或伺服器端主動斷開連線。用戶端主動斷開:用戶端發起http請求,請求斷開連線,伺服器端收到訊息後斷開WebSocket連線;伺服器端主動斷開:直接斷開WebSocket連線,用戶端的API會立刻得知。

 

 

websocket的優越性不言自明,長連線的連線資源(執行緒資源)隨着連線數量的增多,必會耗盡,用戶端輪詢會給服

務器造成很大的壓力,而websocket是在物理層非網路層建立一條用戶端至伺服器的長連線,以此來保證伺服器向客

戶端的即時推播,既不耗費執行緒資源,又不會不斷向伺服器輪詢請求。

webscoket和傳統http協定的區別

 

傳統的http請求的的rest風格現在很流行,它的作用就是通過rest風格,能夠把不同路徑的請求對映到同一個方法進行處理。http協定可以多次請求,因爲每次請求之後,都會關閉鏈接,下次重新請求數據,需要再次開啓鏈接。而對於webscoket來說,它是一種長鏈接,只能通過一次請求來初始化鏈接,然後所有的請求和響應都是通過這個TCP鏈接進行通訊,這意味着它是一種基於事件驅動,非同步的訊息機制 機製,和JMS, AMQP等訊息機制 機製的應用差不多。

以前不管使用HTTP輪詢或使用TCP長連線等方式製作線上聊天系統,都有天然缺陷,隨着Html5的興起,其中有一個新的協定WebSocket protocol,可實現瀏覽器與伺服器全雙工通訊(full-duplex),它可以做到:瀏覽器和伺服器只需要做一個握手的動作,然後,瀏覽器和伺服器之間就形成了一條快速通道。兩者之間就直接可以數據互相傳送。這個新的協定的特點正好適合這種線上即時通訊。

 

傳統的用戶端和伺服器端通訊方式:

 

WebSocket的用戶端和伺服器端通訊方式:

 

 

說明:

http協定是一種應用層協定,已經定義了請求的格式,例如請求的頭部的關鍵字,還有也定義了,伺服器響應數據的格式,它對請求和響應的數據格式做了規範,而websocket協定不同,websocket協定還不夠詳細,它沒有規定請求和接收的數據格式,例如,瀏覽器想向伺服器請求進行socket通訊,但伺服器不知道是否要進行socketon通訊,由於這個原因,websocket就定義了一個子協定,也就是瀏覽器用戶端和伺服器在請求握手的時候,他們能根據頭部的Sec-WebSocket-Protocol,決定是否要進行websocket通訊。當然子協定的使用不是必須的,但是如果不使用子協定,那就必須自己定義一種請求和接收的數據格式規範,然後用戶端和伺服器都使用這種規範來進行通訊。

 

 

 

    1. 用戶端-瀏覽器的支援

WebSocket通訊的用戶端使用的是瀏覽器,用戶端操作的API是HTML5中新增的API,使用這些API可以讓用戶端(瀏覽器)和伺服器端(伺服器)進行全雙工的通訊。

支援的瀏覽器如下:

瀏覽器型別

瀏覽器版本

Chrome

Supported in version 4+

Firefox

Supported in version 4+

Internet Explorer

Supported in version 10+

Opera

Supported in version 10+

Safari

Supported in version 5+

問題出現了,Html5 websocket相容性還不是很好,不是所有的瀏覽器都支援這些新的API,特別是在IE10以下。

但幸運的是現在絕大多數主流的瀏覽器都支援這些API,即使不支援的哪些舊的瀏覽器,也有解決方案。如:

爲了處理不同瀏覽器和瀏覽器版本的相容性,spring webscoket基於SockJS protocol提供了一種解決相容性的方法,在底層遮蔽相容性的問題,提供統一的,透明的,可理解性的webscoket解決方案。

SockJS 是一個瀏覽器上執行的 JavaScript 庫,如果瀏覽器不支援 WebSocket,該庫可以模擬對 WebSocket 的支援,實現瀏覽器和 Web 伺服器之間低延遲、全雙工、跨域的通訊通道。

 

    1. 伺服器端-伺服器的支援

本課程是基於Java語言開發的,因此伺服器只討論JEE伺服器。

新版本的應用伺服器新增了支援的API,如Tomcat 7.0.47+等

 

  1. 開發環境搭建-基礎專案匯入
    1. 整體框架介紹

伺服器端:Maven+spring mvc+Spring WebSocket+jQuery+Gson

用戶端:html5的WebSocket的api

引入pom.xml設定:

<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>cn.itcast.projects</groupId>

  <artifactId>chatroomdemo</artifactId>

  <version>0.0.1-SNAPSHOT</version>

  <packaging>war</packaging>

  <name>chatroomdemo</name>

  <description>聊天室的demo</description>

  <!-- 自定義屬性管理 -->

    <properties>

       <!-- 編譯等所有操作使用utf-8編碼 -->

       <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

       <!-- 統一版本維護管理 -->

       <spring.version>4.2.8.RELEASE</spring.version>

       <servlet.version>3.1.0</servlet.version>

       <jsp.version>2.0</jsp.version>

       <gson.version>2.7</gson.version>

       <junit.version>4.12</junit.version>

    </properties>

    <!-- 依賴管理 -->

    <dependencies>

       <dependency>

           <groupId>org.springframework</groupId>

           <artifactId>spring-webmvc</artifactId>

           <version>${spring.version}</version>

       </dependency>

       <dependency>

           <groupId>org.springframework</groupId>

           <artifactId>spring-websocket</artifactId>

           <version>${spring.version}</version>

       </dependency>

       <dependency>

           <groupId>org.springframework</groupId>

           <artifactId>spring-messaging</artifactId>

           <version>${spring.version}</version>

       </dependency>

       <dependency>

           <groupId>javax.servlet</groupId>

           <artifactId>javax.servlet-api</artifactId>

           <version>${servlet.version}</version>

           <scope>provided</scope>

       </dependency>

       <dependency>

           <groupId>junit</groupId>

           <artifactId>junit</artifactId>

           <version>${junit.version}</version>

           <scope>test</scope>

       </dependency>

       <dependency>

           <groupId>com.google.code.gson</groupId>

           <artifactId>gson</artifactId>

           <version>${gson.version}</version>

       </dependency>

    </dependencies>

 

    <!-- 構建資訊管理 -->

    <build>

       <finalName>chatroom</finalName>

       <plugins>

           <!-- 編譯的jdk版本 -->

           <plugin>

              <groupId>org.apache.maven.plugins</groupId>

              <artifactId>maven-compiler-plugin</artifactId>

              <configuration>

                  <source>1.7</source>

                  <target>1.7</target>

              </configuration>

           </plugin>

           <plugin>

              <groupId>org.apache.tomcat.maven</groupId>

              <artifactId>tomcat7-maven-plugin</artifactId>

              <version>2.2</version>

              <configuration>

                  <port>8080</port>

                  <path>/chatroom</path>

                  <uriEncoding>UTF-8</uriEncoding>

                  <finalName>chatroom</finalName>

                  <server>tomcat7</server>

              </configuration>

           </plugin>

       </plugins>

    </build>

</project>

 

 

    1. 基礎專案匯入

 

 

 

  1. 專案API講解

 

    1. 用戶端的API

 

用戶端如何去連線伺服器端?

用戶端需要主動握手,

需要使用html5的一些程式碼

 

// 建立一個Socket範例(需要瀏覽器支援)ws:WebSocket協定地址開頭

var socket = new WebSocket('ws://localhost:8080');

 

//下面 下麪有幾個回撥函數,自動呼叫(什麼時候呼叫?)

// 開啓Socket

socket.onopen = function(event) {

//握手成功後,會自動呼叫該函數

  }

 

  // 監聽訊息:用來獲取伺服器端的訊息

  socket.onmessage = function(event) {

    console.log('Client received a message',event);

  };

 

  // 監聽Socket的關閉

  socket.onclose = function(event) {

    console.log('Client notified socket has closed',event);

  };

 

  // 關閉Socket....

  //socket.close()

};

 

 

 

    1. 伺服器端的API

 

spring WebSocket:jee:WebSocket的封裝。

用:只需要知道搭建步驟即可。

 

 

 

/**

 *

 * 說明:WebScoket設定處理器

 * 把處理器和攔截器註冊到spring websocket

 */

@Component("webSocketConfig")

//設定開啓WebSocket服務用來接收ws請求

@EnableWebSocket

public class WebSocketConfig implements WebSocketConfigurer {

 

    //注入處理器

    @Autowired

    private ChatWebSocketHandler webSocketHandler;

    @Autowired

    private ChatHandshakeInterceptor chatHandshakeInterceptor;

 

    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {

       //新增一個處理器還有定義處理器的處理路徑

       registry.addHandler(webSocketHandler, "/ws").addInterceptors(chatHandshakeInterceptor);

       /*

        * 在這裏我們用到.withSockJS()SockJSspring用來處理瀏覽器對websocket的相容性,

        * 目前瀏覽器支援websocket還不是很好,特別是IE11以下.

        * SockJS能根據瀏覽器能否支援websocket來提供三種方式用於websocket請求,

        * 三種方式分別是 WebSocket, HTTP Streaming以及 HTTP Long Polling

        */

       registry.addHandler(webSocketHandler, "/ws/sockjs").addInterceptors(chatHandshakeInterceptor).withSockJS();

    }

   

 

}

 

/**

 * websocket的鏈接建立是基於http握手協定,我們可以新增一個攔截器處理握手之前和握手之後過程

 * @author BoBo

 *

 */

@Component

public class ChatHandshakeInterceptor implements HandshakeInterceptor{

 

    /**

     * 握手之前,若返回false,則不建立鏈接

     */

    @Override

    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,

           Map<String, Object> attributes) throws Exception {

       if (request instanceof ServletServerHttpRequest) {

           ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;

           HttpSession session = servletRequest.getServletRequest().getSession(false);

           //如果使用者已經登錄,允許聊天

           if(session.getAttribute("loginUser")!=null){

              //獲取登錄的使用者

              User loginUser=(User)session.getAttribute("loginUser") ;

              //將使用者放入socket處理器的對談(WebSocketSession)

              attributes.put("loginUser", loginUser);

              System.out.println("Websocket:使用者[ID:" + (loginUser.getId() + ",Name:"+loginUser.getNickname()+"]要建立連線"));

           }else{

              //使用者沒有登錄,拒絕聊天

              //握手失敗!

              System.out.println("--------------握手已失敗...");

              return false;

           }

       }

       System.out.println("--------------握手開始...");

       return true;

    }

 

    /**

     * 握手之後

     */

    @Override

    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,

           Exception exception) {

       System.out.println("--------------握手成功啦...");

    }

 

}

 

 

 

@Component("chatWebSocketHandler")

public class ChatWebSocketHandler implements WebSocketHandler {

   

    //線上使用者的SOCKETsession(儲存了所有的通訊通道)

    public static final Map<String, WebSocketSession> USER_SOCKETSESSION_MAP;

   

    //儲存所有的線上使用者

    static {

       USER_SOCKETSESSION_MAP = new HashMap<String, WebSocketSession>();

    }

   

    /**

     * webscoket建立好鏈接之後的處理常式--連線建立後的準備工作

     */

    @Override

    public void afterConnectionEstablished(WebSocketSession webSocketSession) throws Exception {

       //將當前的連線的使用者對談放入MAP,key是使用者編號

       User loginUser=(User) webSocketSession.getAttributes().get("loginUser");

       USER_SOCKETSESSION_MAP.put(loginUser.getId(), webSocketSession);

      

       //羣發訊息告知大家

       Message msg = new Message();

       msg.setText("風騷的【"+loginUser.getNickname()+"】踩着輕盈的步伐來啦。。。大家歡迎!");

       msg.setDate(new Date());

       //獲取所有線上的WebSocketSession物件集合

       Set<Entry<String, WebSocketSession>> entrySet = USER_SOCKETSESSION_MAP.entrySet();

       //將最新的所有的線上人列表放入訊息物件的list集閤中,用於頁面顯示

       for (Entry<String, WebSocketSession> entry : entrySet) {

           msg.getUserList().add((User)entry.getValue().getAttributes().get("loginUser"));

       }

      

       //將訊息轉換爲json

       TextMessage message = new TextMessage(GsonUtils.toJson(msg));

       //羣發訊息

       sendMessageToAll(message);

      

    }

 

    @Override

    /**

     * 用戶端發送伺服器的訊息時的處理常式,在這裏收到訊息之後可以分發訊息

     */

    //處理訊息:當一個新的WebSocket到達的時候,會被呼叫(在用戶端通過Websocket API發送的訊息會經過這裏,然後進行相應的處理)

    public void handleMessage(WebSocketSession webSocketSession, WebSocketMessage<?> message) throws Exception {

       //如果訊息沒有任何內容,則直接返回

       if(message.getPayloadLength()==0)return;

       //反序列化伺服器端收到的json訊息

       Message msg = GsonUtils.fromJson(message.getPayload().toString(), Message.class);

       msg.setDate(new Date());

       //處理html的字元,跳脫:

       String text = msg.getText();

       //轉換爲HTML跳脫字元表示

       String htmlEscapeText = HtmlUtils.htmlEscape(text);

       msg.setText(htmlEscapeText);

       System.out.println("訊息(可存數據庫作爲歷史記錄):"+message.getPayload().toString());

       //判斷是羣發還是單發

       if(msg.getTo()==null||msg.getTo().equals("-1")){

           //羣發

           sendMessageToAll(new TextMessage(GsonUtils.toJson(msg)));

       }else{

           //單發

           sendMessageToUser(msg.getTo(), new TextMessage(GsonUtils.toJson(msg)));

       }

    }

 

    @Override

    /**

     * 訊息傳輸過程中出現的例外處理函數

     * 處理傳輸錯誤:處理由底層WebSocket訊息傳輸過程中發生的異常

     */

    public void handleTransportError(WebSocketSession webSocketSession, Throwable exception) throws Exception {

       // 記錄日誌,準備關閉連線

       System.out.println("Websocket異常斷開:" + webSocketSession.getId() + "已經關閉");

       //一旦發生異常,強制使用者下線,關閉session

       if (webSocketSession.isOpen()) {

           webSocketSession.close();

       }

      

       //羣發訊息告知大家

       Message msg = new Message();

       msg.setDate(new Date());

      

       //獲取異常的使用者的對談中的使用者編號

       User loginUser=(User)webSocketSession.getAttributes().get("loginUser");

       //獲取所有的使用者的對談

       Set<Entry<String, WebSocketSession>> entrySet = USER_SOCKETSESSION_MAP.entrySet();

       //並查詢出線上使用者的WebSocketSession(對談),將其移除(不再對其發訊息了。。)

       for (Entry<String, WebSocketSession> entry : entrySet) {

           if(entry.getKey().equals(loginUser.getId())){

              msg.setText("萬衆矚目的【"+loginUser.getNickname()+"】已經退出。。。!");

              //清除線上對談

              USER_SOCKETSESSION_MAP.remove(entry.getKey());

              //記錄日誌:

              System.out.println("Socket對談已經移除:使用者ID" + entry.getKey());

              break;

           }

       }

      

       //並查詢出線上使用者的WebSocketSession(對談),將其移除(不再對其發訊息了。。)

       for (Entry<String, WebSocketSession> entry : entrySet) {

           msg.getUserList().add((User)entry.getValue().getAttributes().get("loginUser"));

       }

      

       TextMessage message = new TextMessage(GsonUtils.toJson(msg));

       sendMessageToAll(message);

      

    }

 

    @Override

    /**

     * websocket鏈接關閉的回撥

     * 連線關閉後:一般是回收資源等

     */

    public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus closeStatus) throws Exception {

       // 記錄日誌,準備關閉連線

       System.out.println("Websocket正常斷開:" + webSocketSession.getId() + "已經關閉");

      

       //羣發訊息告知大家

       Message msg = new Message();

       msg.setDate(new Date());

      

       //獲取異常的使用者的對談中的使用者編號

       User loginUser=(User)webSocketSession.getAttributes().get("loginUser");

       Set<Entry<String, WebSocketSession>> entrySet = USER_SOCKETSESSION_MAP.entrySet();

       //並查詢出線上使用者的WebSocketSession(對談),將其移除(不再對其發訊息了。。)

       for (Entry<String, WebSocketSession> entry : entrySet) {

           if(entry.getKey().equals(loginUser.getId())){

              //羣發訊息告知大家

              msg.setText("萬衆矚目的【"+loginUser.getNickname()+"】已經有事先走了,大家繼續聊...");

              //清除線上對談

              USER_SOCKETSESSION_MAP.remove(entry.getKey());

               //記錄日誌:

              System.out.println("Socket對談已經移除:使用者ID" + entry.getKey());

              break;

           }

       }

      

       //並查詢出線上使用者的WebSocketSession(對談),將其移除(不再對其發訊息了。。)

       for (Entry<String, WebSocketSession> entry : entrySet) {

           msg.getUserList().add((User)entry.getValue().getAttributes().get("loginUser"));

       }

      

       TextMessage message = new TextMessage(GsonUtils.toJson(msg));

       sendMessageToAll(message);

    }

 

    @Override

     /**

     * 是否支援處理拆分訊息,返回true返回拆分訊息

     */

    //是否支援部分訊息:如果設定爲true,那麼一個大的或未知尺寸的訊息將會被分割,並會收到多次訊息(會通過多次呼叫方法handleMessage(WebSocketSession, WebSocketMessage).

    //如果分爲多條訊息,那麼可以通過一個apiorg.springframework.web.socket.WebSocketMessage.isLast() 是否是某條訊息的最後一部分。

    //預設一般爲false,訊息不分割

    public boolean supportsPartialMessages() {

       return false;

    }

 

    /**

     *

     * 說明:給某個人發資訊

     * @param id

     * @param message

     * @author 傳智.BoBo老師

     * @throws IOException

     * @time20161027 下午10:40:52

     */

    private void sendMessageToUser(String id, TextMessage message) throws IOException{

       //獲取到要接收訊息的使用者的session

       WebSocketSession webSocketSession = USER_SOCKETSESSION_MAP.get(id);

       if (webSocketSession != null && webSocketSession.isOpen()) {

           //發送訊息

           webSocketSession.sendMessage(message);

       }

    }

   

    /**

     *

     * 說明:羣發資訊:給所有線上使用者發送訊息

     * @author 傳智.BoBo老師

     * @time20161027 下午10:40:07

     */

    private void sendMessageToAll(final TextMessage message){

       //對使用者發送的訊息內容進行跳脫

      

       //獲取到所有線上使用者的SocketSession物件

       Set<Entry<String, WebSocketSession>> entrySet = USER_SOCKETSESSION_MAP.entrySet();

       for (Entry<String, WebSocketSession> entry : entrySet) {

           //某使用者的WebSocketSession

           final WebSocketSession webSocketSession = entry.getValue();

           //判斷連線是否仍然開啓的

           if(webSocketSession.isOpen()){

              //開啓多執行緒發送訊息(效率高)

              new Thread(new Runnable() {

                  public void run() {

                     try {

                         if (webSocketSession.isOpen()) {

                            webSocketSession.sendMessage(message);

                         }

                     } catch (IOException e) {

                         e.printStackTrace();

                     }

                  }

 

              }).start();

             

           }

       }

    }

   

}