• 用NodeJS打造你的靜態文件服務器

    1
    JavaScript C/C++ NodeJS Go ico 39344 次瀏覽

    本文是我對V5Node項目的總結,該項目的特性包括:

    1. 項目大多數的文件都是屬于靜態文件,只有數據部分存在動態請求。
    2. 數據部分的請求都呈現為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會吃不消。

    在解決這個問題之前,我們有必要了解一番前端瀏覽器緩存的一些機制和提高性能的方案。

    1. GZip壓縮文件可以減少響應的大小,能夠達到節省帶寬的目的。
    2. 瀏覽器緩存中存有文件副本的時候,不能確定有效的時候,會生成一個條件get請求。
      1. 在請求的頭中會包含 If-Modified-Since。
      2. 如果服務器端文件在這個時間后發生過修改,則發送整個文件給前端。
      3. 如果沒有修改,則返回304狀態碼。并不發送整個文件給前端。
      4. 另外一種判斷機制是ETag。在此并不討論。
    3. 如果副本有效,這個get請求都會省掉。判斷有效的最主要的方法是服務端響應的時候帶上Expires的頭。
      1. 瀏覽器會判斷Expires頭,直到制定的日期過期,才會發起新的請求。
      2. 另一個可以達到相同目的的方法是返回Cache-Control: max-age=xxxx。

    欲了解更多緩存機制,請參見Steve Sounders著作的《高性能網站建設指南》。

    為了簡化問題,我們只做如下這幾件事情:

    1. 為指定幾種后綴的文件,在響應時添加Expires頭和Cache-Control: max-age頭。超時日期設置為1年。
    2. 由于這是靜態文件服務器,為所有請求,響應時返回Last-Modified頭。
    3. 為帶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定義,可以參見這兩篇文章:

    接下來,我將簡單地介紹一下range的作用和其定義。

    當用戶在聽一首歌的時候,如果聽到一半(網絡下載了一半),網絡斷掉了,用戶需要繼續聽的時候,文件服務器不支持斷點的話,則用戶需要重新下載這個 文件。而Range支持的話,客戶端應該記錄了之前已經讀取的文件范圍,網絡恢復之后,則向服務器發送讀取剩余Range的請求,服務端只需要發送客戶端 請求的那部分內容,而不用整個文件發送回客戶端,以此節省網絡帶寬。

    那么HTTP1.1規范的Range是怎樣一個約定呢。

    1. 如果Server支持Range,首先就要告訴客戶端,咱支持Range,之后客戶端才可能發起帶Range的請求。
    2. response.setHeader('Accept-Ranges', 'bytes');
    3. 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的請求頭,則繼續通過常規的方式響應。
    4. 有必要對Range請求做一下解釋。
    5. 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 ,歡迎持續跟蹤。

    相似問題

    相關經驗

    相關資訊

    相關文檔

  • sesese色