介紹
在本文中,我會闡述HTTP狀態206 分部分內容 的基礎概念,並使用Node.js一步步地實現它. 我們還將用一個基於它用法最常見情境的樣本來測試代碼:一個能夠在任何時間點開始播放視頻檔案的HTML5頁面.
Partial Content 的簡要介紹
HTTP 的 206 Partial Content 狀態代碼和其相關的訊息頭提供了讓瀏覽器以及其他使用者代理程式從伺服器接收部分內容而不是全部內容,這樣一種機制. 這一機制被廣泛使用在一個被大多數瀏覽器和諸如Windows Media Player和VLC Player這樣的播放器所可使用視訊檔案的傳輸上.
基礎的流程可以用下面這幾步描述:
- 瀏覽器請求內容.
- 伺服器告訴瀏覽器,該內容可以使用 Accept-Ranges 訊息頭進行分部分請求.
- 瀏覽器重新發送請求,用 Range 訊息頭告訴伺服器需要的內容約制.
伺服器會分如下兩種情況響應瀏覽器的請求:
- 如果範圍是合理的,伺服器會返回所請求的部分內容,並帶上 206 Partial Content 狀態代碼. 當前內容的範圍會在 Content-Range 訊息頭中申明.
- 如果範圍是停用(例如,比內容的總位元組數大), 伺服器會返回 416 請求範圍不合理 Requested Range Not Satisfiable 狀態代碼. 可用的範圍也會在 Content-Range 訊息頭中聲明.
讓我們來看看這幾個步驟中的每一個關鍵訊息頭.
Accept-Ranges: 位元組(bytes)
這是會有伺服器發送的位元組頭,展示可以被分部分發送給瀏覽器的內容. 這個值聲明了可被接受的每一個範圍請求, 大多數情況下是位元組數 bytes.
Range: 位元組數(bytes)=(開始)-(結束)
這是瀏覽器告知伺服器所需分部分內容約制的訊息頭. 注意開始和結束位置是都包括在內的,而且是從0開始的. 這個訊息頭也可以不發送兩個位置,其含義如下:
- 如果結束位置被去掉了,伺服器會返回從聲明的開始位置到整個內容的結束位置內容的最後一個可用位元組.
- 如果開始位置被去掉了,結束位置參數可以被描述成從最後一個可用的位元組算起可以被伺服器返回的位元組數.
Content-Range:位元組數(bytes)=(開始)-(結束)/(總數)
這個訊息頭將會跟隨 HTTP 狀態代碼 206 一起出現. 開始和結束的值展示了當前內容的範圍. 跟 Range 訊息頭一樣, 兩個值都是包含在內的,並且也是從零開始的. 總數這個值聲明了可用位元組的總數.
Content-Range: */(總數)
這個頭資訊和上面一個是一樣的,不過是用另一種格式,並且僅在返回HTTP狀態代碼416時被發送。其中總數代表了本文總共可用的位元組數。
這裡有一對有2048個位元組檔案的例子。注意省略起點和重點的區別。
請求開始的1024個位元組
瀏覽器發送:
GET /dota2/techies.mp4 HTTP/1.1Host: localhost:8000Range: bytes=0-1023
伺服器返回:
HTTP/1.1 206 Partial ContentDate: Mon, 15 Sep 2014 22:19:34 GMTContent-Type: video/mp4Content-Range: bytes 0-1023/2048Content-Length: 1024 (Content...)
沒有終點位置的請求
瀏覽器發送:
GET /dota2/techies.mp4 HTTP/1.1Host: localhost:8000Range: bytes=1024-
伺服器返回:
HTTP/1.1 206 Partial ContentDate: Mon, 15 Sep 2014 22:19:34 GMTContent-Type: video/mp4Content-Range: bytes 1024-2047/2048Content-Length: 1024 (Content...)
注意:伺服器並不需要在單個響應中返回所有剩下的位元組,特別是當本文太長或者有其他效能的考慮。所以下面的兩個例子在這種情況下也是可接受的:
Content-Range: bytes 1024-1535/2048Content-Length: 512
伺服器僅返回剩餘本文的一半。下一次請求的範圍將從第1536個位元組開始。
Content-Range: bytes 1024-1279/2048Content-Length: 256
伺服器僅返回剩餘本文的256個位元組。下一次請求的範圍將從第1280個位元組開始。
請求最後512個位元組
瀏覽器發送:
GET /dota2/techies.mp4 HTTP/1.1Host: localhost:8000Range: bytes=-512
伺服器返回:
HTTP/1.1 206 Partial ContentDate: Mon, 15 Sep 2014 22:19:34 GMTContent-Type: video/mp4Content-Range: bytes 1536-2047/2048Content-Length: 512 (Content...)
請求停用範圍:
瀏覽器發送:
GET /dota2/techies.mp4 HTTP/1.1Host: localhost:8000Range: bytes=1024-4096
伺服器返回:
HTTP/1.1 416 Requested Range Not SatisfiableDate: Mon, 15 Sep 2014 22:19:34 GMTContent-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;}
步驟 2 - 使用Regex捕獲Range訊息頭
有了這個HTTP伺服器做基礎,我們現在就可以用如下代碼處理Range訊息頭了. 我們使用Regex將訊息頭分割,以擷取開始和結束字串。然後使用 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;}
步驟 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; }
步驟 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 }));
下面是完整的 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 }));}
測試實現
我們怎麼來測試我們的代碼呢?就像在介紹中提到的,部分本文最常用的情境是流和播放視頻。所以我們建立了一個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>
現在我們把頁面儲存為"player.html"並和"dota2/techies.mp4"一起放在initFolder目錄下。然後在瀏覽器中開啟URL:http://localhost:8000/player.html
在Chrome中看起來像這樣:
因為在URL中沒有任何參數,檔案將從最開始出播放。
接下來就是有趣的部分了。讓我們試著開啟這個然後看看發生了什麼:http://localhost:8000/player.html?60
如果你按F12來開啟Chrome的開發人員工具,切換到網路標籤頁,然後點擊查看最近一次日誌的詳細資料。你會發現範圍的頭資訊(Range)被你的瀏覽器發送了:
很有趣,對吧?當函數onLoad()改變currentTime屬性的時候,瀏覽器計算這部視頻60秒處的位元組位置。因為mainPlayer已經預先載入了中繼資料,包括格式、位元速率和其他基本資料,這個起始位置立刻就被得到了。之後,瀏覽器就可以下載並播放視頻而不需要請求開頭的60秒了。成功了!
結論
我們已經用Node.js來實現支援部分本文的HTTP伺服器端了。我們也用HTML5頁面測試了。但這隻是一個開始。如果你對頭部資訊和工作流程這些都已經理解透徹了,你可以試著用其他像ASP.NET MVC或者WCF服務這類架構來實現它。但是不要忘記啟動工作管理員來查看CPU和記憶體的使用。像我們在之前討論到的,伺服器沒有在單個響應中返回所用剩餘的位元組。要找到效能的平衡點將是一項重要的任務。