使用社交账号登录
在 WebSocket 出现之前,为了实现“实时”效果,开发者们通常采用以下几种技术:
无论采用哪种轮询方式,核心痛点在于 HTTP 协议的被动性。请求必须由客户端发起,服务端无法主动推送。这种同步的“请求-响应”机制导致客户端为了获取最新状态,不得不进行频繁的无效询问或长时间的等待,这在实时性要求高的场景下显得极不合理。 HTTP 协议本质上是半双工、无状态、请求-响应模式的。服务器无法主动向客户端推送数据。
WebSocket 是一种在单个 TCP 连接上进行全双工 (Full Duplex) 通信的协议。
WebSocket 协议 (RFC 6455) 建立在 TCP 之上,它复用了 HTTP 的握手通道,但随后升级为独立的 WebSocket 协议。
连接的建立始于一个标准的 HTTP GET 请求,但带有一些特殊的 Header:
客户端请求:
Connection: Upgrade 和 Upgrade: websocket:告诉服务器我想升级协议。Sec-WebSocket-Key:一个随机的 Base64 字符串,用于验证服务器是否支持 WebSocket。服务端响应:
101 Switching Protocols:表示服务器同意升级。Sec-WebSocket-Accept:服务器通过 Sec-WebSocket-Key 拼接固定的 GUID (258EAFA5-E914-47DA-95CA-C5AB0DC85B11) 后进行 SHA-1 摘要并 Base64 编码得出。客户端校验此值以确认连接成功。握手完成后,双方通过数据帧传输数据。WebSocket 的帧结构设计得非常紧凑:
1,表示这是消息的最后一个分片(Fragment)。0,表示还有后续分片。这允许发送端在不知道完整消息长度的情况下就开始传输(流式传输)。0,除非协商了扩展(Extension)定义了它们的含义。permessage-deflate 扩展进行压缩时,RSV1 可能会被置为 1。0x0: 延续帧 (Continuation Frame)。用于分片传输,表示该帧内容应追加到上一帧之后。0x1: 文本帧 (Text Frame)。UTF-8 编码的文本。0x2: 二进制帧 (Binary Frame)。任意二进制数据。0x3 - 0x7: 保留用于未来的非控制帧。0x8: 连接关闭 (Connection Close)。0x9: Ping。心跳请求。0xA: Pong。心跳响应。0xB - 0xF: 保留用于未来的控制帧。1;服务端发送给客户端的帧必须设置为 0。0-125:实际数据长度。126:实际长度由随后的 16 位(Extended payload length)表示。127:实际长度由随后的 64 位(Extended payload length)表示。1 时存在。我们将使用原生 WebSocket API 并结合 TypeScript 封装一个具有自动重连、心跳检测功能的客户端类。
我们将分别展示 Go 和 Node.js (TypeScript) 的服务端实现,两者都将实现基本的消息回显(Echo)和广播功能。
Go 语言处理 WebSocket 非常高效。我们将使用官方推荐的第三方库 github.com/gorilla/websocket。
Node.js 中最常用的库是 ws。
网络连接可能因为各种原因(NAT 超时、网络波动)中断,但双方并未感知到(TCP 半开连接)。
Ping 消息,服务端回复 Pong。Pong,或服务端在一定时间内未收到任何消息,则主动断开连接并触发重连。在客户端实现指数退避 (Exponential Backoff) 算法,避免网络恢复瞬间大量客户端同时重连冲击服务器。
在移动端或网络不稳定的环境下,仅仅依靠重连和心跳是不够的。我们需要更主动的策略来提升用户体验:
消息队列与 ACK 机制
msg_id)。发送后不立即删除,只有收到服务端回复的 ACK 确认包后才从队列移除。重连成功后,自动重发队列中未确认的消息。网络状态监听
navigator.onLine 属性和 window.addEventListener('online') / offline 事件。offline,立即暂停心跳和消息发送,将 UI 切换为“连接中...”状态;监听到 online,立即触发一次重连尝试。数据压缩
permessage-deflate。智能降级
WebSocket 的鉴权是一个常见的痛点,因为浏览器原生的 WebSocket API 不支持设置自定义 HTTP Header(例如 Authorization: Bearer <token>)。
这是目前最通用、兼容性最好的方案。
new WebSocket('wss://api.example.com/ws?token=eyJhbGciOi...')Upgrade 握手阶段,解析 URL Query 参数,验证 Token。优点:
缺点:
连接后第一时间认证
Cookie 鉴权
Ticket 机制 (推荐用于高安全场景)
ws://...?ticket=xxx。在本地开发完成后,将 WebSocket 服务部署到生产环境时,有两个关键点必须注意:反向代理配置和SSL/TLS 加密。
大多数 Web 服务都会使用 Nginx 作为网关。默认情况下,Nginx 不会转发 Upgrade 和 Connection 头部,导致 WebSocket 握手失败(通常报 400 或 426 错误)。
你需要显式添加以下配置:
在生产环境中,强烈建议(甚至强制)使用 wss:// 协议,而不是 ws://。
ws:// 连接。而 wss:// 基于 TLS 加密,在中间设备看来就是普通的 HTTPS 流量,能有效规避拦截,提高连接成功率。在真实的企业级开发中,我们通常不会从零手写 WebSocket 协议解析,而是站在巨人的肩膀上。以下是各主流语言经过大规模生产环境验证的“杀手级”库:
asyncio 构建,现代、优雅、性能好。WebSocket 是构建实时应用的首选技术。通过本文,我们了解了:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
// ws-client.ts
type MessageHandler = (data: any) => void;
interface WSOptions {
url: string;
reconnectInterval?: number; // 重连间隔
heartbeatInterval?: number; // 心跳间隔
}
export class WSClient {
private ws: WebSocket | null = null;
private options: WSOptions;
private shouldReconnect = true;
private messageHandlers: Set<MessageHandler> = new Set();
private heartbeatTimer: number | null = null;
constructor(options: WSOptions) {
this.options = {
reconnectInterval: 3000,
heartbeatInterval: 30000,
...options,
};
}
// 初始化连接
public connect() {
try {
this.ws = new WebSocket(this.options.url);
this.bindEvents();
} catch (e) {
console.error('WebSocket connection failed:', e);
this.reconnect();
}
}
private bindEvents() {
if (!this.ws) return;
this.ws.onopen = () => {
console.log('WebSocket Connected');
this.startHeartbeat();
};
this.ws.onmessage = (event) => {
try {
// 假设服务端也返回 JSON 格式
const data = JSON.parse(event.data);
// 处理心跳响应(Pong)
if (data.type === 'pong') {
console.log('Received Pong');
return;
}
this.messageHandlers.forEach(handler => handler(data));
} catch (e) {
console.warn('Non-JSON message received:', event.data);
}
};
this.ws.onclose = (event) => {
console.log('WebSocket Closed:', event.code, event.reason);
this.stopHeartbeat();
this.reconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket Error:', error);
// onerror 通常紧接着 onclose,重连逻辑在 onclose 处理
};
}
// 发送消息
public send(data: any) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
} else {
console.error('WebSocket is not connected');
}
}
// 注册消息监听
public onMessage(handler: MessageHandler) {
this.messageHandlers.add(handler);
return () => this.messageHandlers.delete(handler); // 返回取消订阅函数
}
// 自动重连机制
private reconnect() {
if (!this.shouldReconnect) return;
console.log(`Attempting to reconnect in ${this.options.reconnectInterval}ms...`);
setTimeout(() => {
this.connect();
}, this.options.reconnectInterval);
}
// 心跳检测
private startHeartbeat() {
this.stopHeartbeat();
this.heartbeatTimer = window.setInterval(() => {
this.send({ type: 'ping' });
}, this.options.heartbeatInterval);
}
private stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
// 手动关闭
public close() {
this.shouldReconnect = false;
this.stopHeartbeat();
this.ws?.close();
}
}
// main.go
package main
import (
"log"
"net/http"
"sync"
"github.com/gorilla/websocket"
)
// 升级器配置
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true; // 允许所有跨域请求,生产环境需严格配置
},
}
// 简单的客户端管理器
type ClientManager struct {
clients map[*websocket.Conn]bool
broadcast chan []byte
register chan *websocket.Conn
unregister chan *websocket.Conn
mutex sync.Mutex
}
func (manager *ClientManager) start() {
for {
select {
case conn := <-manager.register:
manager.mutex.Lock()
manager.clients[conn] = true
manager.mutex.Unlock()
log.Println("New client connected")
case conn := <-manager.unregister:
manager.mutex.Lock()
if _, ok := manager.clients[conn]; ok {
delete(manager.clients, conn)
conn.Close()
log.Println("Client disconnected")
}
manager.mutex.Unlock()
case message := <-manager.broadcast:
manager.mutex.Lock()
for conn := range manager.clients {
err := conn.WriteMessage(websocket.TextMessage, message)
if err != nil {
log.Printf("Write error: %v", err)
conn.Close()
delete(manager.clients, conn)
}
}
manager.mutex.Unlock()
}
}
}
var manager = ClientManager{
broadcast: make(chan []byte),
register: make(chan *websocket.Conn),
unregister: make(chan *websocket.Conn),
clients: make(map[*websocket.Conn]bool),
}
func handleConnections(w http.ResponseWriter, r *http.Request) {
// 1. 升级 HTTP 为 WebSocket
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Fatal(err)
}
// 2. 注册客户端
manager.register <- ws
// 3. 循环读取消息
defer func() {
manager.unregister <- ws
}()
for {
_, msg, err := ws.ReadMessage()
if err != nil {
break
}
log.Printf("Received: %s", msg)
// 简单处理:将收到的消息广播给所有人
manager.broadcast <- msg
}
}
func main() {
go manager.start()
http.HandleFunc("/ws", handleConnections)
log.Println("Server started on :8080")
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}
// server.ts
import { WebSocketServer, WebSocket } from 'ws';
import http from 'http';
const server = http.createServer();
const wss = new WebSocketServer({ server });
wss.on('connection', (ws: WebSocket) => {
console.log('New client connected');
ws.on('message', (message: Buffer) => {
const msgStr = message.toString();
console.log('Received:', msgStr);
// 处理 Ping/Pong(如果客户端发 JSON 格式的心跳)
try {
const data = JSON.parse(msgStr);
if (data.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
return;
}
} catch (e) {
// 非 JSON 消息,继续处理
}
// 广播消息给所有客户端
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(msgStr); // 原样转发
}
});
});
ws.on('close', () => {
console.log('Client disconnected');
});
ws.on('error', (err) => {
console.error('Socket error:', err);
});
});
server.listen(8081, () => {
console.log('Node.js WebSocket Server started on :8081');
});
// 基于 ws 库的鉴权
import { WebSocketServer } from 'ws';
import http from 'http';
import { URL } from 'url';
const server = http.createServer();
const wss = new WebSocketServer({ noServer: true }); // 开启 noServer 模式,手动处理升级
// 模拟 Token 验证函数
function verifyToken(token: string): boolean {
return token === 'valid-secret-token';
}
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url || '', `http://${request.headers.host}`);
const token = url.searchParams.get('token');
if (!token || !verifyToken(token)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
// 鉴权通过,完成升级
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});
wss.on('connection', (ws) => {
console.log('Authenticated client connected!');
ws.send('Welcome, authorized user!');
});
server.listen(8082);
# nginx.conf
location /ws {
proxy_pass http://backend_server_pool;
proxy_http_version 1.1; # WebSocket 必须使用 HTTP/1.1
# 核心配置:转发 Upgrade 和 Connection 头
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 其他常用配置
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 600s; # 延长超时时间,避免 Nginx 主动断开长连接
}