首頁(yè)技術(shù)文章正文

使用WebSocket實(shí)現(xiàn)網(wǎng)頁(yè)聊天室

更新時(shí)間:2022-11-17 來(lái)源:黑馬程序員 瀏覽量:

  一、文章導(dǎo)讀

  服務(wù)器推送你還在使用輪詢嗎?本文將帶你領(lǐng)略WebSocket的魅力,輕松實(shí)現(xiàn)服務(wù)器推送功能。本文將以下面兩方面讓你理解WebSocket并應(yīng)用到具體的開發(fā)中。

  WebSocket概述

  使用WebSocket實(shí)現(xiàn)網(wǎng)頁(yè)聊天室

  二、WebSocket

      2.WebSocket介紹

  WebSocket 是一種網(wǎng)絡(luò)通信協(xié)議。RFC6455 定義了它的通信標(biāo)準(zhǔn)。

  WebSocket 是 HTML5 開始提供的一種在單個(gè) TCP 連接上進(jìn)行全雙工通訊的協(xié)議。

  HTTP 協(xié)議是一種無(wú)狀態(tài)的、無(wú)連接的、單向的應(yīng)用層協(xié)議。它采用了請(qǐng)求/響應(yīng)模型。通信請(qǐng)求只能由客戶端發(fā)起,服務(wù)端對(duì)請(qǐng)求做出應(yīng)答處理。

  這種通信模型有一個(gè)弊端:HTTP 協(xié)議無(wú)法實(shí)現(xiàn)服務(wù)器主動(dòng)向客戶端發(fā)起消息。

  這種單向請(qǐng)求的特點(diǎn),注定了如果服務(wù)器有連續(xù)的狀態(tài)變化,客戶端要獲知就非常麻煩。大多數(shù) Web 應(yīng)用程序?qū)⑼ㄟ^頻繁的異步 AJAX 請(qǐng)求實(shí)現(xiàn)長(zhǎng)輪詢。輪詢的效率低,非常浪費(fèi)資源(因?yàn)楸仨毑煌_B接,或者 HTTP 連接始終打開)。

  http協(xié)議:

1668665195820_1.jpg

  websocket協(xié)議:

1668665211246_2.jpg

  2. websocket協(xié)議

  本協(xié)議有兩部分:握手和數(shù)據(jù)傳輸。

  握手是基于http協(xié)議的。

  來(lái)自客戶端的握手看起來(lái)像如下形式:

GET ws://localhost/chat HTTP/1.1
Host: localhost
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Extensions: permessage-deflate
Sec-WebSocket-Version: 13

  來(lái)自服務(wù)器的握手看起來(lái)像如下形式:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Extensions: permessage-deflate

  字段說明:

| 頭名稱                    | 說明                                                         |
| :------------------------ | ------------------------------------------------------------ |
| Connection:Upgrade       | 標(biāo)識(shí)該HTTP請(qǐng)求是一個(gè)協(xié)議升級(jí)請(qǐng)求                             |
| Upgrade: WebSocket        | 協(xié)議升級(jí)為WebSocket協(xié)議                                      |
| Sec-WebSocket-Version: 13 | 客戶端支持WebSocket的版本                                    |
| Sec-WebSocket-Key:       | 客戶端采用base64編碼的24位隨機(jī)字符序列,服務(wù)器接受客戶端HTTP協(xié)議升級(jí)的證明。要求服務(wù)端響應(yīng)一個(gè)對(duì)應(yīng)加密的Sec-WebSocket-Accept頭信息作為應(yīng)答 |
| Sec-WebSocket-Extensions  | 協(xié)議擴(kuò)展類型                                                 |

  3. 客戶端(瀏覽器)實(shí)現(xiàn)

  3.1 websocket對(duì)象

  實(shí)現(xiàn) WebSockets 的 Web 瀏覽器將通過 WebSocket 對(duì)象公開所有必需的客戶端功能(主要指支持 Html5 的瀏覽器)。

  以下 API 用于創(chuàng)建 WebSocket 對(duì)象:

var ws = new WebSocket(url);

  > 參數(shù)url格式說明: ws://ip地址:端口號(hào)/資源名稱

  3.2 websocket事件

  WebSocket 對(duì)象的相關(guān)事件

  | 事件 | 事件處理程序 | 描述 |

  | ------- | ----------------------- | -------------------------- |

  | open | websocket對(duì)象.onopen | 連接建立時(shí)觸發(fā) |

  | message | websocket對(duì)象.onmessage | 客戶端接收服務(wù)端數(shù)據(jù)時(shí)觸發(fā) |

  | error | websocket對(duì)象.onerror | 通信發(fā)生錯(cuò)誤時(shí)觸發(fā) |

  | close | websocket對(duì)象.onclose | 連接關(guān)閉時(shí)觸發(fā) |

  3.3 WebSocket方法

  WebSocket 對(duì)象的相關(guān)方法:

  | 方法 | 描述 |

  | ------ | ---------------- |

  | send() | 使用連接發(fā)送數(shù)據(jù) |

  4. 服務(wù)端實(shí)現(xiàn)

  Tomcat的7.0.5 版本開始支持WebSocket,并且實(shí)現(xiàn)了Java WebSocket規(guī)范(JSR356)。

  Java WebSocket應(yīng)用由一系列的WebSocketEndpoint組成。Endpoint 是一個(gè)java對(duì)象,代表WebSocket鏈接的一端,對(duì)于服務(wù)端,我們可以視為處理具體WebSocket消息的接口, 就像Servlet之與http請(qǐng)求一樣。

  我們可以通過兩種方式定義Endpoint:

  第一種是編程式, 即繼承類 javax.websocket.Endpoint并實(shí)現(xiàn)其方法。

  第二種是注解式, 即定義一個(gè)POJO, 并添加 @ServerEndpoint相關(guān)注解。

  Endpoint實(shí)例在WebSocket握手時(shí)創(chuàng)建,并在客戶端與服務(wù)端鏈接過程中有效,最后在鏈接關(guān)閉時(shí)結(jié)束。在Endpoint接口中明確定義了與其生命周期相關(guān)的方法, 規(guī)范實(shí)現(xiàn)者確保生命周期的各個(gè)階段調(diào)用實(shí)例的相關(guān)方法。生命周期方法如下:

  | 方法 | 含義描述 | 注解 |

  | ------- | ------------------------------------------------------------ | -------- |

  | onClose | 當(dāng)會(huì)話關(guān)閉時(shí)調(diào)用。 | @OnClose |

  | onOpen | 當(dāng)開啟一個(gè)新的會(huì)話時(shí)調(diào)用, 該方法是客戶端與服務(wù)端握手成功后調(diào)用的方法。 | @OnOpen |

  | onError | 當(dāng)連接過程中異常時(shí)調(diào)用。 | @OnError |

  服務(wù)端如何接收客戶端發(fā)送的數(shù)據(jù)呢?

  通過為 Session 添加 MessageHandler 消息處理器來(lái)接收消息,當(dāng)采用注解方式定義Endpoint時(shí),我們還可以通過 @OnMessage 注解指定接收消息的方法。

  服務(wù)端如何推送數(shù)據(jù)給客戶端呢?

  發(fā)送消息則由 RemoteEndpoint 完成, 其實(shí)例由 Session 維護(hù), 根據(jù)使用情況, 我們可以通過Session.getBasicRemote 獲取同步消息發(fā)送的實(shí)例 , 然后調(diào)用其 sendXxx()方法就可以發(fā)送消息, 可以通過Session.getAsyncRemote 獲取異步消息發(fā)送實(shí)例。

  服務(wù)端代碼:

@ServerEndpoint("/robin")
public class ChatEndPoint {

    private static Set<ChatEndPoint> webSocketSet = new HashSet<>();

    private Session session;

    @OnMessage
    public void onMessage(String message, Session session) throws IOException {
        System.out.println("接收的消息是:" + message);
        System.out.println(session);
        //將消息發(fā)送給其他的用戶
        for (Chat chat : webSocketSet) {
            if(chat != this) {
                chat.session.getBasicRemote().sendText(message);
            }
        }
    }

    @OnOpen
    public void onOpen(Session session) {
        this.session = session;
        webSocketSet.add(this);
    }

    @OnClose
    public void onClose(Session seesion) {
        System.out.println("連接關(guān)閉了。。。");
    }

    @OnError
    public void onError(Session session,Throwable error) {
        System.out.println("出錯(cuò)了。。。。" + error.getMessage());
    }
}

  三、基于WebSocket的網(wǎng)頁(yè)聊天室

      1.需求

  通過 websocket 實(shí)現(xiàn)一個(gè)簡(jiǎn)易的聊天室功能 。

  1). 登陸聊天室

1668665475662_3.jpg

  2). 登陸之后,進(jìn)入聊天界面進(jìn)行聊天

  登陸成功后,呈現(xiàn)出以后的效果:

1668665493988_4.jpg

  當(dāng)我們想和李四聊天時(shí)就可以點(diǎn)擊 `好友列表` 中的 `李四`,效果如下:

1668665506928_5.jpg

  接下來(lái)就可以進(jìn)行聊天了,“張三”的界面如下:

1668665521021_6.jpg

  “李四” 的界面如下:

1668665543064_7.jpg

  2. 實(shí)現(xiàn)流程

1668665555307_8.jpg

  3. 消息格式

  客戶端 --> 服務(wù)端

  {"toName":"張三","message":"你好"}

  服務(wù)端 --> 客戶端

  系統(tǒng)消息格式:{"isSystem":true,"fromName":null,"message":["李四","王五"]}

  推送給某一個(gè)的消息格式:{"isSystem":false,"fromName":"張三","message":"你好"}

  4. 功能實(shí)現(xiàn)

  4.1 創(chuàng)建項(xiàng)目,導(dǎo)入相關(guān)jar包的坐標(biāo)

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.5.RELEASE</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
   
    <!--devtools熱部署-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-devtools</artifactId>
        <optional>true</optional>
        <scope>true</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <!-- 打jar包時(shí)如果不配置該插件,打出來(lái)的jar包沒有清單文件 -->
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

  4.2 引入靜態(tài)資源文件

1668665676650_流程圖.jpg

  4.3 引入公共資源

  pojo類

/**
   * @version v1.0
   * @ClassName: Message
   * @Description: 瀏覽器發(fā)送給服務(wù)器的websocket數(shù)據(jù)
   * @Author: 黑馬程序員
   */
  public class Message {
      private String toName;
      private String message;
 
      public String getToName() {
          return toName;
      }
 
      public void setToName(String toName) {
          this.toName = toName;
      }
 
      public String getMessage() {
          return message;
      }
 
      public void setMessage(String message) {
          this.message = message;
      }
  }
/**
   * @version v1.0
   * @ClassName: ResultMessage
   * @Description: 服務(wù)器發(fā)送給瀏覽器的websocket數(shù)據(jù)
   * @Author: 黑馬程序員
   */
  public class ResultMessage {
 
      private boolean isSystem;
      private String fromName;
      private Object message;//如果是系統(tǒng)消息是數(shù)組
 
      public boolean getIsSystem() {
          return isSystem;
      }
 
      public void setIsSystem(boolean isSystem) {
          this.isSystem = isSystem;
      }
 
      public String getFromName() {
          return fromName;
      }
 
      public void setFromName(String fromName) {
          this.fromName = fromName;
      }
 
      public Object getMessage() {
          return message;
      }
 
      public void setMessage(Object message) {
          this.message = message;
      }
  }
/**
   * @version v1.0
   * @ClassName: Result
   * @Description: 用于登陸響應(yīng)回給瀏覽器的數(shù)據(jù)
   * @Author: 黑馬程序員
   */
  public class Result {
      private boolean flag;
      private String message;
 
      public boolean isFlag() {
          return flag;
      }
 
      public void setFlag(boolean flag) {
          this.flag = flag;
      }
 
      public String getMessage() {
          return message;
      }
 
      public void setMessage(String message) {
          this.message = message;
      }
  }

  MessageUtils工具類

/**
   * @version v1.0
   * @ClassName: MessageUtils
   * @Description: 用來(lái)封裝消息的工具類
   * @Author: 黑馬程序員
   */
  public class MessageUtils {
 
      public static String getMessage(boolean isSystemMessage,String fromName, Object message) {
          try {
              ResultMessage result = new ResultMessage();
              result.setIsSystem(isSystemMessage);
              result.setMessage(message);
              if(fromName != null) {
                  result.setFromName(fromName);
              }
              ObjectMapper mapper = new ObjectMapper();
 
              return mapper.writeValueAsString(result);
          } catch (JsonProcessingException e) {
              e.printStackTrace();
          }
          return null;
      }
  }

  4.4 登陸功能實(shí)現(xiàn)

  login.html:使用異步進(jìn)行請(qǐng)求發(fā)送

  $(function() {
      $("#btn").click(function() {
          $.get("login",$("#loginForm").serialize(),function(res) {
              if(res.flag) {
                  //跳轉(zhuǎn)到 main.html頁(yè)面
                  location.href = "main.html";
              } else {
                  $("#err_msg").html(res.message);
              }
          },"json");
      });
  })

  UserController:進(jìn)行登陸邏輯處理

@RestController
  public class UserController {
 
      @RequestMapping("/login")
      public Result login(User user, HttpSession session) {
          Result result = new Result();
          if(user != null && "123".equals(user.getPassword())) {
              result.setFlag(true);
              //將用戶名存儲(chǔ)到session對(duì)象中
              session.setAttribute("user",user.getUsername());
          } else {
              result.setFlag(false);
              result.setMessage("登陸失敗");
          }
 
          return result;
      }
  }

  4.5 獲取當(dāng)前登錄的用戶名

  main.html:頁(yè)面加載完畢后,發(fā)送請(qǐng)求獲取當(dāng)前登錄的用戶名

  var username;
  $(function() {
      $.ajax({
          url:"getUsername",
          success:function(res) {
              username = res;
              $("#userName").html("用戶:" + res + "<span style='float: right;color: green'>在線</span>");
          },
          async:false
      });
  }

  UserController

  在UserController中添加一個(gè)getUsername方法,用來(lái)從session中獲取當(dāng)前登錄的用戶名并響應(yīng)回給瀏覽器

  @RequestMapping("/getUsername")
  public String getUsername(HttpSession session) {
      String username = (String) session.getAttribute("user");
      return username;
  }

  4.6 聊天室功能

  客戶端實(shí)現(xiàn)

  在main.html頁(yè)面實(shí)現(xiàn)前端代碼:

var toName;
          var username;
          function showChat(name) {
              toName = name;
              //清除聊天區(qū)的數(shù)據(jù)
              $("#msgs").html("");
              //現(xiàn)在聊天對(duì)話框
              $("#chatArea").css("display","inline");
            //顯示“正在和誰(shuí)聊天”
              $("#chatMes").html("正在和 <font face=\"楷體\">"+toName+"</font> 聊天");
 
              //切換用戶,需要將聊天記錄渲染到聊天區(qū)
              var storeData = sessionStorage.getItem(toName);
              if(storeData != null) {
                  $("#msgs").html(storeData);
              }
          }
 
          $(function() {
              $.ajax({
                  url:"getUsername",
                  success:function(res) {
                      username = res;
                      //顯示在線信息
                      $("#userName").html(" 用戶:"+res+"<span style='float: right;color: green'>在線</span>");
                  },
                  async: false
              })
 
              //創(chuàng)建websocket
              var ws;
              if(window.WebSocket) {
                  ws = new WebSocket("ws://localhost/chat");
              }
 
              //綁定事件
              ws.onopen = function(evt) {
                  //顯示在線信息
                  $("#userName").html(" 用戶:"+username+"<span style='float: right;color: green'>在線</span>");
              }
 
              ws.onmessage = function(evt) {
                  //接收服務(wù)器推送的消息
                  var data = evt.data;
                  //將該字符串?dāng)?shù)據(jù)轉(zhuǎn)換為json
                  var res = JSON.parse(data);
                  //判斷是系統(tǒng)消息還是推送給個(gè)人的消息
                  if(res.isSystem) {
                      //系統(tǒng)消息
                      var names = res.message;
                      var userListStr = "";
                      var broadcastStr = "";
                      for(var name of names) {
                          if(name != username) {
                              userListStr += "<li class=\"rel-item\"><a onclick='showChat(\""+name+"\")'>"+name+"</a></li>";
                              broadcastStr += "<li class=\"rel-item\" style=\"color: #9d9d9d;font-family: 宋體\">您的好友 "+name+" 已上線</li>";
                          }
                      }
                      //將數(shù)據(jù)渲染到頁(yè)面
                      $("#userlist").html(userListStr);
                      $("#broadcastList").html(broadcastStr);
                  } else {
                      //非系統(tǒng)消息
                      var content = res.message;
 
                      //拼接聊天區(qū)展示的數(shù)據(jù)
                      var str = "<div class=\"msg robot\"><div class=\"msg-left\" worker=\"\"><div class=\"msg-host photo\" style=\"background-image: url(img/avatar/Member002.jpg)\"></div><div class=\"msg-ball\">"+content+"</div></div></div>";
 
 
                      //有可能現(xiàn)在不是和指定用戶的聊天框,所以需要進(jìn)行判斷
                      var storeData = sessionStorage.getItem(res.fromName);
                      if(storeData != null) {
                          storeData += str;
                      } else {
                          storeData = str;
                      }
                      sessionStorage.setItem(res.fromName,storeData);
                      if(toName == res.fromName) {
                          //將數(shù)據(jù)追加到聊天區(qū)
                          $("#msgs").append(str);
                      }
                  }
              }
 
              ws.onclose = function() {
                  //顯示在線信息
                  $("#userName").html(" 用戶:"+username+"<span style='float: right;color: red'>離線</span>");
              }
 
              //給發(fā)送按鈕綁定點(diǎn)擊事件
              $("#submit").click(function() {
                  //獲取輸入的內(nèi)容
                  var data = $("#context_text").val();
                  //將該文本框清空
                  $("#context_text").val("");
                  //拼接消息
                  var str = "<div class=\"msg guest\"><div class=\"msg-right\"><div class=\"msg-host headDefault\"></div><div class=\"msg-ball\">"+data+"</div></div></div>";
                  $("#msgs").append(str);
                  //將聊天記錄進(jìn)行存儲(chǔ)sessionStorage
                  var storeData = sessionStorage.getItem(toName);
                  if(storeData != null) {
                      //將此次的內(nèi)容拼接到storeData中
                      str = storeData + str;
                  }
                  //將消息存儲(chǔ)到sessionStorage中
                  sessionStorage.setItem(toName,str);
 
                  //定義服務(wù)端需要的數(shù)據(jù)格式
                  var message = {toName:toName,message:data};
                  //將輸入的數(shù)據(jù)發(fā)送給服務(wù)器
                  ws.send(JSON.stringify(message));
              });
          })

  服務(wù)端代碼實(shí)現(xiàn)

  `WebSocketConfig` 類實(shí)現(xiàn)

  開啟 springboot 對(duì)websocket的支持

@Configuration
  public class WebSocketConfig {
 
      @Bean
      //注入ServerEndpointExporter,自動(dòng)注冊(cè)使用@ServerEndpoint注解的
      public ServerEndpointExporter serverEndpointExporter() {
          return new ServerEndpointExporter();
      }
  }

  `ChatEndPoint` 類實(shí)現(xiàn)

@ServerEndpoint(value = "/chat",configurator = GetHttpSessionConfigurator.class)
  @Component
  public class ChatEndpoint {
 
      //用來(lái)存儲(chǔ)每一個(gè)客戶端對(duì)象對(duì)應(yīng)的ChatEndpoint對(duì)象
      private static Map<String,ChatEndpoint> onlineUsers = new ConcurrentHashMap<>();
 
      //和某個(gè)客戶端連接對(duì)象,需要通過他來(lái)給客戶端發(fā)送數(shù)據(jù)
      private Session session;
 
      //httpSession中存儲(chǔ)著當(dāng)前登錄的用戶名
      private HttpSession httpSession;
 
      @OnOpen
      //連接建立成功調(diào)用
      public void onOpen(Session session, EndpointConfig config) {
          //需要通知其他的客戶端,將所有的用戶的用戶名發(fā)送給客戶端
          this.session = session;
          //獲取HttpSession對(duì)象
          HttpSession httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName());
          //將該httpSession賦值給成員httpSession
          this.httpSession = httpSession;
          //獲取用戶名
          String username = (String) httpSession.getAttribute("user");
          //存儲(chǔ)該鏈接對(duì)象
          onlineUsers.put(username,this);
          //獲取需要推送的消息
          String message = MessageUtils.getMessage(true, null, getNames());
          //廣播給所有的用戶
          broadcastAllUsers(message);
      }
 
      private void broadcastAllUsers(String message) {
          try {
              //遍歷 onlineUsers 集合
              Set<String> names = onlineUsers.keySet();
              for (String name : names) {
                  //獲取該用戶對(duì)應(yīng)的ChatEndpoint對(duì)象
                  ChatEndpoint chatEndpoint = onlineUsers.get(name);
                  //發(fā)送消息
                  chatEndpoint.session.getBasicRemote().sendText(message);
              }
          } catch (Exception e) {
              e.printStackTrace();
          }
      }
 
      private Set<String> getNames() {
          return onlineUsers.keySet();
      }
 
      @OnMessage
      //接收到消息時(shí)調(diào)用
      public void onMessage(String message,Session session) {
          try {
              //獲取客戶端發(fā)送來(lái)的數(shù)據(jù)  {"toName":"張三","message":"你好"}
              ObjectMapper mapper = new ObjectMapper();
              Message mess = mapper.readValue(message, Message.class);
              //獲取當(dāng)前登錄的用戶名
              String username = (String) httpSession.getAttribute("user");
              //拼接推送的消息
              String data = MessageUtils.getMessage(false, username, mess.getMessage());
              //將數(shù)據(jù)推送給指定的客戶端
              ChatEndpoint chatEndpoint = onlineUsers.get(mess.getToName());
              chatEndpoint.session.getBasicRemote().sendText(data);
          } catch (Exception e) {
              e.printStackTrace();
          }
      }
 
      @OnClose
      //連接關(guān)閉時(shí)調(diào)用
      public void onClose(Session session) {
          //獲取用戶名
          String username = (String) httpSession.getAttribute("user");
          //移除連接對(duì)象
          onlineUsers.remove(username);
          //獲取需要推送的消息
          String message = MessageUtils.getMessage(true, null, getNames());
          //廣播給所有的用戶
          broadcastAllUsers(message);
      }
  }

  `GetHttpSessionConfigurator` 配置類實(shí)現(xiàn)

  public class GetHttpSessionConfigurator extends ServerEndpointConfig.Configurator {
      @Override
      public void modifyHandshake(ServerEndpointConfig config, HandshakeRequest request, HandshakeResponse response) {
          HttpSession httpSession = (HttpSession) request.getHttpSession();
          config.getUserProperties().put(HttpSession.class.getName(),httpSession);
      }
  }


分享到:
在線咨詢 我要報(bào)名
和我們?cè)诰€交談!