前言

名词解释

HTTP:HTTP是应用层协议(在传输层使用 TCP,在网络层使用 IP 协议),是一种无状态(即每个请求都是一个新的请求)、无连接(每次连接只处理一个请求)协议,但是HTTP仍然可以借助Cookie(客户端数据储存)和Session(服务端数据储存)实现长连接(HTTP的长连接需要在请求头中加入Connection:keep-alive )。整个通信过程,客户端(浏览器)向服务端发送请求,服务端接收请求并返回响应信息。

Socket:与HTTP不一样,Socket不是一种协议,而是传输层的一种接口((应用程序与网络协议栈进行交互的接口),它是对 TCP/IP 协议的封装。

WebSocket:WebSocket和HTTP一样是应用层的协议。但是WebSocket是一种双向通信协议,是一种有状态协议。在WebSocket中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

长连接

  • 建立连接后不管是否使用,都保持TCP连接。但是长连接并不是永久连接。如果一段时间(可以在请求头中设置)内未发送请求,连接仍会断开。

  • 过程:建立连接–>传输数据…(保持连接)…传输数据–>关闭连接

短连接:

  • 短连接是一次性连接,即浏览器和服务器每次进行操作时都需要重新建立TCP连接,操作结束后即中断连接。
  • 过程:建立连接–>传输数据–>断开连接

HTTP和Socket均支持长连接和短连接;

使用长连接的 Http 协议,浏览器或者服务器在其头信息加入了这行代码:Connection:keep-alive ,TCP 连接在发送后将仍然保持打开状态,于是,浏览器可以继续通过相同的连接发送请求

WebSocket长连接与HTTP长连接区别

既然HTTP也能实现长连接,为什么还要用WebSocket呢?这就要说一说他们两者之间的区别了。

  1. 区别一

    • HTTP协议只能做单向传输即每次进行通信时,都需要客户端发送请求(request)然后服务端进行应答(response),服务端是无法主动向客户端发送请求的。
    • 而WebSocket则是双向传输,服务端与客户端可以互相主动发起请求,并且即使客户端或者服务端其中一方断开连接,后续请求时也不需要再次连接请求。
  2. 区别二

    • HTTP是无状态协议,虽然HTTP可以使用长连接,在一次 TCP 连接中完成多HTTP请求,但是对于每个请求仍要单独发请求头,也就是说仍然是一问一答的模式。
    • 与HTTP不同的是,Websocket是一种有状态的协议,在进行通讯前需要先创建连接(发送一个附加请求头信息(Upgrade:WebSocket)的HTTP协议),连接建立完成后双方就可以自由(使用TCP)进行通信,并且此连接状态会一直持续到其中一方断开为止。
  3. 区别三

    • 由于HTTP的单向请求,如果服务端有连续的状态变化时,客户端就需要频繁的通过请求实现长轮询来获取服务端的最新变化状态,轮询效率较低,且非常浪费资源(因为需要不停的连接,或者HTTP连接始终打开)
    • 而WebSocket是实时的双向通信,就不需要客户端发送请求来获取服务端状态,服务端可以主动推送最新状态

用图表示的话,就是下面这个样子:

HTTP长连接:

HTTP长连接

WebSocket:

websocket

说明

客户端(浏览器)

创建websocket对象

Websocket协议定义了两种URL方案

  • ws: 非加密
  • wss :加密(使用HTTPS采用的安全机制保证HTTP连接的安全)。
1
var ws = new WebSocket(url);

url格式:

不加密:ws://ip地址:端口号/资源地址;

加密:wss://ip地址:端口号/资源地址;

websocket事件

事件 使用 描述
open websocket对象.onopen 连接时触发
message websocket对象.onmessage 客户端接收服务端数据时触发
error websocket对象.onerror 通信错误时触发
close websocket对象.onclose 连接关闭时触发

websocket方法

方法 描述
send() 使用连接发送数据
close() 关闭连接

服务端

服务端使用websocket需要定义Endpoint(就和实现HTTP需要Servlet一样)。

主要通过两种方式定义Endpoint:

  1. 编程式:通过继承javax.websocket.Endpoint类,实现其方法;
  2. 注解式:通过定义一个POJO,并添加@ServicePoint相关注解。

在Endpoint类中可以看到各生命周期相关方法:

方法 注解 描述
onClose @OnClose 当会话关闭时调用
onOpen @OnOpen 当开启一个新会话时调用
onError @OnError 当连接过程中异常时调用

实例

非springboot项目可以引入tomcat中ws:

1
2
3
4
5
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>9.0.56</version>
</dependency>

springboot项目直接引入下面依赖

1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>2.6.7</version>
</dependency>

本示例是springboot项目,非springboot项目可做参考

示例中使用了json和对象间转换,需要引入fastjson依赖:

1
2
3
4
5
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.49</version>
</dependency>

客户端界面

wsPage.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>ws</title>

<script type="text/javascript">
// 创建websocket对象 (url地址为服务端的ws服务地址)
var ws = new WebSocket("ws://localhost:8080/wsTest");

/**
* 绑定事件
*/
// 连接开始
ws.onopen = function () {
alert("连接成功")
}
// 接收信息
ws.onmessage = function (event) {
const data = event.data;
const msg = JSON.parse(data);
if (msg.isSystem) {
const info = document.getElementById("info");
const user = document.getElementById("user");
info.innerText = msg.user + msg.message;
if (undefined === user.innerText || ''===user.innerText) {
user.innerText = msg.user;
}
} else {
const info = document.getElementById("message");
info.innerText = msg.message;
}

}
// 关闭连接
ws.onclose = function () {
alert("连接已断开")
}

function send() {
const input = document.getElementById("input");
const user = document.getElementById("user");
const sendTo = document.getElementById("sendTo");
const msg = input.value;
input.value = "";

const sendMsg = {"msg": user.innerText + ":" + msg, "sendTo": sendTo.value};
// 发送信息给服务端
ws.send(JSON.stringify(sendMsg))
}


</script>
</head>

<body class="login-body">


<h3 id="user"></h3>
<input id="sendTo" placeholder="要给谁发消息" value="ahzoo">发送消息<br/>
<input id="input" placeholder="需要发送的消息">
<button onclick="send()">发送消息</button>
<h3 id="info"></h3>
<p id="message"></p>

</body>
</html>

服务端

wobsocket服务类:

用于信息的传递

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101

import com.alibaba.fastjson.JSONObject;
import com.springboot.config.HttpSessionConfigurator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpSession;
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

//设置ws路径,同时将HttpSessionConfigurator类声明为WsEndpoint的配置类
@ServerEndpoint(value = "/wsTest", configurator = HttpSessionConfigurator.class)
@Component
public class WsEndpoint {
private static final Logger logger = LoggerFactory.getLogger(WsEndpoint.class);

// 储存每个客户端用户的WsEndpoint对象(为了线程安全这里使用的是ConcurrentHashMap)
private static Map<String, WsEndpoint> users = new ConcurrentHashMap<>();

// 声明session对象,用于发送信息
private Session session;

// 声明一个HTTPSession对象
HttpSession httpSession;

/**
* 连接建立时执行方法
*
* @param session
* @param endpointConfig
*/
@OnOpen
public void onOpen(Session session, EndpointConfig endpointConfig) throws IOException, EncodeException {
this.session = session;

// 从获取httpsession对象
HttpSession httpSession = (HttpSession) endpointConfig.getUserProperties().get(HttpSession.class.getName());
this.httpSession = httpSession;
// 读取httpswssion对象中储存的用户名
String user = (String) httpSession.getAttribute("username");

// 将当前对象保存到当前用户中。
users.put(user, this);

// 拓展功能:向客户端推送服务端上线的消息
HashMap<String, String> message = new HashMap<>();
message.put("isSystem", "true");
message.put("user", user);
message.put("message", "上线了!");
this.sendToAll(message);
}

/**
* 接收信息时执行方法
*
* @param message
* @param session
*/
@OnMessage
public void onMessage(String message, Session session) throws IOException {
Object obj = JSONObject.parse(message);
String sendTo = (String) ((JSONObject) obj).get("sendTo");
String msg = (String) ((JSONObject) obj).get("msg");
// String username = (String) httpSession.getAttribute("username");

HashMap<String, String> sendMsg = new HashMap<>();
sendMsg.put("message", msg);
// 获取要发送目标用户的ws对象,并发送信息
users.get(sendTo).session.getBasicRemote().sendText(JSONObject.toJSONString(sendMsg));
}

/**
* 连接关闭时执行方法
*/
@OnClose
public void onClose(Session session) {

}

/**
* 拓展功能:向所有在线用户推送消息
*/
private void sendToAll(HashMap<String, String> message) throws IOException {
Set<String> names = users.keySet();
for (String name : names) {
// 获取用户的wsEndpoint对象
WsEndpoint wsEndpoint = users.get(name);
// 发送消息 (使用sendText方法时,需要先将信息转为String类型)
wsEndpoint.session.getBasicRemote().sendText(JSONObject.toJSONString(message));
}

}

}

websocket配置类:

用于注册websocket服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
public class WsConfig {

/**
* 注入ServerEndpointExporter对象,会自动注册使用使用了@ServerEndpoint注解的bean
* @return
*/
@Bean
public ServerEndpointExporter serverEndpointExporter(){
return new ServerEndpointExporter();
}
}

配置类:

用于获取session中的(用户)信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

import javax.servlet.http.HttpSession;
import javax.websocket.HandshakeResponse;
import javax.websocket.server.HandshakeRequest;
import javax.websocket.server.ServerEndpointConfig;

//继承ServerEndpointConfig的内部类Configurator,用于获取session对象
//需要在ws类中声明当前类,才会生效
public class HttpSessionConfigurator extends ServerEndpointConfig.Configurator {

@Override
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
HttpSession httpSession = (HttpSession) request.getHttpSession();
// 将httpsession储存到配置对象
sec.getUserProperties().put(HttpSession.class.getName(),httpSession);
}
}

控制器类:

用于界面展示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.servlet.http.HttpSession;

@Controller
public class WsController {
@RequestMapping("ws/{username}")
public String toChat(HttpSession httpSession, @PathVariable("username") String username){
if(null==httpSession.getAttribute("username")) {
httpSession.setAttribute("username", username);
}
return "wsPage";
}
}

实例中只是简单实现功能,直接用session储存的用户数据,所以为了避免一个浏览器多个用户登录,防止数据错乱的情况,直接在控制器中限制了session只能储存新数据,不能覆盖储存。

图片

储存功能

消息的储存,如果没有特殊需求的话,可以选择直接储存在浏览器的sessionStorage中

关闭连接

客户端调用websocket对象的close()方法关闭;

服务端调用websocket对象的end()方法关闭

心跳机制

由于WebSocket是实时通信,所以如果客户端和服务端长期为通信的话就需要确定双方是否都还“存活”,然后才能继续通信。

实现方式就是每隔一段时间向服务器发送一个数据包,告诉服务器自己还活着,然后服务端接收到数据后,也向客户端回复一个数据,告诉服务端也还活着,否则的话,就是连接断开了,这个时候就需要进行重连操作。

实现流程:

图片