websocket探索其與語音、圖片的能力
說到websocket想比大家不會陌生,如果陌生的話也沒關系,一句話概括
“WebSocket protocol 是HTML5一種新的協議。它實現了瀏覽器與服務器全雙工通信”
WebSocket相比較傳統那些服務器推技術簡直好了太多,我們可以揮手向comet和長輪詢這些技術說拜拜啦,慶幸我們生活在擁有HTML5的時代~
這篇文章我們將分三部分探索websocket
首先是websocket的常見使用,其次是完全自己打造服務器端websocket,最終是重點介紹利用websocket制作的兩個demo,傳輸圖片和在線語音聊天室,let's go
一、websocket常見用法
這里介紹三種我認為常見的websocket實現……( 注意:本文建立在node上下文環境 )
1、socket.io
先給demo
var http = require('http');
var io = require('socket.io');
var server = http.createServer(function(req, res) {
res.writeHeader(200, {'content-type': 'text/html;charset="utf-8"'});
res.end();
}).listen(8888);
var socket =.io.listen(server);
socket.sockets.on('connection', function(socket) {
socket.emit('xxx', {options});
socket.on('xxx', function(data) {
// do someting
});
});相信知道websocket的同學不可能不知道socket.io,因為socket.io太出名了,也很棒,它本身對超時、握手等都做了處理。我猜測這也是實現websocket使用最多的方式。socket.io最最最優秀的一點就是優雅降級,當瀏覽器不支持websocket時,它會在內部優雅降級為長輪詢等,用戶和開發者是不需要關心具體實現的,很方便。
不過事情是有兩面性的,socket.io因為它的全面也帶來了坑的地方,最重要的就是臃腫,它的封裝也給數據帶來了較多的通訊冗余,而且優雅降級這一優點,也伴隨瀏覽器標準化的進行慢慢失去了光輝
|
Chrome |
Supported in version 4+ |
|
Firefox |
Supported in version 4+ |
|
Internet Explorer |
Supported in version 10+ |
|
Opera |
Supported in version 10+ |
|
Safari |
Supported in version 5+ |
在這里不是指責說socket.io不好,已經被淘汰了,而是有時候我們也可以考慮一些其他的實現~
2、http模塊
剛剛說了socket.io臃腫,那現在就來說說便捷的,首先demo
var http = require(‘http’);
var server = http.createServer();
server.on(‘upgrade’, function(req) {
console.log(req.headers);
});
server.listen(8888);很簡單的實現,其實socket.io內部對websocket也是這樣實現的,不過后面幫我們封裝了一些handle處理,這里我們也可以自己去加上,給出兩張socket.io中的源碼圖
3、ws模塊
后面有個例子會用到,這里就提一下,后面具體看~
二、自己實現一套server端websocket
剛剛說了三種常見的websocket實現方式,現在我們想想,對于開發者來說
websocket相對于傳統http數據交互模式來說,增加了服務器推送的事件,客戶端接收到事件再進行相應處理,開發起來區別并不是太大啊
那是因為那些模塊已經幫我們將 數據幀解析 這里的坑都填好了,第二部分我們將嘗試自己打造一套簡便的服務器端websocket模塊
感謝次碳酸鈷的研究幫助, 我在這里這部分只是簡單說下,如果對此有興趣好奇的請百度【web技術研究所】
自己完成服務器端websocket主要有兩點,一個是使用net模塊接受數據流,還有一個是對照官方的幀結構圖解析數據,完成這兩部分就已經完成了全部的底層工作
首先給一個客戶端發送websocket握手報文的抓包內容
客戶端代碼很簡單
ws = new WebSocket("ws://127.0.0.1:8888");
服務器端要針對這個key驗證,就是講key加上一個特定的字符串后做一次sha1運算,將其結果轉換為base64送回去
var crypto = require('crypto');
var WS = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
require('net').createServer(function(o) {
var key;
o.on('data',function(e) {
if(!key) {
// 獲取發送過來的KEY
key = e.toString().match(/Sec-WebSocket-Key: (.+)/)[1];
// 連接上WS這個字符串,并做一次sha1運算,最后轉換成Base64
key = crypto.createHash('sha1').update(key+WS).digest('base64');
// 輸出返回給客戶端的數據,這些字段都是必須的
o.write('HTTP/1.1 101 Switching Protocols\r\n');
o.write('Upgrade: websocket\r\n');
o.write('Connection: Upgrade\r\n');
// 這個字段帶上服務器處理后的KEY
o.write('Sec-WebSocket-Accept: '+key+'\r\n');
// 輸出空行,使HTTP頭結束
o.write('\r\n');
}
});
}).listen(8888);這樣握手部分就已經完成了,后面就是數據幀解析與生成的活了
先看下官方提供的幀結構示意圖
簡單介紹下
FIN為是否結束的標示
RSV為預留空間,0
opcode標識數據類型,是否分片,是否二進制解析,心跳包等等
給出一張opcode對應圖
MASK是否使用掩碼
Payload len和后面extend payload length表示數據長度,這個是最麻煩的
PayloadLen只有7位,換成無符號整型的話只有0到127的取值,這么小的數值當然無法描述較大的數據,因此規定當數據長度小于或等于125時候它才作為數據長度的描述,如果這個值為126,則時候后面的兩個字節來儲存數據長度,如果為127則用后面八個字節來儲存數據長度
Masking-key掩碼
下面貼出解析數據幀的代碼
function decodeDataFrame(e) {
var i = 0,
j,s,
frame = {
FIN: e[i] >> 7,
Opcode: e[i++] & 15,
Mask: e[i] >> 7,
PayloadLength: e[i++] & 0x7F
};
if(frame.PayloadLength === 126) {
frame.PayloadLength = (e[i++] << 8) + e[i++];
}
if(frame.PayloadLength === 127) {
i += 4;
frame.PayloadLength = (e[i++] << 24) + (e[i++] << 16) + (e[i++] << 8) + e[i++];
}
if(frame.Mask) {
frame.MaskingKey = [e[i++], e[i++], e[i++], e[i++]];
for(j = 0, s = []; j < frame.PayloadLength; j++) {
s.push(e[i+j] ^ frame.MaskingKey[j%4]);
}
} else {
s = e.slice(i, i+frame.PayloadLength);
}
s = new Buffer(s);
if(frame.Opcode === 1) {
s = s.toString();
}
frame.PayloadData = s;
return frame;
}然后是生成數據幀的
function encodeDataFrame(e) {
var s = [],
o = new Buffer(e.PayloadData),
l = o.length;
s.push((e.FIN << 7) + e.Opcode);
if(l < 126) {
s.push(l);
} else if(l < 0x10000) {
s.push(126, (l&0xFF00) >> 8, l&0xFF);
} else {
s.push(127, 0, 0, 0, 0, (l&0xFF000000) >> 24, (l&0xFF0000) >> 16, (l&0xFF00) >> 8, l&0xFF);
}
return Buffer.concat([new Buffer(s), o]);
}都是按照幀結構示意圖上的去處理,在這里不細講,文章重點在下一部分,如果對這塊感興趣的話可以移步web技術研究所~
三、websocket傳輸圖片和websocket語音聊天室
正片環節到了,這篇文章最重要的還是展示一下websocket的一些使用場景
1、傳輸圖片
我們先想想傳輸圖片的步驟是什么,首先服務器接收到客戶端請求,然后讀取圖片文件,將二進制數據轉發給客戶端,客戶端如何處理?當然是使用FileReader對象了
先給客戶端代碼
var ws = new WebSocket("ws://xxx.xxx.xxx.xxx:8888");
ws.onopen = function(){
console.log("握手成功");
};
ws.onmessage = function(e) {
var reader = new FileReader();
reader.onload = function(event) {
var contents = event.target.result;
var a = new Image();
a.src = contents;
document.body.appendChild(a);
}
reader.readAsDataURL(e.data);
};接收到消息,然后readAsDataURL,直接將圖片base64添加到頁面中
轉到服務器端代碼
fs.readdir("skyland", function(err, files) {
if(err) {
throw err;
}
for(var i = 0; i < files.length; i++) {
fs.readFile('skyland/' + files[i], function(err, data) {
if(err) {
throw err;
}
o.write(encodeImgFrame(data));
});
}
});
function encodeImgFrame(buf) {
var s = [],
l = buf.length,
ret = [];
s.push((1 << 7) + 2);
if(l < 126) {
s.push(l);
} else if(l < 0x10000) {
s.push(126, (l&0xFF00) >> 8, l&0xFF);
} else {
s.push(127, 0, 0, 0, 0, (l&0xFF000000) >> 24, (l&0xFF0000) >> 16, (l&0xFF00) >> 8, l&0xFF);
}
return Buffer.concat([new Buffer(s), buf]);
}注意 s.push((1 << 7) + 2) 這一句,這里等于直接把opcode寫死了為2,對于Binary Frame,這樣客戶端接收到數據是不會嘗試進行toString的,否則會報錯~
代碼很簡單,在這里向大家分享一下websocket傳輸圖片的速度如何
測試很多張圖片,總共8.24M
普通靜態資源服務器需要20s左右(服務器較遠)
cdn需要2.8s左右
那我們的websocket方式呢??!
答案是同樣需要20s左右,是不是很失望……速度就是慢在傳輸上,并不是服務器讀取圖片,本機上同樣的圖片資源,1s左右可以完成……這樣看來數據流也無法沖破距離的限制提高傳輸速度
下面我們來看看websocket的另一個用法~
用websocket搭建語音聊天室
先來整理一下語音聊天室的功能
用戶進入頻道之后從麥克風輸入音頻,然后發送給后臺轉發給頻道里面的其他人,其他人接收到消息進行播放
看起來難點在兩個地方,第一個是音頻的輸入,第二是接收到數據流進行播放
先說音頻的輸入,這里利用了HTML5的getUserMedia方法,不過注意了, 這個方法上線是有大坑的 ,最后說,先貼代碼
if (navigator.getUserMedia) {
navigator.getUserMedia(
{ audio: true },
function (stream) {
var rec = new SRecorder(stream);
recorder = rec;
})
}第一個參數是{audio: true},只啟用音頻,然后創建了一個SRecorder對象,后續的操作基本上都在這個對象上進行。此時如果 代碼運行在本地的話 瀏覽器應該提示你是否啟用麥克風輸入,確定之后就啟動了
接下來我們看下SRecorder構造函數是啥,給出重要的部分
var SRecorder = function(stream) {
……
var context = new AudioContext();
var audioInput = context.createMediaStreamSource(stream);
var recorder = context.createScriptProcessor(4096, 1, 1);
……
}AudioContext是一個音頻上下文對象,有做過聲音過濾處理的同學應該知道“一段音頻到達揚聲器進行播放之前,半路對其進行攔截,于是我們就得到了音頻數據了,這個攔截工作是由window.AudioContext來做的,我們所有對音頻的操作都基于這個對象”,我們可以通過AudioContext創建不同的AudioNode節點,然后添加濾鏡播放特別的聲音
錄音原理一樣,我們也需要走AudioContext,不過多了一步對麥克風音頻輸入的接收上,而不是像往常處理音頻一下用ajax請求音頻的ArrayBuffer對象再decode,麥克風的接受需要用到createMediaStreamSource方法,注意這個參數就是getUserMedia方法第二個參數的參數
再說createScriptProcessor方法,它官方的解釋是:
Creates a ScriptProcessorNode, which can be used for direct audio processing via JavaScript.
——————
概括下就是這個方法是使用JavaScript去處理音頻采集操作
終于到音頻采集了!勝利就在眼前!
接下來讓我們把麥克風的輸入和音頻采集相連起來
audioInput.connect(recorder); recorder.connect(context.destination);
context.destination官方解釋如下
The destination property of the AudioContext interface returns an AudioDestinationNode representing the final destination of all audio in the context.
——————
context.destination返回代表在環境中的音頻的最終目的地。
好,到了此時,我們還需要一個監聽音頻采集的事件
recorder.onaudioprocess = function (e) {
audioData.input(e.inputBuffer.getChannelData(0));
}audioData是一個對象,這個是在網上找的,我就加了一個clear方法因為后面會用到,主要有那個encodeWAV方法很贊,別人進行了多次的音頻壓縮和優化,這個最后會伴隨完整的代碼一起貼出來
此時整個 用戶進入頻道之后從麥克風輸入音頻 環節就已經完成啦,下面就該是向服務器端發送音頻流,稍微有點蛋疼的來了,剛才我們說了,websocket通過opcode不同可以表示返回的數據是文本還是二進制數據,而我們onaudioprocess中input進去的是數組,最終播放聲音需要的是Blob,{type: 'audio/wav'}的對象,這樣我們就必須要在發送之前將數組轉換成WAV的Blob,此時就用到了上面說的encodeWAV方法
服務器似乎很簡單,只要轉發就行了
本地測試確實可以, 然而天坑來了! 將程序跑在服務器上時候調用getUserMedia方法提示我必須在一個安全的環境,也就是需要https,這意味著ws也必須換成wss…… 所以服務器代碼就沒有采用我們自己封裝的握手、解析和編碼了,代碼如下
var https = require('https');
var fs = require('fs');
var ws = require('ws');
var userMap = Object.create(null);
var options = {
key: fs.readFileSync('./privatekey.pem'),
cert: fs.readFileSync('./certificate.pem')
};
var server = https.createServer(options, function(req, res) {
res.writeHead({
'Content-Type' : 'text/html'
});
fs.readFile('./testaudio.html', function(err, data) {
if(err) {
return ;
}
res.end(data);
});
});
var wss = new ws.Server({server: server});
wss.on('connection', function(o) {
o.on('message', function(message) {
if(message.indexOf('user') === 0) {
var user = message.split(':')[1];
userMap[user] = o;
} else {
for(var u in userMap) {
userMap[u].send(message);
}
}
});
});
server.listen(8888);代碼還是很簡單的,使用https模塊,然后用了開頭說的ws模塊,userMap是模擬的頻道,只實現轉發的核心功能
使用ws模塊是因為它配合https實現wss實在是太方便了,和邏輯代碼0沖突
https的搭建在這里就不提了,主要是需要私鑰、CSR證書簽名和證書文件,感興趣的同學可以了解下(不過不了解的話在現網環境也用不了getUserMedia……)
下面是完整的前端代碼
var a = document.getElementById('a');
var b = document.getElementById('b');
var c = document.getElementById('c');
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia;
var gRecorder = null;
var audio = document.querySelector('audio');
var door = false;
var ws = null;
b.onclick = function() {
if(a.value === '') {
alert('請輸入用戶名');
return false;
}
if(!navigator.getUserMedia) {
alert('抱歉您的設備無法語音聊天');
return false;
}
SRecorder.get(function (rec) {
gRecorder = rec;
});
ws = new WebSocket("wss://x.x.x.x:8888");
ws.onopen = function() {
console.log('握手成功');
ws.send('user:' + a.value);
};
ws.onmessage = function(e) {
receive(e.data);
};
document.onkeydown = function(e) {
if(e.keyCode === 65) {
if(!door) {
gRecorder.start();
door = true;
}
}
};
document.onkeyup = function(e) {
if(e.keyCode === 65) {
if(door) {
ws.send(gRecorder.getBlob());
gRecorder.clear();
gRecorder.stop();
door = false;
}
}
}
}
c.onclick = function() {
if(ws) {
ws.close();
}
}
var SRecorder = function(stream) {
config = {};
config.sampleBits = config.smapleBits || 8;
config.sampleRate = config.sampleRate || (44100 / 6);
var context = new AudioContext();
var audioInput = context.createMediaStreamSource(stream);
var recorder = context.createScriptProcessor(4096, 1, 1);
var audioData = {
size: 0 //錄音文件長度
, buffer: [] //錄音緩存
, inputSampleRate: context.sampleRate //輸入采樣率
, inputSampleBits: 16 //輸入采樣數位 8, 16
, outputSampleRate: config.sampleRate //輸出采樣率
, oututSampleBits: config.sampleBits //輸出采樣數位 8, 16
, clear: function() {
this.buffer = [];
this.size = 0;
}
, input: function (data) {
this.buffer.push(new Float32Array(data));
this.size += data.length;
}
, compress: function () { //合并壓縮
//合并
var data = new Float32Array(this.size);
var offset = 0;
for (var i = 0; i < this.buffer.length; i++) {
data.set(this.buffer[i], offset);
offset += this.buffer[i].length;
}
//壓縮
var compression = parseInt(this.inputSampleRate / this.outputSampleRate);
var length = data.length / compression;
var result = new Float32Array(length);
var index = 0, j = 0;
while (index < length) {
result[index] = data[j];
j += compression;
index++;
}
return result;
}
, encodeWAV: function () {
var sampleRate = Math.min(this.inputSampleRate, this.outputSampleRate);
var sampleBits = Math.min(this.inputSampleBits, this.oututSampleBits);
var bytes = this.compress();
var dataLength = bytes.length * (sampleBits / 8);
var buffer = new ArrayBuffer(44 + dataLength);
var data = new DataView(buffer);
var channelCount = 1;//單聲道
var offset = 0;
var writeString = function (str) {
for (var i = 0; i < str.length; i++) {
data.setUint8(offset + i, str.charCodeAt(i));
}
};
// 資源交換文件標識符
writeString('RIFF'); offset += 4;
// 下個地址開始到文件尾總字節數,即文件大小-8
data.setUint32(offset, 36 + dataLength, true); offset += 4;
// WAV文件標志
writeString('WAVE'); offset += 4;
// 波形格式標志
writeString('fmt '); offset += 4;
// 過濾字節,一般為 0x10 = 16
data.setUint32(offset, 16, true); offset += 4;
// 格式類別 (PCM形式采樣數據)
data.setUint16(offset, 1, true); offset += 2;
// 通道數
data.setUint16(offset, channelCount, true); offset += 2;
// 采樣率,每秒樣本數,表示每個通道的播放速度
data.setUint32(offset, sampleRate, true); offset += 4;
// 波形數據傳輸率 (每秒平均字節數) 單聲道×每秒數據位數×每樣本數據位/8
data.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), true); offset += 4;
// 快數據調整數 采樣一次占用字節數 單聲道×每樣本的數據位數/8
data.setUint16(offset, channelCount * (sampleBits / 8), true); offset += 2;
// 每樣本數據位數
data.setUint16(offset, sampleBits, true); offset += 2;
// 數據標識符
writeString('data'); offset += 4;
// 采樣數據總數,即數據總大小-44
data.setUint32(offset, dataLength, true); offset += 4;
// 寫入采樣數據
if (sampleBits === 8) {
for (var i = 0; i < bytes.length; i++, offset++) {
var s = Math.max(-1, Math.min(1, bytes[i]));
var val = s < 0 ? s * 0x8000 : s * 0x7FFF;
val = parseInt(255 / (65535 / (val + 32768)));
data.setInt8(offset, val, true);
}
} else {
for (var i = 0; i < bytes.length; i++, offset += 2) {
var s = Math.max(-1, Math.min(1, bytes[i]));
data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
}
return new Blob([data], { type: 'audio/wav' });
}
};
this.start = function () {
audioInput.connect(recorder);
recorder.connect(context.destination);
}
this.stop = function () {
recorder.disconnect();
}
this.getBlob = function () {
return audioData.encodeWAV();
}
this.clear = function() {
audioData.clear();
}
recorder.onaudioprocess = function (e) {
audioData.input(e.inputBuffer.getChannelData(0));
}
};
SRecorder.get = function (callback) {
if (callback) {
if (navigator.getUserMedia) {
navigator.getUserMedia(
{ audio: true },
function (stream) {
var rec = new SRecorder(stream);
callback(rec);
})
}
}
}
function receive(e) {
audio.src = window.URL.createObjectURL(e);
}注意:按住a鍵說話,放開a鍵發送
自己有嘗試不按鍵實時對講,通過setInterval發送,但發現雜音有點重,效果不好,這個需要encodeWAV再一層的封裝,多去除環境雜音的功能,自己選擇了更加簡便的按鍵說話的模式
這篇文章里首先展望了websocket的未來,然后按照規范我們自己嘗試解析和生成數據幀,對websocket有了更深一步的了解
最后通過兩個demo看到了websocket的潛力,關于語音聊天室的demo涉及的較廣,沒有接觸過AudioContext對象的同學最好先了解下AudioContext
文章到這里就結束啦~有什么想法和問題歡迎大家提出來一起討論探索~原文
原文:http://www.alloyteam.com/2015/12/websockets-ability-to-explore-it-with-voi