# WebSocket

# Websocket 是什么?

WebSocket 是一种在客户端服务器之间保持TCP长连接的网络协议,这样它们就可以随时进行信息交换。

虽然任何客户端或服务器上的应用都可以使用 WebSocket,但原则上还是指浏览器服务器之间使用。通过 WebSocket,服务器可以直接向客户端发送数据,而无须客户端周期性的请求服务器,以动态更新数据内容。

# 客户端 API

WebSocket 对象提供了用于创建和管理 WebSocket 连接,以及可以通过该连接发送和接收数据的 API。

使用WebSocket()构造函数来构造一个WebSocket

# 构造函数

WebSocket(urlp[, protocols]) 返回一个 WebSocket 实例对象

# 常量

Constant Value
CONNECTING 0
OPEN 1
CLOSING 2
CLOSED 3

# 属性

实例属性
binaryType bufferedAmount
extensions protocol
readyState url

# 方法

实例方法
close([code[, reason]])
send(data)

# 事件

实例事件
close
error
message
open

# 服务端 API

服务端使用 Node.js 举例

# 构造函数

WebSocket.Server(options, [callback])

const WebSocket = require("ws");
const wss = new WebSocket.Server({ server });

配置参数

options
host String 服务绑定的主机名
port Number 服务绑定的端口
backlog Number 等待连接队列的最大长度
server 一个预先创建好的 HTTP/S 服务
verifyClient Function 用于对客户端进行验证的钩子函数
handleProtocols FunctionWebSocket 子协议的监听函数
path String 允许连接匹配的路径
noServer Boolean 允许无服务器模式
clientTracking Booleans 是否跟踪客户端
maxPayload Number 设置每个数据包的大小

# 内置事件

内置事件
close 当服务关闭时
connection 当握手完成时
error 当服务出错时
headers 在响应头作为握手的一部分写入套接字之前
listening 当绑定基础服务完成时

# 内置方法

server.close([callback]) 关闭 WebSocket 服务

# 内置属性

server.clients 存储所有连接的客户端的集合

# 实例

实例和 Web 端基本一致

# 连接状态

Constant Value
CONNECTING 0
OPEN 1
CLOSING 2
CLOSED 3

# 实例事件

实例事件
close error
message open
ping pong

# 实例属性

实例属性
binaryType
bufferedAmount
readyState

# 实例方法

实例方法
close([code[,reason]]) 关闭连接
send(data[, options,callback]) 发送消息
terminate() 强制关闭

# 和 Node.js 通信

这里本着学习的目的,我们将使用 node 的网络模块net来实现 WebScoket 的

  • 握手 协议升级
  • 数据帧的解析和封装
  • 房间管理

# 数据包解析和封装

// utils.js
const crypto = require("crypto");
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
// 数据头解析和握手
function handshake(sock, data) {
  let str = data.toString();
  // 1.每行数据切开": "
  let lines = str.split("\r\n");
  // 2.舍去第一行和最后两行
  lines = lines.slice(1, lines.length - 2);
  let headers = {};
  lines.forEach((line) => {
    let [key, val] = line.split(": ");
    headers[key.toLowerCase()] = val;
  });
  if (headers["upgrade"] != "websocket") {
    console.log("其他协议", headers["upgrade"]);
    sock.end();
  } else if (headers["sec-websocket-version"] != "13") {
    console.log("版本不对", headers["sec-websocket-version"]);
    sock.end();
  } else {
    let key1 = headers["sec-websocket-key"];
    let key2 = crypto
      .createHash("sha1")
      .update(key1 + GUID)
      .digest("base64");
    sock.write("HTTP/1.1 101 Switching Protocol\r\n");
    sock.write("Connection: Upgrade\r\n");
    sock.write("Upgrade: websocket\r\n");
    sock.write("Sec-WebSocket-Accept: " + key2 + "\r\n");
    sock.write("\r\n");
  }
  console.log("handshake");
}

// 数据帧解析函数
function decodeDataFrame(data) {
  let start = 0;
  let frame = {
    // 解析前两个字节的基本数据
    FIN: data[start] >> 7,
    Opcode: data[start++] & 0x0f,
    Mask: data[start] >> 7,
    PayloadLen: data[start++] & 0x7f,
    MaskingKey: "",
    PayloadData: null
  };
  // 处理特殊长度126和127
  if (frame.PayloadLen === 126) {
    frame.PayloadLen = (data[start++] << 8) + data[start++]; //16位Extended payload length
  }
  if (frame.PayloadLen === 127) {
    frame.PayloadLen = 0;
    for (let i = 7; i >= 0; i--) {
      frame.PayloadLen += data[start++] << (i * 8); //64位Extended payload length
    }
  }
  // 判断是否使用掩码
  if (frame.Mask) {
    // 获取掩码实体
    frame.MaskingKey = [data[start++], data[start++], data[start++], data[start++]];
    // 对数据和掩码做异或运算
    frame.PayloadData = data.slice(start, start + frame.PayloadLen).map((byte, index) => {
      return byte ^ frame.MaskingKey[index % 4];
    });
  } else {
    // 否则的话 直接使用数据
    frame.PayloadData = data.slice(start, start + frame.PayloadLen);
  }
  // 数组转换成缓冲区来使用
  let buf = Buffer.from(frame.PayloadData);
  // 如果有必要则把缓冲区转换成字符串来使用
  if (frame.Opcode === 1) {
    buf = buf.toString();
  }
  // 设置上数据部分
  frame.PayloadData = buf;
  // 返回数据帧
  return frame;
}

// 数据帧编码函数
function encodeDataFrame(data) {
  let arrs = [];
  let PayloadData = data.PayloadData ? Buffer.from(data.PayloadData) : null;
  let PayloadLen = PayloadData ? PayloadData.length : null;
  // 处理第一个字节
  arrs.push((data.FIN << 7) + data.Opcode);
  // 处理第二个字节,判断它的长度并放入相应的位置
  if (PayloadLen < 126) {
    arrs.push(PayloadLen);
  } else if (PayloadLen < 65536) {
    // push 126 和 length的高八位和低八位
    arrs.push(126, PayloadLen >> 8, PayloadLen & 0xff);
  } else {
    arrs.push(127);
    for (let i = 7; i >= 0; i--) {
      arrs.push(PayloadLen & ((0xff << (i * 8)) >> (i & 8)));
    }
  }

  // 返回头部分和数据部分的合并缓冲区
  let frame = PayloadData ? Buffer.concat([Buffer.from(arrs), PayloadData]) : Buffer.from(arrs);
  return frame;
}

function distributionID(sockList, sock) {
  let ran = String(Math.random() * 100);
  let flag = sockList.every((item) => {
    return item.id != ran;
  });
  // 生成的id为唯一值
  if (flag) {
    // 分配id,将当前连接的sock添加进sockList
    sock.id = ran;
    sockList.push(sock);
    console.log("sockList.length", sockList.length);
    return sockList;
  } else {
    // 否则重新生成id
    return distributionID(sock);
  }
}
function createRoom(Rooms, sock, currentRoom) {
  let flag = Object.keys(Rooms).every((room) => {
    return room != currentRoom;
  });
  if (flag) {
    console.log(sock.id, "创建了房间", currentRoom);
    Rooms[currentRoom] = [];
    Rooms[currentRoom].push(sock);
  } else {
    console.log(sock.id, "加入了房间", currentRoom);
    Rooms[currentRoom].push(sock);
  }
  console.log("当前房间列表", [...Object.keys(Rooms)]);
}

function leaveRoom(Rooms, sock) {
  Object.keys(Rooms).forEach((roomName) => {
    let roomIndex = -1;
    Rooms[roomName].forEach((client, index) => {
      if (sock.id == client.id) {
        roomIndex = index;
        return;
      }
    });
    // 找到所在房间和索引
    if (roomIndex >= 0) {
      console.log(sock.id, "离开了房间", roomName);
      Rooms[roomName].splice(roomIndex, 1);
    }
    // 如果房间内没人,销毁此房间
    if (Rooms[roomName].length == 0 && roomName != "default") {
      console.log("销毁房间", roomName);
      delete Rooms[roomName];
    }
    console.log("当前房间列表", [...Object.keys(Rooms)]);
  });
}

function getIPAdress() {
  const interfaces = require("os").networkInterfaces();
  for (var devName in interfaces) {
    var iface = interfaces[devName];
    for (var i = 0; i < iface.length; i++) {
      var alias = iface[i];
      if (alias.family === "IPv4" && alias.address !== "127.0.0.1" && !alias.internal) {
        return alias.address;
      }
    }
  }
}

module.exports = {
  handshake,
  decodeDataFrame,
  encodeDataFrame,
  distributionID,
  createRoom,
  leaveRoom,
  getIPAdress
};

# 后端业务逻辑

// app.js
const port = 2020;
const net = require("net"); //TCP  原生Socket套接字
const utils = require("./utils"); // 引入握手解析工具方法

// 定义sock总列表
let sockList = [];
// 定义房间对象
let Rooms = { default: [] };
// 创建wsServer服务
let wsServer = net.createServer((sock) => {
  // 定义$emit方法(单数据帧发送)
  sock.$emit = function (name, ...args) {
    let sendFrame = {
      FIN: 1,
      Opcode: 1,
      PayloadData: JSON.stringify({ name, data: [...args] })
    };
    this.write(utils.encodeDataFrame(sendFrame));
  };
  // 第一次数据是握手,此过程每个sock只有一次
  sock.once("data", (data) => {
    utils.handshake(sock, data);
    // 分配sock.id
    sockList = utils.distributionID(sockList, sock);
    // 真正的数据在在这里处理
    sock.on("data", (data) => {
      let reciveFrame = utils.decodeDataFrame(data);
      // 客户端断开连接
      if (reciveFrame.Opcode == 0x8) {
        // 服务端也断开连接
        sock.end();
      } else {
        let message = JSON.parse(reciveFrame.PayloadData);
        // 消息分类处理
        if (message.name == "createRoom") {
          let currentRoom = message.data;
          utils.createRoom(Rooms, sock, currentRoom);
          sock.$emit("success", currentRoom);
        }
        if (message.name == "leaveRoom") {
          utils.leaveRoom(Rooms, sock);
          sock.$emit("success", "");
        }
        if (message.name == "chat") {
          chat(message.data);
        }
        if (message.name == "broadcast") {
          broadcast(message.data);
        }
      }
    });
  });
  sock.on("end", () => {
    console.log("客户端断开", sock.id);
    let n = sockList.indexOf(sock);
    // 在sockList列表中则删除此连接
    if (n != -1) {
      sockList.splice(n, 1);
    }
    console.log("sockList.length", sockList.length);
    utils.leaveRoom(Rooms, sock);
  });
  sock.on("error", () => {
    console.log("客户端错误");
  });
});
wsServer.listen(port);

// 房间内发送
function chat(data) {
  console.log("chat", data);
  let message = data[0];
  let roomName = data[1];
  Rooms[roomName].forEach((s) => {
    s.$emit("chat", message);
  });
}
// 全平台广播
function broadcast(data) {
  let message = data[0];
  console.log("boardcast", data);
  sockList.forEach((s) => {
    s.$emit("broadcast", message);
  });
}

console.log("ws://" + utils.getIPAdress() + ":" + port);

# 前端业务逻辑

vue create chat-client
// main.js
import Vue from "vue";
import App from "./App.vue";

// 引入全局样式
import "./styles/common.css";

// 定义全局变量
// 开发环境
if (process.env.NODE_ENV === "development") {
  Vue.prototype.$wsServer = "ws://localhost:2020";
} else {
  // 部署环境
  Vue.prototype.$wsServer = "ws://media.gausszhou.top/ws";
}

// 全局配置
Vue.config.productionTip = false;

// new Vue 配置路由,状态,渲染组件,挂载组件
new Vue({
  render: (h) => h(App)
}).$mount("#app");
<!-- App.vue -->
<template>
  <div id="app">
    <div class="demo">
      <h1>Learn WebSocket</h1>
      <div class="item-box display-flex">
        <chat-vue class="item"></chat-vue>
        <chat-vue class="item"></chat-vue>
      </div>
      <div class="item-box display-flex">
        <chat-vue class="item"></chat-vue>
        <chat-vue class="item"></chat-vue>
      </div>
    </div>
  </div>
</template>

<script>
import chatVue from "./components/WebSocketChat";
export default {
  components: {
    chatVue
  }
};
</script>
// components/WebSocketChat.vue
<template>
  <div>
    <div>
      <div :class="{ green: isLinked }">{{ tips }}</div>
      <li>
        请求地址
        <input v-model="$wsServer" size="mini" />
        <button type="primary" size="mini" @click="initSocket()">发起连接</button>
        <button type="danger" size="mini" @click="closeSocket()">断开连接</button>
      </li>

      <li>
        创建房间
        <input v-model="RoomId" size="mini" />
        <button type="primary" size="mini" @click="createRoom()">创建或加入房间</button>
      </li>
      <li>
        当前房间
        <input v-model="currentRoomId" disabled size="mini" />
        <button type="warning" size="mini" @click="leaveRoom()">离开当前的房间</button>
      </li>
    </div>
    <template>
      <div class="box">
        <li v-for="(msg, index) in messageList" :key="index">{{ msg }}</li>
      </div>
      <textarea class="message" v-model="message"></textarea>
      <div>
        <button size="mini" type="success" v-if="currentRoomId" @click="chat()">房间内聊天</button>
        <button size="mini" v-if="currentRoomId" @click="broadcast()">全平台广播</button>
      </div>
    </template>
  </div>
</template>

<script>
export default {
  data() {
    return {
      // socket相关
      tips: "Hello World",
      socket: null,
      RoomId: "default",
      currentRoomId: "",
      message: "",
      messageList: [],
      isLinked: false
    };
  },
  created() {
    this.initSocket();
  },
  methods: {
    // 初始化socket
    initSocket() {
      if (!this.socket || this.socket.readyState == 3) {
        console.log(this.$wsServer);
        this.socket = new WebSocket(this.$wsServer);
        console.log("----new WebSocket");
      }
      // 封装自定义消息(需要和后端约定)
      this.socket.emit = (name, ...args) => {
        let data = JSON.stringify({ name, data: [...args] });
        this.socket.send(data);
      };
      // 绑定四个基本事件
      // open
      this.socket.onopen = () => {
        this.tips = "连接成功,现在可以接收到广播";
        this.isLinked = true;
      };
      // onmessage(通过定义不同的name字段来区分不同的的消息)
      this.socket.onmessage = (res) => {
        let data = JSON.parse(res.data);
        // 房间操作成功
        if (data.name == "success") {
          this.currentRoomId = data.data[0][0];
          if (this.currentRoomId) {
            this.tips = "当前房间:" + this.currentRoomId;
          } else {
            this.messageList = [];
            this.tips = "已经离开房间,但是仍能接收广播";
          }
        }
        // 接受消息
        if (data.name == "chat") {
          let message = data.data[0];
          this.messageList.push(message);
        }
        if (data.name == "broadcast") {
          let message = data.data[0];
          this.messageList.push(message);
        }
      };
      // onerror
      this.socket.onerror = (err) => {
        console.log(err);
      };
      // onclose
      this.socket.onclose = () => {
        this.tips = "断开连接";
        this.isLinked = false;
      };
    },
    // 关闭socket
    closeSocket() {
      this.socket.close();
    },
    // 创建房间
    createRoom() {
      let room = this.RoomId;
      if (room == "" || room == null) {
        this.tips = "请输入房间号";
      } else {
        if (this.socket) {
          if (this.currentRoomId) this.leaveRoom();
          this.socket.emit("createRoom", room);
        } else {
          this.tips = "请连接聊天室";
        }
      }
    },
    // 离开房间
    leaveRoom() {
      let room = this.currentRoomId;
      if (room) {
        this.socket.emit("leaveRoom", room);
      }
    },
    // 房间内聊天
    chat() {
      if (this.message) {
        this.socket.emit("chat", this.message, this.currentRoomId);
        this.message = "";
      }
    },
    // 广播
    broadcast() {
      this.socket.emit("broadcast", this.message);
      this.message = "";
    }
  },
  beforeDestroy() {
    if (this.socket) {
      this.socket.close();
    }
  }
};
</script>

# 实践-聊天室