Socket.io

742 阅读5分钟

SocketIO 将 Websocket、Ajax 、轮询(Polling)和其他通讯方式全部封装成统一的通信接口,因此 SocketIO没有兼容性的问题,底层会自动选用最佳的通讯方式。
SocketIO实现了实时双向的基于事件的通讯机制,是基于 WebSocket 的封装,另外还包括对轮询(Polling)机制以及其他实时通讯方式封装成了通用的接口,并且在服务端实现了这些实时机制的相应代码,会自动根据浏览器对各种通讯方式上选取最佳的方式。

Socket.IO 支持4种协议:WebSocket、htmlfile、xhr-polling 和 jsonp-lolling,会根据浏览器选择适合的通讯方式。

从 开始socket.io@4.1.0,Engine.IO 服务器发出三个特殊事件:

  • Engine.IO 使用 WebSocket 和 xhr-polling封装了一套自己的协议,在不支持 WebSocket 的低版本浏览器中使用长轮询的方式代替。SocketIO 在 Engine.IO 的基础上增加了 namespace,room,自动重连等特性。
  • Engine.IO 服务器必须支持 polling(包括jsonp和xhr)和 WebSocket 两种传输方式。Engine.IO 使用 WebSocket 时有一套自己的 ping/pong 机制,使用的是 opcode为0x1(Text)类型的数据帧,不是 WebSocket 协议故一定的 ping/pong 类型的帧,标准的 ping/pong 帧被 uwsgi 使用(uwsgi 里发送 ping,浏览器返回 pong;engine.IO 是浏览器客户端发送 ping,服务端返回相应 pong。)。
  • Engine.IO 的书编码分为 Packet 和 Payload,其中 Packet 是数据包,用于 WebSocket 格式的数据传输,而 Payload 是指一系列绑定到一起的编码后的 Packet,只用于 poll 中,WebSocket 里面使用 WebSocket 帧里面的 Payload 字段来传输数据。如客户端不支持 XHR2,则 payload 格式为<length1>:<packet1>[<length2>:<packet2>[...]],其中 length 是数据包的 Packet 的长度,packet 是编码后的数据包内容(也是客户端发送给服务端的 poll 请求 payload 格式)。若只支持 XHR2,则 payload 中内容全部以字节编码,其中第一位0表示字符串,1表示二进制数据,后面接着的数字则表示 packet 的长度(服务端返回给客户端的 payload 为这种字节编码)
  • initial_headers: 针对于会话的第一个 HTTP 请求相应前进行自定义响应头。
io.engine.on("initial_headers", (headers, req) => {
  headers["test"] = "123";
  headers["set-cookie"] = "mycookie=456";
});
  • headers:针对会话的每个 HTTP 请求的响应头发送前进行自定义响应头。
io.engine.on("headers", (headers, req) => {
  headers["test"] = "789";
});
  • connection_error: 连接异常关闭时会发出。
io.engine.on("connection_error", (err) => {
  console.log(err.req);      // the request object
  console.log(err.code);     // the error code, for example 1
  console.log(err.message);  // the error message, for example "Session ID unknown"
  console.log(err.context);  // some additional error context
});

命名空间

所谓命名空间就是指在一个域中发送消息,只有在当前域的 socket 可以收到消息,其他域的则不能;在服务端可以使用of函数进行创建命名空间;客户端则使用io的参数进行连接;

  1. 服务端:通过io.of创建命名空间;
  2. 服务端进行消息推送的时候需要并行使用命名空间;
  3. 客户端向服务器发起连接的时候也需要指定对应的命名空间。

多个命名空间实际上共享相同的WebSockets连接,从而在服务器上节省了我们的套接字端口;同时可以分配不同的端点和路径,最大限度上减少资源数量(tcp连接);

  • 客户端指定命名空间: const newsSocket = io('/news');

  • 服务端对应处理为:

const io = require('socket.io)();
//news 命名空间
const newsSocket = io.of('/news');
newsSocket.on('connection' function(socket){
    ...
})
newsSocket.emit('Lbxin','lbxin one')
  • 在对应的命名空间里发送消息
io.of('nres').send('test');
io.of('news').to('room1').send('Lbxin')

轻松实现群聊和私聊

  • 对于群聊,只需要把每个socket扔到同一个房间即可;
  • 对于私聊只需要把两个socket扔到同一个房间即可;
  • 对应的io方法是socket.join('room1')socket.leave('room2')
  • 加入房间后进行消息推送(广播或者分发可以简单的使用to或者in)
io.on('connection',function(socket){
    socket.join('Lbxin room');
})
io.to('Lbxin room').emit('test lbxin')//向房间中的除发件人以外的用户进行推送;
io.in('room1').emit()//向特定房间中的每个人广播,包括发件人。
//in()和to()方法都可以进行连续使用,to().to().to().....
var app = require('express')();
var http = require('http').Server(app);
var io = require('socket.io')(http);
app.get('/', function (req, res) {
  res.sendfile('index.html');
});
var nsp = io.of('/my-namespace');
nsp.on('connection', function (socket) {
  console.log('someone connected');
  nsp.emit('hi', 'Hello everyone!');
});
http.listen(3000, function () {
  console.log('listening on localhost:3000');
});
<!DOCTYPE html>
<html>

<head>
  <title>Hello world</title>
</head>
<script src="/socket.io/socket.io.js"></script>
<script>
  var socket = io('/my-namespace');
  socket.on('hi', function (data) {
    document.body.innerHTML = '';
    document.write(data);
  });
</script>

<body></body>

</html>

套接字

每个新连接都分配了一个随机的20个字符的标识符,次标识符与客户端的值同步。

// server-side
io.on("connection", (socket) => {
  console.log(socket.id); // ojIckSD2jqNzOqIrAGzL
});

// client-side
socket.on("connect", () => {
  console.log(socket.id); // ojIckSD2jqNzOqIrAGzL
});

加入指定标识符的房间后就可以用于私人消息传递。因此该标识符不能被覆盖。

io.on("connection", socket => {
  socket.on("private message", (anotherSocketId, msg) => {
    socket.to(anotherSocketId).emit("private message", socket.id, msg);
  });
});

中间件

中间件是为每个传入连接执行的函数,可用于日志记录、认证/授权、权速,但该函数只会在每个连接中执行一次即时连接包含多个 HTTP 请求。
可以注册多个中间件函数,会依次执行,但前提是前一个中间件函数调用了next()且没有在next()中抛出错误。当前一个中间件函数没有调用next()函数时,连接将保持挂起,直到在给定的超时时间后关闭。在next()中抛出错误后连接将被拒绝且客户端会收到一个connect_error的事件。起err.message就是next中抛出错误中的信息。
中间件函数在执行的过程中,socket 实例并未连接,这意味值如果最终连接失败,则不会发出任何事件。


io.use((socket, next) => {
  if (isValid(socket.request)) {
    next();
  } else {
    next(new Error("invalid"));
  }
});
io.use((socket, next) => {
  next(new Error("thou shall not pass"));
});

io.use((socket, next) => {
  // not executed, since the previous middleware has returned an error
  next();
});

socket 的取消订阅和关闭连接

  1. 加入 socket 房间后需要订阅房间内的所有聊天内容,假如在加入房间前没有取消订阅,结果会是别人发送一条消息,你接收到多条消息通知。
beforeDestroy() {
    this.sockets.unsubscribe(eventName)
}

  1. 当在指定页面中才加入 socket 房间,则需要在退出页面的时候进行关闭 socket 连接,不然下次进入会重复连接 socket。
created() {
  this.$socket.open()
  // 查看socket是否连接成功
  this.$socket.connected
}
beforeDestroy() {
  this.$socket.close()
}

参考文献

socket.io 原理分析