笔者之前与一位同事研究了 Cypress 的 visit 方法,其源码实现最终是调用了 WebSocket 向 visit 参数里指定的 website 通行并获取数据,见下图变量 ev.data
的值。
我这位同事的研究成果,通过 Joplin 笔记记录如下如下。
于是笔者心里有一个疑问,为什么 Cypress 的 visit 方法选择了 WebSocket 作为与目标网站的通信技术呢?为什么不直接走 HTTP 协议,比如用 ES6 原生支持的 fetch 去访问目标网站呢?
要回答这个问题,我们先要理解到底什么是 WebSocket,以及它与 HTTP 相比较的优缺点。
诚然,WebSocket 可以在用户的浏览器和服务器之间打开交互式通信会话,浏览器可以向服务器发送消息并接收事件驱动的响应,而无需通过轮询服务器的方式以获得响应。
WebSocket 基于 TCP 连接,在服务器和浏览器间提供了全双工通信功能,即服务器可以主动推送数据到浏览器端,而这在 HTTP 协议中是不可能实现的,HTTP 协议只支持浏览器到服务器端的 Request - Response 方式,即浏览器客户端如果想查询服务器端是否有最新的事件发生,则只能采取低效的轮询方式进行。
举个例子,当用户向服务器发送请求时,该请求以 HTTP 或 HTTPS 的形式发送,服务器收到请求后向客户端发送响应,每个请求都与相应的响应相关联,发送响应后连接关闭,每个 HTTP 或 HTTPS 请求每次都会建立与服务器的新连接,并且在获得响应后,连接会自行终止。
笔者注:HTTP 请求头部的 Connection: keep-alive
字段,可以实现连接重用的需求吗?
当启用 Keep-Alive 时,客户端和服务器同意为后续请求或响应保持连接打开。
默认情况下,HTTP 连接在数据事务结束时关闭。 这意味着客户端创建一个新连接来请求页面的每个文件,服务器在发送数据后关闭这些 TCP 连接。
但是,如果服务器需要同时响应多个 HTTP 请求并为每个新的 TCP 连接提供一个文件,则站点页面的加载时间将会增加。 这可能会导致糟糕的用户体验。
为了克服这个问题,网站所有者需要启用 Keep-Alive 标头来限制新连接的数量。
通过打开 Keep-Alive 连接标头,客户端可以通过单个 TCP 连接下载所有内容,例如 JavaScript、CSS、图像和视频,而不是为每个文件发送不同的请求。
这是一张演示 Keep-Alive 工作原理的图片:
问题:启用 Keep-Alive 头部字段后,重用的是 HTTP 连接,还是 TCP 连接?
WebSocket 并不是将 HTTP 的设计完全推翻重建,而是在 HTTP 的基础上增添了一些逻辑来,管理客户端和服务器端的流。这些流的内容也是 HTTP 请求和响应,保留了旧语义,只是编码和打包方式不同。
了解了理论知识后,我们动手开发一套最简单的 WebSocket 服务器端和客户端实现。
WebSocket 服务器端实现
var app = require('express')();
var server = require('http').Server(app);
var io = require('socket.io')(server);
var defaultPort = 3001;
var port = process.env.PORT || defaultPort;
var i = 0;
console.log("Server is listening on port: " + defaultPort);
server.listen(port);
io.on('connection', function (socket) {
console.log("connect comming from client: " + socket.id);
socket.emit('messages_jerry', { hello: 'world greeting from Server!' });
socket.on('messages', function (data) {
console.log("data received from Client:" + JSON.stringify(data,2,2));
});
});
代码实现包含了4个关键点:
-
服务器监听在默认的 3001 端口上。
-
一旦 WebSocket 客户端有发送到 3001 端口上的连接请求时,代码第 12 行的 on 监听函数触发,监听的事件名称为
connection
,然后在监听函数的实现体里,打印出客户端连接的 id 值。 -
服务器端接收了客户端的链接后,向客户端通过第 15 行的 emit 方法,发送一个
messages_jerry
的事件,以及一个 JSON 对象作为事件负载。 -
第 17 行服务器端监听在
messages
事件上的监听函数触发时,说明接收到了从客户端发送过来的事件,在监听函数里打印出客户端传递过来的数据。
WebSocket 客户端实现
// #!/usr/bin/env node
const io = require('socket.io-client');
var socket = io.connect('http://localhost:3001');
socket.on('messages_jerry', function (data) {
console.log("data sent from Server:" + JSON.stringify(data,2,2));
socket.emit('messages', { my: 'data sent from Client' });
});
socket.on('connect', function (socket2) {
console.log('Connection with Server established!');
socket.emit('messages', 'Client has established connection with Server');
});
代码的关键点:
-
客户端通过 connect 方法向 WebSocket 服务器发起连接请求
-
连接成功建立后,客户端第 10 行的 on 监听函数触发,该函数监听在
connect
事件上,会在 Web Socket 连接成功建立后自动触发。 -
客户端在第 12 行调用 emit 向服务器发送一个 messages 事件。
-
客户端监听在
messages_jerry
的监听函数触发,说明服务器端有数据到达。使用第 6 行的 console.log 语句打印出这个数据。 -
在第 7 行代码,客户端调用 emit,向服务器端发送一个请求,通知服务器自己已经收到了服务器发送过来的数据。
使用命令行 node wsServer.js
启动服务器端,看到如下输出:
新开一个命令行窗口,使用 node wsClient.js
启动客户端,能看到客户端打印出的成功建立连接,以及从服务器端发送过来的数据:
切换回服务器端,红色高亮的内容,就是客户端与服务器端建立连接之后,服务器端新打印出的数据:
回到本文开头抛出的问题:
问题1
为什么 Cypress 的 visit 方法选择了 WebSocket 作为与目标网站的通信技术呢?为什么不直接走 HTTP 协议,比如用 ES6 原生支持的 fetch 去访问目标网站呢?
笔者猜测,是不是因为 Cypress 里某些 API,比如 cy.XXX
需要利用到 WebSocket 这种全双工通信的特性才能够充分发挥作用?
问题2
那么问题又来了,在我们 cy.visit('http://xxx.com')
的代码里,如果说最终 Cypress 通过 WebSocket 协议向 http://xxx.com
发送数据报,但是 http://xxx.com
不支持 WebSocket 怎么办?就像本文前一部分介绍的例子一样,WebSocket 需要客户端和服务器端同时支持才行。
那么会不会 cy.visit 和 visit 参数里指定的 webSite 之间,还存在着一个中间层?
问题3
WebSocket Connection,HTTP Connection,TCP connection,这三者的区别和联系是什么?