最近在做项目的时候,遇到了一个前端页面需要实时刷新的功能,一种方法是我们通过短轮询的方式,但这种方式虽然简单,但是无用的请求过多,占用资源,并且如果是对数据要求高较高的场景,就不适用了。
这个时候就要考虑应用长连接了,最开始想到的是,Http1.1以后支持的长连接,但是经过实践后发现,这里可能存在一个误解:Http协议是基于请求/响应模式的,因此客户端请求后只要服务端给了响应,本次Http请求就结束了,没有长连接这么一说,那么自然也就没有短连接这么一说了。也就是说,在这样一个HTTP连接中,可以发送多个Request,接收多个Response。但是Request和Response永远是相对应的,也就是说一个request只能有一个response。并且这个response也是被动的,不能主动发起。
所谓的HTTP长连接和短连接,其实本质上说的是TCP连接。TCP连接是一个双向的通道,它是可以保持一段时间不关闭的,因此TCP连接才有真正的长连接和短连接这么一说。
那么下面我们一起来看下WebSocket是如何实现的。
什么是WebSocket?
WebSocket是一种在单个TCP连接上进行全双工通信的协议,使得客户端与服务端之间的数据交换变得更加简单,允许服务端主动向客户端推送数据,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
这里先来区分下三者的含义:
-
单工:数据传输只允许在一个方向上传输,只能一方发送数据,另一方来接受数据并发送;例如:对讲机。
-
半双工:数据传输允许两个方向上的传输,但是同一时间内,只可以有一方发送或接收消息,并存在最大传输距离的限制。
-
全双工:接口可以同时发送和接收数据,最大吞吐量可达到双倍速率,且消除了半双工的物理距离限制。
解决了什么问题?
客户端(浏览器)和服务器进行通信,只能由客户端发起ajax请求,才能进行通信,服务端无法主动向客户端推送消息。像我们常见的一些场景:体育赛事、聊天室、实时位置之类的场景时,客户端要获取服务器端的变化,就只能通过轮询(定时请求)的方式来了解服务器端有没有新的信息变化
轮询效率低,非常浪费资源(需要不断的发送请求,不停连接服务器)
WebSocket的出现,让服务器端可以主动向客户端发送信息,使得浏览器具备了实时双向通信的能力。
实现案例:
前端示例:
// websocket.js
const WebSocket = require('ws')
const events = []
let latestTimestamp = Date.now()
const clients = new Set()//连接着的socket数组
const EventProducer = () => {
const event = {
id: Date.now(),
timestamp: Date.now()
}
events.push(event)
latestTimestamp = event.timestamp
// 推送给所有连接着的socket
clients.forEach(client => {
client.ws.send(JSON.stringify(events.filter(event => event.timestamp > client.timestamp)))
client.timestamp = latestTimestamp
})
}
// 每10秒生成一个新的事件
setInterval(() => {
EventProducer()
}, 10000)
// 启动socket服务器
const wss = new WebSocket.Server({ port: 8080 })
wss.on('connection', (ws, req) => {
console.log('client connected')
// 首次连接,推送现存事件
ws.send(JSON.stringify(events))
const client = {
timestamp: latestTimestamp,
ws,
}
clients.add(client)//将连接放入数组
ws.on('close', _ => {
clients.delete(client)
})
})
var timestampRef={current:0}
var eventsRef={current:[]}
const ws = new WebSocket(`ws://localhost:8080/ws?timestamp=${timestampRef.current}`)
ws.addEventListener('open', e => {
console.log('successfully connected')
})
ws.addEventListener('close', e => {
console.log('socket close')
})
ws.addEventListener('message', (ev) => {
const latestEvents = JSON.parse(ev.data)
if (latestEvents && latestEvents.length) {
timestampRef.current = latestEvents[latestEvents.length - 1].timestamp
//注意latestEvents数据是后端新生成的数据,前端需要自己拼上老数据
eventsRef.current = [...eventsRef.current, ...latestEvents]
console.log(eventsRef)
}
})
服务端代码(基于SpringBoot):
WebSocketConfig
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Resource
private HttpAuthHandler handler;
@Resource
private WebSocketInterceptor interceptor;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(handler, "/hello")
.addInterceptors(interceptor)
.setAllowedOrigins("*");
}
}
HttpAuthHandler
@Component
public class HttpAuthHandler extends TextWebSocketHandler {
/**
* 建立成功事件
* @param session
* @throws Exception
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
super.afterConnectionEstablished(session);
}
/**
* 接受消息事件
* @param session
* @param message
* @throws Exception
*/
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
Object token = session.getAttributes().get("token");
System.out.println("server 接收到 " + token + " 发送的 " + payload);
session.sendMessage(new TextMessage("server 发送给 " + token + " 消息 " + payload + " " + LocalDateTime.now().toString()));
}
/**
* 断开连接时
* @param session
* @param status
* @throws Exception
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
super.afterConnectionClosed(session, status);
}
}
WebSocketInterceptor
public class WebSocketInterceptor implements HandshakeInterceptor {
/**
* 握手前
*
* @param request
* @param response
* @param wsHandler
* @param attributes
* @return
* @throws Exception
*/
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
System.out.println("握手开始");
// 获得请求参数
Map<String, String> paramMap = HttpUtil.decodeParamMap(request.getURI().getQuery(), StandardCharsets.UTF_8);
String uid = paramMap.get("token");
if (StrUtil.isNotBlank(uid)) {
// 放入属性域
attributes.put("token", uid);
System.out.println("用户 token " + uid + " 握手成功!");
return true;
}
System.out.println("用户登录已失效");
return false;
}
/**
* 握手后
*
* @param request
* @param response
* @param wsHandler
* @param exception
*/
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
System.out.println("握手完成");
}
}
测试:
上边简单实现了一个webSocket通信。实际的东西还有很多,比如webSocket扩展,心跳检测,数据加密,身份认证等知识点。但自己也需要再去研究,所以先不做介绍了。
到这里,基本上使用应该是没问题了,下面我们来继续来继续探讨下有关其实现方面的细节。
实现原理
WebSocket是位于应用层的一个应用层协议,它需要依赖HTTP协议进行一次握手,握手成功后,数据就直接从TCP通道传输,与HTTP无关了。也就是分为握手阶段和数据传输阶段,即:HTTP握手+双工的TCP连接。
下面我们分别来看一下这两个阶段的具体实现原理。
一、握手阶段
客户端发送消息:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Version: 13
在HTTP Header中设置Upgrade字段,其字段值为websocket,并在Connection字段提示Upgrade,服务端若支持WebSocket协议,并同意握手,可以返回如下结构:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
Sec-WebSocket-Version: 13
我们来详细讨论WebSocket握手的细节,发现除了设置Upgrade之外,还需要设置其他的Header字段。
-
Sec-WebSocket-Key:必传,由客户端随机生成的 16 字节值, 然后做 base64 编码, 客户端需要保证该值是足够随机, 不可被预测的 (换句话说, 客户端应使用熵足够大的随机数发生器), 在 WebSocket 协议中, 该头部字段必传, 若客户端发起握手时缺失该字段, 则无法完成握手
-
Sec-WebSocket-Version:必传, 指示 WebSocket 协议的版本, RFC 6455 的协议版本为 13, 在 RFC 6455 的 Draft 阶段已经有针对相应的 WebSocket 实现, 它们当时使用更低的版本号, 若客户端同时支持多个 WebSocket 协议版本, 可以在该字段中以逗号分隔传递支持的版本列表 (按期望使用的程序降序排列), 服务端可从中选取一个支持的协议版本
-
Sec-WebSocket-Protocol :可选, 客户端发起握手的时候可以在头部设置该字段, 该字段的值是一系列客户端希望在与服务端交互时使用的子协议 (subprotocol), 多个子协议之间用逗号分隔, 按客户端期望的顺序降序排列, 服务端可以根据客户端提供的子协议列表选择一个或多个子协议
-
Sec-WebSocket-Extensions:可选, 客户端在 WebSocket 握手阶段可以在头部设置该字段指示自己希望使用的 WebSocket 协议拓展
服务端若支持 WebSocket 协议, 并同意与客户端握手, 则应返回 101 的 HTTP 状态码, 表示同意协议升级, 同时应设置 Upgrade 字段并将值设置为 websocket, 并将 Connection 字段的值设置为 Upgrade, 这些都是与标准 HTTP Upgrade 机制完全相同的, 除了这些以外, 服务端还应设置与 WebSocket 相关的头部字段:
-
Sec-WebSocket-Accept:必传, 客户端发起握手时通过 | Sec-WebSocket-Key | 字段传递了一个将随机生成的 16 字节做 base64 编码后的字符串, 服务端若接收握手, 则应将该值与 WebSocket 魔数 (Magic Number) "258EAFA5-E914-47DA- 95CA-C5AB0DC85B11" 进行字符串连接, 将得到的字符串做 SHA-1 哈希, 将得到的哈希值再做 base64 编码, 最终的值便是该字段的值
-
Sec-WebSocket-Protocol:可选, 若客户端在握手时传递了希望使用的 WebSocket 子协议, 则服务端可在客户端传递的子协议列表中选择其中支持的一个, 服务端也可以不设置该字段表示不希望或不支持客户端传递的任何一个 WebSocket 子协议
-
Sec-WebSocket-Extensions:可选, 与 Sec-WebSocket-Protocol 字段类似, 若客户端传递了拓展列表, 服务端可从中选择其中一个作为该字段的值, 若服务端不支持或不希望使用这些扩展, 则不设置该字段
-
Sec-WebSocket-Version:必传, 服务端从客户端传递的支持的 WebSocket 协议版本中选择其中一个, 若客户端传递的所有 WebSocket 协议版本对服务端来说都不支持, 则服务端应立即终止握手, 并返回 HTTP 426 状态码, 同时在 Header 中设置 | Sec-WebSocket-Version | 字段向客户端指示自己所支持的 WebSocket 协议版本列表
二、数据传输阶段
通信的数据是基于帧(frame)的,可以传输文本数据,也可以直接传输二进制数据,效率高,当然,开发者相应的也需要考虑封包、拆包、编号等细节。
三、优缺点
优点:
-
节约带宽
不停地轮询服务端数据这种方式,使用的是http协议,head信息很大,有效数据占比低, 而使用WebSocket方式,头信息很小,有效数据占比高。 -
实时性
考虑到服务器压力,使用轮询方式不可能很短的时间间隔,否则服务器压力太多,所以轮询时间间隔都比较长,好几秒,设置十几秒。而WebSocket是由服务器主动推送过来,实时性是最高的。 -
压缩效果好
-
可以支持扩展
缺点:
-
不兼容低版本IE
总结
WebSocket是HTML5开始提供的一种独立在单个TCP连接上进行全双工通讯的有状态协议,并且还能支持二进制帧、扩展协议、部分自定义的自协议、压缩等特性。
目前看来,WebSocket可以完美替代AJAX轮询和Comet,但是某些场景还是不能替代SSE,WebSocket和SSE各有所长。
关注公众号,获取更多Java知识。