以前有遇到一些服务端客户端交互问题,有时希望交互是异步的,服务器的响应是非即时的,但是http协议显然不符合我的需求。所以最近专门找时间对websocket进行学习。
websocket和http是不同的网络协议,有关网络协议之间的详细区别和特点就不追究了。这里区分一下websocket协议和http协议的一些重要的,显而易见的区别:
1. websocket请求是以ws://开头的,http请求是以http://开头的。
2. http请求是客户端请求,服务端响应的固定模式,服务端无法主动向客户端发送消息。而websocket协议是长连接,客户端向服务端发起请求建立连接以后,双方可以互相发送消息,注意,这里的互相发送消息是没有什么限制的,不一定要一来一回,服务端一次,客户端一次这样。
下面介绍一些webSocket的典型使用场景,比如聊天室。A向B发送一条消息,如果使用http协议,那B想要收到消息就要通过不断的向服务端发请求,查询有没有发给自己的消息。而webSocket不同,在B登录以后,就建立好长连接,当有发给B的消息的时候,服务端直接通过ws连接向B推送消息即可。
这次我就通过一个非常简易的聊天功能,来完成对webSocket的学习。
先贴效果图,第一个是连接界面(风格极简未修饰)。
输入用户名test1,然后点击连接,如下图:
看一下浏览器network,能够看到发出了ws请求:
然后新开一个窗口,重复上述操作,但是用户名使用test2:
然后在test1的界面收信人填入test2,输入信息123,点击发送:
值得注意的是浏览器并没有发送新的请求,还是之前的那个请求,但是在message这里有一条信息,左边是向上的箭头,代表是由客户端发给服务端的。再切到test2的窗口,界面上展示了test1发来的信息,ws请求的message里多了一条信息,如图:
左边的箭头向下,代表是由服务端推送的客户端的消息。再使用test2的窗口给test1发消息,往返多试几次,是没有问题的。
下面说一下代码实现:
前端代码:
<!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>发送</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);
//连接发生错误的回调方法
websocket.onerror =function () {
alert("WebSocket连接发生错误")
setMessageInnerHTML("WebSocket连接发生错误");
};
//连接成功建立的回调方法
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));
}
//监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload =function () {
closeWebSocket();
}
//关闭WebSocket连接
function closeWebSocket() {
websocket.close();
}
//将消息显示在网页上
function setMessageInnerHTML(innerHTML) {
document.getElementById('record').innerHTML += innerHTML +'<br/>';
}
</html>
其中主要的逻辑点击连接按钮时建立websocket连接,这里有一个需要注意的点是并不是所有浏览器都支持websocket协议的,所以需要先进行判断,当然,对于一些低版本的浏览器,也是有一些JS插件可以提供支持的,这里暂时不讨论这个细节,然后在点击发送按钮的时候通过ws协议向服务端发送消息,目的是给另外一个人发送一条信息,另外还定义了一个回调函数,负责服务器推送来的消息,注意这里是服务器推送来的消息,所以发送消息的请求发出后就结束了,而不是像http请求那样等待服务器的响应,也不是类似异步ajax请求那样的回调方法。
后端实现代码如下:
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的方法处理建立连接的请求,并使用用户名创建一个WebSocketTest对象,然后通过键值对的形式存储在client属性中,此时相当于将这个用户注册到服务端中,当需要给该用户发送消息时,通过用户名取出他的WebSocketTest对象,获取该用户的session,向该用户发送消息。
@onMessage注解方法用于接收客户端发送到服务端的消息,可以看到在方法中获取了消息和接收人,然后从clients中取出接收人的WebSocketTest对象,然后获取他的ws连接并向他发送消息。@onError注解方法顾名思义,在出现错误的时候执行,我没触发过这个方法,所以暂时不太清楚能够触发它的是哪些错误,又不包含哪些错误,值得注意的是,@onClose方法是由客户端触发的,当客户端需要JS的websocket对象调用ws连接时,会触发该方法,另外,回顾前端代码,可以看到监听了窗口关闭事件,当窗口被关闭时,会调用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的例子(源码来源于慕课网示例demo, 对代代码进行了部分调整),效果就不演示了,功能是类似的,但是相比于第一种实现应该使用的更广泛,只是前端使用了sockJS,后端使用springBoot中集成的webSocket.简单说一下核心区别,sockJS是是一个浏览器的JavaScript库,它提供了webSocket的类似实现,但是解决了低版本浏览器不支持webSocket的问题,并且它拥有spring的后端实现支持。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 {
/**
* 注册端点,发布或者订阅消息的时候需要连接此端点
* setAllowedOrigins 非必须,*表示允许其他域进行连接
* withSockJS 表示开启sockejs支持
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/endpoint-websocket").setAllowedOrigins("*").withSockJS();
}
/**
* 配置消息代理(中介)
* enableSimpleBroker 服务端推送给客户端的路径前缀
* setApplicationDestinationPrefixes 客户端发送数据给服务器端的一个前缀
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/getMessage");
registry.setApplicationDestinationPrefixes("/sendMessage");
}
}
这里可以看到和第一版的一个重要区别是,将服务端推送给客户端的路径前缀与客户端向服务端发送数据的路径前缀区分开来了,当然你设置成一样的URL前缀其实也是可以的,但是第一版则不行。不过暂时没有想到这种设置的重要应用有什么,不过相信可区分肯定是比不可区分有优势的。
然后需要一个消息实体类,很简单:
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,另外我们的入参是Message示例类,而不再是String了,这个和Stomp有关系,后续会具体提到:
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连接,发送的请求会被WebSocketController处理,处理完消息数据后,通过SimpMessagingTemplate对象向客户端推送消息。
下面看一下前端代码的核心内容,前面说过这一版的代码前端不再使用原生的webSocket对象,因为在低版本的浏览器中是不支持的,SockJS就是来解决这个问题的,除了SockJS之外,我们还需要再引入StompJS,[STOMP]是一种简单的面向文本的消息传递协议,概念就只说这么一句,谈谈我对他的理解:
- 消息传递协议不是网络协议
不能把它和HTTP,webSocket混淆了,完全不是一码事,它是用来规定服务端和客户端交互的时候数据传输数据的规范的。 - 什么是面向文本的
数据传输通常分为文本和二进制,文本的像json等,二进制通常就是传输文件了,那么面向文本就含义就显而易见了,它是不能传输二进制的,也就是这个协议不能传文件。 - 它和webSocket的关系,这个引用我看到的一段解释,还算通俗易懂
- HTTP协议解决了 web 浏览器发起请求以及 web 服务器响应请求的细节,假设 HTTP 协议 并不存在,只能使用 TCP 套接字来 编写 web 应用,你可能认为这是一件疯狂的事情;
- 直接使用 WebSocket(SockJS) 就很类似于 使用 TCP 套接字来编写 web 应用,因为没有高层协议,就需要我们定义应用间所发送消息的语义,还需要确保连接的两端都能遵循这些语义;
-
同 HTTP 在 TCP 套接字上添加请求-响应模型层一样,STOMP 在 WebSocket 之上提供了一个基于帧的线路格式层,用来定义消息语义;
概括一下就是,webSocket请求的消息体这一块的约定或者协议一直没人管,后来它负责管这些东西。那它到底有什么用呢,怎么管的,我这边肉眼可见的区别是,如前面提到的,处理ws请求的方法入参可以使用结构化的实体Message类了,这里的反例是第一版代码,我们在前端使用json封装数据,但是后台使用String接收,这里我产生了一个疑问,那我能不能直接使用实体类接收呢,答案是不能,这里放出截图看一下效果:
可以看到,是无法通过编译的,但是第二版代码是可以的,我的理解就是stomp协议帮我们处理了这个数据转换的过程。
然后具体看一下核心代码内容(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);
/* 发起ws建立连接请求,第一个参数是header对象,形如 var headers = {
login: 'mylogin',
passcode: 'mypasscode',
// additional header
'client-id': 'my-client-id'
};
这里传空,暂时不知道什么时候需要传值
第二个参数是连接成功的回调,也可以传入第三个函数作为失败回调,这里省略了
回调函数里调用了订阅方法,用于接收服务端推送来的消息,showContent函数将推送消息展示
*/
stompClient.connect({}, function (frame) {
setConnected(true);
console.log('Connected: ' + frame);
stompClient.subscribe('/getMessage/single/'+ from + to, function (result) {
showContent(result.body);
});
});
}
发送消息的方法如下:
//获取消息内容,转JSON发送
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()
}));
}
断开连接使用:
//可以传入回调函数
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>