概览
- 什么是socket
- WebSocket 与 HTTP 有什么关系?
- 什么是 WebSocket 心跳?
- WebSocket 如何断开重连?
- WebSocket 是如何进行握手的?
- 说一下你了解的 WebSocket 鉴权授权方案?
一、什么是socket?
网络上的两个程序通过一个双向的通信连接实现数据的交换,这个连接的一端称为一个 socket (套接字),因此建立网络通信连接至少要一对端口号。socket 本质是对 TCP/IP 协议栈的封装,它提供了一个针对 TCP 或者 UDP 编程的接口,并不是另一种协议。通过 socket,你可以使用 TCP/IP 协议。
关于 Socket,可以总结以下几点:
- 它可以实现底层通信,几乎所有的应用层都是通过 socket 进行通信的
- 对 TCP/IP 协议进行封装,便于应用层协议调用,属于二者之间的中间抽象层
- TCP/IP 协议族中,传输层存在两种通用协议:TCP、UDP,两种协议不同,因为不同参数的 socket 实现过程也不一样
二、WebSocket 与 HTTP 有什么关系?
WebSocket 是一种与 HTTP 不同的协议。两者都位于 OSI 模型的应用层,并且都依赖于传输层的 TCP 协议。虽然它们不同,但是 RFC 6455 中规定: WebSocket 被设计为在 HTTP 80 和 443 端口上工作,并支持 HTTP 代理和中介,从而使其与 HTTP 协议兼容。为了实现兼容性,WebSocket 握手使用 HTTP Upgrade 头,从 HTTP 协议更改为 WebSocket 协议。
三、什么是 WebSocket 心跳?
在实际使用 WebSocket 中,长时间不通消息可能会出现一些连接不稳定的情况,这些未知情况导致的连接中断会影响客户端与服务端之前的通信,
为了防止这种的情况的出现,有一种心跳保活的方法:客户端就像心跳一样每隔固定的时间发送一次 ping ,来告诉服务器,我还活着,而服务器也会返回 pong ,来告诉客户端,服务器还活着。ping/pong 其实是一条与业务无关的假消息,也称为心跳包。
可以在连接成功之后,每隔一个固定时间发送心跳包,比如 60s:
setInterval(() => {
ws.send('这是一条心跳包消息');
}, 60000)
四、WebSocket 如何断开重连?
websocket
在一个完善的即时通讯应用中,webSocket 是及其关键的一环,它为web应用的客户端和服务端提供了一种全双工的通信机制
,但由于它本身以及其底层依赖的TCP链接的不稳定性,开发者不得不为其设计一套完整的保活、验活、重连方案,才能在实际应用中保证应用的即时性和高可用性。就重连而言,其速度严重影响了上层应用的"即时性"和用户体验,试想打开网络一分钟后,微信还不能收发消息的话,岂不是要崩溃?
因此为了保证链接的可持续性和稳定性,WebSocket心跳重连
就应运而生。
心跳重连
在使用原生WebSocket的时候,如果设备网络断开,不会触发WebSocket任何事件函数,前端程序无法得知当前连接已断开。这个时候如果调用 WebSocket.send
方法,浏览器会发现消息发不出去,便会立刻或者一定时间后(不同浏览器或者浏览器版本不同可能表现不同)触发 onclose 函数
后端 WebSocket 服务也可能出现异常,连接断开后前端也并没有接收到消息,因此需要前端定时发送心跳消息,后端接收类似的消息立即返回,告知连接正常。如果一定时间没有收到消息,就说明连接异常,前端需要重新连接
为了解决上面的问题,以前端作为主动方,定时发送给消息,用来检测网络和前后端连接问题。一旦发现异常,前端持续执行重新连接逻辑,直到连接成功。
- 服务端代码
var app = require('express')();
var WebSocket = require('ws');
var wss = new WebSocket.Server({ port: 9000 });
wss.on('connection', function connection(ws) {
console.log('已建立连接');
ws.on('message', function incoming(message) {
console.log('收到消息:', message.toString());
ws.send(`你好 ${new Date()}`);
});
});
app.get('/', function (req, res) {
res.sendFile(__dirname + '/index.html');
});
app.listen(3000);
- 客户端代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script>
let socket;
let url = "ws://localhost:9000";
class HeartCheck {
time = 60000;
checkTimer = null;
checkServer = null;
connecting = false;
startCheck = () => {
// onopen onmessage调用开始心跳检测状态
// time 秒后,发送心跳检测请求
this.checkTimer = setTimeout(() => {
socket.send("发送心跳检测");
this.checkServer = setTimeout(() => {
// 如果没有建立连接,导致没有reset,会close当前连接,然后重连
socket.onclose("心跳检测失败,先断开连接");
}, this.time);
}, this.time);
};
// 清空两个定时器
resetCheck = () => {
clearTimeout(this.checkTimer);
clearTimeout(this.checkServer);
return this; // return this实现链式调用
};
reconnect() {
if (this.connecting) return;
this.connecting = true;
console.log("断线重连中~~~~~~~");
setTimeout(() => {
this.connecting = false;
wsConnect(url);
}, this.time);
}
}
function wsConnect(url) {
const heartCheck = new HeartCheck();
socket = new WebSocket(url);
socket.onclose = () => {
console.log("连接断开");
heartCheck.reconnect();
};
socket.onerror = (err) => {
console.log("连接出错");
heartCheck.reconnect();
};
socket.onopen = () => {
console.log("建立连接");
heartCheck.resetCheck().startCheck();
};
socket.onmessage = (message) => {
console.log("收到信息", message.data);
heartCheck.resetCheck().startCheck();
// todo...
};
}
wsConnect(url);
</script>
</body>
</html>
什么条件下执行心跳?
- 连接建立
- 收到消息
以上两种情况下,开始计时,到达定时器设定时间后发送消息 socket.send('心跳检测')
第二个定时器开始执行,到达设定时间,判定连接已断开,调用 socket.close()
断开连接
五、WebSocket 是如何进行握手的?
在使用 WebSocket 实现全双工通信之前,客户端与服务器之间需要先进行握手(Handshake),在完成握手之后才能开始进行数据的双向通信
1、握手协议
WebSocket 协议属于应用层协议,它依赖于传输层的 TCP 协议。WebSocket 通过 HTTP/1.1 协议的 101 状态码进行握手。为了创建 WebSocket 连接,需要通过浏览器发出请求,之后服务器进行回应,这个过程通常称为“握手”(Handshaking)。
利用 HTTP 完成握手有几个好处。首先,让 WebSocket 与现有 HTTP 基础设施兼容:使得 WebSocket 服务器可以运行在 80 和 443 端口上,这通常是对客户端唯一开放的端口。其次,让我们可以重用并扩展 HTTP 的 Upgrade 流,为其添加自定义的 WebSocket 首部,以完成协商。
2、客户端请求
GET ws://echo.websocket.org/ HTTP/1.1
Host: echo.websocket.org
Origin: file://
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: Zx8rNEkBE4xnwifpuh8DHQ==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
字段 | 说明 |
---|---|
Connection | Connection 必须设置为 Upgrade 表示客户端希望连接升级 |
Upgrade | Upgrade 字段必须设置为 websocket,表示希望升级到 WebSocket 协议 |
Sec-WebSocket-Version | Sec-WebSocket-Version 表示支持的 WebSocket 版本。RFC6455 要求使用的版本是 13,之前草案的版本均应当弃用 |
Sec-WebSocket-Key | Sec-WebSocket-Key 是随机字符串,服务器端会用这些数据来构造出一个 SHA-1 的信息摘要。补充:把Sec-WebSocket-Key 加上一个特殊字符串"258EAFA5-E914-47DA 95CA-C5AB0DC85B11",然后计算 SHA-1 摘要,之后进行 Base64 编码,将结果做为"Sec-WebSocket-Accept"头的值,返回给客户端。如此操作,可以尽量避免普通 HTTP 请求被误认为 WebSocket 协议。 |
Sec-WebSocket-Extensions | 用于协商本次连接要使用的 WebSocket 扩展;客户端发送支持的扩展,服务器通过返回相同的首部确认自己支持一个或多个扩展 |
Origin | Origin 字段是可选的,通常用来表示在浏览器中发起此 WebSocket 连接所在的页面,类似于 Referer。但是,与 Referer 不同的是,Origin 跑含了协议和主机名称。 |
3、服务端响应
HTTP/1.1 101 Web Socket Protocol Handshake ①
Connection: Upgrade ②
Upgrade: websocket ③
Sec-WebSocket-Accept: 52Rg3vW4JQ1yWpkvFlsTsiezlqw= ④
数字 | 说明 |
---|---|
① | 101 响应码确认升级到 WebSocket 协议 |
② | 设置 Connection 头的值为"Upgrade"来表示这是一个升级请求。HTTP 协议提供了一种特殊的机制,这一机制允许将一个已建立的连接升级成新的、不相容的协议 |
③ | Upgrade 头指定一项或多项协议名,按优先级顺序,以逗号分隔。这里表示升级为 WebSocket 协议 |
④ | 签名的键值验证协议支持 |
4、通过 Node 来简单实现握手功能
要开发一个 WebSocket 服务器,首先我们需要先实现握手功能,这里使用 Nodejs 内置的 http 模块来创建一个 HTTP 服务器。
我们首先引入了 http 模块,然后通过调用该模块的createServer()方法
创建一个 HTTP 服务器,接着我们监听 upgrade 事件,每次服务器响应升级请求时就会触发该事件。由于我们的服务器只支持升级到 WebSocket 协议,所以如果客户端请求升级的协议非 WebSocket 协议,我们将会返回"400 Bad Request"。
当服务器接收到升级为 WebSocket 的握手请求时,会先从请求头中获取"Sec-WebSocket-Key" 的值,然后把该值加上一个特殊字符串"258EAFA5-E914-47DA-95CA-C5AB0DC85B11",然后计算 SHA-1 摘要,之后进行 Base64 编码,将结果做为"Sec-WebSocket-Accept"头的值,返回给客户端。
Sec-WebSocket-Accept 头的值的计算通过 Node.js 内置的 crypto 模块,可以轻松搞定
const http = require("http");
const crypto = require("crypto");
const MAGIC_KEY = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const port = 8080;
/**
* 根据请求头的key生成accpet
* 采用sha1算法,加盐
* @param {*} secWsKey
* @returns
*/
function generateAcceptValue(secWsKey) {
return crypto
.createHash("sha1")
.update(secWsKey + MAGIC_KEY, "utf-8")
.digest("base64");
}
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain;charset=utf-8" });
res.end("创建服务");
});
server.on("upgrade", (req, socket) => {
if (req.headers["upgrade"] !== "websocket") {
socket.end("HTTP/1.1 400 Bad Request");
return;
}
// 读取客户端提供的 Sec-WebSocket-Key
const secWsKey = req.headers["sec-websocket-key"];
// 使用SHA-1 算法生成Sec-WebSocket-Accept
const hash = generateAcceptValue(secWsKey);
// 设置HTTP响应头
const responseHeaders = [
"HTTP/1.1 101 Web Socket Protocol Handshake",
"Upgrade: WebSocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${hash}`,
];
// 返回握手请求头信息
socket.write(responseHeaders.join("\r\n") + "\r\n\r\n");
});
server.listen(port, () => {
console.log(`server is running at http://localhost:${port}`);
});
六、说一下你了解的 WebSocket 鉴权授权方案?
通过对协议实现的解读可以得知:在 HTTP 切换到 Socket 之前,没有什么好的机会进行鉴权,因为在这个时间节点,报文(或者说请求的 Headers)必须遵守协议规范。但这不妨碍我们在协议切换完成后,进行鉴权授权。
鉴权
- 在连接建立时,检查连接的 HTTP 请求头信息(比如 cookies 中关于用户的身份信息)
- 在每次接收到信息时,检查连接是否已经授权过以及授权是否过期
- 以上两点,只要答案为否,则服务端主动关闭 socket 连接
授权
服务端在连接建立时,颁发一个 ticket 给 peer 端,这个 ticket 可以包含但不限于:
- peer 端的 uniqueId(可以是 ip,userid,deviceid 等等,任一种具备唯一性的键)
- 过期时间的 timestamp
- token:由以上信息生成的哈希值,最好能加盐
安全性补充说明
比如这一套机制如何防范重放攻击?个人以为可以从以下几点出发:
- 可以用这里提到的 expires,保证过期,如果你愿意,甚至可以每次发下消息时都发送一个新的 Ticket,只要上传消息对不上这个 Ticket,就断开,这样非 Original Peer 是无法重放的
- 可以结合 redis,实现 ratelimit,防止高频刷接口,可以参考 express-rate-limit
- 为防止中间人,最好使用该 wss(TLS)
代码实现
WebSocket 连接处理,基于 NodeJS 的 ws 实现:
import url from "url";
import WebSocket from "ws";
import debug from "debug";
import moment from "moment";
import { Ticket } from "../models";
const debugInfo = debug("server:global");
// server 可以是http server 实例
const wss = new WebSocket.Server({ server });
wss.on("connection", async (ws) => {
const location = url.parse(ws.upgradeReq.url, true);
const cookie = ws.upgradeReq.cookie;
debugInfo("ws request from:" + location, "cookies:", cookie);
// issue & send ticket to the peer
if (!checkIdentify(ws)) {
terminate(ws);
} else {
const ticket = issueTicket(ws);
await ticket.save();
ws.send(ticket.pojo());
ws.on("message", (message) => {
if (!checkTicket(ws, message)) {
terminate(ws);
}
debugInfo("received:%s", message);
});
}
});
function issueTicket(ws) {
const uniqueId = ws.upgradeReq.connection.remoteAddress;
return new Ticket(uniqueId);
}
async function checkTicket(ws, message) {
const uniqueId = ws.upgrade.connection.remoteAddress;
const record = await Ticket.get(uniqueId);
const token = message.token;
return (
record &&
record.expires &&
record.token &&
record.token === token &&
moment(record.expires) >= moment()
);
}
// 身份检查,可填入具体检查逻辑
function checkIdentity(ws) {
return true;
}
function terminate(ws) {
ws.send("BYE!");
ws.close();
}
授权用到的 Ticket(这里存储用到的是 knex+postgreSQL)
import shortid from 'shortid';
import {utils} from '../components';
import {db} from './database';
export default class Ticket{
constructor(uniqueId,expiresMinutes = 30){
const now = new Date();
this.unique_id = uniqueId;
this.token = Ticket.generateToken(uniqueId,now);
this.crated = now;
this.expires = moment(now).add(expiresMinutes,'minute');
}
pojo(){
return {
...this;
}
}
async save(){
return await db.from('tickets').insert(this.pojo()).returning('id');
}
static async get(uniqueId){
const result = await db
.from('tickets')
.select('id','unique_id','token','expires','created')
.where('unique_id',unique_id);
const tickets = JSON.parse(JSON.stringify(result[0]));
return tickets;
}
static generateToken(uniqueId,now){
const part1 = uniqueId;
const part2 = now.getTime().toString();
const part3 = shortid.generate();
return utils.sha1(`${part1}:${part2}:${part3}`);
}
}
utils 的哈希方法:
import crypto from "crypto";
export default {
sha1(str) {
const shaAlog = crypto.createHash("sha1");
shaAlog.update(str);
return shaAlog.digest("hex");
},
};