更新時間:2022-11-17 來源:黑馬程序員 瀏覽量:
一、文章導讀
服務器推送你還在使用輪詢嗎?本文將帶你領略WebSocket的魅力,輕松實現(xiàn)服務器推送功能。本文將以下面兩方面讓你理解WebSocket并應用到具體的開發(fā)中。
WebSocket概述
使用WebSocket實現(xiàn)網(wǎng)頁聊天室
二、WebSocket
2.WebSocket介紹
WebSocket 是一種網(wǎng)絡通信協(xié)議。RFC6455 定義了它的通信標準。
WebSocket 是 HTML5 開始提供的一種在單個 TCP 連接上進行全雙工通訊的協(xié)議。
HTTP 協(xié)議是一種無狀態(tài)的、無連接的、單向的應用層協(xié)議。它采用了請求/響應模型。通信請求只能由客戶端發(fā)起,服務端對請求做出應答處理。
這種通信模型有一個弊端:HTTP 協(xié)議無法實現(xiàn)服務器主動向客戶端發(fā)起消息。
這種單向請求的特點,注定了如果服務器有連續(xù)的狀態(tài)變化,客戶端要獲知就非常麻煩。大多數(shù) Web 應用程序將通過頻繁的異步 AJAX 請求實現(xiàn)長輪詢。輪詢的效率低,非常浪費資源(因為必須不停連接,或者 HTTP 連接始終打開)。
http協(xié)議:
websocket協(xié)議:
2. websocket協(xié)議
本協(xié)議有兩部分:握手和數(shù)據(jù)傳輸。
握手是基于http協(xié)議的。
來自客戶端的握手看起來像如下形式:
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
來自服務器的握手看起來像如下形式:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= Sec-WebSocket-Extensions: permessage-deflate
字段說明:
| 頭名稱 | 說明 | | :------------------------ | ------------------------------------------------------------ | | Connection:Upgrade | 標識該HTTP請求是一個協(xié)議升級請求 | | Upgrade: WebSocket | 協(xié)議升級為WebSocket協(xié)議 | | Sec-WebSocket-Version: 13 | 客戶端支持WebSocket的版本 | | Sec-WebSocket-Key: | 客戶端采用base64編碼的24位隨機字符序列,服務器接受客戶端HTTP協(xié)議升級的證明。要求服務端響應一個對應加密的Sec-WebSocket-Accept頭信息作為應答 | | Sec-WebSocket-Extensions | 協(xié)議擴展類型 |
3. 客戶端(瀏覽器)實現(xiàn)
3.1 websocket對象
實現(xiàn) WebSockets 的 Web 瀏覽器將通過 WebSocket 對象公開所有必需的客戶端功能(主要指支持 Html5 的瀏覽器)。
以下 API 用于創(chuàng)建 WebSocket 對象:
var ws = new WebSocket(url);
> 參數(shù)url格式說明: ws://ip地址:端口號/資源名稱
3.2 websocket事件
WebSocket 對象的相關事件
| 事件 | 事件處理程序 | 描述 |
| ------- | ----------------------- | -------------------------- |
| open | websocket對象.onopen | 連接建立時觸發(fā) |
| message | websocket對象.onmessage | 客戶端接收服務端數(shù)據(jù)時觸發(fā) |
| error | websocket對象.onerror | 通信發(fā)生錯誤時觸發(fā) |
| close | websocket對象.onclose | 連接關閉時觸發(fā) |
3.3 WebSocket方法
WebSocket 對象的相關方法:
| 方法 | 描述 |
| ------ | ---------------- |
| send() | 使用連接發(fā)送數(shù)據(jù) |
4. 服務端實現(xiàn)
Tomcat的7.0.5 版本開始支持WebSocket,并且實現(xiàn)了Java WebSocket規(guī)范(JSR356)。
Java WebSocket應用由一系列的WebSocketEndpoint組成。Endpoint 是一個java對象,代表WebSocket鏈接的一端,對于服務端,我們可以視為處理具體WebSocket消息的接口, 就像Servlet之與http請求一樣。
我們可以通過兩種方式定義Endpoint:
第一種是編程式, 即繼承類 javax.websocket.Endpoint并實現(xiàn)其方法。
第二種是注解式, 即定義一個POJO, 并添加 @ServerEndpoint相關注解。
Endpoint實例在WebSocket握手時創(chuàng)建,并在客戶端與服務端鏈接過程中有效,最后在鏈接關閉時結束。在Endpoint接口中明確定義了與其生命周期相關的方法, 規(guī)范實現(xiàn)者確保生命周期的各個階段調用實例的相關方法。生命周期方法如下:
| 方法 | 含義描述 | 注解 |
| ------- | ------------------------------------------------------------ | -------- |
| onClose | 當會話關閉時調用。 | @OnClose |
| onOpen | 當開啟一個新的會話時調用, 該方法是客戶端與服務端握手成功后調用的方法。 | @OnOpen |
| onError | 當連接過程中異常時調用。 | @OnError |
服務端如何接收客戶端發(fā)送的數(shù)據(jù)呢?
通過為 Session 添加 MessageHandler 消息處理器來接收消息,當采用注解方式定義Endpoint時,我們還可以通過 @OnMessage 注解指定接收消息的方法。
服務端如何推送數(shù)據(jù)給客戶端呢?
發(fā)送消息則由 RemoteEndpoint 完成, 其實例由 Session 維護, 根據(jù)使用情況, 我們可以通過Session.getBasicRemote 獲取同步消息發(fā)送的實例 , 然后調用其 sendXxx()方法就可以發(fā)送消息, 可以通過Session.getAsyncRemote 獲取異步消息發(fā)送實例。
服務端代碼:
@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("連接關閉了。。。"); } @OnError public void onError(Session session,Throwable error) { System.out.println("出錯了。。。。" + error.getMessage()); } }
三、基于WebSocket的網(wǎng)頁聊天室
1.需求
通過 websocket 實現(xiàn)一個簡易的聊天室功能 。
1). 登陸聊天室
2). 登陸之后,進入聊天界面進行聊天
登陸成功后,呈現(xiàn)出以后的效果:
當我們想和李四聊天時就可以點擊 `好友列表` 中的 `李四`,效果如下:
接下來就可以進行聊天了,“張三”的界面如下:
“李四” 的界面如下:
2. 實現(xiàn)流程
3. 消息格式
客戶端 --> 服務端
{"toName":"張三","message":"你好"}
服務端 --> 客戶端
系統(tǒng)消息格式:{"isSystem":true,"fromName":null,"message":["李四","王五"]}
推送給某一個的消息格式:{"isSystem":false,"fromName":"張三","message":"你好"}
4. 功能實現(xiàn)
4.1 創(chuàng)建項目,導入相關jar包的坐標
<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包時如果不配置該插件,打出來的jar包沒有清單文件 --> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>
4.2 引入靜態(tài)資源文件
4.3 引入公共資源
pojo類
/** * @version v1.0 * @ClassName: Message * @Description: 瀏覽器發(fā)送給服務器的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: 服務器發(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: 用于登陸響應回給瀏覽器的數(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: 用來封裝消息的工具類 * @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 登陸功能實現(xiàn)
login.html:使用異步進行請求發(fā)送
$(function() { $("#btn").click(function() { $.get("login",$("#loginForm").serialize(),function(res) { if(res.flag) { //跳轉到 main.html頁面 location.href = "main.html"; } else { $("#err_msg").html(res.message); } },"json"); }); })
UserController:進行登陸邏輯處理
@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); //將用戶名存儲到session對象中 session.setAttribute("user",user.getUsername()); } else { result.setFlag(false); result.setMessage("登陸失敗"); } return result; } }
4.5 獲取當前登錄的用戶名
main.html:頁面加載完畢后,發(fā)送請求獲取當前登錄的用戶名
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中添加一個getUsername方法,用來從session中獲取當前登錄的用戶名并響應回給瀏覽器
@RequestMapping("/getUsername") public String getUsername(HttpSession session) { String username = (String) session.getAttribute("user"); return username; }
4.6 聊天室功能
客戶端實現(xiàn)
在main.html頁面實現(xiàn)前端代碼:
var toName; var username; function showChat(name) { toName = name; //清除聊天區(qū)的數(shù)據(jù) $("#msgs").html(""); //現(xiàn)在聊天對話框 $("#chatArea").css("display","inline"); //顯示“正在和誰聊天” $("#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) { //接收服務器推送的消息 var data = evt.data; //將該字符串數(shù)據(jù)轉換為json var res = JSON.parse(data); //判斷是系統(tǒng)消息還是推送給個人的消息 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ù)渲染到頁面 $("#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)在不是和指定用戶的聊天框,所以需要進行判斷 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ā)送按鈕綁定點擊事件 $("#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); //將聊天記錄進行存儲sessionStorage var storeData = sessionStorage.getItem(toName); if(storeData != null) { //將此次的內(nèi)容拼接到storeData中 str = storeData + str; } //將消息存儲到sessionStorage中 sessionStorage.setItem(toName,str); //定義服務端需要的數(shù)據(jù)格式 var message = {toName:toName,message:data}; //將輸入的數(shù)據(jù)發(fā)送給服務器 ws.send(JSON.stringify(message)); }); })
服務端代碼實現(xiàn)
`WebSocketConfig` 類實現(xiàn)
開啟 springboot 對websocket的支持
@Configuration public class WebSocketConfig { @Bean //注入ServerEndpointExporter,自動注冊使用@ServerEndpoint注解的 public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }
`ChatEndPoint` 類實現(xiàn)
@ServerEndpoint(value = "/chat",configurator = GetHttpSessionConfigurator.class) @Component public class ChatEndpoint { //用來存儲每一個客戶端對象對應的ChatEndpoint對象 private static Map<String,ChatEndpoint> onlineUsers = new ConcurrentHashMap<>(); //和某個客戶端連接對象,需要通過他來給客戶端發(fā)送數(shù)據(jù) private Session session; //httpSession中存儲著當前登錄的用戶名 private HttpSession httpSession; @OnOpen //連接建立成功調用 public void onOpen(Session session, EndpointConfig config) { //需要通知其他的客戶端,將所有的用戶的用戶名發(fā)送給客戶端 this.session = session; //獲取HttpSession對象 HttpSession httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName()); //將該httpSession賦值給成員httpSession this.httpSession = httpSession; //獲取用戶名 String username = (String) httpSession.getAttribute("user"); //存儲該鏈接對象 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) { //獲取該用戶對應的ChatEndpoint對象 ChatEndpoint chatEndpoint = onlineUsers.get(name); //發(fā)送消息 chatEndpoint.session.getBasicRemote().sendText(message); } } catch (Exception e) { e.printStackTrace(); } } private Set<String> getNames() { return onlineUsers.keySet(); } @OnMessage //接收到消息時調用 public void onMessage(String message,Session session) { try { //獲取客戶端發(fā)送來的數(shù)據(jù) {"toName":"張三","message":"你好"} ObjectMapper mapper = new ObjectMapper(); Message mess = mapper.readValue(message, Message.class); //獲取當前登錄的用戶名 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 //連接關閉時調用 public void onClose(Session session) { //獲取用戶名 String username = (String) httpSession.getAttribute("user"); //移除連接對象 onlineUsers.remove(username); //獲取需要推送的消息 String message = MessageUtils.getMessage(true, null, getNames()); //廣播給所有的用戶 broadcastAllUsers(message); } }
`GetHttpSessionConfigurator` 配置類實現(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); } }