websocket兼容性
DEMO
基本结构
客户端:
//./client/index.html
var socket = new WebSocket("ws://localhost:3000/test/123");
var timmer;
socket.onopen = function (evt) {
console.log('open',evt);
timmer = setInterval(function(){
socket.send(new Date().getSeconds());
},1000)
};
//收到消息 触发回调
socket.onmessage = function (evt) {
console.log('msg',evt);
};
socket.onerror = function (evt) { //失败重连
console.log('e',evt);
clearInterval(timmer);
};
服务端:
//./server/index.js
const Koa = require('koa');
const route = require('koa-route');
const websockify = require('koa-websocket');
const app = websockify(new Koa());
// Regular middleware
// Note it's app.ws.use and not app.use
app.ws.use(function(ctx, next) {
// return `next` to pass the context (ctx) on to the next ws middleware
return next(ctx);
});
// Using routes
app.ws.use(route.all('/test/:id', function (ctx) {
// `ctx` is the regular koa context created from the `ws` onConnection `socket.upgradeReq` object.
// the websocket is added to the context on `ctx.websocket`.
ctx.websocket.send('Hello World');
ctx.websocket.on('message', function(message) {
// do something with the message from client
console.log(message);
});
}));
app.listen(3000);
加入心跳机制
注意这里用的ping、pong不是一端各自使用其中一个,而是两个都需要用到。我们可以这样理解,pong:你在吗? ping:我在啊。对方pong你时你必须马上回应ping。如果对方已经正在和你聊天,你就得把pong这事延一延,不要聊着聊着突然来一句,你在吗?[翻白眼]
客户端:
//./client/index.html
var socket = new WebSocket("ws://127.0.0.1:3000/test/123");
var socketLive = false;
var heartCheckTimer;
var timmer;
function heartCheck(){
clearTimeout(heartCheckTimer);
heartCheckTimer = setTimeout(function(){
if(socketLive){
socketLive = false;
socket.send('pong');
heartCheck();
}else{
console.log('对方挂了,准备重启');
}
},3000);
}
socket.onopen = function (evt) {
console.log('open',evt.data);
socketLive = true;
var s = 0
timmer = setInterval(function(){
socket.send(++s);
},1000)
heartCheck();
};
//收到消息 触发回调
socket.onmessage = function (evt) {
console.log('msg',evt.data);
socketLive = true;
heartCheck();
if(evt.data == 'pong'){
socket.send('ping');
}
};
socket.onerror = function (evt) { //失败重连
console.log('e',evt);
clearInterval(timmer);
};
socket.onclose = function (evt) { //失败重连
console.log('close',evt);
clearInterval(timmer);
};
服务端:
//./server/index.js
var heartCheckTimer;
function heartCheck(socket){
clearTimeout(heartCheckTimer);
heartCheckTimer = setTimeout(function(){
if(socket.socketLive){
socket.socketLive = false;
socket.send('pong');
heartCheck(socket);
}else{
console.log('对方挂了,准备重启');
}
},3000);
}
app.ws.use(route.all('/test/:id', function (ctx) {
ctx.websocket.socketLive = true;
heartCheck(ctx.websocket);
setInterval(function(){
ctx.websocket.send('Hello World');
},1000)
ctx.websocket.on('message', function(message) {
console.log(message);
ctx.websocket.socketLive = true;
if(message == 'pong'){
ctx.websocket.send('ping');
}
heartCheck(ctx.websocket);
});
ctx.websocket.on('close', function(message) {
console.log(message);
console.log("close");
});
ctx.websocket.on('error', function(message) {
console.log(message);
console.log("error");
});
}));
注意
如果你的服务所在的域是HTTPS,那么使用的WebSocket协议也必须是wss, 而不能是ws
浏览器端(客户端)或者服务端中有一方突然切断网络,另一方是无法通过事件监听到的。所以心跳机制不止用于保持连接,还用于确认对方是否存活。
为什么要把注意写在这里?因为一开始我在本地测试发现,无论是浏览器端的关闭浏览器,刷新,关闭标签页,调用close函数,还是服务端的退出进程、抛出异常、调用close,都会使对方的close事件被触发。并且我在nodejs服务器环境下开启websocket服务,发现不管过了多久,就算没有数据发送,连接也一直存在(比如用定时器设置几小时后再发一条信息也是能成功的)。这让我产生了“我们不需要心跳机制”的错觉(错觉一:不会断开;错觉二:断开可以通过监听close事件去重连)。
后来我用手机测试,发现关闭手机WIFI时,服务器不会触发任何事件,意味着他不知道浏览器什么时候已经断开了连接。并且引入nginx做反向代理后,如果没有数据交互,没过一会就自动断开了。
正文
websocket什么时候会断开
再次强调:太突然的异常如断网之类,一方来不及通知对方关闭,那么对方也不会触发关闭事件。
客户端原因
比如断网、关闭浏览器、关闭标签页,刷新页面等。
服务端原因
服务端直接退出进程、抛出错误、重启等。
服务端异常断开重启后,客户端需要重新new websocket才能重新连接上
- 在客户端:
第一次连接失败会提示:
index.html:10 WebSocket connection to 'ws://xxxx' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED
这时候会触发WebSocket实例的onerror、onclose回调函数执行
连接成功后如果后面服务端关闭,而客户端执行socket.send,chrome控制台会报错(其他浏览器不会):WebSocket is already in CLOSING or CLOSED state.
并且把错误栈指向我的代码:socket.send
控制台会提示你WebSocket正在关闭或者已经关闭。
注意!上述的连接失败、发送数据失败,用try catch、window.onerror、window.addEventListener('error',function(){})都无法监听、捕获该异常。
服务端意外报错或者关闭(调用websocket.terminate())时,各浏览器表现不一:
| 浏览器 | onerror | onclose |
|---|---|---|
| chrome | × | √ |
| firefox | 服务端报错时√ | 服务端报错时、关闭时√ |
| IE10 | √ | √ |
在chrome浏览器下,onerror函数并不会触发,而是触发onclose函数;
而在firefox下,服务端报错时触发onerror、onclose,关闭时触发onclose;
而在IE10下,服务端报错时onerror和onclose函数都会触发,并提示 WebSocket Error: Network Error 12030, 与服务器的连接意外终止 ;
所以在onerror和onclose中,只用onclose是个不错的选择;
为什么需要心跳机制?
在我的node服务上,我发现就算没有心跳,websocket也能一直连接着,收集了网上的说法后发现,nginx会主动关闭websocket
我们用ngnix开启反向代理:
server {
listen 80;
server_name node-test.com;
location / {
proxy_pass http://localhost:3000;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Origin "";
}
}
果然没一会儿就触发close事件,所以等到关闭的时候再来重新连接,不如用一个心跳机制让他保持不断开。
小结
websocket除了监听自身的error,close状态,无其他手段进行异常监听
在浏览器端,使用onclose处理端口情况可有效解决浏览器兼容性问题
nginx会在规定时间内自动端口websocket连接
websocket传输文件
服务端发送文件:
服务端使用fs.readFileSync读取得到Buffer类型数据,然后发送给浏览器端,浏览器端接收到的是一个Blob类型的数据:
let content = fs.readFileSync(path.resolve('./test.txt'));
ctx.websocket.send(content);
这时浏览器打印结果:Blob {size: 10, type: ""}
如果将Buffer类型数据放入对象并序列化:
ctx.websocket.send(JSON.stringify({foo:content}));
这时浏览器打印结果:{"foo":{"type":"Buffer","data":[97,98,99,100,13,10,65,66,67,68]}},对于data里的数组,假如数据是张图片,如何转成url呢?两种方法
- 第一种方法:
let url = URL.createObjectURL(new Blob([new Uint8Array(fileObj.foo.data)]));//这个地址只是一个临时的引用地址
url不用时记得释放掉:URL.revokeObjectURL(url)
- 第二种方法:
let B = new Blob([new Uint8Array(fileObj.foo.data)]);
var reader = new FileReader();
reader.readAsDataURL(B);
reader.onload = function (e) {
console.info(reader);
let url = reader.result;//base64格式
}
浏览器端发送文件:
浏览器用:socket.send(new File(["a".repeat(100)], "test.txt"));;服务端接收到的是一个Buffer类型的数据:<Buffer 61 61 ... 50 more bytes>
当我用大文件传输时,直接报错:close CloseEvent {isTrusted: true, wasClean: false, code: 1006, reason: "", type: "close", …}
实测在我电脑上,只要传输的文件大于或等于100M,就会触发RangeError: Max payload size exceeded
这就需要考虑分片上传了
分片
分片上传的核心在于Blob类型的数据可以用slice进行分割,那么我们来考虑的一个问题,如何保证分片后的传输顺序正确?那就需要给分片后的数据带上序号(额外信息) 如上文所说,服务端传浏览器端时可以把添加的额外信息和Buffer数据一起放在空对象中,并序列化后发送;
而浏览器端不可以将Blob类型JSON.stringfy,但是转成base64就可以序列化了。
在服务端,接收到base64格式的数据后需要做点处理,关键代码:
//服务端
let base64Data = message.replace(/^data:\w+\/\w+;base64,/, "");
let dataBuffer = Buffer.from(base64Data, 'base64');
fs.writeFile("./server_save/a.png", dataBuffer, function(err) {
console.log(err);
});
现在我们不用序列化,将附加信息直接写入Blob或者Buffer中
在浏览器端,我们将额外的信息(序号)先转成Blob,再与分片后的Blob合到一起
首先我们约定好结构:序号+分隔符+分片数据
然后我们先给服务端发送分隔符,比如我用---作为分隔符:
var splitB = new Blob(['---']);
socket.send(splitB);
然后我再发送约定好的数据结构:
var blobToSend = new Blob([123,splitB,sliceBlob])//sliceBlob为某个想发送的大文件切割后的小片段之一
后端拿到数据后是Buffer类型的数据:
let splitIndex = message.indexOf(splitBuffer);//message是前端传过来的数据;splitBuffer也是前端传过来的,是分隔符(分隔符也可以为了方便一开始就约定好长什么样)
let indexNum = message.slice(0,splitIndex);//得到序号(Buffer类型数据)
let chunkData = message.slice(splitIndex + splitBuffer.length)//得到分片数据
剩下的工作就是约定好开始、接收、及如何拼接分片:
var TestB = a.target.files[0];//此处为监听input file的onchange事件得到的File类型文件
var splitB = new Blob(['---']);
socket.send(new Blob(['00000']));//开始,这是约定的开始数据
socket.send(splitB);//传个分隔符
let chunk = 5000;//每5000字节为一个单位进行分割
let chunkNums = Math.ceil(TestB.size / 5000);//向上取整
for(var i = 0;i < chunkNums;i++){
let chunkData;
if(i == chunkNums - 1){
chunkData = TestB.slice(i*chunk);//最后一个chunk
}else{
chunkData = TestB.slice(i*chunk,(i+1)*chunk);
}
let TestB2 = new Blob([i,splitB,chunkData]);
console.log('传输',i);
socket.send(TestB2);
}
socket.send(new Blob(['11111']));//结束,这是约定的结束数据
这个过程,如果数据量太大,分片会分成很多片。执行for循环去发送数据会堵塞js进程、UI进程。所以我们需要进行一些处理
- 引入
requestIdleCallback(兼容性不太好)
for(var i = 0;i < chunkNums;i++){
(function(i){
requestIdleCallback(function(){
let chunkData;
if(i == chunkNums - 1){
chunkData = TestB.slice(i*chunk);//最后一个chunk
}else{
chunkData = TestB.slice(i*chunk,(i+1)*chunk);
}
let TestB2 = new Blob([i,splitB,chunkData]);
console.log('传输',i);
socket.send(TestB2);
if(i == chunkNums - 1){
socket.send(new Blob(['11111']));//结束
}
})
})(i)
}
上述代码有两问题:
- requestIdleCallback不能保证执行的顺序,就是那个结束的信号
socket.send(new Blob(['11111'])),不一定是最后才执行(比如在传第三个分片数据时,有可能因为别的耗时工作而打断,等到其他分片传输完毕才执行这个被打断的函数,见上方截图) - 没有暂停功能,比如异常时暂停、主动暂停
既然不能解决执行顺序问题,那么换种思路,保证所有数据传输完毕再发送信号就可以了。这需要引入一个变量,来判断是否传输完毕,这里我用promise(可不用) + 一个不断自增的变量Sign:
let p = new Promise((resolve,reject)=>{
var Sign = 0
for(var i = 0;i < chunkNums;i++){
(function(i){
requestIdleCallback(function(){
let chunkData;
if(i == chunkNums - 1){
chunkData = TestB.slice(i*chunk);//最后一个chunk
}else{
chunkData = TestB.slice(i*chunk,(i+1)*chunk);
}
let TestB2 = new Blob([i,splitB,chunkData]);
console.log('传输',i);
process_span.innerText = ++Sign;
socket.send(TestB2);
if(Sign == chunkNums){
resolve(1);
}
})
})(i)
}
});
p.then((r)=>{
console.log(r);
socket.send(new Blob(['11111']));//结束
console.log("结束");
});
解决第二个问题,暂停: 要实现这个功能,看来我不能用for循环来发送分片数据,得换成递归的方式:
let p = new Promise((resolve,reject)=>{
var Sign = 0;
window.uploadData = function uploadData(i){
let chunkData;
if(i == chunkNums - 1){
chunkData = TestB.slice(i*chunk);//最后一个chunk
}else{
chunkData = TestB.slice(i*chunk,(i+1)*chunk);
}
let TestB2 = new Blob([i,splitB,chunkData]);
console.log('传输',i);
++Sign
requestAnimationFrame(()=>{
process_span.innerText = Sign;
});
socket.send(TestB2);
if(Sign == chunkNums){
resolve(1);
return;
}
currentIndex = i;//currentIndex为全局变量,方便恢复上传数据
//cancelIdleCallBackId注册为全局变量,方便调用cancelIdleCallback来暂停上传数据
cancelIdleCallBackId = requestIdleCallback(function(){
uploadData(++i)
});
}
uploadData(0);
})
p.then((r)=>{
console.log(r);
socket.send(new Blob(['11111']));//结束
console.log("结束");
});
为什么在上述代码中引入了requestAnimationFrame(IE9及以下不兼容):
“ 避免在空闲回调中改变 DOM。 空闲回调执行的时候,当前帧已经结束绘制了,所有布局的更新和计算也已经完成。如果你做的改变影响了布局, 你可能会强制停止浏览器并重新计算,而从另一方面来看,这是不必要的。 如果你的回调需要改变DOM,它应该使用Window.requestAnimationFrame()来调度它。”
我发现requestIdleCallback会在chrome浏览器切换到其他的标签页或者将浏览器缩下来时会暂停,这就不太合适了,我想后台运行时你给我暂停。由于requestIdleCallback是在浏览器每一帧渲染后有剩余时间时执行,那么当切换便签页后原来的标签页应该会停止渲染,所以requestIdleCallback也随之暂停、requestAnimationFrame同理。(firefox会缓慢执行,ie就算了,就Edge旧版本也不支持requestIdleCallback)
如果我把requestIdleCallback换成setTimeout的话,一旦我把浏览器按同样的方法操作,setTimeout会立即变得相当缓慢,即使我的第二个参数设置为0(chrome、firefox、ie一样)
查阅资料后(How can I make setInterval also work when a tab is inactive in Chrome? ),我决定用Web Workers试试,刚好也就是下面要提及的方法。
- 要考虑兼容性的话,还是推荐使用Web Workers(IE9以下不兼容) 核心代码:
//./client/index.html
var worker = new Worker('./js/work.js');
inputF.onchange = function(a){
worker.postMessage(a.target.files[0]);
}
//./client/js/work.js
var self = this;
var socket = new WebSocket("ws://127.0.0.1:3000/test/123");
self.addEventListener('message', function (e) {
var TestB = e.data;
var splitB = new Blob(['---']);
socket.send(splitB);//开始,传个分隔符
let chunk = 50000;//每5000字节为一个单位进行分割
let chunkNums = Math.ceil(TestB.size / chunk);//向上取整
console.log(chunkNums);
self.postMessage(JSON.stringify({
name:'total_span',
value:chunkNums
}));
let p = new Promise((resolve,reject)=>{
var Sign = 0;
self.uploadData = function uploadData(i){
let chunkData;
if(i == chunkNums - 1){
chunkData = TestB.slice(i*chunk);//最后一个chunk
}else{
chunkData = TestB.slice(i*chunk,(i+1)*chunk);
}
let TestB2 = new Blob([i,splitB,chunkData]);
console.log('传输',i);
// process_span.innerText = ++Sign;
++Sign;
requestAnimationFrame(()=>{
self.postMessage(JSON.stringify({
name:'process_span',
value:Sign
}));
});
socket.send(TestB2);
if(Sign == chunkNums){
resolve(1);
return;
}
currentIndex = i;
cancelIdleCallBackId = setTimeout(function(){//requestIdleCallback未定义
uploadData(++i)
},1);
}
uploadData(0);
})
p.then((r)=>{
console.log(r);
socket.send(new Blob(['11111']));//结束
console.log("结束");
});
}, false);
注意:
requestIdleCallback在web worker中提示未定义
数组最大长度是2^32-1,所以分块不能分这么多(一般也不会有这情况)
以上完美解决后台运行+分片上传功能
如果想参考demo,我的github地址在此
后续
- 再进一步可以做的功能就是发现缺失的分片,并向对方重新发起请求
- 根据网络状况自动调整分片大小
额外 node的websocket库
socket.io github 51.6 stars
如果客户端要使用该库,服务端也要相应的配合使用该库,目前支持node.js、java、C++、Swift、Dart
特性(简单翻译下官网给的特性+解释)
- 可靠性,可以用在:代理、负载均衡、个人防火墙、反病毒软件
- 自动重连支持
- 断开连接检测
- 二进制支持(浏览器:ArrayBuffer、Blob;Node.js:ArrayBuffer、Buffer)
- 简单方便的api
- 跨浏览器
- 多路复用支持(就是以命名空间为单位,创建任意个对象,便于管理,但是底层还是用的同一个socket连接)
- 支持Room(支持以命名空间为单位,实现分组,用在群聊天等方面)
保持长连接的原理
他的核心是用了Engine.IO这个库,该库先发起长连接(LongPolling),并尝试升级连接(换成websocket)
他所谓的长连接长什么样,为了看到清楚点,我在客户端一开始就WebSocket = undefined;,然后可以在控制台看到以下截图:
可以看到除了第一次会发起5个请求,之后每次会发起2个请求,其中一个会处于pending状态,持续一段时间。这段时间内如果服务器有数据要推过来,会请求成功,否则这段时间过后也会自动请求成功并又再起发起两个请求。以下贴一段引用:
LongPolling
Browser/UA发送Get请求到Web服务器,这时Web服务器可以做两件事情,第一,如果服务器端有新的数据需要传送,就立即把数据发回给Browser/UA,Browser/UA收到数据后,立即再发送Get请求给Web Server;第二,如果服务器端没有新的数据需要发送,这里与Polling方法不同的是,服务器不是立即发送回应给Browser/UA,而是把这个请求保持住,等待有新的数据到来时,再来响应这个请求;当然了,如果服务器的数据长期没有更新,一段时间后,这个Get请求就会超时,Browser/UA收到超时消息后,再立即发送一个新的Get请求给服务器。然后依次循环这个过程。
这种方式虽然在某种程度上减小了网络带宽和CPU利用率等问题,但是仍然存在缺陷,例如假设服务器端的数据更新速率较快,服务器在传送一个数据包给Browser后必须等待Browser的下一个Get请求到来,才能传递第二个更新的数据包给Browser,那么这样的话,Browser显示实时数据最快的时间为2×RTT(往返时间),另外在网络拥塞的情况下,这个应该是不能让用户接受的。另外,由于http数据包的头部数据量往往很大(通常有400多个字节),但是真正被服务器需要的数据却很少(有时只有10个字节左右),这样的数据包在网络上周期性的传输,难免对网络带宽是一种浪费。
通过上面的分析可知,要是在Browser能有一种新的网络协议,能支持客户端和服务器端的双向通信,而且协议的头部又不那么庞大就好了。WebSocket就是肩负这样一个使命登上舞台的。
ws github 15.2 stars
看express-ws的源码可以发现用的是ws库,koa-websocket也是
socket.io vs ws
可以先看下这个结论,理性看待,具体使用哪个看情况(主要考虑后端语言、浏览器兼容性) Differences between socket.io and websockets