WebSocket是一种在单个TCP连接上进行全双工通信的协议,已被W3C定为标准。
使用WebSocket可以使得客户端和服务器之间的数据交换变得更加简单,它允许服务端主动向客户端推送数据。
在WebSocket协议中,浏览器和服务器只需要完成一次握手
,两者之间就可以直接创建持久性的连接,并进行双向数据传输。
WebSocket使用了HTTP/1.1的协议升级特性,一个WebSocket请求首先使用非正常的HTTP请求以特定的模式访问一个URL,这个URL有两种模式,分别是ws和wss,对应HTTP协议中的HTTP和HTTPS,在请求头中有一个Connection:Upgrade字段
,表示客户端想要对协议进行升级
,另外还有一个Upgrade:websocket字段
,表示客户端想要将请求协议升级为WebSocket协议
。这两个字段共同告诉服务器要将连接升级为WebSocket这样一种全双工协议
,如果服务端同意协议升级,那么在握手完成之后,文本消息或者其他二进制消息就可以同时在两个方向上进行发送,而不需要关闭和重建连接。此时的客户端和服务端关系是对等的,它们可以互相向对方主动发送消息。
WebSocket 是一种重要的客户端-服务器通信工具,人们需要充分了解其实用性并避免使用其最大潜力的场景。
在以下情况下使用 WebSocket:
1.开发实时网络应用程序
WebSocket 最常见的用途是实时应用程序开发,其中它有助于在客户端连续显示数据。当后端服务器不断发回这些数据时,WebSocket 允许在已经打开的连接中不间断地推送或传输这些数据。WebSocket 的使用使此类数据传输变得快速并充分利用了应用程序的性能。
此类 WebSocket 实用程序的一个现实示例是比特币交易网站。在这里,WebSocket 协助部署的后端服务器向客户端发送数据处理。
2.创建聊天应用程序
聊天应用程序开发人员在一次性交换和发布/广播消息等操作中向 WebSocket 寻求帮助。由于使用相同的 WebSocket 连接来发送/接收消息,因此通信变得简单快捷。
3.正在开发游戏应用程序
在游戏应用程序开发过程中,服务器必须不间断地接收数据,而不要求 UI 刷新。WebSocket 可以在不影响游戏应用程序 UI 的情况下实现这一目标。
既然已经清楚了应该在哪里使用 WebSocket,请不要忘记了解应该避免使用 WebSocket 的情况,让自己远离大量的操作麻烦。
当需要获取旧数据或仅需要一次性处理数据时,不应该使用 WebSocket。在这些情况下,使用 HTTP 协议是明智的选择。
由于 HTTP 和 WebSocket 都用于应用程序通信,因此人们经常感到困惑,并且很难从这两者中选择一个。看一下下面提到的文本,可以更清楚地了解 HTTP 和 WebSocket。
如前所述,WebSocket 是一种框架式双向协议。相反,HTTP 是一个在 TCP 协议之上运行的单向协议。
由于WebSocket协议能够支持连续的数据传输,因此主要用于实时应用程序开发。HTTP 是无状态的,用于开发RESTful和 SOAP 应用程序。Soap仍然可以使用HTTP来实现,但是REST被广泛传播和使用。
在 WebSocket 中,通信发生在两端,这使其成为更快的协议。在 HTTP 中,连接是在一端建立的,这使得它比 WebSocket 有点慢。
WebSocket使用统一的TCP连接,需要一方终止连接。在发生这种情况之前,连接将保持活动状态。HTTP 需要为单独的请求构建不同的连接。请求完成后,连接会自动断开。
总结:
导入依赖:
<dependency>
<groupId>javax.websocketgroupId>
<artifactId>javax.websocket-apiartifactId>
<version>1.1version>
<scope>providedscope>
dependency>
创建一个WebSocket的服务端:
/**
* WebSocket的服务端
*
* @ServerEndpoint注解表示当前类是一个websocket
* 的服务端,value属性指定一个连接服务端的url地址
*/
@ServerEndpoint("/connect")
@Slf4j
public class WebSocketServer {
//用户列表
private static final List<Session> users = new ArrayList<>();
/**
* 打开链接的方法,只有在客户端连接服务端的时候
* 这个方法会调用一次
*
* session表示一个websocket客户端的连接会话,
* 每一个客户端连接就会创建一个Session会话
*/
@OnOpen
public void onOpen(Session session) {
log.info("客户端已连接");
//将session添加到用户列表中
users.add(session);
}
/**
* 接收客户端发送的消息方法
*/
@OnMessage
public void onMessage(String message, Session session) throws IOException {
log.info("消息:" + message);
//向当前客户端发送一个消息
//session.getBasicRemote().sendText("Hello client");
//群发消息
for(Session s : users) {
s.getBasicRemote().sendText(message);
}
}
/**
* 当客户端断开连接后调用此方法
*/
@OnClose
public void onClose(Session session) {
log.info("客户端已断开连接");
//用户离线
users.remove(session);
}
}
这里服务端创建好后,使用html页面进行测试
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>客户端title>
<script src="js/vue.js">script>
head>
<body>
<div id="app">
<h1>websocket客户端h1>
请输入消息:<input v-model="sendMsg" type="text">
<button @click="onSend">发送button>
<div>
<h2>消息h2>
<p v-for="msg in messages">{{msg}}p>
div>
div>
body>
<script>
new Vue({
el:"#app",
data:{
messages:[],
sendMsg:"",
ws: null
},
methods:{
onSend() {
this.ws.send(this.sendMsg)
},
initWS(){
console.log("服务端初始化")
let that = this
this.ws = new WebSocket("ws://localhost:8080/connect")
this.ws.onmessage = function (event) {
console.log("这是消息初始化")
console.log(that.messages.push(event.data))
console.log(event.data)
}
},
},
mounted(){
this.initWS()
}
})
script>
html>
这里可以看到,当我们前端创建了WebSocket对象会跟WebSocket的服务端进行连接,只需要在输入框中输入需要发送的信息,服务端就会接收到前端发送过来的信息。
首先,创建一个html页面 login.html 进行登录,将用户保存到HttpSession会话中
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Titletitle>
head>
<body>
<h1>登录h1>
<div>
<form action="/login" method="post">
账号:<input type="text" name="userName"><br>
<input type="submit" value="登录">
form>
div>
body>
html>
创建Servlet,将用户信息保存到会话作用域中,然后进行重定向到发送信息页面
@WebServlet("/login")
@Slf4j
public class LoginServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String userName = req.getParameter("userName");
log.info("servlet用户名:{}",userName);
HttpSession session = req.getSession();
session.setAttribute("userName",userName);
resp.sendRedirect("chat.html");
}
}
创建发送信息的页面 chat.html
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>聊天室title>
<script src="js/jquery.min.js">script>
<script src="js/vue.js">script>
head>
<body>
<div id="app">
<input type="text" v-model="msg">
<input type="button" value="发送" @click="send()">
div>
<script>
new Vue({
el:"#app",
data:{
ws:null,
msg:""
},
methods:{
initWS(){
let that = this;
this.ws = new WebSocket("ws://localhost:8080/connect")
this.ws.onmessage = function (ev) {
let dataMsg = ev.data;
dataMsg = JSON.parse(dataMsg)
console.log(dataMsg)
}
},
send(){
this.ws.send(this.msg)
}
},
mounted(){
this.initWS()
}
})
script>
body>
html>
创建握手处理类,因为在用户的信息保存在HttpSession中,websocket在交互前的第一次请求是基于http协议进行握手,因此可以在这个类中得到握手请求对象,从而得到HttpSession的信息
public class WebSocketHandshake extends ServerEndpointConfig.Configurator {
@Override
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
//获取HttpSession对象
HttpSession httpSession = (HttpSession) request.getHttpSession();
//获取用户名
String userName = (String) httpSession.getAttribute("userName");
//将用户名保持到当前用户连接websocket的Session中
sec.getUserProperties().put("userName",userName);
}
}
创建WebSocket服务端
/**
* configurator属性指定自定义的握手连接处理类
*/
@ServerEndpoint(value = "/connect",
configurator = WebSocketHandshake.class)
public class ChatServer {
// 用户列表
// key为用户的id或者是name,
// value则是每一个客户端的Session
private static final Map<String, Session> users = new HashMap<>();
@OnOpen
public void onOpen(Session session) {
//获取Session中的用户名
String userName = (String) session.getUserProperties().get("user");
//添加到用户列表中
users.put(userName, session);
}
@OnMessage
public void onMessage(String message, Session session) throws Exception {
//获取发送人
String formUser = (String) session.getUserProperties().get("user");
//创建发送时间
String sendTime = new SimpleDateFormat("hh:mm").format(new Date());
//封装消息对象并序列为json
Message msg = new Message(formUser, sendTime, message);
String jsonMessage = new ObjectMapper().writeValueAsString(msg);
//群发给所有人
for(String userName : users.keySet()) {
Session s = users.get(userName);
s.getBasicRemote().sendText(jsonMessage);
}
}
@OnClose
public void onClose(Session session) {
//将用户移除在线列表
String userName = (String) session.getUserProperties().get("user");
users.remove(userName);
}
}
这就实现了一个群发布的功能,只要是其中一个人发送了信息,那么所有登录的后建立连接的人都能收到信息。
创建一个dto,用来保存信息
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Message {
private String fromUser;
private String sendTime;
private String content;
}
创建WebSocket服务端,Spring对WebSocket服务端进行了封装
/**
* spring封装的websocket服务端,可以继承一个TextWebSocketHandler,
* 表示这个服务端用于处理文本数据的消息
*/
public class ChatServer extends TextWebSocketHandler {
//用户列表
//每一个用户连接时都会创建一个WebSocketSession对象
private static final Map<String, WebSocketSession>
users = new HashMap<>();
/**
* 客户端建立连接后执行的方法,等效于onOpen方法
* @param session
* @throws Exception
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
//获取登录的用户信息
String userName = (String) session.getAttributes().get("user");
//保存到用户列表
users.put(userName, session);
}
/**
* 接收客户端的消息,等效于onMessage方法
* @param session
* @param message
* @throws Exception
*/
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
//获取消息载体,也就是客户端发送的文本内容
String msgContent = message.getPayload();
//获取发送人
String fromUser = (String) session.getAttributes()
.get("user");
//发送时间
String sendTime = new SimpleDateFormat("hh:mm")
.format(new Date());
//封装消息对象
Message msg = new Message(fromUser, sendTime, msgContent);
//序列化为json字符串
String json = new ObjectMapper().writeValueAsString(msg);
//群发给所有人
for(String userName : users.keySet()) {
WebSocketSession s = users.get(userName);
//发送消息,必须是TextMessage的对象
s.sendMessage(new TextMessage(json));
}
}
/**
* 连接关闭后执行的方法,等效于onClose方法
* @param session
* @param status
* @throws Exception
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
String userName = (String) session.getAttributes().get("user");
//将用户移除在线列表
users.remove(userName);
}
}
创建WebSocket配置类,将服务端类纳入到容器中管理
@Configuration
//启用websocket支持
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
/**
* 装配服务端
* @return
*/
@Bean
public WebSocketHandler webSocketHandler() {
return new ChatServer();
}
/**
* 装配HttpSession的拦截器,这样就可以在握手阶段
* 获取HttpSession的内容,在使用WebSocketSession时
* 就能直接得到HttpSession的数据
* @return
*/
@Bean
public HandshakeInterceptor handshakeInterceptor() {
return new HttpSessionHandshakeInterceptor();
}
/**
* 给服务端注册请求的端点(映射连接地址)
* @param registry
*/
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
//给ChatServer设置连接的url
registry.addHandler(webSocketHandler(), "/connect")
//设置握手拦截器
.addInterceptors(handshakeInterceptor());
}
}
创建MvcConfig
@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {
@Override
public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
configurer.enable();
}
}
创建主配置类SpringConfig
@Configuration
@ComponentScan(basePackages = "edu.nf.ch03")
@Import({MvcConfig.class, WebSocketConfig.class})
public class AppConfig {
}
创建WebConfig配置类
public class WebConfig extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class<?>[] getRootConfigClasses() {
return new Class[0];
}
@Override
protected Class<?>[] getServletConfigClasses() {
return new Class[]{AppConfig.class};
}
@Override
protected String[] getServletMappings() {
return new String[]{"/"};
}
}
最后创建控制层和页面进行测试
@Controller
public class UserController {
/**
* 简单的用户登录
* @param userName
* @param session
* @return
*/
@PostMapping("/user/login")
public String login(String userName, HttpSession session) {
//将用户信息保存到会话作用域
session.setAttribute("user", userName);
//重定向到聊天的首页
return "redirect:/static/chat.html";
}
}
}
登录页
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Titletitle>
head>
<body>
<h1>用户登录h1>
<form name="f1" method="post" action="../user/login">
Name:<input type="text" name="userName"/>
<input type="submit" value="登录"/>
form>
body>
html>
发送信息页
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>聊天室title>
<script src="js/jquery.min.js">script>
<script src="js/vue.js">script>
head>
<body>
<div id="app">
<input type="text" v-model="msg">
<input type="button" value="发送" @click="send()">
div>
<script>
new Vue({
el:"#app",
data:{
ws:null,
msg:""
},
methods:{
initWS(){
let that = this;
this.ws = new WebSocket("ws://localhost:8080/connect")
this.ws.onmessage = function (ev) {
let dataMsg = ev.data;
dataMsg = JSON.parse(dataMsg)
console.log(dataMsg)
}
},
send(){
this.ws.send(this.msg)
}
},
mounted(){
this.initWS()
}
})
script>
body>
html>
最后的结果和Servlet实现的效果一样,都是能够实现群发功能。
协议上节中我们在上面简单介绍了Spring中对于WebSocket的封装,并实现一个简单的服务端,这节我们将会结合STOMP子协议实现WebSocket通信。
WebSocket协议定义了两种消息类型(文本类型和二进制类型),但是消息内容却是未定义的。
STOMP
(Simple Text Oriented Messaging Protocol) 起源于脚本语言,比如Ruby、Python和Perl,用于连接企业消息代理,它可以用于任何可靠的双向网络协议中,如TCP和WebSocket。尽管STOMP是一个面向文本的协议,但消息负载可以是文本或者二进制。
STOMP
基于WebSocket在客户端和服务端之间定义了一种机制,协商通过子协议(更高级的消息协议)来定义可以发送何种消息,每条消息的内容是什么,等等。
STOMP是一个基于帧的协议,帧的结构如下:
COMMAND
header1:value1
header2:value2
Body
客户端可以用SEND或者SUBSCRIBE命令去发送和订阅消息,destination头部用来描述消息发送到哪里以及谁应该接收消息,下面的消息结构是客户端订阅股票行情的例子,如下:
SUBSCRIBE
id:sub-1
destination:/topic/price.stock.*
STOMP服务端可以使用MESSAGE命令广播消息给所有的订阅者,下面的例子为广播股票行情消息给所有消息订阅者
MESSAGE
message-id:nxahklf6-1
subscription:sub-1
destination:/topic/price.stock.MMM
{"ticker":"MMM","price":129.45}
在Spring中使用STOMP与原生WebSockets相比提供了更加丰富的编程模型,下面是使用STOMP的优点:
目前使用的是spring提供的消息代理
添加依赖:
<dependency>
<groupId>org.springframeworkgroupId>
<artifactId>spring-messagingartifactId>
<version>5.3.3version>
dependency>
创建MVC配置类
@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {
@Override
public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
configurer.enable();
}
}
创建WebSocket配置类
@Configuration
//启用websocket消息代理中间件
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/**
* 注册一个连接消息中间件的端点(路径url)
* @param registry
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("broker");
}
/**
* 配置消息代理,主要是设置相关的主题
* 消息代理是服务中心的核心,spring-websocket内置了
* 一个简单的消息代理,但也只是能够满足基本要求,如果
* 需要强大的消息中心的功能,通常都会集成第三方的消息队列
* 例如:RabbitMQ等
* @param registry
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
//启用spring内置简单的消息代理并设置一个主题(topic)的前缀,
//用于消息的发布和订阅
registry.enableSimpleBroker("/news", "/video");
//如果需要集成外部其他的消息代理,使用下面的方法
//registry.enableStompBrokerRelay();
}
}
创建主配置类
@Configuration
@ComponentScan(basePackages = "edu.nf.ch04")
@Import({MvcConfig.class, WebSocketConfig.class})
public class AppConfig {
}
创建Web配置类
public class WebConfig extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class<?>[] getRootConfigClasses() {
return new Class[0];
}
@Override
protected Class<?>[] getServletConfigClasses() {
return new Class[]{AppConfig.class};
}
@Override
protected String[] getServletMappings() {
return new String[]{"/"};
}
}
创建消息对象:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Message {
private String content;
private String sendTime;
}
创建控制层:
@RestController
@RequiredArgsConstructor
public class PublishController {
/**
* 消息处理模版,用于发布消息
*/
private final SimpMessagingTemplate template;
/**
* 后台发布消息
*/
@PostMapping("/publish/{topic}/{sub}")
public void publish(@PathVariable("topic") String topic,
@PathVariable("sub") String sub,
String message) {
String sendTime = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(new Date());
Message msg = new Message(message, sendTime);
//将消息发布到消息代理指定的主题中
template.convertAndSend("/" +topic +"/" + sub, msg);
}
}
现在模拟一个消息发布于订阅功能,比如说现在发布者需要发布消息,创建一个页面代表发布者:
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>后台发布title>
<script src="js/vue.js">script>
<script src="js/jquery.min.js">script>
head>
<body>
<div id="app">
<select v-model="pathName">
<option value="video">影视option>
<option value="news">娱乐option>
select>
<input type="text" name="message" v-model="msg">
<button @click="OnSend">发送button>
div>
body>
<script>
new Vue({
el:"#app",
data:{
msg:"",
pathName:"video",
},
methods:{
OnSend(){
$.ajax({
url:"publish/"+this.pathName,
type:"post",
data:{
message:this.msg
},
success:() => {
},
error:() => {
}
})
}
},
})
script>
html>
创建两个客户端对,比如说者两个用户都订阅了,那发布者可以对两个用户分别发布信息:
client.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>客户端</title>
<script src="js/stomp.min.js"></script>
<script src="js/jquery.min.js"></script>
<script src="js/vue.js"></script>
</head>
<body>
<div id="app">
<h1>消息:{{msg.content}}</h1>
</div>
</body>
<script>
new Vue({
el: "#app",
data: {
msg: {}
},
methods: {
initWS() {
let that = this
//创建WebSocket对象
let ws = new WebSocket("ws://localhost:8080/broker");
//将WebSocket包装成stomp客户端
let stompClient = Stomp.over(ws);
//连接服务器并订阅消息
stompClient.connect({}, function () {
//执行订阅
stompClient.subscribe("/news/sport", function (data) {
//接收发布的通知内容
let message = JSON.parse(data.body);
that.msg = message
console.log(message)
})
}, {})
}
},
mounted() {
this.initWS()
}
})
</script>
</html>
client2.html
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>客户端title>
<script src="js/stomp.min.js">script>
<script src="js/jquery.min.js">script>
<script src="js/vue.js">script>
head>
<body>
<div id="app">
<h1>消息:{{msg}}h1>
div>
body>
<script>
new Vue({
el:"#app",
data:{
msg:""
},
methods:{
initWS(){
//创建WebSocket对象
let ws = new WebSocket("ws://localhost:8080/broker");
//将WebSocket包装成stomp客户端
let stompClient = Stomp.over(ws);
//连接服务器并订阅消息
stompClient.connect({},function () {
//执行订阅
stompClient.subscribe("/video/sport",function (data) {
//接收发布的通知内容
let message = JSON.parse(data.body);
console.log(message)
})
},{})
}
},
mounted(){
this.initWS()
}
})
script>
html>