Node.js 中實現 HTTP 206 內容分片
介紹
在本文中,我會闡述HTTP狀態206 分部分內容 的基礎概念,并使用Node.js一步步地實現它. 我們還將用一個基于它用法最常見場景的示例來測試代碼:一個能夠在任何時間點開始播放視頻文件的HTML5頁面.
Partial Content 的簡要介紹
HTTP 的 206 Partial Content 狀態碼和其相關的消息頭提供了讓瀏覽器以及其他用戶代理從服務器接收部分內容而不是全部內容,這樣一種機制. 這一機制被廣泛使用在一個被大多數瀏覽器和諸如Windows Media Player和VLC Player這樣的播放器所支持視頻文件的傳輸上.
基礎的流程可以用下面這幾步描述:
-
瀏覽器請求內容.
</li> -
服務器告訴瀏覽器,該內容可以使用 Accept-Ranges 消息頭進行分部分請求.
</li> -
瀏覽器重新發送請求,用 Range 消息頭告訴服務器需要的內容范圍.
</li> -
服務器會分如下兩種情況響應瀏覽器的請求:
</li>-
如果范圍是合理的,服務器會返回所請求的部分內容,并帶上 206 Partial Content 狀態碼. 當前內容的范圍會在 Content-Range 消息頭中申明.
</li> -
如果范圍是不可用的(例如,比內容的總字節數大), 服務器會返回 416 請求范圍不合理 Requested Range Not Satisfiable 狀態碼. 可用的范圍也會在 Content-Range 消息頭中聲明.
</li> </ul> </ol>讓我們來看看這幾個步驟中的每一個關鍵消息頭.
Accept-Ranges: 字節(bytes)
這是會有服務器發送的字節頭,展示可以被分部分發送給瀏覽器的內容. 這個值聲明了可被接受的每一個范圍請求, 大多數情況下是字節數 bytes.
Range: 字節數(bytes)=(開始)-(結束)
這是瀏覽器告知服務器所需分部分內容范圍的消息頭. 注意開始和結束位置是都包括在內的,而且是從0開始的. 這個消息頭也可以不發送兩個位置,其含義如下:
-
如果結束位置被去掉了,服務器會返回從聲明的開始位置到整個內容的結束位置內容的最后一個可用字節.
</li> -
如果開始位置被去掉了,結束位置參數可以被描述成從最后一個可用的字節算起可以被服務器返回的字節數.
</li> </ul>Content-Range:字節數(bytes)=(開始)-(結束)/(總數)
這個消息頭將會跟隨 HTTP 狀態碼 206 一起出現. 開始和結束的值展示了當前內容的范圍. 跟 Range 消息頭一樣, 兩個值都是包含在內的,并且也是從零開始的. 總數這個值聲明了可用字節的總數.
Content-Range: */(總數)
這個頭信息和上面一個是一樣的,不過是用另一種格式,并且僅在返回HTTP狀態碼416時被發送。其中總數代表了正文總共可用的字節數。
這里有一對有2048個字節文件的例子。注意省略起點和重點的區別。
請求開始的1024個字節
瀏覽器發送:
GET /dota2/techies.mp4 HTTP/1.1 Host: localhost:8000 Range: bytes=0-1023
服務器返回:
HTTP/1.1 216 Partial Content Date: Mon, 15 Sep 2014 22:19:34 GMT Content-Type: video/mp4 Content-Range: bytes 0-1023/2048 Content-Length: 1024
(Content...)</pre>
沒有終點位置的請求
瀏覽器發送:
GET /dota2/techies.mp4 HTTP/1.1 Host: localhost:8000 Range: bytes=1024-
服務器返回:
HTTP/1.1 216 Partial Content Date: Mon, 15 Sep 2014 22:19:34 GMT Content-Type: video/mp4 Content-Range: bytes 1024-2047/2048 Content-Length: 1024
(Content...)</pre>
注意:服務器并不需要在單個響應中返回所有剩下的字節,特別是當正文太長或者有其他性能的考慮。所以下面的兩個例子在這種情況下也是可接受的:
Content-Range: bytes 1024-1535/2048 Content-Length: 512
服務器僅返回剩余正文的一半。下一次請求的范圍將從第1536個字節開始。
Content-Range: bytes 1024-1279/2048 Content-Length: 256
服務器僅返回剩余正文的256個字節。下一次請求的范圍將從第1280個字節開始。
請求最后512個字節
瀏覽器發送:
GET /dota2/techies.mp4 HTTP/1.1 Host: localhost:8000 Range: bytes=-512
服務器返回:
HTTP/1.1 216 Partial Content Date: Mon, 15 Sep 2014 22:19:34 GMT Content-Type: video/mp4 Content-Range: bytes 1536-2047/2048 Content-Length: 512
(Content...)</pre>
請求不可用的范圍:
瀏覽器發送:
GET /dota2/techies.mp4 HTTP/1.1 Host: localhost:8000 Range: bytes=1024-4096
服務器返回:
HTTP/1.1 416 Requested Range Not Satisfiable Date: Mon, 15 Sep 2014 22:19:34 GMT Content-Range: bytes */2048
理解了工作流和頭部信息后,現在我們可以用Node.js去實現這個機制。
開始用Node.js實現
第一步:創建一個簡單的HTTP服務器
我們將像下面的例子那樣,從一個基本的HTTP服務器開始。這已經可以基本足夠處理大多數的瀏覽器請求了。首先,我們初始化我們需要用到的對象,并且用initFolder來代表文件的位置。為了生成Content-Type頭部,我們列出文件擴展名和它們相對應的MIME名稱來構成一個字典。在回調函數httpListener()中,我們將僅允許GET可用。如果出現其他方法,服務器將返回405 Method Not Allowed,在文件不存在于initFolder,服務器將返回404 Not Found。
// 初始化需要的對象 var http = require("http"); var fs = require("fs"); var path = require("path"); var url = require("url");
// 初始的目錄,隨時可以改成你希望的目錄 var initFolder = "C:\Users\User\Videos";
// 將我們需要的文件擴展名和MIME名稱列出一個字典 var mimeNames = { ".css": "text/css", ".html": "text/html", ".js": "application/javascript", ".mp3": "audio/mpeg", ".mp4": "video/mp4", ".ogg": "application/ogg", ".ogv": "video/ogg", ".oga": "audio/ogg", ".txt": "text/plain", ".wav": "audio/x-wav", ".webm": "video/webm"; };
http.createServer(httpListener).listen(8000);
function httpListener (request, response) { // 我們將只接受GET請求,否則返回405 'Method Not Allowed' if (request.method != "GET") { sendResponse(response, 405, {"Allow" : "GET"}, null); return null; }
var filename = initFolder + url.parse(request.url, true, true).pathname.split('/').join(path.sep);
var responseHeaders = {}; var stat = fs.statSync(filename); // 檢查文件是否存在,不存在就返回404 Not Found if (!fs.existsSync(filename)) { sendResponse(response, 404, null, null); return null; } responseHeaders["Content-Type"] = getMimeNameFromExt(path.extname(filename)); responseHeaders["Content-Length"] = stat.size; // 文件大小 sendResponse(response, 200, responseHeaders, fs.createReadStream(filename)); }
function sendResponse(response, responseStatus, responseHeaders, readable) { response.writeHead(responseStatus, responseHeaders);
if (readable == null) response.end(); else readable.on("open", function () { readable.pipe(response); });
return null; }
function getMimeNameFromExt(ext) { var result = mimeNames[ext.toLowerCase()]; // 最好給一個默認值 if (result == null) result = "application/octet-stream"; return result; }</pre>
步驟 2 - 使用正則表達式捕獲Range消息頭
有了這個HTTP服務器做基礎,我們現在就可以用如下代碼處理Range消息頭了. 我們使用正則表達式將消息頭分割,以獲取開始和結束字符串。然后使用 parseInt() 方法將它們轉換成整形數. 如果返回值是 NaN (非數字not a number), 那么這個字符串就是沒有在這個消息頭中的. 參數totalLength展示了當前文件的總字節數. 我們將使用它計算開始和結束位置.
function readRangeHeader(range, totalLength) { / Example of the method 'split' with regular expression. Input: bytes=100-200 Output: [null, 100, 200, null] Input: bytes=-200 Output: [null, null, 200, null] */
if (range == null || range.length == 0) return null;
var array = range.split(/bytes=([0-9])-([0-9])/); var start = parseInt(array[1]); var end = parseInt(array[2]); var result = { Start: isNaN(start) ? 0 : start, End: isNaN(end) ? (totalLength - 1) : end }; if (!isNaN(start) && isNaN(end)) { result.Start = start; result.End = totalLength - 1; }
if (isNaN(start) && !isNaN(end)) { result.Start = totalLength - end; result.End = totalLength - 1; }
return result; }</pre>
步驟 3 - 檢查數據范圍是否合理
回到函數 httpListener(), 在HTTP方法通過之后,現在我們來檢查請求的數據范圍是否可用. 如果瀏覽器沒有發送 Range 消息頭過來, 請求就會直接被當做一般的請求對待. 服務器會返回整個文件,HTTP狀態將會是 200 OK. 另外我們還會看看開始和結束位置是否比文件長度更大或者相等. 只要有一個是這種情況,請求的數據范圍就是不能被滿足的. 返回的狀態就將會是 416 Requested Range Not Satisfiable 而 Content-Range 也會被發送.
var responseHeaders = {}; var stat = fs.statSync(filename); var rangeRequest = readRangeHeader(request.headers['range'], stat.size); // If 'Range' header exists, we will parse it with Regular Expression. if (rangeRequest == null) { responseHeaders['Content-Type'] = getMimeNameFromExt(path.extname(filename)); responseHeaders['Content-Length'] = stat.size; // File size. responseHeaders['Accept-Ranges'] = 'bytes'; // If not, will return file directly. sendResponse(response, 200, responseHeaders, fs.createReadStream(filename)); return null; }
var start = rangeRequest.Start; var end = rangeRequest.End;
// If the range can't be fulfilled. if (start >= stat.size || end >= stat.size) { // Indicate the acceptable range. responseHeaders['Content-Range'] = 'bytes */' + stat.size; // File size.
// Return the 416 'Requested Range Not Satisfiable'. sendResponse(response, 416, responseHeaders, null); return null; }</pre>
步驟 4 - 滿足請求
最后使人迷惑的一塊來了。對于狀態 216 Partial Content, 我們有另外一種格式的 Content-Range 消息頭,包括開始,結束位置以及當前文件的總字節數. 我們也還有 Content-Length 消息頭,其值就等于開始和結束位置之間的差。在最后一句代碼中,我們調用了 createReadStream() 并將開始和結束位置的值給了第二個參數選項的對象, 這意味著返回的流將只包含從開始到結束位置的只讀數據.
// Indicate the current range. responseHeaders['Content-Range'] = 'bytes ' + start + '-' + end + '/' + stat.size; responseHeaders['Content-Length'] = start == end ? 0 : (end - start + 1); responseHeaders['Content-Type'] = getMimeNameFromExt(path.extname(filename)); responseHeaders['Accept-Ranges'] = 'bytes'; responseHeaders['Cache-Control'] = 'no-cache';
// Return the 206 'Partial Content'. sendResponse(response, 206, responseHeaders, fs.createReadStream(filename, { start: start, end: end }));</pre>
下面是完整的 httpListener() 回調函數.
function httpListener(request, response) { // We will only accept 'GET' method. Otherwise will return 405 'Method Not Allowed'. if (request.method != 'GET') { sendResponse(response, 405, { 'Allow': 'GET' }, null); return null; }
var filename = initFolder + url.parse(request.url, true, true).pathname.split('/').join(path.sep);
// Check if file exists. If not, will return the 404 'Not Found'. if (!fs.existsSync(filename)) { sendResponse(response, 404, null, null); return null; }
var responseHeaders = {}; var stat = fs.statSync(filename); var rangeRequest = readRangeHeader(request.headers['range'], stat.size);
// If 'Range' header exists, we will parse it with Regular Expression. if (rangeRequest == null) { responseHeaders['Content-Type'] = getMimeNameFromExt(path.extname(filename)); responseHeaders['Content-Length'] = stat.size; // File size. responseHeaders['Accept-Ranges'] = 'bytes';
// If not, will return file directly. sendResponse(response, 200, responseHeaders, fs.createReadStream(filename)); return null; }
var start = rangeRequest.Start; var end = rangeRequest.End;
// If the range can't be fulfilled. if (start >= stat.size || end >= stat.size) { // Indicate the acceptable range. responseHeaders['Content-Range'] = 'bytes */' + stat.size; // File size.
// Return the 416 'Requested Range Not Satisfiable'. sendResponse(response, 416, responseHeaders, null); return null; }
// Indicate the current range. responseHeaders['Content-Range'] = 'bytes ' + start + '-' + end + '/' + stat.size; responseHeaders['Content-Length'] = start == end ? 0 : (end - start + 1); responseHeaders['Content-Type'] = getMimeNameFromExt(path.extname(filename)); responseHeaders['Accept-Ranges'] = 'bytes'; responseHeaders['Cache-Control'] = 'no-cache';
// Return the 206 'Partial Content'. sendResponse(response, 206, responseHeaders, fs.createReadStream(filename, { start: start, end: end })); }</pre>
測試實現
我們怎么來測試我們的代碼呢?就像在介紹中提到的,部分正文最常用的場景是流和播放視頻。所以我們創建了一個ID為mainPlayer并包含一個<source/>標簽的<video/>。函數onLoad()將在mainPlayer預讀取當前視頻的元數據時被觸發,這用于檢查在URL中是否有數字參數,如果有,mainPlayer將跳到指定的時間點。
<!DOCTYPE html> <html> <head> <script type="text/javascript">
function onLoad() { var sec = parseInt(document.location.search.substr(1)); if (!isNaN(sec)) mainPlayer.currentTime = sec; } </script> <title>Partial Content Demonstration</title> </head> <body> <h3>Partial Content Demonstration</h3> <hr /> <video id="mainPlayer" width="640" height="360" autoplay="autoplay" controls="controls" onloadedmetadata="onLoad()"> <source src="dota2/techies.mp4" /> </video> </body> </html></pre>
現在我們把頁面保存為"player.html"并和"dota2/techies.mp4"一起放在initFolder目錄下。然后在瀏覽器中打開URL:http://localhost:8000/player.html
在Chrome中看起來像這樣:
因為在URL中沒有任何參數,文件將從最開始出播放。
接下來就是有趣的部分了。讓我們試著打開這個然后看看發生了什么:http://localhost:8000/player.html?60
如果你按F12來打開Chrome的開發者工具,切換到網絡標簽頁,然后點擊查看最近一次日志的詳細信息。你會發現范圍的頭信息(Range)被你的瀏覽器發送了:
Range:bytes=225084502-
很有趣,對吧?當函數onLoad()改變currentTime屬性的時候,瀏覽器計算這部視頻60秒處的字節位置。因為mainPlayer已經預加載了元數據,包括格式、比特率和其他基本信息,這個起始位置立刻就被得到了。之后,瀏覽器就可以下載并播放視頻而不需要請求開頭的60秒了。成功了!
結論
我們已經用Node.js來實現支持部分正文的HTTP服務器端了。我們也用HTML5頁面測試了。但這只是一個開始。如果你對頭部信息和工作流這些都已經理解透徹了,你可以試著用其他像ASP.NET MVC或者WCF服務這類框架來實現它。但是不要忘記啟動任務管理器來查看CPU和內存的使用。像我們在之前討論到的,服務器沒有在單個響應中返回所用剩余的字節。要找到性能的平衡點將是一項重要的任務。
-
-