說到websocket想比大家不會陌生,如果陌生的話也沒關係,一句話概括
“WebSocket protocol 是HTML5一種新的協議。它實現了瀏覽器與伺服器全雙工通訊”
WebSocket相比較傳統那些伺服器推技術簡直好了太多,我們可以揮手向comet和長輪詢這些技術說拜拜啦,慶幸我們生活在擁有HTML5的時代~
這篇文章我們將分三部分探索websocket
首先是websocket的常見使用,其次是完全自己打造伺服器端websocket,最終是重點介紹利用websocket製作的兩個demo,傳輸圖片和線上語音聊天室,let’s go
一、websocket常見用法
這裡介紹三種我認為常見的websocket實現……(注意:本文建立在node上下文環境)
1、socket.io
先給demo
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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
1 2 3 4 5 6 |
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握手報文的抓包內容
客戶端程式碼很簡單
1 |
ws = new WebSocket("ws://127.0.0.1:8888"); |
伺服器端要針對這個key驗證,就是講key加上一個特定的字串後做一次sha1運算,將其結果轉換為base64送回去
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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掩碼
下面貼出解析資料幀的程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
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; } |
然後是生成資料幀的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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物件了
先給客戶端程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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新增到頁面中
轉到伺服器端程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
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方法,不過注意了,這個方法上線是有大坑的,最後說,先貼程式碼
1 2 3 4 5 6 7 8 |
if (navigator.getUserMedia) { navigator.getUserMedia( { audio: true }, function (stream) { var rec = new SRecorder(stream); recorder = rec; }) } |
第一個引數是{audio: true},只啟用音訊,然後建立了一個SRecorder物件,後續的操作基本上都在這個物件上進行。此時如果程式碼執行在本地的話瀏覽器應該提示你是否啟用麥克風輸入,確定之後就啟動了
接下來我們看下SRecorder建構函式是啥,給出重要的部分
1 2 3 4 5 6 7 |
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去處理音訊採集操作
終於到音訊採集了!勝利就在眼前!
接下來讓我們把麥克風的輸入和音訊採集相連起來
1 2 |
audioInput.connect(recorder); recorder.connect(context.destination); |
context.destination官方解釋如下
The destination property of the AudioContext interface returns an AudioDestinationNoderepresenting the final destination of all audio in the context.
——————
context.destination返回代表在環境中的音訊的最終目的地。
好,到了此時,我們還需要一個監聽音訊採集的事件
1 2 3 |
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……所以伺服器程式碼就沒有采用我們自己封裝的握手、解析和編碼了,程式碼如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
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……)
下面是完整的前端程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 |
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
文章到這裡就結束啦~有什麼想法和問題歡迎大家提出來一起討論探索~