以前有遇到一些服務端客戶端交互問題,有時希望交互是異步的,服務器的響應是非即時的,但是http協(xié)議顯然不符合我的需求。所以最近專門找時間對websocket進行學習。
websocket和http是不同的網(wǎng)絡協(xié)議,有關網(wǎng)絡協(xié)議之間的詳細區(qū)別和特點就不追究了。這里區(qū)分一下websocket協(xié)議和http協(xié)議的一些重要的,顯而易見的區(qū)別:
1. websocket請求是以ws://開頭的,http請求是以http://開頭的。
2. http請求是客戶端請求,服務端響應的固定模式,服務端無法主動向客戶端發(fā)送消息。而websocket協(xié)議是長連接,客戶端向服務端發(fā)起請求建立連接以后,雙方可以互相發(fā)送消息,注意,這里的互相發(fā)送消息是沒有什么限制的,不一定要一來一回,服務端一次,客戶端一次這樣。
下面介紹一些webSocket的典型使用場景,比如聊天室。A向B發(fā)送一條消息,如果使用http協(xié)議,那B想要收到消息就要通過不斷的向服務端發(fā)請求,查詢有沒有發(fā)給自己的消息。而webSocket不同,在B登錄以后,就建立好長連接,當有發(fā)給B的消息的時候,服務端直接通過ws連接向B推送消息即可。
這次我就通過一個非常簡易的聊天功能,來完成對webSocket的學習。
先貼效果圖,第一個是連接界面(風格極簡未修飾)。

輸入用戶名test1,然后點擊連接,如下圖:

看一下瀏覽器network,能夠看到發(fā)出了ws請求:

然后新開一個窗口,重復上述操作,但是用戶名使用test2:
然后在test1的界面收信人填入test2,輸入信息123,點擊發(fā)送:

值得注意的是瀏覽器并沒有發(fā)送新的請求,還是之前的那個請求,但是在message這里有一條信息,左邊是向上的箭頭,代表是由客戶端發(fā)給服務端的。再切到test2的窗口,界面上展示了test1發(fā)來的信息,ws請求的message里多了一條信息,如圖:

左邊的箭頭向下,代表是由服務端推送的客戶端的消息。再使用test2的窗口給test1發(fā)消息,往返多試幾次,是沒有問題的。


下面說一下代碼實現(xiàn):
前端代碼:
<!DOCTYPE html>
<html lang="en">
<meta charset="UTF-8">
<title>Title</title>
<script src="https://cdn.staticfile.org/jquery/1.10.2/jquery.min.js">
<input id="username" type="text" placeholder="用戶名" />
<input id="to" type="text" placeholder="收信人" hidden/>
<input id="msg" type="text" placeholder="消息內容" hidden/>
<button id="connectBtn" type="button" onclick="connect()">連接</button>
<button id="sendBtn" type="button" onclick="send()" hidden>發(fā)送</button>
<div id="record">
var websocket =null;
function connect() {
var host =document.location.host;
var username = $("#username").val();
//判斷當前瀏覽器是否支持WebSocket
if ('WebSocket' in window) {
websocket =new WebSocket('ws://' + host +'/websocketDemo/webSocket/' + username);
$("#username").attr("hidden", "hidden");
$("#connectBtn").attr("hidden", "hidden");
$("#msg").attr("hidden", false);
$("#to").attr("hidden", false);
$("#sendBtn").attr("hidden", false);
//連接發(fā)生錯誤的回調方法
websocket.onerror =function () {
alert("WebSocket連接發(fā)生錯誤")
setMessageInnerHTML("WebSocket連接發(fā)生錯誤");
};
//連接成功建立的回調方法
websocket.onopen =function () {
alert("連接成功")
setMessageInnerHTML("連接成功");
}
//接收到消息的回調方法
websocket.onmessage =function (event) {
setMessageInnerHTML(event.data);
}
//連接關閉的回調方法
websocket.onclose =function () {
setMessageInnerHTML("WebSocket連接關閉");
}
}else {
alert('當前瀏覽器 Not support websocket'
}
}
function send() {
var data = {
message: $("#msg").val(),
To: $("#to").val()
};
websocket.send(JSON.stringify(data));
}
//監(jiān)聽窗口關閉事件,當窗口關閉時,主動去關閉websocket連接,防止連接還沒斷開就關閉窗口,server端會拋異常。
window.onbeforeunload =function () {
closeWebSocket();
}
//關閉WebSocket連接
function closeWebSocket() {
websocket.close();
}
//將消息顯示在網(wǎng)頁上
function setMessageInnerHTML(innerHTML) {
document.getElementById('record').innerHTML += innerHTML +'<br/>';
}
</html>
其中主要的邏輯點擊連接按鈕時建立websocket連接,這里有一個需要注意的點是并不是所有瀏覽器都支持websocket協(xié)議的,所以需要先進行判斷,當然,對于一些低版本的瀏覽器,也是有一些JS插件可以提供支持的,這里暫時不討論這個細節(jié),然后在點擊發(fā)送按鈕的時候通過ws協(xié)議向服務端發(fā)送消息,目的是給另外一個人發(fā)送一條信息,另外還定義了一個回調函數(shù),負責服務器推送來的消息,注意這里是服務器推送來的消息,所以發(fā)送消息的請求發(fā)出后就結束了,而不是像http請求那樣等待服務器的響應,也不是類似異步ajax請求那樣的回調方法。
后端實現(xiàn)代碼如下:
package websocket;
import net.sf.json.JSONObject;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
@ServerEndpoint("/webSocket/{username}")
public class WebSocketTest {
private static int onlineCount =0;
private static Map<String, WebSocketTest> 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(){
clients.remove(username);
subOnlineCount();
}
@OnMessage
public void onMessage(String message){
JSONObject jsonTo = JSONObject.fromObject(message);
String mes = (String) jsonTo.get("message");
if (!jsonTo.get("To").equals("All")) {
sendMessageTo(mes, jsonTo.get("To").toString());
}else {
sendMessageAll(mes);
}
}
@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}
public void sendMessageTo(String message, String To) {
for (WebSocketTest item : clients.values()) {
if (item.username.equals(To)) {
item.session.getAsyncRemote().sendText(message);
}
}
}
public void sendMessageAll(String message){
for (WebSocketTest item : clients.values()) {
item.session.getAsyncRemote().sendText(message);
}
}
public static synchronized int getOnlineCount() {
return onlineCount;
}
public static synchronized void addOnlineCount() {
WebSocketTest.onlineCount++;
}
public static synchronized void subOnlineCount() {
WebSocketTest.onlineCount--;
}
public static synchronized Map getClients() {
return clients;
}
}
主要是通過ServerEndpoint注解聲明該類處理webSocket請求,注解的值是請求路徑,然后用Component注解將類注冊到上下文中,@OnOpen的方法處理建立連接的請求,并使用用戶名創(chuàng)建一個WebSocketTest對象,然后通過鍵值對的形式存儲在client屬性中,此時相當于將這個用戶注冊到服務端中,當需要給該用戶發(fā)送消息時,通過用戶名取出他的WebSocketTest對象,獲取該用戶的session,向該用戶發(fā)送消息。
@onMessage注解方法用于接收客戶端發(fā)送到服務端的消息,可以看到在方法中獲取了消息和接收人,然后從clients中取出接收人的WebSocketTest對象,然后獲取他的ws連接并向他發(fā)送消息。@onError注解方法顧名思義,在出現(xiàn)錯誤的時候執(zhí)行,我沒觸發(fā)過這個方法,所以暫時不太清楚能夠觸發(fā)它的是哪些錯誤,又不包含哪些錯誤,值得注意的是,@onClose方法是由客戶端觸發(fā)的,當客戶端需要JS的websocket對象調用ws連接時,會觸發(fā)該方法,另外,回顧前端代碼,可以看到監(jiān)聽了窗口關閉事件,當窗口被關閉時,會調用ws對象的關閉連接方法。這是因為如果ws連接沒有被關閉,連接就突然斷開,服務端是會報錯的,所以在使用websocket的時候,務必確保連接不再使用時,正確的將它關閉。
最后附上pom依賴的內容。
<?xml version="1.0" encoding="UTF-8"?>
<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>com.test</groupId>
<artifactId>websocket</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>war</packaging>
<name>websocket Maven Webapp</name>
<url>http://www.example.com</url>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring.version>4.1.4.RELEASE</spring.version>
<jackson.version>2.5.0</jackson.version>
<lucene.version>6.0.1</lucene.version>
</properties>
<dependencies>
<!-- webSocket 開始-->
<dependency>
<groupId>javax.websocket</groupId>
<artifactId>javax.websocket-api</artifactId>
<version>1.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax</groupId>
<artifactId>javaee-api</artifactId>
<version>7.0</version>
<scope>provided</scope>
</dependency>
<!-- webSocket 結束-->
<dependency>
<groupId>net.sf.json-lib</groupId>
<artifactId>json-lib</artifactId>
<version>2.4</version>
<classifier>jdk15</classifier>
</dependency>
<!-- spring -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>${spring.version}</version>
<scope>test</scope>
</dependency>
<!-- log4j -->
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>1.2.17</version>
</dependency>
<!-- servlet -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<version>3.0-alpha-1</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>websocket</finalName>
<pluginManagement><!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) -->
<plugins>
<plugin>
<artifactId>maven-clean-plugin</artifactId>
<version>3.1.0</version>
</plugin>
<!-- see http://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_war_packaging -->
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>3.0.2</version>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.0</version>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.1</version>
</plugin>
<plugin>
<artifactId>maven-war-plugin</artifactId>
<version>3.2.2</version>
</plugin>
<plugin>
<artifactId>maven-install-plugin</artifactId>
<version>2.5.2</version>
</plugin>
<plugin>
<artifactId>maven-deploy-plugin</artifactId>
<version>2.8.2</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.12.4</version>
<configuration>
<forkMode>once</forkMode>
<argLine>-Dfile.encoding=UTF-8</argLine>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
然后再看一版以sockJS與springBoot集成的websocket的例子(源碼來源于慕課網(wǎng)示例demo, 對代代碼進行了部分調整),效果就不演示了,功能是類似的,但是相比于第一種實現(xiàn)應該使用的更廣泛,只是前端使用了sockJS,后端使用springBoot中集成的webSocket.簡單說一下核心區(qū)別,sockJS是是一個瀏覽器的JavaScript庫,它提供了webSocket的類似實現(xiàn),但是解決了低版本瀏覽器不支持webSocket的問題,并且它擁有spring的后端實現(xiàn)支持。spring的重要性不用多說,這版的demo就是使用spring集成的webSocket,實踐一下用法。
先看后端代碼,主要分析一些不同點:
首先需要一個配置類,來配置注冊URL,前后端交互的URL前綴:
package fxz.test.websocketdemo.Config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/**
* 注冊端點,發(fā)布或者訂閱消息的時候需要連接此端點
* setAllowedOrigins 非必須,*表示允許其他域進行連接
* withSockJS 表示開啟sockejs支持
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/endpoint-websocket").setAllowedOrigins("*").withSockJS();
}
/**
* 配置消息代理(中介)
* enableSimpleBroker 服務端推送給客戶端的路徑前綴
* setApplicationDestinationPrefixes 客戶端發(fā)送數(shù)據(jù)給服務器端的一個前綴
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/getMessage");
registry.setApplicationDestinationPrefixes("/sendMessage");
}
}
這里可以看到和第一版的一個重要區(qū)別是,將服務端推送給客戶端的路徑前綴與客戶端向服務端發(fā)送數(shù)據(jù)的路徑前綴區(qū)分開來了,當然你設置成一樣的URL前綴其實也是可以的,但是第一版則不行。不過暫時沒有想到這種設置的重要應用有什么,不過相信可區(qū)分肯定是比不可區(qū)分有優(yōu)勢的。
然后需要一個消息實體類,很簡單:
package fxz.test.websocketdemo.model;
public class Message {
private String fromUser;
private String toUser;
private String message;
public String getFromUser() {
return fromUser;
}
public void setFromUser(String fromUser) {
this.fromUser = fromUser;
}
public String getToUser() {
return toUser;
}
public void setToUser(String toUser) {
this.toUser = toUser;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
@Override
public String toString() {
return "Message{" +
"fromUser='" + fromUser + '\'' +
", toUser='" + toUser + '\'' +
", message='" + message + '\'' +
'}';
}
}
再需要一個Controller來接收消息,這里需要注意的點是URL注解使用@MessageMapping,另外我們的入?yún)⑹荕essage示例類,而不再是String了,這個和Stomp有關系,后續(xù)會具體提到:
package fxz.test.websocketdemo.controller;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;
import fxz.test.websocketdemo.Service.WebSocketService;
import fxz.test.websocketdemo.model.Message;
@Controller
public class WebSocketController {
private WebSocketService webSocketService;
public WebSocketController(WebSocketService webSocketService) {
this.webSocketService = webSocketService;
}
@MessageMapping(value = "/single/chat")
public void sendMessage(Message message){
webSocketService.sendMessageTo(message.getFromUser(), message.getToUser(), message.getMessage());
}
}
補充WebSocketService的代碼內容,其中的SimpMessagingTemplate對象是直接從應用上下文中注入進來的,通過它可以向客戶端推送消息,URL需要符合我們注冊過的推送URL:
package fxz.test.websocketdemo.Service;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
@Service
public class WebSocketService {
private final SimpMessagingTemplate simpMessagingTemplate;
public WebSocketService(SimpMessagingTemplate simpMessagingTemplate) {
this.simpMessagingTemplate = simpMessagingTemplate;
}
public void sendMessageTo(String fromUser, String toUser, String message){
simpMessagingTemplate.convertAndSend("/getMessage/single/" + toUser + fromUser, message);
}
}
后端代碼主要就是這些內容,主要流程是,應用啟動時注冊相關的URL,然后前端通過注冊端點建立和服務端的WS連接,發(fā)送的請求會被WebSocketController處理,處理完消息數(shù)據(jù)后,通過SimpMessagingTemplate對象向客戶端推送消息。
下面看一下前端代碼的核心內容,前面說過這一版的代碼前端不再使用原生的webSocket對象,因為在低版本的瀏覽器中是不支持的,SockJS就是來解決這個問題的,除了SockJS之外,我們還需要再引入StompJS,[STOMP]是一種簡單的面向文本的消息傳遞協(xié)議,概念就只說這么一句,談談我對他的理解:
- 消息傳遞協(xié)議不是網(wǎng)絡協(xié)議
不能把它和HTTP,webSocket混淆了,完全不是一碼事,它是用來規(guī)定服務端和客戶端交互的時候數(shù)據(jù)傳輸數(shù)據(jù)的規(guī)范的。 - 什么是面向文本的
數(shù)據(jù)傳輸通常分為文本和二進制,文本的像json等,二進制通常就是傳輸文件了,那么面向文本就含義就顯而易見了,它是不能傳輸二進制的,也就是這個協(xié)議不能傳文件。 - 它和webSocket的關系,這個引用我看到的一段解釋,還算通俗易懂
- HTTP協(xié)議解決了 web 瀏覽器發(fā)起請求以及 web 服務器響應請求的細節(jié),假設 HTTP 協(xié)議 并不存在,只能使用 TCP 套接字來 編寫 web 應用,你可能認為這是一件瘋狂的事情;
- 直接使用 WebSocket(SockJS) 就很類似于 使用 TCP 套接字來編寫 web 應用,因為沒有高層協(xié)議,就需要我們定義應用間所發(fā)送消息的語義,還需要確保連接的兩端都能遵循這些語義;
-
同 HTTP 在 TCP 套接字上添加請求-響應模型層一樣,STOMP 在 WebSocket 之上提供了一個基于幀的線路格式層,用來定義消息語義;
概括一下就是,webSocket請求的消息體這一塊的約定或者協(xié)議一直沒人管,后來它負責管這些東西。那它到底有什么用呢,怎么管的,我這邊肉眼可見的區(qū)別是,如前面提到的,處理ws請求的方法入?yún)⒖梢允褂媒Y構化的實體Message類了,這里的反例是第一版代碼,我們在前端使用json封裝數(shù)據(jù),但是后臺使用String接收,這里我產(chǎn)生了一個疑問,那我能不能直接使用實體類接收呢,答案是不能,這里放出截圖看一下效果:
image.png
可以看到,是無法通過編譯的,但是第二版代碼是可以的,我的理解就是stomp協(xié)議幫我們處理了這個數(shù)據(jù)轉換的過程。
然后具體看一下核心代碼內容(DOM與前端交互邏輯的代碼就不再貼出了):
function connect() {
var from = $("#from").val();
var to = $("#to").val();
//新建SockJS實例
var socket = new SockJS('/endpoint-websocket');
//使用SockJS對象初始化Stomp對象,Stomp.over方法接收一個符合WebSocket定義的對象
stompClient = Stomp.over(socket);
/* 發(fā)起ws建立連接請求,第一個參數(shù)是header對象,形如 var headers = {
login: 'mylogin',
passcode: 'mypasscode',
// additional header
'client-id': 'my-client-id'
};
這里傳空,暫時不知道什么時候需要傳值
第二個參數(shù)是連接成功的回調,也可以傳入第三個函數(shù)作為失敗回調,這里省略了
回調函數(shù)里調用了訂閱方法,用于接收服務端推送來的消息,showContent函數(shù)將推送消息展示
*/
stompClient.connect({}, function (frame) {
setConnected(true);
console.log('Connected: ' + frame);
stompClient.subscribe('/getMessage/single/'+ from + to, function (result) {
showContent(result.body);
});
});
}
發(fā)送消息的方法如下:
//獲取消息內容,轉JSON發(fā)送
function sendMsg() {
var toUser = document.getElementById("to").value;
var fromUser = document.getElementById("from").value;
var message = document.getElementById("content").value;
stompClient.send("/sendMessage/single/chat", {}, JSON.stringify({
'message': $("#content").val(),
'toUser': $("#to").val(),
'fromUser': $("#from").val()
}));
}
斷開連接使用:
//可以傳入回調函數(shù)
stompClient.disconnect();
最后附上pom依賴內容不迷路:
<?xml version="1.0" encoding="UTF-8"?>
<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>xyz.suiwo</groupId>
<artifactId>websocket-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>websocket-demo</name>
<description>Demo project for Spring Boot</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.5.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>webjars-locator</artifactId>
<version>0.31</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>sockjs-client</artifactId>
<version>1.0.2</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>stomp-websocket</artifactId>
<version>2.3.3</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>bootstrap</artifactId>
<version>3.3.7</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>jquery</artifactId>
<version>3.1.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
