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