作者:陈广 日期:2018-12-17
WebSocket 是 HTML5 开始提供的一种新协议,它让浏览器与服务器在 TCP 连接上实现全双工通信。WebSocket 协议在 2008 年诞生,2011 年成为国际标准。现在所有浏览器都已经支持。
早期的 HTTP 协议由于带宽和服务器资源的限制,被设计为无状态,不支持持久连接的协议。它采用了 请求/响应 模型。通信请求只能由客户端发起,服务器端对请求做出应答处理,而且必须是一个请求对应一个响应,我们称之为一个动作。HTTP 1.0 中,一个动作的过程是:连接➤请求➤响应➤断开连接。我们前面已经知道,一个网页中的每个 CSS、JavaScript、图片都需要单独建立连接去服务器下载。HTTP 是建立在 TCP 协议的基础之上的,了解 TCP 的同学都知道,TCP 连接的建立需要三次握手,断开需要四次挥手。也就是说,连接的建立及断开对于服务器来说也是一个不小的开销。
在 HTTP 1.1 中,为了解决这个问题,出现了Keep-Alive
,它把多个 HTTP 请求放在了一个连接之内。多个动作的执行过程变为:连接➤请求➤响应➤...请求➤响应➤断开连接。也就是说一个网页的打开只需要建立一次连接即可。同时需要注意的是,HTTP 1.1 的本质并没有改变,还是一个请求对应一个响应。
随着互联网的发展,各种浏览器应用层出不穷,聊天室,股票K线图数据,网页聊天系统...。看过前面 Socket 编程文章的同学应该知道,对于聊天室来说,服务器经常需要向所有客户端群发信息,这时 HTTP 协议就有点捉襟见肘了。我们知道 HTTP 协议只能由客户端发起,不能由服务器主动向客户端发起消息。为了解决上述问题,工程师们想出了各种方案。
最早实现实时 Web 应用的方案就是 Ajax 轮询,浏览器每隔几秒向服务端发出 Ajax 请求,询问服务器是否有新信息,以频繁请求的方式来保持浏览器与服务器的同步。
这种同步方案的缺点是,当客户端以固定频率向服务器发起请求的时候,服务器端的数据可能并没有更新,这样会带来很多无谓的网络传输,所以这是一种非常低效的实时方案。
长轮询原理跟 Ajax 轮询差不多,都是采用轮询方式,不过采用的是阻塞模型,也就是说,客户端发起连接后,服务器如果没有给客户端的消息就一直保持连接,不返回响应给客户端,直到有消息后才返回并关闭连接(如果超过一定时间服务器没有消息,也会主动向客户端发送响应以关闭连接)。客户端收到消息后处理完并再次建立连接,周而复始。
这种方案的优点是在无消息的情况下不会频繁的请求,耗费资源较少。但服务器保持连接也是会消耗资源的,而且一旦服务器的消息较为频繁,那它就退化成 ajax 轮询,需要建立大量连接。
浏览器发送一个完整的请求,但是服务器发送和维护一个持续更新并无限期(或一段时间)保持打开的开放响应。然后,每当消息准备发送时,响应就会被更新,但是服务器从不发出信号来完成响应,从而保持连接打开以传递之后的消息。但是,由于流仍然封装在 HTTP 中,所以中间的防火墙和代理服务器可能会选择缓冲响应,从而增加消息传递的延迟。因此,如果检测到缓冲代理服务器,许多流解决方案将返回到长轮询。或者,可以使用 TLS(SSL)连接来保护响应不被缓冲,但在这种情况下,每个连接的设置和断开都会使用更多的的服务器资源。
最终,所有这些提供实时数据的方法都涉及 HTTP 请求和响应 header,其中包含大量额外的不必要的 header 数据并引入了延迟,并且在资源消耗方面带来了巨大的开销,并增加了许多复杂性。简单地说,HTTP 不是为实时、全双工通信而设计的。
我们知道,以前写程序有 C/S(Client/Server) 和 B/S(Browser/Server) 之分。早期的各类需要用到网络的系统用的都是 C/S 模式进行开发,它的主要问题是安培升级维护程序并不容易。随着网络的发展越来越多的程序都选择了使用 B/S 模式,而且各种 Web 应用层出不穷,有代表性的应用就是 Web 游戏。轮询及长轮询等方式已经完全满足不了各种应用对实时性的要求,需要有一种类似 C/S 程序中的 Socket 编程的机制,以实现服务器端主动向客户端发送信息的功能。此时,WebSocket 就运应而生了。
WebSocket 定义于 HTML5 规范的通信部分。HTML 5 Web Sockets 代表了网络通信的下一个演化:在 Web 上通过单个 Socket 实现全双工双向通信通道。HTML 5 Web Sockets 提供了一个真正的标准,您可以使用它来构建可扩展的、实时的 Web 应用程序。
它的特点包括:
ws
(如果加密,则为wss
),服务器网址就是 URL。可以这样理解,WebSocket 就是 .NET Core 里的 Socket 的浏览器版本。但它的使用与 Socket 是有区别的,最大的区别在于 WebSocket 需要通过 HTTP 来建立连接。
WebSocket 官网(http://websocket.org/)上架设了一个简易的 WebSocket 服务器,对于客户端发送的信息,它会原样返回。我们可以借助这个服务器来写一个极简例子,并分析 WebSocket 协议运行流程。
新建一个 WebSocket 文件夹,鼠标右键菜单【Open with Code】打开。在里面新建一个 Index.html 文件,输入如下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>WebSocket Demo</title>
</head>
<body>
<button onclick="Btn_Click()">连接 WebSocket 服务器</button><br><br>
<textarea id="txt" rows="10" cols="30"></textarea>
</body>
<script>
function Btn_Click() {
var txt = document.getElementById("txt");
var ws = new WebSocket("ws://echo.websocket.org");
ws.onopen = function (evt) {
txt.value += "连接成功\r\n";
ws.send("Hello WebSocket!");
}
ws.onmessage = function (evt) {
txt.value += "收到消息:" + evt.data + "\r\n";
ws.close();
}
ws.onclose = function (evt) {
txt.value += "关闭连接\r\n";
}
}
</script>
</html>
在这个页面里,我安排了一个按钮和一个多行文本框,当点击按钮时,程序会启动一个 WebSocket 连接 websocket.org 上架设的简易服务器。注意,使用的网址是(ws://echo.websocket.org)。
ws.onopen
事件,我们在事件方法内通过ws.send
方法向服务器发送一个字符串。ws.onmessage
事件,我们在事件方法内通过调用ws.close
方法来关闭 WebSocket。ws.onclose
事件。【Ctrl + F5】键运行程序(请确保安装了 IIS Express 扩展,请参考本系列《HTTP 协议》这篇文章)。浏览器启动后按【F12】打开开发者工具,点击页面的【连接 WebSocket 服务器】按钮,结果如下图所示:
最终,连接 WebSocket 服务器成功,并收到了和之前发送一样的信息,然后关闭连接。
要建立起一个 WebSocket,浏览器首先需要向服务器发起一个 HTTP 请求,告诉服务器,浏览器需要使用 WebSocket 连接。而这些信息是通过 HTTP 请求的 header 发送的。我们发现返回的是 101 状态码(Web Socket Protocol Handshake),表示 WebSocket 协议握手成功。
下面我们来看 Request Headers:
Connection: Upgrade
这个 header 告诉服务器连接需要升级。
Upgrade: websocket
这个 header 告诉服务器指定升级的协议是 WebSocket。
Sec-WebSocket-Version: 13
WebSocket 协议在初始阶段有很多的版本,甚至不同的浏览器使用的是不同的版本,现在已经统一。这个 header 指定 WebSocket 的版本。
Sec-WebSocket-Key
这个 header 的内容是一个加密过的字符串,用于握手认证。
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: ooY6lls1BEWMD1wGxk+ubdM=
响应 Headers 与请求 Headers 类似。其中Sec-WebSocket-Accept
与请求 Header 中的Sec-WebSocket-Key
互相配合,用于握手认证。
当握手完成后,就可以象 Socket 一样进行通信了。
以上示例只是为演示 WebSocket 握手过程,写得非常简单。在 WebSocket 官网(http://websocket.org/)上有一个针对这个服务器的示例。微软也有一个针对 WebSocket 的示例,我将两者整合一下,做一个复杂些的示例给大家演示。
更改 Index.html 代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>IOT小分队</title>
</head>
<body>
<div id="echo">
<div id="echo-config" style="float: left;">
<strong>Location:</strong><br>
<input class="draw-border" id="wsUri" value="ws://echo.websocket.org" size="35">
<br>
<p id="stateLabel" style="color:blue">Ready to connect...</p>
<button class="echo-button" id="connect">Connect</button>
<button class="echo-button" id="disconnect">Disconnect</button>
<br>
<br>
<strong>Message:</strong><br>
<input class="draw-border" id="sendMessage" size="35" value="Rock it with HTML5 WebSocket">
<br>
<button class="echo-button" id="send" class="wsButton">Send</button>
</div>
<div id="echo-log" style="float: left; margin-left: 20px; padding-left: 20px; width: 360px; border-left: solid 1px #cccccc;">
<strong>Log:</strong><br>
<textarea id="consoleLog" style="width: 350px; height: 200px; border: solid 1px #cccccc"></textarea>
<button class="echo-button" id="clearLogBtn" style="position: relative; top: 3px;">Clear log</button>
</div>
</div>
</body>
<script src='echo.js'></script>
</html>
接下来在 WebSocket 文件夹下新建一个 echo.js 文件,输入代码如下:
var wsUri = document.getElementById('wsUri');
var stateLabel = document.getElementById("stateLabel");
var connectBtn = document.getElementById('connect');
var disconnectBtn = document.getElementById('disconnect');
var sendMessage = document.getElementById('sendMessage');
var sendBtn = document.getElementById('send');
var consoleLog = document.getElementById('consoleLog');
var clearLogBtn = document.getElementById('clearLogBtn');
var socket;
//更新状态
function updateState() {
function disable() {
sendMessage.disabled = true;
sendBtn.disabled = true;
disconnectBtn.disabled = true;
}
function enable() {
sendMessage.disabled = false;
sendBtn.disabled = false;
disconnectBtn.disabled = false;
}
wsUri.disabled = true;
connectBtn.disabled = true;
if (!socket) {
disable();
} else {
switch (socket.readyState) {
case WebSocket.CLOSED:
stateLabel.innerHTML = "Closed";
disable();
wsUri.disabled = false;
connectBtn.disabled = false;
break;
case WebSocket.CLOSING:
stateLabel.innerHTML = "Closing...";
disable();
break;
case WebSocket.CONNECTING:
stateLabel.innerHTML = "Connecting...";
disable();
break;
case WebSocket.OPEN:
stateLabel.innerHTML = "Open";
enable();
break;
default:
stateLabel.innerHTML = "Unknown WebSocket State: " + socket.readyState;
disable();
break;
}
}
}
//断开连接
disconnectBtn.onclick = function () {
if (!socket || socket.readyState !== WebSocket.OPEN) {
alert("socket not connected");
}
socket.close(1000, "Closing from client");
};
//发送消息
sendBtn.onclick = function () {
if (!socket || socket.readyState !== WebSocket.OPEN) {
alert("socket not connected");
}
var data = sendMessage.value;
socket.send(data);
logText("SEND:" + data);
};
//开始连接
connectBtn.onclick = function () {
logText("Connecting");
socket = new WebSocket(wsUri.value);
socket.onopen = function (event) {
updateState();
logText("Connection opened");
};
socket.onclose = function (event) {
updateState();
logText('Connection closed. Code: ' + event.code + '. Reason: ' + event.reason);
};
socket.onerror = updateState;
socket.onmessage = function (event) {
logText("RECEIVE:" + event.data);
};
};
//清除消息框内容
clearLogBtn.onclick = function () {
consoleLog.value = "";
}
//写入消息框
function logText(text) {
if (consoleLog.value == "") {
consoleLog.value += text;
} else {
consoleLog.value += "\r\n" + text;
}
}
这个程序最大的不同是读取socket.readyState
可获知 WebSocket 状态,从而进行不同的处理。
运行程序,效果如下图所示:
接下来介绍上面 JavaScript 程序中用到的 WebSocket 客户端的 API。
WebSocket 对象作为一个构造函数,用于新建 WebSocket 实例。
var ws = new WebSocket('ws://localhost:8080');
执行上面语句之后,客户端就会与服务器进行连接。
实例对象的所有属性和方法清单,参见这里。
readyState
属性返回实例对象的当前状态,共有四种。
实例对象的onopen
属性,用于指定连接成功后的回调函数。
ws.onopen = function () {
ws.send('Hello Server!');
}
如果要指定多个回调函数,可以使用addEventListener
方法。
ws.addEventListener('open', function (event) {
ws.send('Hello Server!');
});
实例对象的onclose
属性,用于指定连接关闭后的回调函数。
ws.onclose = function(event) {
var code = event.code;
var reason = event.reason;
var wasClean = event.wasClean;
// handle close event
};
ws.addEventListener("close", function(event) {
var code = event.code;
var reason = event.reason;
var wasClean = event.wasClean;
// handle close event
});
实例对象的onmessage
属性,用于指定收到服务器数据后的回调函数。
ws.onmessage = function(event) {
var data = event.data;
// 处理数据
};
ws.addEventListener("message", function(event) {
var data = event.data;
// 处理数据
});
注意,服务器数据可能是文本,也可能是二进制数据(blob
对象或Arraybuffer
对象)。
ws.onmessage = function(event){
if(typeof event.data === String) {
console.log("Received data string");
}
if(event.data instanceof ArrayBuffer){
var buffer = event.data;
console.log("Received arraybuffer");
}
}
除了动态判断收到的数据类型,也可以使用binaryType属性,显式指定收到的二进制数据类型。
// 收到的是 blob 数据
ws.binaryType = "blob";
ws.onmessage = function(e) {
console.log(e.data.size);
};
// 收到的是 ArrayBuffer 数据
ws.binaryType = "arraybuffer";
ws.onmessage = function(e) {
console.log(e.data.byteLength);
};
实例对象的send()方法用于向服务器发送数据。
发送文本的例子。
ws.send('your message');
发送 Blob 对象的例子。
var file = document
.querySelector('input[type="file"]')
.files[0];
ws.send(file);
发送 ArrayBuffer 对象的例子。
// Sending canvas ImageData as ArrayBuffer
var img = canvas_context.getImageData(0, 0, 400, 320);
var binary = new Uint8Array(img.data.length);
for (var i = 0; i < img.data.length; i++) {
binary[i] = img.data[i];
}
ws.send(binary.buffer);
实例对象的bufferedAmount属性,表示还有多少字节的二进制数据没有发送出去。它可以用来判断发送是否结束。
var data = new ArrayBuffer(10000000);
socket.send(data);
if (socket.bufferedAmount === 0) {
// 发送完毕
} else {
// 发送还没结束
}
实例对象的onerror属性,用于指定报错时的回调函数。
socket.onerror = function(event) {
// handle error event
};
socket.addEventListener("error", function(event) {
// handle error event
});
;