yaofly2012 / note

Personal blog

Home Page:https://github.com/yaofly2012/note/issues

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

socketio

yaofly2012 opened this issue · comments

commented

Socket.IO

Socket.IO的能力

image

实现Socket.IO服务器和Socket.IO客户端之间双向通讯。基于websocket技术实现,并以HTTP长轮询(HTTP long-polling, 简称polling)作为兜底方案。

Socket.IO不是WebScoket的实现

Socket.IO不是WebScoket的实现!
Socket.IO不是WebScoket的实现!
Socket.IO不是WebScoket的实现!
重要的事情说三遍~~~
socket.io本质是一个实现双向通讯的库,只是在实现上依赖了websocket技术。弄清楚这一点很重要,明白这个就会更好的理解Socket.IO一些行为。

代码结构

image

Socket.IO客户端和服务端分别是两个库,并且各自又分为两层:

  1. socket.io-clientsocket.io提供顶层的API,供外部直接使用。
  2. Engine.IO则负责底层的连接管理,如:传输方式,升级机制,离线检测等。让上层API不用关注数据是如何传输的。

传输方式(transports)

  1. HTTP长轮询
  2. WebSocket

既然websocket是实现双向通讯的最佳方案,那为啥Socket.IO默认采用HTTP长轮询?
Socket.IO-client代码片段

_this.transports = opts.transports || ["polling", "websocket"];

理想很丰富,现实很骨感。websocket服务总是不可用?
Engine.IO 首先关注可靠性和用户体验,其次是潜在的用户体验改进和提高服务器性能。

Polling工作机制

客户端定时发起轮询,一个轮询包含一个GET请和一个POST请求:

  1. GET请求是为了获取服务端数据(模拟服务端向客户端“推送”数据)
  2. POST请求是把客户端数据传给服务端。

websocket工作机制

客户端请求query string

客户群请求时会拼接用户自定义的query string,还有一些内置的query string:

  1. EIO: EIO是Engine.IO缩写,表示Engine.IO的版本
  2. transport: 当前采用的传输类型
  3. t:时间戳hash
  4. sid: SessionId。在握手时由服务端生成,客户端后续请求都必须带上。

image

升级机制(Upgrade)

为什么要升级?

Socket.IO优先采用HTTP长轮询实现双向通讯,但毕竟webscoket技术双向通讯的最佳方案。条件合适时Socket.IO便升级采用websocket技术传输。

什么情况下会触发升级?

  1. 客户和服务端都支持webscoket协议(由客户端发起握手请求诊断)
  2. 客户端和服务端都确保传出缓冲区为空(即没有待通过HTTP方式传输的数据)

升级过程

image

这个过程涉及两个“握手”:

  1. 第一个是Socket.IO里的概念,即Engne.IO建立连接的第一个HTTP请求。
  2. 第二个是webscoket里的概念,即请求升级协议的HTTP请求。

离线检测(心跳检测)

由服务端发起。
当采用Polling传输时怎么进行心跳检测的?只针对websocket方式么?毕竟Polling方式本身就是一种心跳检测。

服务端

概念:

image

  1. Server instance
  2. Socket instance:
    一个Service instace管理多个socket instance?
  3. engine
  4. namespace
  5. room
  6. handshake
  7. packet
  8. middleware
  9. sticky-session

namespace和room用了组织sockets?

Server instance

表示Socket服务。

import { Server } from "socket.io";

const io = new Server({ /* options */ });

Server和Namespcae什么关系啊,他们具有许多相同方法和事件。

Namespaces(命名空间)

Socket instance

负责和客户端进行交互,即通信

  1. 监听事件,on, once
  2. 向客户端发消息emit
  3. 加入,离开room?

中间件

什么时候执行
客户端向服务端发送消息时?【不是】
中间件执行的时候连接还未建立

一个连接只执行一次
连接一致活着不用多次执行。这个跟Express等HTTP服务中间件不同。

客户端

每个WebSocket客户端是一个socket实例(Socket服务端对应着多个socket实例),并且每个socket实例都归属一个命名空间,默认是/

WebSocket客户端一般有三个主要操作:

  1. 主动向Socket服务请求连接;
  2. 想Socket服务器发消息;
  3. 监听事件。

概念:

  1. Socket instance
  2. Manager, Manager instance
  3. long-polling
  4. ping

生命周期

image

三大角色

image

  1. Socket实例:
    客户端JS直接操作的API,负责和服务交互。和服务的建立连接则依赖Manager实例。
  2. Manager实例
    管理Engine.IO客户端实例,主要负责重新连接的逻辑。一个Manager实例被多个Socket实例复用(多路复用)。
  3. Engine.IO

Socket实例

var socket = io();
  1. 默认Socket服务地址是当前域名+/socket.io/
  2. 默认会自动连接服务器。
  3. 每次刷新页面socket实例id都会发生变化,即之前的会被disconnect,而重新建立连接。

注意:

  1. 虽然执行io()返回的是socket实例,但是这个函数其实会创建一个新的Mananger实例。

事件(events)

Socket继承EventEmitter,可以采用相关API进行发送,监听事件。

如何绑定事件

  1. 虽然运行在浏览器里,但是绑定事件方式跟DOM不一样哦。
    DOM继承自EventTarget),Socket继承EventEmitter,即同Nodejs环境绑定方式。
  2. 不要在connect回调函数里绑定其他事件。
    因为Socket可能会断开连接,并自动重新建立连接。这会导致重复帮忙事件。

事件类型

  1. 内置事件,即Socket内置占用的事件,如connect, disconnect等;
  2. 自定义事件,即非内置事件。

connect

disconnect

  1. 当已存在连接断开后会触发disconnect事件。注意未建立连接前的断开不会触发改事件。
  2. 当主动(即socket客户端或者socket服务端调用disconnect方法)断开连接时socket instance不会自动出发重连。

参考

  1. Everything you need to know about Socket.IO
commented

WebSocket

是什么

双工通讯协议,ws:///wss://,一般也叫有状态协议。

协议升级

websocket协议升级的请求和响应首部由协议升级机制相关首部和websocket协议特有的首部构成。
请求首部:

GET ws://xxxx HTTP/1.1
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: VUZh0hbu6SdzHvT5vQAFXA==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

响应首部:

HTTP/1.1 101 Switching Protocols
Date: Thu, 24 Nov 2022 12:16:23 GMT
Connection: upgrade
Upgrade: websocket
Sec-WebSocket-Accept: 1VI4mAt55cMbKxpP4xYnqA05/Mo=

PK HTTP

image

都是应用层协议。

参考

  1. MDN WebSocket API
  2. What is web socket and how it is different from the HTTP?
  3. Protocol upgrade mechanism
commented

Namespaces(命名空间)

image

A Namespace is a communication channel that allows you to split the logic of your application over a single shared connection (also called "multiplexing").
Represents a pool of sockets connected under a given scope identified by a pathname.

字面翻译就是:

  1. 命名空间是一个通讯通道,可以在同一个连接上拆分应用逻辑。
  2. 表示一个特定范围下的已连接的Socket池。

一、有啥使用场景

命名空间是一个通讯通道,每个客户端的连接都属于且只属于一个命名空间,即客户端本质连接的是Socket服务命名空间。
这也要求:

  1. 命名空间必须在客户端请求连接前创建好,否则客户端就会抛Invalid namespace异常;
  2. 客户端必须指定请求的命名空间(当然了默认是主命名空间)。如果指定错了,那相当于服务的没有创建命名空间(即也会抛Invalid namespace异常)。

image

主要用于代码逻辑拆分(是得代码更新模块化),一般基于两个方面拆分:

  1. 权限:不同权限的客户端的访问不同的命名空间;
  2. 业务功能:将独立的业务功能放在不同的命名空间下面,比如视频聊天和文字聊天分别放在/videoChat/textChat命名空间下面。

总结下就是

  1. 命名空间用于组织代码,各个命名空间之间互相独立;
  2. 多个命名空间共享同一个连接(即多路复用),命名空间就是多路复用中“多路”;
  3. 命名空间管理着已连接的Socket。

二、主命名空间自定义命名空间

2.1 主命名空间(Main Namespace)

Socket服务默认的命名空间,并且在创建Server对象时就内部创建了,不用显示的创建。代码如下

this.sockets = this.of("/");

主命名空间和Server实例关系

从关系图上看可以看出一个Server实例对应多个命名空间,但是一个Server实例却有且只有一个主命名空间,并且Server实例提供一里一些直接操作主命名空间的方法和属性,比如:

io.sockets === io.of("/") // true

io.on("connection", (socket) => {}); // 等价 io.of("/").on("connection", (socket) => {});
io.use((socket, next) => { next() }); // 等价 io.of("/").use((socket, next) => { next() });
io.emit("hello"); // 等价 io.of("/").emit("hello");

详细的可以看Server源码

2.2 自定义命名空间

io.of(nsp);

只能通过Server实例的of方法创建自定义命名空间。

动态命名空间

2.3 主命名空间 PK 自定义命名空间总结

  1. 自定义命名空间必须显示的创建,且要在在客户端发起连接前创建,否则客户端无法连接服务(客户端会抛异常Invalid Namespace);

三、特性

3.1 互相独立

  1. 事件处理
  2. rooms
  3. 中间件

3.2 多路复用

  1. 同源下的不同名称命名空间会使用同一个websocket连接,即多路复用。
  2. 非同源命名空间或者创建相同名称的命名空间,则使用不同的websocket连接。
// 这三个命名空间会共用同一个连接
const socket = io("https://example.com"); // or io("https://example.com/"), the main namespace
const orderSocket = io("https://example.com/orders"); // the "orders" namespace
const userSocket = io("https://example.com/users"); // the "users" namespace

// 不同源会采用多个连接
const socket1 = io("https://first.example.com");
const socket2 = io("https://second.example.com"); // no multiplexing, two distinct WebSocket connections

// 同源同命的命名空间(本质是两个不同的Server服务),会采用多个连接
const socket1 = io();
const socket2 = io();

四、和Rooms

👇
Rooms

参考

  1. How can I use dynamic namespaces in socket.io.
commented

Rooms

image

Within each Namespace, you can also define arbitrary channels (called room) that the Socket can join and leave. That provides a convenient way to broadcast to a group of Sockets

  1. 属于具体某个命名空间,只是为了方便广播消息。
  2. 是个抽象的概念,为什么这样说呢?
    Namespace实例里并没获取room的属性或者方法,相反却又关于sockets的属性。room只是为了方便广播消息给socket分组下。
  3. socket可以加入或者离开的一个任意渠道;
  4. socket可以join多个房间。

一、有啥使用场景

room用确定广播消息的范围,即决定哪些socket可以收到广播消息。这是服务端的概念,客户端只是一个单一的socket,并不知道自己属于哪个room。

二、PK 命名空间 ?

一定程度上讲两者都是用于“分组”socket实例。但具体两者的关注点不同:

  1. 命名空间用于通讯渠道(可用于权限控制等),分割代码逻辑;
  2. 房间更关注广播消息的范围;
  3. 房间是命名空间在广播消息维度的细分,也就是说Room属于一个特定的命名空间。

相似点

  1. 命名空间也可以划定广播的范围。
    但这就没有room灵活了,room可以动态创建,而命名空间虽然也可以动态,但相对较弱,并且还需要提前创建。

选择Room还是命名空间 ?

  1. 主要从大的方面看看socket是否具有相同的权限,是否在处理同一个业务逻辑;
  2. 两者的能力是不同的,根据实际业务情况选择。

总结下: 两者本来就是不同的概念,职责不同。不要刻意去对比。

参考

  1. socket.io rooms or namespacing?
  2. Socket.io Namespaces and Rooms
commented

多进程,多服务器问题

多进程/多服务器会带来两个问题:

  1. 长轮询transport时要粘性会话(Sticky Session)
    长轮询机制下每个Socket会话客户端会发送多个HTTP请求(握手,拉取数据,提交数据,协议升级),但处理这些请求必须保证是同一个进程,否则就会报400错误;
  2. 广播消息
    触发广播消息在具体某个进程里,但客户端连接的socket服务可能是其他进程(或者其他服务器的进程),如何保证连接在非当前进程的客户端也能收到消息呢。

一、粘性会话解决方案

1.1 不用长轮询兜底

听着有点粗暴,但是如果不考虑老浏览器,只兼容现代浏览器(比如内部的管理系统)这倒是很不错的解决方案。

const socket = io("xxx", {
  // 注意:这种情况下不会用长轮询作为兜底方案
  transports: [ "websocket" ]
});

1.2 开启粘性会话

Socket IO官网提供了两种方式开启粘性会话。

  1. 根据cookie路由客户(推荐)
  2. 根据客户的原始地址路由客户

二、跨进程广播消息(同一个服务器里的不同进程)

2.1 Cluster多进程

采用官方提供的@socket.io/cluster-adapter即可解决,具体参考文档Cluster适配器即可。
代码片段:

const cluster = require('node:cluster');
const { createAdapter, setupPrimary } = require("@socket.io/cluster-adapter");
if (cluster.isPrimary) {
  // xxx
  setupPrimary();
} else {
  const { Server } = require('socket.io');
  const io = new Server();
  // use the cluster adapter
  io.adapter(createAdapter());
 // XXXX
}

注意: 如果不用解决粘性会话问题,就不需要引入@socket.io/sticky

工作原理

  1. 触发广播的进程向Mater进程发个消息,
  2. 然后Master进程再向其他进程发消息,
  3. 最后其他进程再向客户端推送消息。

2.2 PM2多进程

现实中基本使用PM2管理nodejs进程。

采用@socket.io/pm2

参考文档结合PM2使用,本质上同Cluster方式,可以看看commit

但这个方式可行性弱些,一般服务器会预装官方PM2。

使用官方PM2

可以利用PM2 API实现进程间通讯。
代码片段:

pm2.launchBus(function(err, pm2_bus) {
  // 每个进程都会收到消息(包含发送消息的进程)
  pm2_bus.on('process:msg', function(packet) {
    const { data } = packet;
    const { msgType, msg } = data;
    io.emit(msgType, msg);
  })
});

io.on('connection', socket => {
  // 广播
  process.send({
    type : 'process:msg',
    data : {
      msgType: 'joinus',
      msg: `Hi all, someone from ${process.pid} join us`
    }
  });
});

三、跨服务器广播消息

3.1 发布/订阅机制(如 Redis)

在 worker里声明Redis订阅既处理夸服务器,也可以解决夸进程广播问题。

3.2 消息队列(如 RabbitMQ)

留意下多进程问题。如果同一个服务器消费消息只有一个进程会消费到消息,那还得结合夸进程消息广播。

参考

  1. SocketIO: Using multiple nodes
  2. Everything you need to know about Socket.IO
commented

自定义adapter

socket.io-adapter

commented

问题汇总

发布后socket接口很慢,本地倒没问题?!

image

原因没有处理粘性会话,Socket IO内部处理了粘性会话,但是性能很差。参考[Why is sticky-session required](https://socket.io/docs/v4/using-multiple-nodes/#why-is-sticky-session-required)

有些浏览器不准许建立ws连接

image
Modern browsers do not allow connecting to an unsecure server from a secure origin。
目前发现:Chrome 版本86.0.4240.75 不行, 但版本106.0.5249.119却可以。
解决方案:

  1. 采用降级方案,即HTTP轮询;
  2. 启动wss

websocket连接时报400错误

image
原因:协议升级时头部参数不对,
解决方案:排查发现时Nginx反向代理配置需要增加协议升级相关配置。
参考协议升级机制Socket.io 连接异常

参考

  1. websocket在线测试工具