用NodeJS打造你的靜態文件服務器
本文是我對V5Node項目的總結,該項目的特性包括:
- 項目大多數的文件都是屬于靜態文件,只有數據部分存在動態請求。
- 數據部分的請求都呈現為RESTful的特性。
所以項目主要包含兩個部分就是靜態服務器和RESTful服務器。本文講的是靜態文件服務器部分。
既是一個新的項目,那么創建v5node目錄是應該的。既是一個Node應用,創建一個app.js文件也是應該的。
我們的app.js文件里的結構很明確:
var PORT = 8000; var http = require('http'); var server = http.createServer(function (request, response) { // TODO }); server.listen(PORT); console.log("Server runing at port: " + PORT + ".");
因為當前要實現的功能是靜態文件服務器,那么以Apache為例,讓我們回憶一下靜態文件服務器都有哪些功能。
瀏覽器發送URL,服務端解析URL,對應到硬盤上的文件。如果文件存在,返回200狀態碼,并發送文件到瀏覽器端;如果文件不存在,返回404狀態碼,發送一個404的文件到瀏覽器端。
以下兩圖是Apache經典的兩種狀態。
以下兩圖是Apache經典的兩種狀態。
現在需求已經明了,那么我們開始實現吧。
實現路由
路由部分的實現在《The Node Beginner Book》已經被描述過,此處不例外。
添加url模塊是必要的,然后解析pathname。
以下是實現代碼:
var server = http.createServer(function (request, response) { var pathname = url.parse(request.url).pathname; response.write(pathname); response.end(); });
現在的代碼是向瀏覽器端輸出請求的路徑,類似一個echo服務器。接下來我們為其添加輸出對應文件的功能。
讀取靜態文件
為了不讓用戶在瀏覽器端通過請求/app.js查看到我們的代碼,我們設定用戶只能請求assets目錄下的文件。服務器會將路徑信息映射到assets目錄。
涉及到了文件讀取的這部分,自然不能避開fs(file system)這個模塊。同樣,涉及到了路徑處理,path模塊也是需要的。
我們通過path模塊的path.exists方法來判斷靜態文件是否存在磁盤上。不存在我們直接響應給客戶端404錯誤。
如果文件存在則調用fs.readFile方法讀取文件。如果發生錯誤,我們響應給客戶端500錯誤,表明存在內部錯誤。正常狀態下則發送讀取到的文件給客戶端,表明200狀態。
var server = http.createServer(function (request, response) { var pathname = url.parse(request.url).pathname; var realPath = "assets" + pathname; path.exists(realPath, function (exists) { if (!exists) { response.writeHead(404, { 'Content-Type': 'text/plain' }); response.write("This request URL " + pathname + " was not found on this server."); response.end(); } else { fs.readFile(realPath, "binary", function (err, file) { if (err) { response.writeHead(500, { 'Content-Type': 'text/plain' }); response.end(err); } else { response.writeHead(200, { 'Content-Type': 'text/html' }); response.write(file, "binary"); response.end(); } }); } }); });
以上這段簡單的代碼加上一個assets目錄,就構成了我們最基本的靜態文件服務器。
那么眼尖的你且看看,這個最基本的靜態文件服務器存在哪些問題呢?答案是MIME類型支持。因為我們的服務器同時要存放html, css, js, png, gif, jpg等等文件。并非每一種文件的MIME類型都是text/html的。
MIME類型支持
像其他服務器一樣,支持MIME的話,就得一張映射表。
exports.types = { "css": "text/css", "gif": "image/gif", "html": "text/html", "ico": "image/x-icon", "jpeg": "image/jpeg", "jpg": "image/jpeg", "js": "text/javascript", "json": "application/json", "pdf": "application/pdf", "png": "image/png", "svg": "image/svg+xml", "swf": "application/x-shockwave-flash", "tiff": "image/tiff", "txt": "text/plain", "wav": "audio/x-wav", "wma": "audio/x-ms-wma", "wmv": "video/x-ms-wmv", "xml": "text/xml" };
以上代碼另存在mime.js文件中。該文件僅僅只列舉了一些常用的MIME類型,以文件后綴作為key,MIME類型為value。那么引入mime.js文件吧。
var mime = require("./mime").types;
我們通過path.extname來獲取文件的后綴名。由于extname返回值包含”.”,所以通過slice方法來剔除掉”.”,對于沒有后綴名的文件,我們一律認為是unknown。
var ext = path.extname(realPath); ext = ext ? ext.slice(1) : 'unknown';
接下來我們很容易得到真正的MIME類型了。
var contentType = mime[ext] || "text/plain"; response.writeHead(200, {'Content-Type': contentType}); response.write(file, "binary"); response.end();
對于未知的類型,我們一律返回text/plain類型。
緩存支持/控制
在MIME支持之后,靜態文件服務器看起來已經很完美了。任何靜態文件只要丟進assets目錄之后就可以萬事大吉不管了。看起來已經達到了Apache作為靜態文件服務器的相同效果了。我們實現這樣的服務器用的代碼只有這么多行而已。是不是很簡單呢?
但是,我們發現用戶在每次請求的時候,服務器每次都要調用fs.readFile方法去讀取硬盤上的文件的。當服務器的請求量一上漲,硬盤IO會吃不消。
在解決這個問題之前,我們有必要了解一番前端瀏覽器緩存的一些機制和提高性能的方案。
- GZip壓縮文件可以減少響應的大小,能夠達到節省帶寬的目的。
- 瀏覽器緩存中存有文件副本的時候,不能確定有效的時候,會生成一個條件get請求。
- 在請求的頭中會包含 If-Modified-Since。
- 如果服務器端文件在這個時間后發生過修改,則發送整個文件給前端。
- 如果沒有修改,則返回304狀態碼。并不發送整個文件給前端。
- 另外一種判斷機制是ETag。在此并不討論。
- 如果副本有效,這個get請求都會省掉。判斷有效的最主要的方法是服務端響應的時候帶上Expires的頭。
- 瀏覽器會判斷Expires頭,直到制定的日期過期,才會發起新的請求。
- 另一個可以達到相同目的的方法是返回Cache-Control: max-age=xxxx。
欲了解更多緩存機制,請參見Steve Sounders著作的《高性能網站建設指南》。
為了簡化問題,我們只做如下這幾件事情:
- 為指定幾種后綴的文件,在響應時添加Expires頭和Cache-Control: max-age頭。超時日期設置為1年。
- 由于這是靜態文件服務器,為所有請求,響應時返回Last-Modified頭。
- 為帶If-Modified-Since的請求頭,做日期檢查,如果沒有修改,則返回304。若修改,則返回文件。
對于以上的靜態文件服務器,Node給的響應頭是十分簡單的:
Connection: keep-alive Content-Type: text/html Transfer-Encoding: chunked
對于指定后綴文件和過期日期,為了保證可配置。那么建立一個config.js文件是應該的。
exports.Expires = { fileMatch: /^(gif|png|jpg|js|css)$/ig, maxAge: 60 * 60 * 24 * 365 };
引入config.js文件。
var config = require("./config");
我們在相應之前判斷后綴名是否符合我們要添加過期時間頭的條件。
var ext = path.extname(realPath); ext = ext ? ext.slice(1) : 'unknown'; if (ext.match(config.Expires.fileMatch)) { var expires = new Date(); expires.setTime(expires.getTime() + config.Expires.maxAge * 1000); response.setHeader("Expires", expires.toUTCString()); response.setHeader("Cache-Control", "max-age=" + config.Expires.maxAge); }
這次的響應頭中多了兩個header。
Cache-Control: max-age=31536000 Connection: keep-alive Content-Type: image/png Expires: Fri, 09 Nov 2012 12:55:41 GMT Transfer-Encoding: chunked
瀏覽器在發送請求之前由于檢測到Cache-Control和Expires(Cache-Control的優先級高于Expires,但有的瀏覽器不支持Cache-Control,這時采用Expires),如果沒有過期,則不會發送請求,而直接從緩存中讀取文件。
接下來我們為所有請求的響應都添加Last-Modified頭。
讀取文件的最后修改時間是通過fs模塊的fs.stat()方法來實現的。關于stat的詳細介紹請參見此處。
fs.stat(realPath, function (err, stat) { var lastModified = stat.mtime.toUTCString(); response.setHeader("Last-Modified", lastModified); });
我們同時也要檢測瀏覽器是否發送了If-Modified-Since請求頭。如果發送而且跟文件的修改時間相同的話,我們返回304狀態。
if (request.headers[ifModifiedSince] && lastModified == request.headers[ifModifiedSince]) { response.writeHead(304, "Not Modified"); response.end(); }
如果沒有發送或者跟磁盤上的文件修改時間不相符合,則發送回磁盤上的最新文件。
通過Expires和Last-Modified兩個方案以及與瀏覽器之間的通力合作,會節省相當大的一部分網絡流量,同時也會降低部分硬盤IO的請求。如果在這之前還存在CDN的話,整個方案就比較完美了。
由于Expires和Max-Age都是由瀏覽器來進行判斷的,如果判斷成功,http請求都不會發送到服務端的,這里只能通過fiddler和瀏覽器配合進行測試。但是Last-Modified卻是可以通過curl來進行測試的。
#:~$ curl --header "If-Modified-Since: Fri, 11 Nov 2011 19:14:51 GMT" -i http://localhost:8000 HTTP/1.1 304 Not Modified Content-Type: text/html Last-Modified: Fri, 11 Nov 2011 19:14:51 GMT Connection: keep-alive
注意,我們看到這個304請求的響應是不帶body信息的。所以,達到我們節省帶寬的需求。只需幾行代碼,就可以省下許多的帶寬費用。
但是,貌似我們有提到gzip這樣的東西。對于CSS、JS等文件如果不采用GZip的話,還是會浪費掉部分網絡帶寬。那么接下來把GZip代碼添加進來。
GZip啟用
如果你是前端達人,你應該是知道YUI Compressor或Google Closure Complier這樣的壓縮工具的。在這基礎上,再進行gzip壓縮,則會減少很多的網絡流量。那么,我們看看Node中,怎么把gzip搞起來。
要用到gzip,就需要zlib模塊,該模塊在Node的0.5.8版本開始原生支持。
var zlib = require("zlib");
對于圖片一類的文件,不需要進行gzip壓縮,所以我們在config.js中配置一個啟用壓縮的列表。
exports.Compress = { match: /css|js|html/ig };
這里為了防止大文件,也為了滿足zlib模塊的調用模式,將讀取文件改為流的形式進行讀取。
var raw = fs.createReadStream(realPath); var acceptEncoding = request.headers['accept-encoding'] || ""; var matched = ext.match(config.Compress.match); if (matched && acceptEncoding.match(/\bgzip\b/)) { response.writeHead(200, "Ok", { 'Content-Encoding': 'gzip' }); raw.pipe(zlib.createGzip()).pipe(response); } else if (matched && acceptEncoding.match(/\bdeflate\b/)) { response.writeHead(200, "Ok", { 'Content-Encoding': 'deflate' }); raw.pipe(zlib.createDeflate()).pipe(response); } else { response.writeHead(200, "Ok"); raw.pipe(response); }
對于支持壓縮的文件格式以及瀏覽器端接受gzip或deflate壓縮,我們調用壓縮。若不,則管道方式轉發給response。
啟用壓縮其實就這么簡單。如果你有fiddler的話,可以監聽一下請求,會看到被壓縮的請求。
安全問題
我們搞了一大堆的事情,但是安全方面也不能少。想想哪一個地方是最容易出問題的?
我們發現上面的這段代碼寫得還是有點糾結的,通常這樣糾結的代碼我是不愿意拿出去讓人看見的。但是,假如一個同學用瀏覽器訪問http://localhost:8000/../app.js 怎么辦捏?
不用太害怕,瀏覽器會自動干掉那兩個作為父路徑的點的。瀏覽器會把這個路徑組裝成http://localhost:8000/app.js的,這個文件在assets目錄下不存在,返回404 Not Found。
但是聰明一點的同學會通過curl -i http://localhost:8000/../app.js 來訪問。于是,問題出現了。
# curl -i http://localhost:8000/../app.js HTTP/1.1 200 Ok Content-Type: text/javascript Last-Modified: Thu, 10 Nov 2011 17:16:51 GMT Expires: Sat, 10 Nov 2012 04:59:27 GMT Cache-Control: max-age=31536000 Connection: keep-alive Transfer-Encoding: chunked var PORT = 8000; var http = require("http"); var url = require("url"); var fs = require("fs"); var path = require("path"); var mime = require("./mime").types;
那么怎么辦呢?暴力點的解決方案就是禁止父路徑。
首先替換掉所有的..,然后調用path.normalize方法來處理掉不正常的/。
var realPath = path.join("assets", path.normalize(pathname.replace(/\.\./g, "")));
于是這個時候通過curl -i http://localhost:8000/../app.js 訪問,/../app.js會被替換掉為//app.js。normalize方法會將//app.js返回為/app.js。再加上真實的 assets,就被實際映射為assets/app.js。這個文件不存在,于是返回404。搞定父路徑問題。與瀏覽器的行為保持一致。
Welcome頁的錦上添花
再來回憶一下Apache的常見行為。當進入一個目錄路徑的時候,會去尋找index.html頁面,如果index.html文件不存在,則返回 目錄索引。目錄索引這里我們暫不考慮,如果用戶請求的路徑是/結尾的,我們就自動為其添加上index.html文件。如果這個文件不存在,繼續返回 404錯誤。
如果用戶請求了一個目錄路徑,而且沒有帶上/。那么我們為其添加上/index.html,再重新做解析。
那么不喜歡硬編碼的你,肯定是要把這個文件配置進config.js。這樣你就可以選擇各種后綴作為welcome頁面。
exports.Welcome = { file: "index.html" };
那么第一步,為/結尾的請求,自動添加上”index.html”。
if (pathname.slice(-1) === "/") { pathname = pathname + config.Welcome.file; }
第二步,如果請求了一個目錄路徑,并且沒有以/結尾。那么我們需要做判斷。如果當前讀取的路徑是目錄,就需要添加上/和index.html
if (stats.isDirectory()) { realPath = path.join(realPath, "/", config.Welcome.file); }
由于我們目前的結構發生了一點點變化。所以需要重構一下函數。而且,fs.stat方法具有比fs.exsits方法更多的功能。我們直接替代掉它。
就這樣。一個各方面都比較完整的靜態文件服務器就這樣打造完畢。
Range支持,搞定媒體斷點支持
關于http1.1中的Range定義,可以參見這兩篇文章:
- http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
- http://labs.apache.org/webarch/http/draft-fielding-http/p5-range.html
接下來,我將簡單地介紹一下range的作用和其定義。
當用戶在聽一首歌的時候,如果聽到一半(網絡下載了一半),網絡斷掉了,用戶需要繼續聽的時候,文件服務器不支持斷點的話,則用戶需要重新下載這個 文件。而Range支持的話,客戶端應該記錄了之前已經讀取的文件范圍,網絡恢復之后,則向服務器發送讀取剩余Range的請求,服務端只需要發送客戶端 請求的那部分內容,而不用整個文件發送回客戶端,以此節省網絡帶寬。
那么HTTP1.1規范的Range是怎樣一個約定呢。
- 如果Server支持Range,首先就要告訴客戶端,咱支持Range,之后客戶端才可能發起帶Range的請求。
- Server通過請求頭中的Range: bytes=0-xxx來判斷是否是做Range請求,如果這個值存在而且有效,則只發回請求的那部分文件內容,響應的狀態碼變成206,表示 Partial Content,并設置Content-Range。如果無效,則返回416狀態碼,表明Request Range Not Satisfiable(http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.17 )。如果不包含Range的請求頭,則繼續通過常規的方式響應。
- 有必要對Range請求做一下解釋。
response.setHeader('Accept-Ranges', 'bytes');
ranges-specifier = byte-ranges-specifier byte-ranges-specifier = bytes-unit "=" byte-range-set byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec ) byte-range-spec = first-byte-pos "-" [last-byte-pos] first-byte-pos = 1*DIGIT last-byte-pos = 1*DIGIT
上面這段定義來自w3定義的協議http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35。大致可以表述為Range: bytes=[start]-[end][,[start]-[end]]。簡言之有以下幾種情況:
bytes=0-99,從0到99之間的數據字節。
bytes=-100,文件的最后100個字節。
bytes=100-,第100個字節開始之后的所有字節。
bytes=0-99,200-299,從0到99之間的數據字節和200到299之間的數據字節。
那么,我們就開始實現吧。首先判斷Range請求和檢測其是否有效。為了保持代碼干凈,我們封裝一個parseRange方法,這個方法屬于util性質的,那么我們放進utils.js文件。
var utils = require("./utils");
我們暫且不支持多區間。于是遇見逗號,就報416錯誤。
exports.parseRange = function (str, size) { if (str.indexOf(",") != -1) { return; } var range = str.split("-"), start = parseInt(range[0], 10), end = parseInt(range[1], 10); // Case: -100 if (isNaN(start)) { start = size - end; end = size - 1; // Case: 100- } else if (isNaN(end)) { end = size - 1; } // Invalid if (isNaN(start) || isNaN(end) || start > end || end > size) { return; } return { start: start, end: end }; };
如果滿足Range的條件,則為響應添加上Content-Range和修改掉Content-Lenth。
response.setHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + stats.size); response.setHeader("Content-Length", (range.end - range.start + 1));
非常開心的一件事情是,Node的讀文件流,原生支持range讀取。
var raw = fs.createReadStream(realPath, {"start": range.start, "end": range.end});
設置狀態碼為206。
由于選取Range之后,依然還是需要經過GZip的。于是代碼已經有點面條的味道了。重構一下吧。于是代碼大致如此:
var compressHandle = function (raw, statusCode, reasonPhrase) { var stream = raw; var acceptEncoding = request.headers['accept-encoding'] || ""; var matched = ext.match(config.Compress.match); if (matched && acceptEncoding.match(/\bgzip\b/)) { response.setHeader("Content-Encoding", "gzip"); stream = raw.pipe(zlib.createGzip()); } else if (matched && acceptEncoding.match(/\bdeflate\b/)) { response.setHeader("Content-Encoding", "deflate"); stream = raw.pipe(zlib.createDeflate()); } response.writeHead(statusCode, reasonPhrase); stream.pipe(response); }; if (request.headers["range"]) { var range = utils.parseRange(request.headers["range"], stats.size); if (range) { response.setHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + stats.size); response.setHeader("Content-Length", (range.end - range.start + 1)); var raw = fs.createReadStream(realPath, { "start": range.start, "end": range.end }); compressHandle(raw, 206, "Partial Content"); } else { response.removeHeader("Content-Length"); response.writeHead(416, "Request Range Not Satisfiable"); response.end(); } } else { var raw = fs.createReadStream(realPath); compressHandle(raw, 200, "Ok"); }
通過curl --header "Range:0-20" -i http://localhost:8000/index.html請求測試一番試試。
HTTP/1.1 206 Partial Content Server: Node/V5 Accept-Ranges: bytes Content-Type: text/html Content-Length: 21 Last-Modified: Fri, 11 Nov 2011 19:14:51 GMT Content-Range: bytes 0-20/54 Connection: keep-alive <html> <body> <h1>I
index.html文件并沒有被整個發送給客戶端。這里之所以沒有完全的21個字節,是因為\t和\r都各算一個字節。
再用curl --header "Range:0-100" -i http://localhost:8000/index.html反向測試一下吧。
HTTP/1.1 416 Request Range Not Satisfiable Server: Node/V5 Accept-Ranges: bytes Content-Type: text/html Last-Modified: Fri, 11 Nov 2011 19:14:51 GMT Connection: keep-alive Transfer-Encoding: chunked
嗯,要的就是這個效果。至此,Range支持完成,這個靜態文件服務器支持一些流媒體文件。
嗯。就這么簡單。
本文轉載自CNode社區田永強的文章。
作者介紹
田永強,新浪微博@樸靈,前端工程師,現職于SAP,從事Mobile Web App方面的研發工作,對NodeJS持有高度的熱情,寄望打通前端JavaScript與NodeJS的隔閡,將NodeJS引薦給更多的前端工程師。 興趣:讀萬卷書,行萬里路。個人Github地址: http://github.com/JacksonTian。這個項目的地址是https://github.com/JacksonTian/nodev5 ,歡迎持續跟蹤。