随着 web 技术的发展,使用场景和需求也越来越复杂,客户端不再满足于简单的请求得到状态的需求。实时通讯越来越多应用于各个领域。
HTTP 是最常用的客户端与服务端的通信技术,但是 HTTP 通信只能由客户端发起,无法及时获取服务端的数据改变。只能依靠定期轮询来获取最新的状态。时效性无法保证,同时更多的请求也会增加服务器的负担。
WebSocket 技术应运而生。
WebSocket 概念
不同于 HTTP 半双工协议,WebSocket 是基于 TCP 连接的全双工协议,支持客户端服务端双向通信。
WebSocket
使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
在 WebSocket API
中,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以数据互相传送。
实现
原生实现
WebSocket 对象一共支持四个消息 onopen, onmessage, onclose 和 onerror。
建立连接
通过 javascript 可以快速的建立一个 WebSocket 连接:
var Socket = new WebSocket(url, [protocol] );
以上代码中的第一个参数 url
, 指定连接的 URL。第二个参数 protocol
是可选的,指定了可接受的子协议。
同 http 协议使用 http://
开头一样,WebSocket 协议的 URL 使用 ws://
开头,另外安全的 WebSocket 协议使用 wss://
开头。
- 当 Browser 和 WebSocketServer 连接成功后,会触发 onopen 消息。
Socket.onopen = function(evt) {};
- 如果连接失败,发送、接收数据失败或者处理数据出现错误,browser 会触发 onerror 消息。
Socket.onerror = function(evt) {};
- 当 Browser 接收到 WebSocketServer 端发送的关闭连接请求时,就会触发 onclose 消息。
Socket.onclose = function(evt) {};
收发消息
- 当 Browser 接收到 WebSocketServer 发送过来的数据时,就会触发 onmessage 消息,参数 evt 中包含 server 传输过来的数据。
Socket.onmessage = function(evt) {};
- send 用于向服务端发送消息。
Socket.send();
socket
WebSocket 是跟随 HTML5 一同提出的,所以在兼容性上存在问题,这时一个非常好用的库就登场了——Socket.io。
socket.io 封装了 websocket,同时包含了其它的连接方式,你在任何浏览器里都可以使用 socket.io 来建立异步的连接。socket.io 包含了服务端和客户端的库,如果在浏览器中使用了 socket.io 的 js,服务端也必须同样适用。
socket.io 是基于 Websocket 的 Client-Server 实时通信库。
socket.io 底层是基于 engine.io 这个库。engine.io 为 socket.io 提供跨浏览器 / 跨设备的双向通信的底层库。engine.io 使用了 Websocket 和 XHR 方式封装了一套 socket 协议。在低版本的浏览器中,不支持 Websocket,为了兼容使用长轮询 (polling) 替代。
API 文档
Socket.io 允许你触发或响应自定义的事件,除了 connect,message,disconnect 这些事件的名字不能使用之外,你可以触发任何自定义的事件名称。
建立连接
const socket = io("ws://0.0.0.0:port"); // port 为自己定义的端口号
let io = require("socket.io")(http);
io.on("connection", function(socket) {})
消息收发
一、发送数据
socket.emit(自定义发送的字段, data);
二、接收数据
socket.on(自定义发送的字段, function(data) {console.log(data);
})
断开连接
一、全部断开连接
let io = require("socket.io")(http);
io.close();
二、某个客户端断开与服务端的链接
// 客户端
socket.emit("close", {});
// 服务端
socket.on("close", data => {socket.disconnect(true);
});
room 和 namespace
有时候 websocket 有如下的使用场景:1. 服务端发送的消息有分类,不同的客户端需要接收的分类不同;2. 服务端并不需要对所有的客户端都发送消息,只需要针对某个特定群体发送消息;
针对这种使用场景,socket 中非常实用的 namespace 和 room 就上场了。
先来一张图看看 namespace 与 room 之间的关系:
namespace
服务端
io.of("/post").on("connection", function(socket) {socket.emit("new message", { mess: ` 这是 post 的命名空间 `});
});
io.of("/get").on("connection", function(socket) {socket.emit("new message", { mess: ` 这是 get 的命名空间 `});
});
客户端
// index.js
const socket = io("ws://0.0.0.0:****/post");
socket.on("new message", function(data) {console.log('index',data);
}
//message.js
const socket = io("ws://0.0.0.0:****/get");
socket.on("new message", function(data) {console.log('message',data);
}
room
客户端
// 可用于客户端进入房间;
socket.join('room one');
// 用于离开房间;
socket.leave('room one');
服务端
io.sockets.on('connection',function(socket){
// 提交者会被排除在外(即不会收到消息)socket.broadcast.to('room one').emit('new messages', data);
// 向所有用户发送消息
io.sockets.to(data).emit("recive message", "hello, 房间中的用户");
}
用 socket.io 实现一个实时接收信息的例子
终于来到应用的阶段啦,服务端用 node.js
模拟了服务端接口。以下的例子都在本地服务器中实现。
服务端
先来看看服务端,先来开启一个服务,安装 express
和socket.io
安装依赖
npm install --Dev express
npm install --Dev socket.io
构建 node 服务器
let app = require("express")();
let http = require("http").createServer(handler);
let io = require("socket.io")(http);
let fs = require("fs");
http.listen(port); //port: 输入需要的端口号
function handler(req, res) {fs.readFile(__dirname + "/index.html", function(err, data) {if (err) {res.writeHead(500);
return res.end("Error loading index.html");
}
res.writeHead(200);
res.end(data);
});
}
io.on("connection", function(socket) {console.log('连接成功');
// 连接成功之后发送消息
socket.emit("new message", { mess: ` 初始消息 `});
});
客户端
核心代码——index.html(向服务端发送数据)
<div> 发送信息 </div>
<input placeholder="请输入要发送的信息" />
<button onclick="postMessage()"> 发送 </button>
// 接收到服务端传来的 name 匹配的消息
socket.on("new message", function(data) {console.log(data);
});
function postMessage() {
socket.emit("recive message", {
message: content,
time: new Date()});
messList.push({
message: content,
time: new Date()});
}
核心代码——message.html(从服务端接收数据)
socket.on("new message", function(data) {console.log(data);
});
效果
实时通讯效果
客户端全部断开连接
某客户端断开连接
namespace 应用
加入房间
离开房间
框架中的应用
npm install socket.io-client
const socket = require('socket.io-client')('http://localhost:port');
componentDidMount() {socket.on('login', (data) => {console.log(data)
});
socket.on('add user', (data) => {console.log(data)
});
socket.on('new message', (data) => {console.log(data)
});
}
分析 webSocket 协议
Headers
请求包
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cache-Control: no-cache
Connection: Upgrade
Cookie: MEIQIA_VISIT_ID=1IcBRlE1mZhdVi1dEFNtGNAfjyG; token=0b81ffd758ea4a33e7724d9c67efbb26; io=ouI5Vqe7_WnIHlKnAAAG
Host: 0.0.0.0:2699
Origin: http://127.0.0.1:5500
Pragma: no-cache
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: PJS0iPLxrL0ueNPoAFUSiA==
Sec-WebSocket-Version: 13
Upgrade: websocket
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1
请求包说明:
- 必须是有效的 http request 格式;
- HTTP request method 必须是 GET,协议应不小于 1.1 如:Get / HTTP/1.1;
- 必须包括 Upgrade 头域,并且其值为“websocket”,用于告诉服务器此连接需要升级到 websocket;
- 必须包括”Connection”头域,并且其值为“Upgrade”;
- 必须包括”Sec-WebSocket-Key”头域,其值采用 base64 编码的随机 16 字节长的字符序列;
- 如果请求来自浏览器客户端,还必须包括 Origin 头域。该头域用于防止未授权的跨域脚本攻击,服务器可以从 Origin 决定是否接受该 WebSocket 连接;
- 必须包括“Sec-webSocket-Version”头域,是当前使用协议的版本号,当前值必须是 13;
- 可能包括“Sec-WebSocket-Protocol”,表示 client(应用程序)支持的协议列表,server 选择一个或者没有可接受的协议响应之;
- 可能包括“Sec-WebSocket-Extensions”,协议扩展,某类协议可能支持多个扩展,通过它可以实现协议增强;
- 可能包括任意其他域,如 cookie.
应答包
应答包说明:
Connection: Upgrade
Sec-WebSocket-Accept: I4jyFwm0r1J8lrnD3yN+EvxTABQ=
Sec-WebSocket-Extensions: permessage-deflate
Upgrade: websocket
- 必须包括 Upgrade 头域,并且其值为“websocket”;
- 必须包括 Connection 头域,并且其值为“Upgrade”;
- 必须包括 Sec-WebSocket-Accept 头域,其值是将请求包“Sec-WebSocket-Key”的值,与”258EAFA5-E914-47DA-95CA-C5AB0DC85B11″这个字符串进行拼接,然后对拼接后的字符串进行 sha- 1 运算,再进行 base64 编码,就是“Sec-WebSocket-Accept”的值;
- 应答包中冒号后面有一个空格;
- 最后需要两个空行作为应答包结束。
请求数据
EIO: 3
transport: websocket
sid: 8Uehk2UumXoHVJRzAAAA
- EIO:3 表示使用的是 engine.io 协议版本 3
- transport 表示传输采用的类型
- sid: session id(String)
Frames
WebSocket 协议使用帧(Frame)收发数据, 在控制台 ->Frames 中可以查看发送的帧数据。
其中帧数据前的数字代表什么意思呢?
这是 Engine.io 协议,其中的数字是数据包编码:
<Packet type id> [<data>]
- 0 open——在打开新传输时从服务器发送(重新检查)
- 1 close——请求关闭此传输,但不关闭连接本身。
-
2 ping——由客户端发送。服务器应该用包含相同数据的乓包应答
客户端发送:2probe 探测帧
-
3 pong——由服务器发送以响应 ping 数据包。
服务器发送:3probe, 响应客户端
- 4 message——实际消息,客户端和服务器应该使用数据调用它们的回调。
- 5 upgrade——在 engine.io 切换传输之前,它测试,如果服务器和客户端可以通过这个传输进行通信。如果此测试成功,客户端发送升级数据包,请求服务器刷新其在旧传输上的缓存并切换到新传输。
- 6 noop——noop 数据包。主要用于在接收到传入 WebSocket 连接时强制轮询周期。
实例
以上的截图是上述例子中数据传输的实例,分析一下大概过程就是:
- connect 握手成功
- 客户端会发送 2 probe 探测帧
- 服务端发送响应帧 3probe
- 客户端会发送内容为 5 的 Upgrade 帧
- 服务端回应内容为 6 的 noop 帧
- 探测帧检查通过后,客户端停止轮询请求,将传输通道转到 websocket 连接,转到 websocket 后,接下来就开始定期 (默认是 25 秒) 的 ping/pong
- 客户端、服务端收发数据,4 表示的是 engine.io 的 message 消息,后面跟随收发的消息内容
为了知道 Client 和 Server 链接是否正常,项目中使用的 ClientSocket 和 ServerSocket 都有一个心跳的线程,这个线程主要是为了检测 Client 和 Server 是否正常链接,Client 和 Server 是否正常链接主要是用 ping pong 流程来保证的。
该心跳定期发送的间隔是 socket.io 默认设定的 25m,在上图中也可观察发现。该间隔可通过配置修改。
参考 engine.io-protocol
参考文章
Web 实时推送技术的总结
engine.io 原理详解
广而告之
本文发布于薄荷前端周刊,欢迎 Watch & Star ★,转载请注明出处。