什麼是 WebSocket?
隨着互聯網的發展,傳統的HTTP協議已經很難滿足Web應用日益複雜的需求了。近年來,隨着HTML5的誕生,WebSocket協議被提出,它實現了瀏覽器與服務器的全雙工通信,擴展了瀏覽器與服務端的通信功能,使服務端也能主動向客戶端發送數據。
我們知道,傳統的HTTP協議是無狀態的,每次請求(request)都要由客戶端(如 瀏覽器)主動發起,服務端進行處理後返回response結果,而服務端很難主動向客戶端發送數據;這種客戶端是主動方,服務端是被動方的傳統Web模式 對於信息變化不頻繁的Web應用來說造成的麻煩較小,而對於涉及實時信息的Web應用卻帶來了很大的不便,如帶有即時通信、實時數據、訂閱推送等功能的應 用。在WebSocket規範提出之前,開發人員若要實現這些實時性較強的功能,經常會使用折衷的解決方法:輪詢(polling)和Comet技術。其實後者本質上也是一種輪詢,只不過有所改進。
輪詢是最原始的實現實時Web應用的解決方案。輪詢技術要求客戶端以設定的時間間隔週期性地向服務端發送請求,頻繁地查詢是否有新的數據改動。明顯地,這種方法會導致過多不必要的請求,浪費流量和服務器資源。
Comet技術又可以分爲長輪詢和流技術。長輪詢改進了上述的輪詢技術,減小了無用的請求。它會爲某些數據設定過期時間,當數據過期後纔會向服務端發送請求;這種機制適合數據的改動不是特別頻繁的情況。流技術通常是指客戶端使用一個隱藏的窗口與服務端建立一個HTTP長連接,服務端會不斷更新連接狀態以保持HTTP長連接存活;這樣的話,服務端就可以通過這條長連接主動將數據發送給客戶端;流技術在大併發環境下,可能會考驗到服務端的性能。
這兩種技術都是基於請求-應答模式,都不算是真正意義上的實時技術;它們的每一次請求、應答,都浪費了一定流量在相同的頭部信息上,並且開發複雜度也較大。
伴隨着HTML5推出的WebSocket,真正實現了Web的實時通信,使B/S模式具備了C/S模式的實時通信能力。WebSocket的工作流程是這 樣的:瀏覽器通過JavaScript向服務端發出建立WebSocket連接的請求,在WebSocket連接建立成功後,客戶端和服務端就可以通過 TCP連接傳輸數據。因爲WebSocket連接本質上是TCP連接,不需要每次傳輸都帶上重複的頭部數據,所以它的數據傳輸量比輪詢和Comet技術小 了很多。本文不詳細地介紹WebSocket規範,主要介紹下WebSocket在Java Web中的實現。
JavaEE 7中出了JSR-356:Java API for WebSocket規範。不少Web容器,如Tomcat,Nginx,Jetty等都支持WebSocket。Tomcat從7.0.27開始支持 WebSocket,從7.0.47開始支持JSR-356,下面的Demo代碼也是需要部署在Tomcat7.0.47以上的版本才能運行。
WebSocket是HTML5開始提供的一種在單個 TCP 連接上進行全雙工通訊的協議。
在WebSocket API中,瀏覽器和服務器只需要做一個握手的動作,然後,瀏覽器和服務器之間就形成了一條快速通道。兩者之間就直接可以數據互相傳送。
瀏覽器通過 JavaScript 向服務器發出建立 WebSocket 連接的請求,連接建立以後,客戶端和服務器端就可以通過 TCP 連接直接交換數據。
當你獲取 Web Socket 連接後,你可以通過 send() 方法來向服務器發送數據,並通過 onmessage 事件來接收服務器返回的數據。
以下 API 用於創建 WebSocket 對象。
var Socket = new WebSocket(url, [protocol] );
以上代碼中的第一個參數 url, 指定連接的 URL。第二個參數 protocol 是可選的,指定了可接受的子協議。
實現方式
-
常用的 Node 實現有以下三種。
- Tomcat實現websocket方法
- spring整合websocket方法
具體實現
-
Tomcat實現websocket方法
使用這種方式無需別的任何配置,只需服務端一個處理類
服務端
/**
* 服務器
* @ClassName: WebSocket
* @Description: TODO
* @author huangk
* @Date 2018年8月16日 下午2:46:54
*
*/
@ServerEndpoint("/webSocketByTomcat/{username}")
public class WebSocket {
private static int onlineCount = 0;
private static Map<String, WebSocket> clients = new ConcurrentHashMap<String, WebSocket>();
private Session session;
private String username;
@OnOpen
public void onOpen(@PathParam("username") String username, Session session) throws IOException {
this.username = username;
this.session = session;
addOnlineCount();
clients.put(username, this);
System.out.println("已連接");
}
@OnClose
public void onClose() throws IOException {
clients.remove(username);
subOnlineCount();
}
@OnMessage
public void onMessage(String message) throws IOException {
JSONObject jsonTo = JSONObject.parseObject(message);
System.out.println(jsonTo.getString("to") +":"+ jsonTo.getString("msg"));
if (!jsonTo.getString("to").toLowerCase().equals("all")){
sendMessageTo(jsonTo.getString("msg"), jsonTo.getString("to"));
}else{
sendMessageAll(jsonTo.getString("msg"));
}
}
@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}
public void sendMessageTo(String message, String To) throws IOException {
// session.getBasicRemote().sendText(message);
//session.getAsyncRemote().sendText(message);
for (WebSocket item : clients.values()) {
if (item.username.equals(To) )
item.session.getAsyncRemote().sendText(message);
}
}
public void sendMessageAll(String message) throws IOException {
for (WebSocket item : clients.values()) {
item.session.getAsyncRemote().sendText(message);
}
}
public static synchronized int getOnlineCount() {
return onlineCount;
}
public static synchronized void addOnlineCount() {
WebSocket.onlineCount++;
}
public static synchronized void subOnlineCount() {
WebSocket.onlineCount--;
}
public static synchronized Map<String, WebSocket> getClients() {
return clients;
}
}
客戶端
<%@ page language="java" import="java.util.*" pageEncoding="utf-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/fmt" prefix="fmt"%>
<c:set var="ctx" value="${pageContext.request.contextPath}" />
<c:set var="ctxpath"
value="${pageContext.request.scheme}${'://'}${pageContext.request.serverName}${':'}${pageContext.request.serverPort}${pageContext.request.contextPath}" />
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta charset=UTF-8">
<title>登錄測試</title>
</head>
<body>
<h2>Hello World!</h2>
<div>
<span>sessionId:</span>
<%
HttpSession s= request.getSession();
out.println(s.getId());
%>
</div>
<input id="sessionId" type="hidden" value="<%=session.getId() %>" />
<input id="text" type="text" />
<button οnclick="send()">發送消息</button>
<hr />
<button οnclick="closeWebSocket()">關閉WebSocket連接</button>
<hr />
<div id="message"></div>
</body>
<script type="text/javascript" src="http://localhost:8088/static/js/sockjs-0.3.min.js"></script>
<script type="text/javascript">
var websocket = null;
if('WebSocket' in window) {
websocket = new WebSocket("ws://localhost:8088/websocket/webSocketByTomcat/"+document.getElementById('sessionId').value);
} else if('MozWebSocket' in window) {
websocket = new MozWebSocket("ws://localhost:8088/websocket/webSocketByTomcat/"+document.getElementById('sessionId').value);
} else {
websocket = new SockJS("localhost:8088/websocket/webSocketByTomcat/"+document.getElementById('sessionId').value);
}
//連接發生錯誤的回調方法
websocket.onerror = function () {
setMessageInnerHTML("WebSocket連接發生錯誤");
};
//連接成功建立的回調方法
websocket.onopen = function () {
setMessageInnerHTML("WebSocket連接成功");
}
//接收到消息的回調方法
websocket.onmessage = function (event) {
setMessageInnerHTML(event.data);
}
//連接關閉的回調方法
websocket.onclose = function () {
setMessageInnerHTML("WebSocket連接關閉");
}
//監聽窗口關閉事件,當窗口關閉時,主動去關閉websocket連接,防止連接還沒斷開就關閉窗口,server端會拋異常。
window.onbeforeunload = function () {
closeWebSocket();
}
//將消息顯示在網頁上
function setMessageInnerHTML(innerHTML) {
document.getElementById('message').innerHTML += innerHTML + '<br/>';
}
//關閉WebSocket連接
function closeWebSocket() {
websocket.close();
}
//發送消息
function send() {
var message = document.getElementById('text').value;
websocket.send(message);
}
</script>
</html>
注意導入socketjs時要使用地址全稱,並且連接使用的是http而不是websocket的ws
服務端如何向客戶端推送消息呢?
代碼如下
/**
* 服務端推送消息對客戶端
* @ClassName: ServiceClientController
* @Description: TODO
* @author huangk
* @Date 2018年8月16日 下午2:45:22
*
*/
@Controller
@RequestMapping(value="webSocketByTomcat/serviceToClient")
public class ServiceClientByTomcatController {
private WebSocket websocket = new WebSocket();
@RequestMapping
public void sendMsg(HttpServletRequest request, HttpServletResponse response) throws IOException {
JSONObject json = new JSONObject();
json.put("to", request.getSession().getId());
json.put("msg", "歡迎連接WebSocket!!!!");
websocket.onMessage(json.toJSONString());
}
}
效果如下圖所示
-
spring整合websocket方法
springboot對websocket支持很友好,只需要繼承webSocketHandler類,重寫幾個方法就可以了
這個類是對消息的一些處理,比如是發給一個人,還是發給所有人,並且前端連接時觸發的一些動作
/**
* 創建一個WebSocket server
*
* @ClassName: CustomWebSocketHandler
* @Description: TODO
* @author huangk
* @Date 2018年8月16日 下午3:17:34
*
*/
@Service
public class CustomWebSocketHandler extends TextWebSocketHandler implements WebSocketHandler {
private Logger logger = LoggerFactory.getLogger(CustomWebSocketHandler.class);
// 在線用戶列表
private static final Map<String, WebSocketSession> users;
// 用戶標識
private static final String CLIENT_ID = "mchNo";
static {
users = new HashMap<>();
}
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
logger.info("成功建立websocket-spring連接");
String mchNo = getMchNo(session);
if (StringUtils.isNotEmpty(mchNo)) {
users.put(mchNo, session);
session.sendMessage(new TextMessage("成功建立websocket-spring連接"));
logger.info("用戶標識:{},Session:{}", mchNo, session.toString());
}
}
@Override
public void handleTextMessage(WebSocketSession session, TextMessage message) {
logger.info("收到客戶端消息:{}", message.getPayload());
JSONObject msgJson = JSONObject.parseObject(message.getPayload());
String to = msgJson.getString("to");
String msg = msgJson.getString("msg");
WebSocketMessage<?> webSocketMessageServer = new TextMessage("server:" +message);
try {
session.sendMessage(webSocketMessageServer);
if("all".equals(to.toLowerCase())) {
sendMessageToAllUsers(new TextMessage(getMchNo(session) + ":" +msg));
}else {
sendMessageToUser(to, new TextMessage(getMchNo(session) + ":" +msg));
}
} catch (IOException e) {
logger.info("handleTextMessage method error:{}", e);
}
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
if (session.isOpen()) {
session.close();
}
logger.info("連接出錯");
users.remove(getMchNo(session));
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
logger.info("連接已關閉:" + status);
users.remove(getMchNo(session));
}
@Override
public boolean supportsPartialMessages() {
return false;
}
public void sendMessage(String jsonData) {
logger.info("收到客戶端消息sendMessage:{}", jsonData);
JSONObject msgJson = JSONObject.parseObject(jsonData);
String mchNo = StringUtils.isEmpty(msgJson.getString(CLIENT_ID)) ? "陌生人" : msgJson.getString(CLIENT_ID);
String to = msgJson.getString("to");
String msg = msgJson.getString("msg");
if("all".equals(to.toLowerCase())) {
sendMessageToAllUsers(new TextMessage(mchNo + ":" +msg));
}else {
sendMessageToUser(to, new TextMessage(mchNo + ":" +msg));
}
}
/**
* 發送信息給指定用戶
* @Title: sendMessageToUser
* @Description: TODO
* @Date 2018年8月21日 上午11:01:08
* @author huangk
* @param mchNo
* @param message
* @return
*/
public boolean sendMessageToUser(String mchNo, TextMessage message) {
if (users.get(mchNo) == null)
return false;
WebSocketSession session = users.get(mchNo);
logger.info("sendMessage:{} ,msg:{}", session, message.getPayload());
if (!session.isOpen()) {
logger.info("客戶端:{},已斷開連接,發送消息失敗", mchNo);
return false;
}
try {
session.sendMessage(message);
} catch (IOException e) {
logger.info("sendMessageToUser method error:{}", e);
return false;
}
return true;
}
/**
* 廣播信息
* @Title: sendMessageToAllUsers
* @Description: TODO
* @Date 2018年8月21日 上午11:01:14
* @author huangk
* @param message
* @return
*/
public boolean sendMessageToAllUsers(TextMessage message) {
boolean allSendSuccess = true;
Set<String> mchNos = users.keySet();
WebSocketSession session = null;
for (String mchNo : mchNos) {
try {
session = users.get(mchNo);
if (session.isOpen()) {
session.sendMessage(message);
}else {
logger.info("客戶端:{},已斷開連接,發送消息失敗", mchNo);
}
} catch (IOException e) {
logger.info("sendMessageToAllUsers method error:{}", e);
allSendSuccess = false;
}
}
return allSendSuccess;
}
/**
* 獲取用戶標識
* @Title: getMchNo
* @Description: TODO
* @Date 2018年8月21日 上午11:01:01
* @author huangk
* @param session
* @return
*/
private String getMchNo(WebSocketSession session) {
try {
String mchNo = session.getAttributes().get(CLIENT_ID).toString();
return mchNo;
} catch (Exception e) {
return null;
}
}
}
這個類的作用就是在連接成功前和成功後增加一些額外的功能
我們希望能夠把websocketSession和httpsession對應起來,這樣就能根據當前不同的session,定向對websocketSession進行數據返回;在查詢資料之後,發現spring中有一個攔截器接口,HandshakeInterceptor,可以實現這個接口,來攔截握手過程,向其中添加屬性
/**
* WebSocket握手時的攔截器
* @ClassName: CustomWebSocketInterceptor
* @Description: TODO
* @author huangk
* @Date 2018年8月16日 下午3:17:04
*
*/
public class CustomWebSocketInterceptor implements HandshakeInterceptor {
private Logger logger = LoggerFactory.getLogger(CustomWebSocketInterceptor.class);
/**
* 關聯HeepSession和WebSocketSession,
* beforeHandShake方法中的Map參數 就是對應websocketSession裏的屬性
*/
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler handler, Map<String, Object> map) throws Exception {
if (request instanceof ServletServerHttpRequest) {
logger.info("*****beforeHandshake******");
HttpServletRequest httpServletRequest = ((ServletServerHttpRequest) request).getServletRequest();
HttpSession session = httpServletRequest.getSession(true);
logger.info("mchNo:{}", httpServletRequest.getParameter("mchNo"));
if (session != null) {
map.put("sessionId",session.getId());
map.put("mchNo", httpServletRequest.getParameter("mchNo"));
}
}
return true;
}
@Override
public void afterHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Exception e) {
logger.info("******afterHandshake******");
}
}
這個類是配置類向Spring中注入handler
/**
* websocket的配置類
* @ClassName: CustomWebSocketConfig
* @Description: TODO
* @author huangk
* @Date 2018年8月16日 下午3:17:26
*
*/
@Configuration
@EnableWebSocket
public class CustomWebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(customWebSocketHandler(), "/webSocketBySpring/customWebSocketHandler").addInterceptors(new CustomWebSocketInterceptor()).setAllowedOrigins("*");
registry.addHandler(customWebSocketHandler(), "/sockjs/webSocketBySpring/customWebSocketHandler").addInterceptors(new CustomWebSocketInterceptor()).setAllowedOrigins("*").withSockJS();
}
@Bean
public WebSocketHandler customWebSocketHandler() {
return new CustomWebSocketHandler();
}
}
補充說明:
setAllowedOrigins("*")一定要加上,不然只有訪問localhost,其他的不予許訪問
setAllowedOrigins(String[] domains),允許指定的域名或IP(含端口號)建立長連接,如果只允許自家域名訪問,這裏輕鬆設置。如果不限時使用"*"號,如果指定了域名,則必須要以http或https開頭
經查閱官方文檔springwebsocket 4.1.5版本前默認支持跨域訪問,之後的版本默認不支持跨域,需要設置
使用withSockJS()的原因:
一些瀏覽器中缺少對WebSocket的支持,因此,回退選項是必要的,而Spring框架提供了基於SockJS協議的透明的回退選項。
SockJS的一大好處在於提供了瀏覽器兼容性。優先使用原生WebSocket,如果在不支持websocket的瀏覽器中,會自動降爲輪詢的方式。
除此之外,spring也對socketJS提供了支持。
如果代碼中添加了withSockJS()如下,服務器也會自動降級爲輪詢。
registry.addEndpoint("/coordination").withSockJS();
SockJS的目標是讓應用程序使用WebSocket API,但在運行時需要在必要時返回到非WebSocket替代,即無需更改應用程序代碼。
SockJS是爲在瀏覽器中使用而設計的。它使用各種各樣的技術支持廣泛的瀏覽器版本。對於SockJS傳輸類型和瀏覽器的完整列表,可以看到SockJS客戶端頁面。
傳輸分爲3類:WebSocket、HTTP流和HTTP長輪詢(按優秀選擇的順序分爲3類)
客戶端
<%@ page language="java" import="java.util.*" pageEncoding="utf-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/fmt" prefix="fmt"%>
<c:set var="ctx" value="${pageContext.request.contextPath}" />
<c:set var="ctxpath"
value="${pageContext.request.scheme}${'://'}${pageContext.request.serverName}${':'}${pageContext.request.serverPort}${pageContext.request.contextPath}" />
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta charset=UTF-8">
<title>登錄測試</title>
</head>
<body>
<h2>Hello World! Web Socket by Spring</h2>
<div>
<span>sessionId:</span>
<%
HttpSession s= request.getSession();
out.println(s.getId());
%>
</div>
<input id="sessionId" type="hidden" value="<%=session.getId() %>" />
<input id="text" type="text" />
<button οnclick="send()">發送消息</button>
<hr />
<button οnclick="closeWebSocket()">關閉WebSocket連接</button>
<hr />
<div id="message"></div>
</body>
<script type="text/javascript" src="http://localhost:8088/static/js/sockjs-0.3.min.js"></script>
<script type="text/javascript">
var websocket = null;
//判斷當前瀏覽器是否支持WebSocket
//判斷當前瀏覽器是否支持WebSocket
if('WebSocket' in window) {
websocket = new WebSocket("ws://localhost:8088/websocket/webSocketBySpring/customWebSocketHandler?mchNo="+ 123);
} else if('MozWebSocket' in window) {
websocket = new MozWebSocket("ws://localhost:8088/websocket/webSocketBySpring/customWebSocketHandler?mchNo="+ 123);
} else {
websocket = new SockJS("http://localhost:8088/websocket/sockjs/webSocketBySpring/customWebSocketHandler?mchNo="+ 123);
}
//連接發生錯誤的回調方法
websocket.onerror = function () {
setMessageInnerHTML("WebSocket連接發生錯誤");
};
//連接成功建立的回調方法
websocket.onopen = function () {
setMessageInnerHTML("WebSocket連接成功");
}
//接收到消息的回調方法
websocket.onmessage = function (event) {
setMessageInnerHTML(event.data);
}
//連接關閉的回調方法
websocket.onclose = function () {
setMessageInnerHTML("WebSocket連接關閉");
}
//監聽窗口關閉事件,當窗口關閉時,主動去關閉websocket連接,防止連接還沒斷開就關閉窗口,server端會拋異常。
window.onbeforeunload = function () {
closeWebSocket();
}
//將消息顯示在網頁上
function setMessageInnerHTML(innerHTML) {
document.getElementById('message').innerHTML += innerHTML + '<br/>';
}
//關閉WebSocket連接
function closeWebSocket() {
websocket.close();
}
//發送消息
function send() {
var message = document.getElementById('text').value;
websocket.send(message);
}
</script>
</html>
效果如圖所示