一、文章導(dǎo)讀
服務(wù)器推送你還在使用輪詢嗎?本文將帶你領(lǐng)略WebSocket的魅力,輕松實(shí)現(xiàn)服務(wù)器推送功能。本文將以下面兩方面讓你理解WebSocket并應(yīng)用到具體的開發(fā)中
- WebSocket概述
- 使用WebSocket實(shí)現(xiàn)網(wǎng)頁聊天室
二、WebSocket
1. WebSocket介紹
WebSocket 是一種網(wǎng)絡(luò)通信協(xié)議。RFC6455 定義了它的通信標(biāo)準(zhǔn)。
WebSocket 是 HTML5 開始提供的一種在單個(gè) TCP 連接上進(jìn)行全雙工通訊的協(xié)議。
HTTP 協(xié)議是一種無狀態(tài)的、無連接的、單向的應(yīng)用層協(xié)議。它采用了請求/響應(yīng)模型。通信請求只能由客戶端發(fā)起,服務(wù)端對請求做出應(yīng)答處理。
這種通信模型有一個(gè)弊端:HTTP 協(xié)議無法實(shí)現(xiàn)服務(wù)器主動(dòng)向客戶端發(fā)起消息。
這種單向請求的特點(diǎn),注定了如果服務(wù)器有連續(xù)的狀態(tài)變化,客戶端要獲知就非常麻煩。大多數(shù) Web 應(yīng)用程序?qū)⑼ㄟ^頻繁的異步 AJAX 請求實(shí)現(xiàn)長輪詢。輪詢的效率低,非常浪費(fèi)資源(因?yàn)楸仨毑煌_B接,或者 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
來自服務(wù)器的握手看起來像如下形式:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Extensions: permessage-deflate
字段說明:
頭名稱 | 說明 |
Connection:Upgrade | 標(biāo)識該HTTP請求是一個(gè)協(xié)議升級請求 |
Upgrade: WebSocket | 協(xié)議升級為WebSocket協(xié)議 |
Sec-WebSocket-Version: 13 | 客戶端支持WebSocket的版本 |
Sec-WebSocket-Key: | 客戶端采用base64編碼的24位隨機(jī)字符序列,服務(wù)器接受客戶端HTTP協(xié)議升級的證明。要求服務(wù)端響應(yīng)一個(gè)對應(yīng)加密的Sec-WebSocket-Accept頭信息作為應(yīng)答 |
Sec-WebSocket-Extensions | 協(xié)議擴(kuò)展類型 |
3. 客戶端(瀏覽器)實(shí)現(xiàn)
3.1 websocket對象
實(shí)現(xiàn) WebSockets 的 Web 瀏覽器將通過 WebSocket 對象公開所有必需的客戶端功能(主要指支持 Html5 的瀏覽器)。
以下 API 用于創(chuàng)建 WebSocket 對象:
var ws = new WebSocket(url);
參數(shù)url格式說明: ws://ip地址:端口號/資源名稱
3.2 websocket事件
WebSocket 對象的相關(guān)事件
事件 | 事件處理程序 | 描述 |
open | websocket對象.onopen | 連接建立時(shí)觸發(fā) |
message | websocket對象.onmessage | 客戶端接收服務(wù)端數(shù)據(jù)時(shí)觸發(fā) |
error | websocket對象.onerror | 通信發(fā)生錯(cuò)誤時(shí)觸發(fā) |
close | websocket對象.onclose | 連接關(guān)閉時(shí)觸發(fā) |
3.3 WebSocket方法
WebSocket 對象的相關(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對象,代表WebSocket鏈接的一端,對于服務(wù)端,我們可以視為處理具體WebSocket消息的接口, 就像Servlet之與http請求一樣。
我們可以通過兩種方式定義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 消息處理器來接收消息,當(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 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)頁聊天室
1. 需求
通過 websocket 實(shí)現(xiàn)一個(gè)簡易的聊天室功能 。
1). 登陸聊天室
2). 登陸之后,進(jìn)入聊天界面進(jìn)行聊天
登陸成功后,呈現(xiàn)出以后的效果:
當(dāng)我們想和李四聊天時(shí)就可以點(diǎn)擊 好友列表 中的 李四,效果如下:
接下來就可以進(jìn)行聊天了,張三 的界面如下:
李四 的界面如下:
2. 實(shí)現(xiàn)流程
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.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.1.5.RELEASEversion>
parent>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-websocketartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-devtoolsartifactId>
<optional>trueoptional>
<scope>truescope>
dependency>
dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-maven-pluginartifactId>
plugin>
plugins>
build>
4.2 引入靜態(tài)資源文件
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: 用來封裝消息的工具類
* @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)行請求發(fā)送
$(function() {
$("#btn").click(function() {
$.get("login",$("#loginForm").serialize(),function(res) {
if(res.flag) {
//跳轉(zhuǎn)到 main.html頁面
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對象中
session.setAttribute("user",user.getUsername());
} else {
result.setFlag(false);
result.setMessage("登陸失敗");
}
return result;
}
}
4.5 獲取當(dāng)前登錄的用戶名
- main.html:頁面加載完畢后,發(fā)送請求獲取當(dāng)前登錄的用戶名
var username;
$(function() {
$.ajax({
url:"getUsername",
success:function(res) {
username = res;
$("#userName").html("用戶:" + res + "在線");
},
async:false
});
}
- UserController
在UserController中添加一個(gè)getUsername方法,用來從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頁面實(shí)現(xiàn)前端代碼:
var toName;
var username;
function showChat(name) {
toName = name;
//清除聊天區(qū)的數(shù)據(jù)
$("#msgs").html("");
//現(xiàn)在聊天對話框
$("#chatArea").css("display","inline");
//顯示“正在和誰聊天”
$("#chatMes").html("正在和 "+toName+" 聊天");
//切換用戶,需要將聊天記錄渲染到聊天區(qū)
var storeData = sessionStorage.getItem(toName);
if(storeData != null) {
$("#msgs").html(storeData);
}
}
$(function() {
$.ajax({
url:"getUsername",
success:function(res) {
username = res;
//顯示在線信息
$("#userName").html(" 用戶:"+res+"在線");
},
async: false
})
//創(chuàng)建websocket
var ws;
if(window.WebSocket) {
ws = new WebSocket("ws://localhost/chat");
}
//綁定事件
ws.onopen = function(evt) {
//顯示在線信息
$("#userName").html(" 用戶:"+username+"在線");
}
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 += ""+name+" ";
broadcastStr += "您的好友 "+name+" 已上線 ";
}
}
//將數(shù)據(jù)渲染到頁面
$("#userlist").html(userListStr);
$("#broadcastList").html(broadcastStr);
} else {
//非系統(tǒng)消息
var content = res.message;
//拼接聊天區(qū)展示的數(shù)據(jù)
var str = "";"+content+"
//有可能現(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+"離線");
}
//給發(fā)送按鈕綁定點(diǎn)擊事件
$("#submit").click(function() {
//獲取輸入的內(nèi)容
var data = $("#context_text").val();
//將該文本框清空
$("#context_text").val("");
//拼接消息
var str = "";"+data+"
$("#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 對websocket的支持
@Configuration
public class WebSocketConfig {
@Bean
//注入ServerEndpointExporter,自動(dòng)注冊使用@ServerEndpoint注解的
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
- ChatEndPoint 類實(shí)現(xiàn)
@ServerEndpoint(value = "/chat",configurator =
GetHttpSessionConfigurator.class)
@Component
public class ChatEndpoint {
//用來存儲(chǔ)每一個(gè)客戶端對象對應(yīng)的ChatEndpoint對象
private static MaponlineUsers = new ConcurrentHashMap<>();
//和某個(gè)客戶端連接對象,需要通過他來給客戶端發(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對象
HttpSession httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName());
//將該httpSession賦值給成員httpSession
this.httpSession = httpSession;
//獲取用戶名
String username = (String) httpSession.getAttribute("user");
//存儲(chǔ)該鏈接對象
onlineUsers.put(username,this);
//獲取需要推送的消息
String message = MessageUtils.getMessage(true, null, getNames());
//廣播給所有的用戶
broadcastAllUsers(message);
}
private void broadcastAllUsers(String message) {
try {
//遍歷 onlineUsers 集合
Setnames = onlineUsers.keySet();
for (String name : names) {
//獲取該用戶對應(yīng)的ChatEndpoint對象
ChatEndpoint chatEndpoint = onlineUsers.get(name);
//發(fā)送消息
chatEndpoint.session.getBasicRemote().sendText(message);
}
} catch (Exception e) {
e.printStackTrace();
}
}
private SetgetNames() {
return onlineUsers.keySet();
}
@OnMessage
//接收到消息時(shí)調(diào)用
public void onMessage(String message,Session session) {
try {
//獲取客戶端發(fā)送來的數(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");
//移除連接對象
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);
}
}