一、WebSocket 原理
WebSocket是HTML5提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议。它基于TCP传输协议,并复用HTTP的握手通道。浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接, 并进行双向数据传输。
WebSocket 的出现就解决了半双工通信的弊端。它最大的特点是:服务器可以向客户端主动推动消息,客户端也可以主动向服务器推送消息。
WebSocket 特点的如下:
● 支持双向通信,实时性更强
● 可以发送文本,也可以发送二进制数据
● 建立在TCP协议之上,服务端的实现比较容易
● 数据格式比较轻量,性能开销小,通信高效
● 没有同源限制,客户端可以与任意服务器通信
● 协议标识符是ws(如果加密,则为wss),服务器网址就是 URL
● 与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。
二、WebSocket 握手
WebSocket 服务端使用标准 TCP 套接字监听进入的连接。下文假定服务端监听 http://example.com 的 8000 端口,响应 http://example.com/chat 上的 GET 请求。
握手是 WebSocket 中 “Web”。它是从 HTTP 到 WebSocket 的桥梁。在握手过程中,协商连接的细节,并且如果行为不合法,那么任何一方都可以在完成前退出。服务端必须仔细理解客户端的所有要求,否则可能出现安全问题。
2.1 客户端握手请求
客户端通过联系服务端,请求 WebSocket 连接的方式,发起 WebSocket 握手流程。客户端发送带有如下请求头的标准 HTTP 请求(HTTP 版本必须是 1.1 或更高,并且请求方法必须是 GET):
GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
在这里,客户端可以请求扩展和/或子协议。此外,也可以使用常见的请求头,比如 User-Agent、Referer、Cookie 或者身份验证请求头。这些请求头与 WebSocket 没有直接关联。
如果存在不合法的请求头,那么服务端应该发送 400 响应(“Bad Request”),并且立即关闭套接字。通常情况下,服务端可以在 HTTP 响应体中提供握手失败的原因 。如果服务端不支持该版本的 WebSocket,那么它应该发送包含它支持的版本的 Sec-WebSocket-Version 头。在上面的示例中,它指示 WebSocket 协议的版本为 13。
在请求头中,最值得关注的是 Sec-WebSocket-Key。接下来,将讲述它。
2.2 服务端握手响应
当服务端收到握手请求时,将发送一个特殊响应,该响应表明协议将从 HTTP 变更为 WebSocket。
该响应头大致如下(记住,每个响应头行以 \r\n 结尾,在最后一行的后面添加额外的 \r\n,以说明响应头结束):
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
此外,服务端可以在这里对扩展/子协议请求做出选择。Sec-WebSocket-Accept响应头很重要,服务端必须通过客户端发送的Sec-WebSocket-Key请求头生成它。具体的方式是,将客户端的Sec-WebSocket-Key与字符串"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"(“魔法字符串”)连接在一起,然后对结果进行 SHA-1 哈希运算,最后返回哈希值的 Base64 编码。
因此,如果 Key 为"dGhlIHNhbXBsZSBub25jZQ==“,那么Sec-WebSocket-Accept响应头的值是"s3pPLMBiTxaQ9kYGzzhZRbK+xOo=”。服务端发送这些响应头后,握手完成,可以开始交换数据。
下面的 Python 代码根据Sec-WebSocket-Key请求头生成Sec-WebSocket-Accept响应头的值:
import typing
from hashlib import sha1
import base64
SEC_WS_MAGIC_STRING: bytes = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
def get_sec_ws_accept(sec_ws_key: typing.Union[bytes, str]) -> bytes:
if isinstance(sec_ws_key, str):
sec_ws_key = sec_ws_key.encode()
return base64.b64encode(sha1(sec_ws_key + SEC_WS_MAGIC_STRING).digest())
if __name__ == "__main__":
assert get_sec_ws_accept(b"dGhlIHNhbXBsZSBub25jZQ==") == b"s3pPLMBiTxaQ9kYGzzhZRbK+
三、数据帧(Data Framing)
3.1 概览
在 WebSocket 协议中,使用一系列帧传输数据。为避免混淆网络中间人(比如拦截代理),以及出于安全考虑,客户端必须对发送给服务端的所有帧进行掩码(Mask)处理。(注意,无论 WebSocket 协议是否运行在 TLS 上,都需要进行掩码处理。)服务端在收到未进行掩码处理的帧时,必须关闭连接。在这种情况下,服务端可以发送状态码为 1002(协议错误)的关闭帧。服务端不得对发送给客户端的任何帧进行掩码处理。如果客户端检测到掩码帧,那么必须关闭连接。在这种情况下,可以使用状态码 1002(协议错误)。
基础帧协议定义了一种帧类型,包括操作码(Opcode)、有效载荷长度,以及“扩展数据”和“应用数据”的指定位置,它们一起定义“有效载荷数据”。一些位和操作码被保留,以供未来扩展协议。
在握手完成后,端点被发送关闭帧前,客户端和服务端可以随时传输数据帧。
3.2 基础帧协议
帧的格式如下图所示:
FIN:1 比特
表示该帧是消息中的最后一个分片。第一个分片也可能是最后一个分片。
RSV1、RSV2、RSV3:每个 1 比特
除非协商了定义非零值含义的扩展,否则必须为 0。如果收到非零值,并且没有协商的扩展定义该非零值的含义,那么接收端点必须使该 WebSocket 连接失败。
操作码:4 比特
定义对“有效载荷数据”的解释。如果收到未知操作码,那么接收端点必须使该 WebSocket 连接失败。定义的值如下:
%x0 表示延续帧
%x1 表示文本帧
%x2 表示二进制帧
%x3-7 为将来的非控制帧预留
%x8 表示连接关闭
%x9 表示 PING
%xA 表示 PONG
%xB-F 为将来的控制帧保留
掩码:1 比特
定义“有效载荷数据”是否被掩码处理。如果设置为 1,那么掩码键出现在 Masking-key 中,它用于解除“有效载荷数据”的掩码。从客户端发送到服务器的所有帧都将此位设置为 1。
有效载荷长度:7 比特,7+16 比特,或 7+64 比特
“有效载荷数据”的长度,单位是字节:如果设置为 0-125,那么它是有效载荷长度。如果设置为 126,那么接下来的 2 个字节(被解释为 16 位无符号整数)是有效载荷长度。如果设置为 127,那么接下来的 8 个字节(被解释为 64 位无符号整数,最高有效位必须为 0)是有效载荷长度。多字节长度量使用网络字节序表示。注意,在所有情况下,必须使用最小字节数编码长度,比如,124 字节长的字符串的长度不能编码为序列 126, 0, 124。有效载荷的长度是“扩展数据”的长度 + “应用数据”的长度。“扩展数据”的长度可能为 0,在这种情况下,有效载荷长度是“应用数据”的长度。
掩码键:0 或 4 字节
从客户端发送到服务端的所有帧必须通过包含在帧里的 32 位数值进行掩码处理。如果掩码位为 1,那么该字段存在,如果掩码位为 0,那么该字段不存在。
有效载荷数据:(x+y) 字节
“有效载荷数据”被定义为将 “扩展数据” 与 “应用数据” 连接在一起。
扩展数据:x 字节
除非已经协商了扩展,否则“扩展数据”为 0 字节。所有扩展必须指定"扩展数据"的长度,或者如何计算该长度,并且在开始握手期间,必须协商扩展的使用方式。如果存在,那么“扩展数据”包含在总有效载荷长度中。
应用数据:y 字节
任意“应用数据”,占用帧中“扩展数据”后面的剩余部分。“应用数据”的长度等于有效载荷长度减去“扩展数据”的长度。
3.3 消息分片(Message Fragmentation)
FIN 和 Opcode 字段共同协作,发送被拆分成单独帧的消息。这被称为消息分片。分片仅适用于 Opcode 0x0 到 0x2 的情况。
Opcode 说明帧的用途。如果为 0x1,那么有效载荷是文本。如果为 0x2,那么有效载荷是二进制数据。如果为 0x0,那么该帧是延续帧;这意味着服务端应该将该帧的有效载荷连接到其从该客户端收到的最后一个帧。在下面的草图中,服务端对发送文本消息的客户端做出响应。第一条消息以单个帧发送,而第二条消息用三个帧发送。下图仅显示客户端的 FIN 和 Opcode 细节:
Client: FIN=1, opcode=0x1, msg="hello"
Server: (process complete message immediately) Hi.
Client: FIN=0, opcode=0x1, msg="and a"
Server: (listening, new message containing text started)
Client: FIN=0, opcode=0x0, msg="happy new"
Server: (listening, payload concatenated to previous message)
Client: FIN=1, opcode=0x0, msg="year!"
Server: (process complete message) Happy new year to you too!
注意,第一个帧包含整个消息(FIN=1,并且opcode!=0x0),因此服务端可以按需处理或响应。客户端发送的第二个帧的有效载荷是文本(opcode=0x1),但整个消息尚未到达(FIN=0)。该消息的所有剩余部分使用延续帧(opcode=0x0)发送,并且消息的最后一帧用FIN=1标记。
四、Websocket的使用
4.1 客户端开发示例
const ws = new WebSocket('ws://localhost:8080/ws/zhangsan');
ws.onmessage = messageEvent => {
console.log('onmessage: ', messageEvent.data)
}
ws.onopen = messageEvent => {
console.log('onopen: ', messageEvent)
}
ws.onclose = messageEvent => {
console.log('onclose: ', messageEvent)
}
ws.onerror = messageEvent => {
console.log('onerror: ', messageEvent)
}
// 发送消息
ws.send('hello world~')
4.2 服务端开发示例
4.2.1 J2EE的websocket
下面的示例是用javaee的接口来进行开发,而不是用springboot提供的实现
依赖引入
<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>
配置类
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter(){
return new ServerEndpointExporter();
}
}
服务端代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
@Slf4j
@Component
@ServerEndpoint("/ws/{userName}")
public class MyServerEndpoint {
public void sendMessage(Session session, String message) throws IOException {
log.info("准备向客户端程序{}发送消息:{}", session.getId(), message);
session.getBasicRemote().sendText(message);
}
@OnMessage
public void onMessage(@PathParam("userName") String userName,String message, Session session) {
log.info("收到来自客户端{}的消息! sessionId: {}, 消息:{}", userName, session.getId(), message);
try {
sendMessage(session, message);
} catch (IOException e) {
log.error("消息发送失败!", e);
}
}
@OnOpen
public void onOpen(@PathParam("userName") String userName, Session session) {
log.info("客户端程序{}建立连接成功! sessionId:{}", userName, session.getId());
}
@OnClose
public void onClose(@PathParam("userName") String userName, Session session, CloseReason closeReason) {
log.info("客户端{}断开连接,原因:{}", userName, closeReason);
}
@OnError
public void onError(Session session, Throwable throwable) {
log.info("连接{}发生错误!", session.getId());
throwable.printStackTrace();
}
}
4.2.2 SpringFramework提供的websocket
依赖引入
<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>
配置类
@Slf4j
@Configuration
@EnableWebSocket
public class SockConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(echoWebSocketHandler(), "/ws/{userName}")
.setAllowedOriginPatterns("*")
.setHandshakeHandler(new DefaultHandshakeHandler())
.addInterceptors(new HttpSessionHandshakeInterceptor() {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
// 认证等操作。。。
return true;
}
});
}
public WebSocketHandler echoWebSocketHandler() {
return new TextWebSocketHandler() {
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// 收到的信息
String requestMsg = message.getPayload();
System.out.println("服务器收到:" + requestMsg);
// 组织响应信息
String responseMsg = "服务器返回: " + requestMsg;
System.out.println(responseMsg);
TextMessage respMsg = new TextMessage(responseMsg.getBytes(StandardCharsets.UTF_8));
// 返回给客户端
session.sendMessage(respMsg);
}
};
}
}
推荐使用springBoot提供的包来进行开发,封装了很多功能,简化了开发