都2022年了,作为一个前端还不会websocket?

1,677 阅读4分钟

概览

  • 什么是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 协议。

image.png

三、什么是 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 首部,以完成协商。

image.png

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
字段说明
ConnectionConnection 必须设置为 Upgrade 表示客户端希望连接升级
UpgradeUpgrade 字段必须设置为 websocket,表示希望升级到 WebSocket 协议
Sec-WebSocket-VersionSec-WebSocket-Version 表示支持的 WebSocket 版本。RFC6455 要求使用的版本是 13,之前草案的版本均应当弃用
Sec-WebSocket-KeySec-WebSocket-Key 是随机字符串,服务器端会用这些数据来构造出一个 SHA-1 的信息摘要。补充:把Sec-WebSocket-Key加上一个特殊字符串"258EAFA5-E914-47DA 95CA-C5AB0DC85B11",然后计算 SHA-1 摘要,之后进行 Base64 编码,将结果做为"Sec-WebSocket-Accept"头的值,返回给客户端。如此操作,可以尽量避免普通 HTTP 请求被误认为 WebSocket 协议。
Sec-WebSocket-Extensions用于协商本次连接要使用的 WebSocket 扩展;客户端发送支持的扩展,服务器通过返回相同的首部确认自己支持一个或多个扩展
OriginOrigin 字段是可选的,通常用来表示在浏览器中发起此 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");
  },
};