websocket探索其與語音、圖片的能力

jopen 8年前發布 | 33K 次閱讀 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中的源碼圖

websocket探索其與語音、圖片的能力

websocket探索其與語音、圖片的能力

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);

這樣握手部分就已經完成了,后面就是數據幀解析與生成的活了

先看下官方提供的幀結構示意圖

websocket探索其與語音、圖片的能力

簡單介紹下

FIN為是否結束的標示

RSV為預留空間,0

opcode標識數據類型,是否分片,是否二進制解析,心跳包等等

給出一張opcode對應圖

websocket探索其與語音、圖片的能力

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

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!